diff options
Diffstat (limited to 'src/LivingVillage.Kernel.Tests/OccupationStoryTests.fs')
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/OccupationStoryTests.fs | 230 |
1 files changed, 230 insertions, 0 deletions
diff --git a/src/LivingVillage.Kernel.Tests/OccupationStoryTests.fs b/src/LivingVillage.Kernel.Tests/OccupationStoryTests.fs new file mode 100644 index 0000000..80bc9a4 --- /dev/null +++ b/src/LivingVillage.Kernel.Tests/OccupationStoryTests.fs @@ -0,0 +1,230 @@ +namespace LivingVillage.Kernel.Tests + +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim + +module private StoryHarness = + + let seed = 42UL + + let worldAtTick (tick: int64) : World = + { Sim.initialWorldN 4UL 4 with Tick = tick; Time = float tick * Sim.dtSeconds } + + let stateOf (kind: Occupation.Kind) (stage: int) : Occupation.State = + { Occupation.stateOf kind with StoryStage = stage } + + let dayTick (day: int64) : int64 = day * Sim.ticksPerDay + + let withMemory (valence: float32) (tick: int64) (world: World) : World = + { world with + Npcs = + world.Npcs + |> Array.mapi (fun index npc -> + if index = 0 then + { npc with + Mind = + { npc.Mind with + Memory = + [ { Tick = tick; Kind = Chatted(Sim.playerId); Valence = valence } ] } } + else npc) } + +[<TestClass>] +type OccupationStoryTests () = + + // ---- 命名:四条轻剧情线 + 四段进度 ---- + + [<TestMethod>] + member _.EachOccupationHasItsOwnStoryLineName () = + Assert.AreEqual<string>("雨水与年成", Occupation.Story.lineNameOf Occupation.Farmer) + Assert.AreEqual<string>("水位与收成", Occupation.Story.lineNameOf Occupation.Fisher) + Assert.AreEqual<string>("新货的路子", Occupation.Story.lineNameOf Occupation.Peddler) + Assert.AreEqual<string>("庙前的争论", Occupation.Story.lineNameOf Occupation.Scholar) + + [<TestMethod>] + member _.StageNamesCoverTheThreeSegmentArc () = + Assert.AreEqual<string>("未开始", Occupation.Story.stageNameOf 0) + Assert.AreEqual<string>("已接任务", Occupation.Story.stageNameOf 1) + Assert.AreEqual<string>("走访中", Occupation.Story.stageNameOf 2) + Assert.AreEqual<string>("已收束", Occupation.Story.stageNameOf 3) + + // ---- 开线日:hash(seed ^ storySeed) mod 3 + 2,纯函数、确定性 ---- + + [<TestMethod>] + member _.StartDayIsDeterministicAndInsideTwoToFour () = + for kind in Occupation.all do + for seed in [ 42UL; 7UL; 4242UL; 999UL ] do + let first = Occupation.Story.startDayOf seed kind + let second = Occupation.Story.startDayOf seed kind + Assert.AreEqual<int64>(first, second) + Assert.IsTrue(2L <= first && first <= 4L, $"{kind} start day {first} out of range") + + [<TestMethod>] + member _.StorySeedsDifferFromDailyOccupationSeeds () = + // 开线日与每日任务抽取互不串线(种子常量两两不同) + let storySeeds = Occupation.all |> List.map Occupation.Story.storySeedOf + let taskSeeds = Occupation.all |> List.map Occupation.occupationSeedOf + Assert.AreEqual<int>(storySeeds.Length, storySeeds |> Set.ofList |> Set.count) + for story in storySeeds do + Assert.IsFalse(taskSeeds |> List.contains story, "story seed must not collide with a task seed") + + // ---- 推进门控:开线日前的对话不动剧情线 ---- + + [<TestMethod>] + member _.DialogueBeforeStartDayDoesNotAdvanceTheStory () = + for kind in Occupation.all do + let state = StoryHarness.stateOf kind 0 + let world = StoryHarness.worldAtTick (StoryHarness.dayTick 1L) + let nextState, summary = Occupation.Story.onDialogue StoryHarness.seed state world + Assert.AreEqual<int>(0, nextState.StoryStage) + Assert.AreEqual<string option>(None, summary) + + // ---- 三段式推进:接任务 → 走访 → 收束,逐段一对话 ---- + + [<TestMethod>] + member _.StoryAdvancesOneStagePerDialogueAcrossThreeDays () = + for kind in Occupation.all do + let startDay = Occupation.Story.startDayOf StoryHarness.seed kind + let mutable state = StoryHarness.stateOf kind 0 + let mutable summaries = [] + for offset in 0L .. 2L do + let world = StoryHarness.worldAtTick (StoryHarness.dayTick (startDay + offset)) + let nextState, summary = Occupation.Story.onDialogue StoryHarness.seed state world + state <- nextState + summaries <- summary :: summaries + Assert.AreEqual<int>(3, state.StoryStage) + Assert.AreEqual<int>(3, summaries.Length) + Assert.AreEqual<string option>(Some "走访了几户人家,改日再问。", summaries |> List.rev |> List.item 1) + Assert.IsTrue( + summaries + |> List.choose id + |> List.exists (fun text -> text.Contains("有了结果")), + "the third dialogue must conclude the line") + // 终态后不再推进 + let world = StoryHarness.worldAtTick (StoryHarness.dayTick (startDay + 3L)) + let nextState, summary = Occupation.Story.onDialogue StoryHarness.seed state world + Assert.AreEqual<int>(3, nextState.StoryStage) + Assert.AreEqual<string option>(None, summary) + + [<TestMethod>] + member _.LateFirstDialogueStillAdvancesOneStageAtATime () = + let kind = Occupation.Fisher + let startDay = Occupation.Story.startDayOf StoryHarness.seed kind + let state = StoryHarness.stateOf kind 0 + let world = StoryHarness.worldAtTick (StoryHarness.dayTick (startDay + 5L)) + let stage1, summary1 = Occupation.Story.onDialogue StoryHarness.seed state world + Assert.AreEqual<int>(1, stage1.StoryStage) + Assert.IsTrue(summary1.IsSome) + let stage2, _ = Occupation.Story.onDialogue StoryHarness.seed stage1 world + Assert.AreEqual<int>(2, stage2.StoryStage) + let stage3, summary3 = Occupation.Story.onDialogue StoryHarness.seed stage2 world + Assert.AreEqual<int>(3, stage3.StoryStage) + Assert.IsTrue(summary3.Value.Contains("有了结果")) + + // ---- 关系均值:与 relationMatrix 同口径的衰减 valence,仅计认识玩家的人 ---- + + [<TestMethod>] + member _.RelationMeanAveragesOnlyAcquaintedNpcs () = + let baseWorld = StoryHarness.worldAtTick 0L + // 单个熟识村民:Helpful 口径 0.5 + let friendly = baseWorld |> StoryHarness.withMemory 0.5f 0L + Assert.AreEqual(0.5f, Occupation.Story.relationMeanToPlayer friendly, 1e-5f) + // 无记忆 = 0(中性,不取负) + Assert.AreEqual(0.0f, Occupation.Story.relationMeanToPlayer baseWorld, 1e-5f) + // 两名熟识村民(0.5 与 -0.4)均值 0.05 + let mixed = + { friendly with + Npcs = + friendly.Npcs + |> Array.mapi (fun index npc -> + if index = 1 then + { npc with + Mind = { npc.Mind with Memory = [ { Tick = 0L; Kind = Chatted(Sim.playerId); Valence = -0.4f } ] } } + else npc) } + Assert.AreEqual(0.05f, Occupation.Story.relationMeanToPlayer mixed, 1e-5f) + + [<TestMethod>] + member _.RelationMeanDecaysWithTheRelationHalfLife () = + let baseWorld = StoryHarness.worldAtTick Sim.ticksPerDay + let decayed = baseWorld |> StoryHarness.withMemory 0.5f 0L + // 半衰期 = 1 模拟日:一天后 0.5 → 0.25 + Assert.AreEqual(0.25f, Occupation.Story.relationMeanToPlayer decayed, 1e-5f) + + [<TestMethod>] + member _.DialogueMemoriesCountTowardTheRelationMean () = + let baseWorld = StoryHarness.worldAtTick 0L + let dialogued = + { baseWorld with + Npcs = + baseWorld.Npcs + |> Array.mapi (fun index npc -> + if index = 0 then + { npc with + Mind = + { npc.Mind with + Memory = + [ { Tick = 0L + Kind = Dialogue(Sim.playerId, SmallTalk, Friendly) + Valence = 0.4f } ] } } + else npc) } + Assert.AreEqual(0.4f, Occupation.Story.relationMeanToPlayer dialogued, 1e-5f) + + // ---- 三种结局:由当时关系均值确定性决定(三方对照)---- + + [<TestMethod>] + member _.EndingThresholdsSeparateThreeOutcomes () = + Assert.AreEqual<Occupation.Story.Ending>(Occupation.Story.Warm, Occupation.Story.endingOf 0.5f) + Assert.AreEqual<Occupation.Story.Ending>(Occupation.Story.Warm, Occupation.Story.endingOf 0.3f) + Assert.AreEqual<Occupation.Story.Ending>(Occupation.Story.Plain, Occupation.Story.endingOf 0.1f) + Assert.AreEqual<Occupation.Story.Ending>(Occupation.Story.Plain, Occupation.Story.endingOf 0.0f) + Assert.AreEqual<Occupation.Story.Ending>(Occupation.Story.Distant, Occupation.Story.endingOf -0.1f) + + [<TestMethod>] + member _.EachOccupationHasThreeDistinctEndingTexts () = + for kind in Occupation.all do + let warm = Occupation.Story.endingTextOf kind Occupation.Story.Warm + let plain = Occupation.Story.endingTextOf kind Occupation.Story.Plain + let distant = Occupation.Story.endingTextOf kind Occupation.Story.Distant + Assert.AreNotEqual(warm, plain, $"{kind} warm/plain must differ") + Assert.AreNotEqual(plain, distant, $"{kind} plain/distant must differ") + Assert.AreNotEqual(warm, distant, $"{kind} warm/distant must differ") + Assert.IsTrue(warm.Contains(Occupation.Story.lineNameOf kind)) + // 两次调用逐字节一致 + Assert.AreEqual<string>(warm, Occupation.Story.endingTextOf kind Occupation.Story.Warm) + + [<TestMethod>] + member _.ConcludingStageWritesTheEndingDeterminedByTheRelationMean () = + for kind in Occupation.all do + let startDay = Occupation.Story.startDayOf StoryHarness.seed kind + let conclusionTick = StoryHarness.dayTick (startDay + 2L) + // 热络结局:收束当天熟识村民 Helpful 口径 0.5 + let warmWorld = + StoryHarness.worldAtTick conclusionTick + |> StoryHarness.withMemory 0.5f conclusionTick + let state2 = StoryHarness.stateOf kind 2 + let warmState, warmSummary = Occupation.Story.onDialogue StoryHarness.seed state2 warmWorld + Assert.AreEqual<int>(3, warmState.StoryStage) + Assert.AreEqual<string>(Occupation.Story.endingTextOf kind Occupation.Story.Warm, warmSummary.Value) + // 淡漠结局:收束当天熟识村民 Offended 口径 -0.4 + let distantWorld = + StoryHarness.worldAtTick conclusionTick + |> StoryHarness.withMemory -0.4f conclusionTick + let distantState, distantSummary = Occupation.Story.onDialogue StoryHarness.seed state2 distantWorld + Assert.AreEqual<int>(3, distantState.StoryStage) + Assert.AreEqual<string>(Occupation.Story.endingTextOf kind Occupation.Story.Distant, distantSummary.Value) + // 平常结局:无关系记忆 → 均值 0 + let plainWorld = StoryHarness.worldAtTick conclusionTick + let plainState, plainSummary = Occupation.Story.onDialogue StoryHarness.seed state2 plainWorld + Assert.AreEqual<int>(3, plainState.StoryStage) + Assert.AreEqual<string>(Occupation.Story.endingTextOf kind Occupation.Story.Plain, plainSummary.Value) + + [<TestMethod>] + member _.OnDialogueIsDeterministicForTheSameInputs () = + let kind = Occupation.Fisher + let startDay = Occupation.Story.startDayOf StoryHarness.seed kind + let world = StoryHarness.worldAtTick (StoryHarness.dayTick startDay) |> StoryHarness.withMemory 0.5f 0L + let state = StoryHarness.stateOf kind 0 + let firstState, firstSummary = Occupation.Story.onDialogue StoryHarness.seed state world + let secondState, secondSummary = Occupation.Story.onDialogue StoryHarness.seed state world + Assert.AreEqual<Occupation.State>(firstState, secondState) + Assert.AreEqual<string option>(firstSummary, secondSummary) |
