From 9972b3025788341c9fe568b6ac49927f5a37353d Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 18:15:55 +0300 Subject: [PATCH 1/2] Keep defender-victim opinions as allies after a quarrel. The clash penalty applied to every pair except defender looking at the victim, so the person being helped disliked the defender like a second enemy. Co-authored-by: Cursor --- src/HSchool.Simulation/TalkCircleSystem.cs | 26 +++++++++++++---- tests/HSchool.Ai.Tests/ConflictTests.cs | 10 +++++++ tests/HSchool.People.Tests/AffinityTests.cs | 24 ++++++++++++++++ .../QuarrelFightTests.cs | 28 +++++++++++++++++++ 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/src/HSchool.Simulation/TalkCircleSystem.cs b/src/HSchool.Simulation/TalkCircleSystem.cs index 49b37c0..8ac3b6e 100644 --- a/src/HSchool.Simulation/TalkCircleSystem.cs +++ b/src/HSchool.Simulation/TalkCircleSystem.cs @@ -1056,11 +1056,7 @@ internal static class TalkCircleSystem OpinionStore.Get(person, other.Id) ?? 0); var now = OpinionStore.Get(person, other.Id) ?? 0; var shift = delta; - if (circle.VictimId is not null - && other.Id.Equals(circle.VictimId, StringComparison.Ordinal) - && circle.OpinionBaseline.TryGetValue(PairKey(person.Id, circle.VictimId), out var viewOfVictim) - && Conflict.ShouldDefend(viewOfVictim, rules) - && !person.Id.Equals(circle.VictimId, StringComparison.Ordinal)) + if (AllyOfVictim(circle, person.Id, rules) && AllyOfVictim(circle, other.Id, rules)) { shift = Math.Max(2, -delta / 4); } @@ -1076,6 +1072,26 @@ internal static class TalkCircleSystem } } + /// + /// Victim and anyone who joined on their side. Clash penalty is for opposite sides; allies + /// get a small plus instead — otherwise the victim's view of the defender falls like a fight. + /// + private static bool AllyOfVictim(ActiveTalkCircle circle, string personId, BehaviorDef? rules) + { + if (circle.VictimId is null) + { + return false; + } + + if (personId.Equals(circle.VictimId, StringComparison.Ordinal)) + { + return true; + } + + return circle.OpinionBaseline.TryGetValue(PairKey(personId, circle.VictimId), out var viewOfVictim) + && Conflict.ShouldDefend(viewOfVictim, rules); + } + private static void ApplyApology(School school, IReadOnlyList people, BehaviorDef? rules) { foreach (var person in people) diff --git a/tests/HSchool.Ai.Tests/ConflictTests.cs b/tests/HSchool.Ai.Tests/ConflictTests.cs index bccbadc..cd1f7d4 100644 --- a/tests/HSchool.Ai.Tests/ConflictTests.cs +++ b/tests/HSchool.Ai.Tests/ConflictTests.cs @@ -52,6 +52,16 @@ public class ConflictTests Assert.True(restored <= baseline); } + [Fact] + public void QuarrelOpinion_IsStrongerThanRudeTalk() + { + var (catalog, _) = Fixtures.Vanilla(); + var quarrel = -Conflict.QuarrelOpinionDelta(catalog.BehaviorRules); + var rude = Math.Abs(catalog.Topics["TopicRude"].OpinionShift); + Assert.True(quarrel > rude); + Assert.True(-Conflict.FightOpinionDelta(catalog.BehaviorRules) > quarrel); + } + [Fact] public void Catalog_HasNoHealthNeed() { diff --git a/tests/HSchool.People.Tests/AffinityTests.cs b/tests/HSchool.People.Tests/AffinityTests.cs index 19b5558..595b970 100644 --- a/tests/HSchool.People.Tests/AffinityTests.cs +++ b/tests/HSchool.People.Tests/AffinityTests.cs @@ -122,6 +122,30 @@ public class AffinityTests Assert.True((OpinionStore.Get(pupil, teacher.Id) ?? 0) < before); } + [Fact] + public void RosterJson_RoundTripsCrushesAndPartner() + { + var catalog = Fixtures.RomanceCatalog(); + var left = Staff("s1", female: false, "Bisexual"); + var right = Staff("s2", female: true, "Bisexual"); + SetOpinion(left, right.Id, 80); + SetOpinion(right, left.Id, 80); + var roster = new Roster([left, right], [], []); + Affinity.Refresh(catalog, roster, AsOf); + Assert.Equal(right.Id, left.Bonds!.PartnerId); + + var json = RosterJson.Serialize(RosterDocument.From(1, roster)); + Assert.Contains("\"partnerId\"", json, StringComparison.Ordinal); + Assert.Contains("\"crushes\"", json, StringComparison.Ordinal); + var loaded = RosterJson.Parse(json).ToRoster(); + var loadedLeft = loaded.People.First(person => person.Id.Equals(left.Id, StringComparison.Ordinal)); + var loadedRight = loaded.People.First(person => person.Id.Equals(right.Id, StringComparison.Ordinal)); + Assert.Equal(right.Id, loadedLeft.Bonds?.PartnerId); + Assert.Equal(left.Id, loadedRight.Bonds?.PartnerId); + Assert.Contains(right.Id, loadedLeft.Bonds!.Crushes); + Assert.Contains(left.Id, loadedRight.Bonds!.Crushes); + } + [Fact] public void AdjacentYearStudents_CanHaveSympathy() { diff --git a/tests/HSchool.Simulation.Tests/QuarrelFightTests.cs b/tests/HSchool.Simulation.Tests/QuarrelFightTests.cs index 1e85145..a656bfc 100644 --- a/tests/HSchool.Simulation.Tests/QuarrelFightTests.cs +++ b/tests/HSchool.Simulation.Tests/QuarrelFightTests.cs @@ -230,6 +230,34 @@ public class QuarrelFightTests } } + [Fact] + public void Defender_AndVictim_OpinionsMoveAsAllies() + { + var (school, pupils) = ThreePupilsOnBreak(); + using (school) + { + var bully = pupils[0]; + var victim = pupils[1]; + var defender = pupils[2]; + OpinionStore.Set(bully, victim.Id, -55); + OpinionStore.Set(victim, bully.Id, -55); + OpinionStore.Set(defender, victim.Id, 70); + PlaceAt(school, bully.Id, "corridor-1"); + PlaceAt(school, victim.Id, "corridor-1"); + PlaceAt(school, defender.Id, "corridor-1"); + + Assert.True(school.TryStartAction(bully.Id, TalkActions.Quarrel)); + TickWhile(school, personId => ActivityOf(school, personId) == TalkActions.Quarrel, bully.Id, victim.Id, defender.Id); + + Assert.True((OpinionStore.Get(defender, victim.Id) ?? 0) > 70); + Assert.True((OpinionStore.Get(victim, defender.Id) ?? 0) > 0); + Assert.True((OpinionStore.Get(defender, bully.Id) ?? 0) < 0); + Assert.True((OpinionStore.Get(victim, bully.Id) ?? 0) < -55); + Assert.True((OpinionStore.Get(bully, victim.Id) ?? 0) < -55); + Assert.True((OpinionStore.Get(bully, defender.Id) ?? 0) < 0); + } + } + private static (School School, Person First, Person Second) TwoPupilsOnBreak() { var (catalog, map) = Vanilla(); From 77dafeaf391c1da7c6b34921436811011ceb0694 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 18:17:05 +0300 Subject: [PATCH 2/2] Record slice 9 C-D recheck after talk-circle paths drifted. Co-authored-by: Cursor --- docs/phases/09-social/reviewed.md | 27 +++++++++++++++++++++++++++ docs/phases/reviewed.md | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/phases/09-social/reviewed.md b/docs/phases/09-social/reviewed.md index a302f9a..f74ad12 100644 --- a/docs/phases/09-social/reviewed.md +++ b/docs/phases/09-social/reviewed.md @@ -66,6 +66,33 @@ - Черта «общительный» задаёт `talkCircleBonus: 1`, но `MaxSize` клампит к 4 при уже `talkCircleMax` 4 — поле мёртвое, пока верхний потолок не ниже 4. Менять ванильный размер кружка ревью не стало. +## Срез 9. Этапы C–D — перепроверка конфликта, присутствия, речи и romance + +- **Фазы:** 44–47 +- **Проверен на:** `9972b30`, 2026-08-20 +- **Пути:** `src/HSchool.Ai/Conflict.cs`, `src/HSchool.Simulation/TalkCircleSystem.cs`, `src/HSchool.Simulation/PresenceSystem.cs`, `src/HSchool.Simulation/AffinitySystem.cs`, `src/HSchool.Simulation/MorningOpinions.cs`, `src/HSchool.People/Affinity.cs`, `src/HSchool.People/OrientationGenerator.cs`, `src/HSchool.Content/SpeechRules.cs`, `src/HSchool.Protocol/ProtocolCodec.cs`, `src/HSchool.Client/src/net/protocol.ts`, `src/HSchool.Client/src/format/talkCircle.ts`, `src/HSchool.Client/src/ui/dressRulesPanel.ts`, `src/HSchool.Client/src/ui/personCardNow.ts`, `src/HSchool.Client/src/ui/personCardConnections.ts`, `src/HSchool.Server/mods/romance/`, `src/HSchool.Server/Game/PresenceFrame.cs`, `src/HSchool.Server/Api/SpeechRulesModels.cs`, `docs/protocol.md`, `tests/HSchool.Ai.Tests/ConflictTests.cs`, `tests/HSchool.Simulation.Tests/QuarrelFightTests.cs`, `tests/HSchool.Simulation.Tests/TalkCircleTests.cs`, `tests/HSchool.Simulation.Tests/MorningOpinionsTests.cs`, `tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs`, `tests/HSchool.People.Tests/AffinityTests.cs`, `tests/HSchool.People.Tests/OrientationGeneratorTests.cs`, `tests/HSchool.Content.Tests/RomancePackTests.cs`, `tests/HSchool.AppHost.Tests/RomancePackTests.cs`, `tests/HSchool.AppHost.Tests/SpeechRulesApiTests.cs` +- **Итог:** перепроверка после `19554d4` (дальше сели A–B recheck в `TalkCircleSystem` и правки `protocol.md` у портретов). Обещания 44–47 на месте; прошлые правки (сейв ссоры, id жертвы задиры) живы. Дописано 3 теста; исправлено мнение жертвы о заступнике. `Seed.Mix` в политиковых тестах не трогали. A–B не переписывали. Хостовые `RomancePackTests` / `SpeechRulesApiTests` не гонялись. +- **Открыто:** строка локации для тем пака `romance` по-прежнему `defName`; ванильный `quarrelChance` 1 съедает `conflictChance` черт на ссоре. + +### Что проверено + +Каждый пункт «Тесты, без которых фаза не закрыта» у 44–47 имеет отдельный тест. Ссора/драка — поля черт, не `defName`; skip пустого двора бросает кружок; нужды «здоровье» нет. Кадр присутствия v9 несёт id кружка и темы, имён нет; «Сейчас» собирает ту же строку из directory. Политика речи — с завтра; POST сегодня не переписывает живой кружок. Ваниль и `example` без романтических тем; пара только 18+; учитель → ученик не получает симпатию. Отказ врагу в чужой кружок (A–B) не ломает заступника: ссора идёт через `FindOpenConflict`, не `JoinCircle`. `dotnet test` с `--filter` по `QuarrelFightTests`, `ConflictTests` и `AffinityTests` зелёный. + +### Что дописано + +- `QuarrelFightTests.Defender_AndVictim_OpinionsMoveAsAllies` +- `ConflictTests.QuarrelOpinion_IsStrongerThanRudeTalk` +- `AffinityTests.RosterJson_RoundTripsCrushesAndPartner` + +### Что исправлено + +- Конец ссоры с заступником писал жертве к заступнику тот же штраф, что к задире. Теперь жертва и защитник — союзники: мнение друг к другу растёт, к задире падает. + +### Открытое + +- Клиентский `talkTopicLabel` знает только ванильные ключи. Тема пака в локации — сырой id; в логе карточки подпись берётся из каталога. Вшивать строки пака в ванильный словарь ревью не стало. +- Ванильный `quarrelChance` уже 1, поэтому `conflictChance` задиры и вспыльчивого не двигает частоту ссоры (кламп 0…1). На драке поле живое (`fightChance` 0.1). Менять ванильный шанс ссоры ревью не стало. + ## Срез 9. Этапы C–D — конфликт, присутствие, речь и romance - **Фазы:** 44–47 diff --git a/docs/phases/reviewed.md b/docs/phases/reviewed.md index 4fac29d..29d781c 100644 --- a/docs/phases/reviewed.md +++ b/docs/phases/reviewed.md @@ -30,7 +30,7 @@ | Срез 7. Этап B–C — климат, жизнь одежды и правила | `549634b` | [07-inventory](07-inventory/reviewed.md) | | Срез 8. Сессия и темп | `7100065` | [08-session](08-session/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 | `9972b30` | [09-social](09-social/reviewed.md) | | Срез 10. Этап A — страховка | `cecd79c` | [10-craft](10-craft/reviewed.md) | | Срез 10. Этапы B–C — работник, карточка и дамп | `34f28f9` | [10-craft](10-craft/reviewed.md) | | Связность урока (вне очереди) 48, 50–53 | `efa27ab` | [off-queue](off-queue/reviewed.md) |