Merge branch 'review/slice-9-ab-recheck'
This commit is contained in:
@@ -1,5 +1,33 @@
|
|||||||
# Журнал ревью — срез 9
|
# Журнал ревью — срез 9
|
||||||
|
|
||||||
|
## Срез 9. Этапы A–B — перепроверка мнений и разговоров
|
||||||
|
|
||||||
|
- **Фазы:** 41–43
|
||||||
|
- **Проверен на:** `a80c6f0`, 2026-08-20
|
||||||
|
- **Пути:** `src/HSchool.People/OpinionStore.cs`, `src/HSchool.People/OpinionGenerator.cs`, `src/HSchool.People/OpinionLabels.cs`, `src/HSchool.Ai/TalkCircles.cs`, `src/HSchool.Simulation/MorningOpinions.cs`, `src/HSchool.Simulation/TalkCircleSystem.cs`, `src/HSchool.Simulation/School.cs`, `src/HSchool.Server/Game/PersonCardReader.cs`, `src/HSchool.Server/Game/PersonCardReader.Connections.cs`, `src/HSchool.Server/mods/core/defs/topics/`, `src/HSchool.Server/mods/core/defs/traits/`, `src/HSchool.Client/src/ui/personCard.ts`, `src/HSchool.Client/src/ui/personCardConnections.ts`, `docs/protocol.md`, `tests/HSchool.People.Tests`, `tests/HSchool.Ai.Tests`, `tests/HSchool.Simulation.Tests`, `tests/HSchool.Content.Tests/TopicDefTests.cs`
|
||||||
|
- **Итог:** перепроверка после `b38d0bb` (дальше сели 44–47 и сплит карточки). Обещания 41–43 на месте; прошлые правки (сейв кружка, `PickTopic`) живы. Дописано 4 теста; исправлено вступление врага в чужой кружок. `Seed.Mix` в политиковых тестах не трогали. C–D не переписывали.
|
||||||
|
- **Открыто:** ванильный `talkCircleMax` 4 по-прежнему съедает `Outgoing.talkCircleBonus` (поле работает, если потолок ниже 4).
|
||||||
|
|
||||||
|
### Что проверено
|
||||||
|
|
||||||
|
Каждый пункт «Тесты, без которых фаза не закрыта» у 41–43 имеет отдельный тест. После 58 вкладка «Связи» в `personCardConnections.ts`; клиент рисует присланную `opinionLabel`, лог не монтирует, POST с вкладки нет. Каталог без `TopicDef` падает на загрузке. `RosterTalkDirty` после кружка жив. Сокет 41–43 не бампили (бамп — фаза 45). `dotnet test` с `--filter` по `TalkCirclesTests`, `TalkCircleTests`, `WhisperTests`, `OpinionStoreTests`, `OpinionGeneratorTests`, `MorningOpinionsTests`, `TopicDefTests` зелёный.
|
||||||
|
|
||||||
|
### Что дописано
|
||||||
|
|
||||||
|
- `TalkCirclesTests.Joiner_IsRejectedWhenEitherSideIsEnemy`
|
||||||
|
- `TalkCirclesTests.LessonWhisperFactor_CutsWhisper_LeavesOrdinaryChat`
|
||||||
|
- `TalkCirclesTests.Outgoing_RaisesMaxSize_WhenTalkCircleMaxIsBelowHardLimit`
|
||||||
|
- `TalkCircleTests.Enemy_StartsNeighborCircle_InsteadOfJoiningFriends`
|
||||||
|
- `TalkCirclesTests.AppearanceTopic_UsesDressAppropriateness` — усилен: `OpinionDelta` на теме внешности, не только хелпер
|
||||||
|
|
||||||
|
### Что исправлено
|
||||||
|
|
||||||
|
- `FindOpenCircle` / `JoinCircle` не смотрели мнения: враг в том же узле вступал в уже открытый кружок друзей, хотя старт кружка врагов не зовёт. Теперь отказ и соседний кружок, если есть с кем.
|
||||||
|
|
||||||
|
### Открытое
|
||||||
|
|
||||||
|
- Черта «общительный» задаёт `talkCircleBonus: 1`, но ванильный `talkCircleMax` уже 4, а жёсткий потолок тоже 4. Код бонус считает (тест с max 3), в ванили поле по-прежнему не двигает размер. Менять ванильный потолок ревью не стало.
|
||||||
|
|
||||||
## Срез 9. Этапы A–B — мнения и разговоры
|
## Срез 9. Этапы A–B — мнения и разговоры
|
||||||
|
|
||||||
- **Фазы:** 41–43
|
- **Фазы:** 41–43
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
| Срез 7. Этап A — данные и гардероб | `5468852` | [07-inventory](07-inventory/reviewed.md) |
|
| Срез 7. Этап A — данные и гардероб | `5468852` | [07-inventory](07-inventory/reviewed.md) |
|
||||||
| Срез 7. Этап B–C — климат, жизнь одежды и правила | `549634b` | [07-inventory](07-inventory/reviewed.md) |
|
| Срез 7. Этап B–C — климат, жизнь одежды и правила | `549634b` | [07-inventory](07-inventory/reviewed.md) |
|
||||||
| Срез 8. Сессия и темп | `7100065` | [08-session](08-session/reviewed.md) |
|
| Срез 8. Сессия и темп | `7100065` | [08-session](08-session/reviewed.md) |
|
||||||
| Срез 9. Этапы A–B — мнения и разговоры | `b38d0bb` | [09-social](09-social/reviewed.md) |
|
| Срез 9. Этапы A–B — мнения и разговоры | `a80c6f0` | [09-social](09-social/reviewed.md) |
|
||||||
| Срез 9. Этапы C–D — конфликт, присутствие, речь и romance | `19554d4` | [09-social](09-social/reviewed.md) |
|
| Срез 9. Этапы C–D — конфликт, присутствие, речь и romance | `19554d4` | [09-social](09-social/reviewed.md) |
|
||||||
| Срез 10. Этап A — страховка | `cecd79c` | [10-craft](10-craft/reviewed.md) |
|
| Срез 10. Этап A — страховка | `cecd79c` | [10-craft](10-craft/reviewed.md) |
|
||||||
| Срез 10. Этапы B–C — работник, карточка и дамп | `f4f726a` | [10-craft](10-craft/reviewed.md) |
|
| Срез 10. Этапы B–C — работник, карточка и дамп | `f4f726a` | [10-craft](10-craft/reviewed.md) |
|
||||||
|
|||||||
@@ -369,6 +369,31 @@ public static class TalkCircles
|
|||||||
BehaviorDef? rules) =>
|
BehaviorDef? rules) =>
|
||||||
Math.Clamp(current + (rules?.TalkLanguageSkillPerHour ?? 0.005f) * hours, skill.Range.Min, skill.Range.Max);
|
Math.Clamp(current + (rules?.TalkLanguageSkillPerHour ?? 0.005f) * hours, skill.Range.Min, skill.Range.Max);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enemies do not sit in the same circle. RankInvitees only looks at the initiator when
|
||||||
|
/// forming; a later join is a self-invite, so either side already treating the other as an
|
||||||
|
/// enemy is enough to refuse.
|
||||||
|
/// </summary>
|
||||||
|
public static bool JoinerFits(Person joiner, IReadOnlyList<Person> members, BehaviorDef? rules)
|
||||||
|
{
|
||||||
|
foreach (var member in members)
|
||||||
|
{
|
||||||
|
if (member.Id.Equals(joiner.Id, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
joiner.Opinions.TryGetValue(member.Id, out var fromJoiner);
|
||||||
|
member.Opinions.TryGetValue(joiner.Id, out var fromMember);
|
||||||
|
if (IsEnemy(fromJoiner, rules) || IsEnemy(fromMember, rules))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Invite priority: friends, classmates, others. Enemies never invited.</summary>
|
/// <summary>Invite priority: friends, classmates, others. Enemies never invited.</summary>
|
||||||
public static IReadOnlyList<string> RankInvitees(
|
public static IReadOnlyList<string> RankInvitees(
|
||||||
Person initiator,
|
Person initiator,
|
||||||
|
|||||||
@@ -147,10 +147,10 @@ internal static class TalkCircleSystem
|
|||||||
return TryStartPhone(school, person, nodeId, action);
|
return TryStartPhone(school, person, nodeId, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
var existing = FindOpenCircle(school, nodeId, actionId);
|
var existing = FindOpenCircle(school, nodeId, actionId, person);
|
||||||
if (existing is not null)
|
if (existing is not null && JoinCircle(school, existing, personId, action))
|
||||||
{
|
{
|
||||||
return JoinCircle(school, existing, personId, action);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return TryStartGroup(school, person, nodeId, action);
|
return TryStartGroup(school, person, nodeId, action);
|
||||||
@@ -319,6 +319,14 @@ internal static class TalkCircleSystem
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var sitting = circle.Members
|
||||||
|
.Select(id => school.Roster!.People.First(row => row.Id.Equals(id, StringComparison.Ordinal)))
|
||||||
|
.ToArray();
|
||||||
|
if (!TalkCircles.JoinerFits(person, sitting, rules))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
circle.Members.Add(personId);
|
circle.Members.Add(personId);
|
||||||
circle.Members.Sort(StringComparer.Ordinal);
|
circle.Members.Sort(StringComparer.Ordinal);
|
||||||
school.TalkCircleByPerson[personId] = circle.Id;
|
school.TalkCircleByPerson[personId] = circle.Id;
|
||||||
@@ -326,22 +334,28 @@ internal static class TalkCircleSystem
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ActiveTalkCircle? FindOpenCircle(School school, string nodeId, string actionId)
|
private static ActiveTalkCircle? FindOpenCircle(School school, string nodeId, string actionId, Person joiner)
|
||||||
{
|
|
||||||
foreach (var circle in school.TalkCirclesById.Values)
|
|
||||||
{
|
|
||||||
if (circle.NodeId.Equals(nodeId, StringComparison.Ordinal)
|
|
||||||
&& circle.ActionId.Equals(actionId, StringComparison.Ordinal)
|
|
||||||
&& circle.RemainingMinutes > 0)
|
|
||||||
{
|
{
|
||||||
var rules = school.Catalog!.BehaviorRules;
|
var rules = school.Catalog!.BehaviorRules;
|
||||||
var max = TalkCircles.MaxSize(actionId, rules, [], school.Catalog);
|
var max = TalkCircles.MaxSize(actionId, rules, [], school.Catalog);
|
||||||
if (circle.Members.Count < max)
|
foreach (var circle in school.TalkCirclesById.Values)
|
||||||
|
{
|
||||||
|
if (!circle.NodeId.Equals(nodeId, StringComparison.Ordinal)
|
||||||
|
|| !circle.ActionId.Equals(actionId, StringComparison.Ordinal)
|
||||||
|
|| circle.RemainingMinutes <= 0
|
||||||
|
|| circle.Members.Count >= max)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sitting = circle.Members
|
||||||
|
.Select(id => school.Roster!.People.First(row => row.Id.Equals(id, StringComparison.Ordinal)))
|
||||||
|
.ToArray();
|
||||||
|
if (TalkCircles.JoinerFits(joiner, sitting, rules))
|
||||||
{
|
{
|
||||||
return circle;
|
return circle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,6 +206,15 @@ public class TalkCirclesTests
|
|||||||
Assert.Equal(2, TalkCircles.AppearanceModifier(ApparelIssue.None));
|
Assert.Equal(2, TalkCircles.AppearanceModifier(ApparelIssue.None));
|
||||||
Assert.Equal(-2, TalkCircles.AppearanceModifier(ApparelIssue.Formality));
|
Assert.Equal(-2, TalkCircles.AppearanceModifier(ApparelIssue.Formality));
|
||||||
Assert.Equal(0, TalkCircles.AppearanceModifier(ApparelIssue.Color));
|
Assert.Equal(0, TalkCircles.AppearanceModifier(ApparelIssue.Color));
|
||||||
|
|
||||||
|
var (catalog, _, _) = World();
|
||||||
|
var rules = catalog.BehaviorRules!;
|
||||||
|
var topic = catalog.Topics["TopicAppearance"];
|
||||||
|
var from = Person("a", ["RussianLanguage"]);
|
||||||
|
var to = Person("b", ["RussianLanguage"]);
|
||||||
|
var compliment = TalkCircles.OpinionDelta(from, to, topic, 50f, true, TalkActions.Chat, ApparelIssue.None, rules, catalog);
|
||||||
|
var mockery = TalkCircles.OpinionDelta(from, to, topic, 50f, true, TalkActions.Chat, ApparelIssue.Formality, rules, catalog);
|
||||||
|
Assert.True(compliment > mockery);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -219,6 +228,42 @@ public class TalkCirclesTests
|
|||||||
Assert.Equal(50f + rules.TalkSkillPerHour, afterTalkHour);
|
Assert.Equal(50f + rules.TalkSkillPerHour, afterTalkHour);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LessonWhisperFactor_CutsWhisper_LeavesOrdinaryChat()
|
||||||
|
{
|
||||||
|
var (catalog, _, _) = World();
|
||||||
|
var rules = catalog.BehaviorRules!;
|
||||||
|
Assert.True(TalkCircles.LessonWhisperFactor(TalkActions.Whisper, rules) < 1f);
|
||||||
|
Assert.Equal(1f, TalkCircles.LessonWhisperFactor(TalkActions.Chat, rules));
|
||||||
|
Assert.Equal(rules.LessonWhisperSkillFactor, TalkCircles.LessonWhisperFactor(TalkActions.Whisper, rules));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Outgoing_RaisesMaxSize_WhenTalkCircleMaxIsBelowHardLimit()
|
||||||
|
{
|
||||||
|
var (catalog, _, _) = World();
|
||||||
|
var rules = new BehaviorDef { DefName = "Rules", TalkCircleMin = 2, TalkCircleMax = 3 };
|
||||||
|
Assert.Equal(3, TalkCircles.MaxSize(TalkActions.Chat, rules, [], catalog));
|
||||||
|
Assert.Equal(4, TalkCircles.MaxSize(TalkActions.Chat, rules, ["Outgoing"], catalog));
|
||||||
|
Assert.Equal(4, TalkCircles.MaxSize(TalkActions.Chat, catalog.BehaviorRules, ["Outgoing"], catalog));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Joiner_IsRejectedWhenEitherSideIsEnemy()
|
||||||
|
{
|
||||||
|
var (catalog, _, _) = World();
|
||||||
|
var rules = catalog.BehaviorRules!;
|
||||||
|
var sitting = Person("a", []) with { Opinions = new Dictionary<string, int>(StringComparer.Ordinal) { ["c"] = 10 } };
|
||||||
|
var friend = Person("b", []) with { Opinions = new Dictionary<string, int>(StringComparer.Ordinal) { ["a"] = 50 } };
|
||||||
|
var enemy = Person("c", []) with { Opinions = new Dictionary<string, int>(StringComparer.Ordinal) { ["a"] = -55 } };
|
||||||
|
Assert.True(TalkCircles.JoinerFits(friend, [sitting], rules));
|
||||||
|
Assert.False(TalkCircles.JoinerFits(enemy, [sitting], rules));
|
||||||
|
|
||||||
|
var hated = Person("d", []) with { Opinions = new Dictionary<string, int>(StringComparer.Ordinal) };
|
||||||
|
var hater = Person("a", []) with { Opinions = new Dictionary<string, int>(StringComparer.Ordinal) { ["d"] = -55 } };
|
||||||
|
Assert.False(TalkCircles.JoinerFits(hated, [hater], rules));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StudyOnlyPolicy_NeverPicksNonStudyTopic()
|
public void StudyOnlyPolicy_NeverPicksNonStudyTopic()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -52,6 +52,44 @@ public class TalkCircleTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Enemy_StartsNeighborCircle_InsteadOfJoiningFriends()
|
||||||
|
{
|
||||||
|
var (school, pupils) = FivePupilsOnBreak();
|
||||||
|
using (school)
|
||||||
|
{
|
||||||
|
var first = pupils[0];
|
||||||
|
var second = pupils[1];
|
||||||
|
var enemy = pupils[2];
|
||||||
|
var ally = pupils[3];
|
||||||
|
OpinionStore.Set(first, second.Id, 55);
|
||||||
|
OpinionStore.Set(second, first.Id, 55);
|
||||||
|
OpinionStore.Set(enemy, first.Id, -55);
|
||||||
|
OpinionStore.Set(first, enemy.Id, -55);
|
||||||
|
OpinionStore.Set(enemy, second.Id, -55);
|
||||||
|
OpinionStore.Set(second, enemy.Id, -55);
|
||||||
|
OpinionStore.Set(enemy, ally.Id, 55);
|
||||||
|
OpinionStore.Set(ally, enemy.Id, 55);
|
||||||
|
|
||||||
|
PlaceAt(school, first.Id, "corridor-1");
|
||||||
|
PlaceAt(school, second.Id, "corridor-1");
|
||||||
|
Assert.True(school.TryStartAction(first.Id, TalkActions.Chat));
|
||||||
|
var friends = school.TalkCircleOf(first.Id);
|
||||||
|
Assert.NotNull(friends);
|
||||||
|
Assert.DoesNotContain(enemy.Id, friends.MemberIds);
|
||||||
|
|
||||||
|
PlaceAt(school, enemy.Id, "corridor-1");
|
||||||
|
PlaceAt(school, ally.Id, "corridor-1");
|
||||||
|
Assert.True(school.TryStartAction(enemy.Id, TalkActions.Chat));
|
||||||
|
var neighbor = school.TalkCircleOf(enemy.Id);
|
||||||
|
Assert.NotNull(neighbor);
|
||||||
|
Assert.NotEqual(friends.MemberIds, neighbor.MemberIds);
|
||||||
|
Assert.Contains(ally.Id, neighbor.MemberIds);
|
||||||
|
Assert.DoesNotContain(first.Id, neighbor.MemberIds);
|
||||||
|
Assert.Equal(friends.MemberIds, school.TalkCircleOf(first.Id)?.MemberIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FifthPerson_DoesNotJoinFullCircle()
|
public void FifthPerson_DoesNotJoinFullCircle()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user