Review slice 9 C-D: persist clash opinions and the bully's victim.
FinishConflict never set RosterTalkDirty, so people.json skipped quarrel shifts until some other save — the same seam as the A-B talk-circle fix. First BullyVictimId write stayed in RAM only. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -37,3 +37,31 @@
|
|||||||
### Открытое
|
### Открытое
|
||||||
|
|
||||||
- Черта «общительный» задаёт `talkCircleBonus: 1`, но `MaxSize` клампит к 4 при уже `talkCircleMax` 4 — поле мёртвое, пока верхний потолок не ниже 4. Менять ванильный размер кружка ревью не стало.
|
- Черта «общительный» задаёт `talkCircleBonus: 1`, но `MaxSize` клампит к 4 при уже `talkCircleMax` 4 — поле мёртвое, пока верхний потолок не ниже 4. Менять ванильный размер кружка ревью не стало.
|
||||||
|
|
||||||
|
## Срез 9. Этапы C–D — конфликт, присутствие, речь и romance
|
||||||
|
|
||||||
|
- **Фазы:** 44–47
|
||||||
|
- **Проверен на:** `PLACEHOLDER`, 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`
|
||||||
|
- **Итог:** обещания 44–47 на месте; дописано 4 теста; исправлено 2 расхождения с тем же швом, что у 41–43 (сейв после ссоры; id жертвы задиры). `Seed.Mix` в политиковых тестах не трогали. Фазу 60 не закрывали.
|
||||||
|
- **Открыто:** строка локации для тем пака `romance` показывает `defName`, не локаль пака.
|
||||||
|
|
||||||
|
### Что проверено
|
||||||
|
|
||||||
|
Каждый пункт «Тесты, без которых фаза не закрыта» у 44–47 имеет отдельный тест. Ссора/драка — поля черт, не `defName`; skip пустого двора бросает кружок, а не тащит драку через выходные; нужды «здоровье» нет. Кадр присутствия v9 несёт id кружка и темы, имён нет; «Сейчас» собирает ту же строку из directory. Политика речи — с завтра, POST сегодня не переписывает живой кружок. Ваниль и `example` без романтических тем; пара только 18+; учитель → ученик не получает симпатию. Хостовые `RomancePackTests` / `SpeechRulesApiTests` в этом проходе не гонялись (политика тестирования: не AppHost, сервер пользователя не трогали). `dotnet test` с `--filter` по `QuarrelFightTests`, `RosterJson_RoundTripsBullyVictimId` и `PupilTalkWithTeacherCrush` зелёный.
|
||||||
|
|
||||||
|
### Что дописано
|
||||||
|
|
||||||
|
- `QuarrelFightTests.QuarrelFinish_MarksTickPeopleChangedSoSaveWritesOpinions`
|
||||||
|
- `QuarrelFightTests.BullyPickingAVictim_DirtiesRosterSoSaveKeepsTheId`
|
||||||
|
- `OpinionGeneratorTests.RosterJson_RoundTripsBullyVictimId`
|
||||||
|
- `AffinityTests.PupilTalkWithTeacherCrush_WritesRebuffAndDropsOpinion`
|
||||||
|
|
||||||
|
### Что исправлено
|
||||||
|
|
||||||
|
- Конец ссоры, драки и извинения писал мнения, но не ставил `RosterTalkDirty` — воркер не сохранял `people.json` до следующего чужого сдвига, как с кружком до ревью A–B.
|
||||||
|
- Первая запись `BullyVictimId` оставалась только в памяти: после перезагрузки задира бросал кубик заново.
|
||||||
|
|
||||||
|
### Открытое
|
||||||
|
|
||||||
|
- Клиентский `talkTopicLabel` знает только ванильные ключи. Тема пака в локации — сырой id; в логе карточки подпись берётся из каталога. Вшивать строки пака в ванильный словарь ревью не стало.
|
||||||
|
|||||||
@@ -32,4 +32,5 @@
|
|||||||
| Срез 7. Этап B–C — климат, жизнь одежды и правила | `5dcdee6` | [07-inventory](07-inventory/reviewed.md) |
|
| Срез 7. Этап B–C — климат, жизнь одежды и правила | `5dcdee6` | [07-inventory](07-inventory/reviewed.md) |
|
||||||
| Срез 8. Сессия и темп | `1c3ae9a` | [08-session](08-session/reviewed.md) |
|
| Срез 8. Сессия и темп | `1c3ae9a` | [08-session](08-session/reviewed.md) |
|
||||||
| Срез 9. Этапы A–B — мнения и разговоры | `b38d0bb` | [09-social](09-social/reviewed.md) |
|
| Срез 9. Этапы A–B — мнения и разговоры | `b38d0bb` | [09-social](09-social/reviewed.md) |
|
||||||
|
| Срез 9. Этапы C–D — конфликт, присутствие, речь и romance | `PLACEHOLDER` | [09-social](09-social/reviewed.md) |
|
||||||
| Срез 10. Этапы B–C — работник, карточка и дамп | `f4f726a` | [10-craft](10-craft/reviewed.md) |
|
| Срез 10. Этапы B–C — работник, карточка и дамп | `f4f726a` | [10-craft](10-craft/reviewed.md) |
|
||||||
|
|||||||
@@ -780,11 +780,16 @@ internal static class TalkCircleSystem
|
|||||||
{
|
{
|
||||||
var rules = school.Catalog!.BehaviorRules;
|
var rules = school.Catalog!.BehaviorRules;
|
||||||
var day = DateOnly.FromDateTime(school.Clock.Time).DayNumber;
|
var day = DateOnly.FromDateTime(school.Clock.Time).DayNumber;
|
||||||
|
var previousVictim = initiator.BullyVictimId;
|
||||||
var remembered = Conflict.ResolveVictim(
|
var remembered = Conflict.ResolveVictim(
|
||||||
initiator,
|
initiator,
|
||||||
school.Roster!.People,
|
school.Roster!.People,
|
||||||
school.Catalog,
|
school.Catalog,
|
||||||
Seed.Mix(school.PeopleSeed, initiator.Id, day, Seed.ConflictSalt + 1));
|
Seed.Mix(school.PeopleSeed, initiator.Id, day, Seed.ConflictSalt + 1));
|
||||||
|
if (!string.Equals(previousVictim, initiator.BullyVictimId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
school.RosterTalkDirty = true;
|
||||||
|
}
|
||||||
var idle = GatherCandidates(school, initiator.Id, nodeId, requirePhone: false, studentsOnly: true)
|
var idle = GatherCandidates(school, initiator.Id, nodeId, requirePhone: false, studentsOnly: true)
|
||||||
.Where(candidate => candidate.IsIdle)
|
.Where(candidate => candidate.IsIdle)
|
||||||
.Select(candidate => candidate.Id)
|
.Select(candidate => candidate.Id)
|
||||||
@@ -998,6 +1003,7 @@ internal static class TalkCircleSystem
|
|||||||
}
|
}
|
||||||
|
|
||||||
school.TalkCirclesById.Remove(circle.Id);
|
school.TalkCirclesById.Remove(circle.Id);
|
||||||
|
school.RosterTalkDirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ApplyClash(
|
private static void ApplyClash(
|
||||||
|
|||||||
@@ -104,6 +104,24 @@ public class AffinityTests
|
|||||||
Assert.Equal(third.Id, left.Bonds.PartnerId);
|
Assert.Equal(third.Id, left.Bonds.PartnerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PupilTalkWithTeacherCrush_WritesRebuffAndDropsOpinion()
|
||||||
|
{
|
||||||
|
var catalog = Fixtures.RomanceCatalog();
|
||||||
|
var teacher = Staff("t", female: false, "Bisexual");
|
||||||
|
var pupil = Pupil("p", year: 8, age: 14, female: true, "Bisexual");
|
||||||
|
SetOpinion(pupil, teacher.Id, 80);
|
||||||
|
var roster = RosterOf(teacher, pupil, classYear: 8);
|
||||||
|
Affinity.Refresh(catalog, roster, AsOf);
|
||||||
|
Assert.Contains(teacher.Id, pupil.Bonds!.Crushes);
|
||||||
|
var before = OpinionStore.Get(pupil, teacher.Id) ?? 0;
|
||||||
|
|
||||||
|
var events = Affinity.RebuffTalk(catalog, roster, [pupil.Id, teacher.Id], AsOf);
|
||||||
|
|
||||||
|
Assert.Contains(events, row => row.Type == Affinity.Rebuff && row.PersonId == pupil.Id);
|
||||||
|
Assert.True((OpinionStore.Get(pupil, teacher.Id) ?? 0) < before);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AdjacentYearStudents_CanHaveSympathy()
|
public void AdjacentYearStudents_CanHaveSympathy()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -66,6 +66,22 @@ public class OpinionGeneratorTests
|
|||||||
Assert.Equal(before, loadedChild.Opinions[parentId]);
|
Assert.Equal(before, loadedChild.Opinions[parentId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RosterJson_RoundTripsBullyVictimId()
|
||||||
|
{
|
||||||
|
var roster = Fixtures.Generate(Fixtures.Classrooms(4));
|
||||||
|
var bully = roster.People.First(person => person.IsStudent && !person.IsParent);
|
||||||
|
var victim = roster.People.First(person =>
|
||||||
|
person.IsStudent && !person.Id.Equals(bully.Id, StringComparison.Ordinal));
|
||||||
|
bully.BullyVictimId = victim.Id;
|
||||||
|
|
||||||
|
var json = RosterJson.Serialize(RosterDocument.From(1, roster));
|
||||||
|
Assert.Contains("\"bullyVictimId\"", json, StringComparison.Ordinal);
|
||||||
|
var loaded = RosterJson.Parse(json).ToRoster();
|
||||||
|
var loadedBully = loaded.People.First(person => person.Id.Equals(bully.Id, StringComparison.Ordinal));
|
||||||
|
Assert.Equal(victim.Id, loadedBully.BullyVictimId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SaveWithoutOpinionsField_SeedsFamilyOnLoad()
|
public void SaveWithoutOpinionsField_SeedsFamilyOnLoad()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -50,6 +50,51 @@ public class QuarrelFightTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void QuarrelFinish_MarksTickPeopleChangedSoSaveWritesOpinions()
|
||||||
|
{
|
||||||
|
var (school, first, second) = TwoPupilsOnBreak();
|
||||||
|
using (school)
|
||||||
|
{
|
||||||
|
const int baseline = -55;
|
||||||
|
OpinionStore.Set(first, second.Id, baseline);
|
||||||
|
OpinionStore.Set(second, first.Id, baseline);
|
||||||
|
PlaceAt(school, first.Id, "yard");
|
||||||
|
PlaceAt(school, second.Id, "yard");
|
||||||
|
Assert.True(school.TryStartAction(first.Id, TalkActions.QuarrelYard));
|
||||||
|
|
||||||
|
var peopleChanged = false;
|
||||||
|
for (var i = 0; i < 12 && ActivityOf(school, first.Id) == TalkActions.QuarrelYard; i++)
|
||||||
|
{
|
||||||
|
peopleChanged |= school.Tick(0.2d, 5d);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(peopleChanged);
|
||||||
|
Assert.True((OpinionStore.Get(first, second.Id) ?? 0) < baseline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BullyPickingAVictim_DirtiesRosterSoSaveKeepsTheId()
|
||||||
|
{
|
||||||
|
var (school, first, second) = TwoPupilsOnBreak();
|
||||||
|
using (school)
|
||||||
|
{
|
||||||
|
GiveTrait(school, first.Id, "Bully");
|
||||||
|
var bully = school.Roster!.People.First(person => person.Id == first.Id);
|
||||||
|
var other = school.Roster.People.First(person => person.Id == second.Id);
|
||||||
|
OpinionStore.Set(bully, other.Id, -55);
|
||||||
|
OpinionStore.Set(other, bully.Id, -55);
|
||||||
|
PlaceAt(school, bully.Id, "yard");
|
||||||
|
PlaceAt(school, other.Id, "yard");
|
||||||
|
school.RosterTalkDirty = false;
|
||||||
|
|
||||||
|
Assert.True(school.TryStartAction(bully.Id, TalkActions.QuarrelYard));
|
||||||
|
Assert.NotNull(bully.BullyVictimId);
|
||||||
|
Assert.True(school.RosterTalkDirty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Apology_RaisesOpinion_ButNotAbovePreQuarrel()
|
public void Apology_RaisesOpinion_ButNotAbovePreQuarrel()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user