namespace LivingVillage.Kernel.Tests open Microsoft.VisualStudio.TestTools.UnitTesting open LivingVillage.Kernel open LivingVillage.Kernel.Sim module private StoryHarness = let seed = 42UL let withTempSave (action: string -> unit) = let id = System.Guid.NewGuid().ToString("N") let path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"living-village-p31-{id}.save") try action path finally if System.IO.File.Exists path then System.IO.File.Delete path 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) } [] type OccupationStoryTests () = // ---- 命名:四条轻剧情线 + 四段进度 ---- [] member _.EachOccupationHasItsOwnStoryLineName () = Assert.AreEqual("雨水与年成", Occupation.Story.lineNameOf Occupation.Farmer) Assert.AreEqual("水位与收成", Occupation.Story.lineNameOf Occupation.Fisher) Assert.AreEqual("新货的路子", Occupation.Story.lineNameOf Occupation.Peddler) Assert.AreEqual("庙前的争论", Occupation.Story.lineNameOf Occupation.Scholar) [] member _.StageNamesCoverTheThreeSegmentArc () = Assert.AreEqual("未开始", Occupation.Story.stageNameOf 0) Assert.AreEqual("已接任务", Occupation.Story.stageNameOf 1) Assert.AreEqual("走访中", Occupation.Story.stageNameOf 2) Assert.AreEqual("已收束", Occupation.Story.stageNameOf 3) // ---- 开线日:hash(seed ^ storySeed) mod 3 + 2,纯函数、确定性 ---- [] 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(first, second) Assert.IsTrue(2L <= first && first <= 4L, $"{kind} start day {first} out of range") [] member _.StorySeedsDifferFromDailyOccupationSeeds () = // 开线日与每日任务抽取互不串线(种子常量两两不同) let storySeeds = Occupation.all |> List.map Occupation.Story.storySeedOf let taskSeeds = Occupation.all |> List.map Occupation.occupationSeedOf Assert.AreEqual(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") // ---- 推进门控:开线日前的对话不动剧情线 ---- [] 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(0, nextState.StoryStage) Assert.AreEqual(None, summary) // ---- 三段式推进:接任务 → 走访 → 收束,逐段一对话 ---- [] 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(3, state.StoryStage) Assert.AreEqual(3, summaries.Length) Assert.AreEqual(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(3, nextState.StoryStage) Assert.AreEqual(None, summary) [] 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(1, stage1.StoryStage) Assert.IsTrue(summary1.IsSome) let stage2, _ = Occupation.Story.onDialogue StoryHarness.seed stage1 world Assert.AreEqual(2, stage2.StoryStage) let stage3, summary3 = Occupation.Story.onDialogue StoryHarness.seed stage2 world Assert.AreEqual(3, stage3.StoryStage) Assert.IsTrue(summary3.Value.Contains("有了结果")) // ---- 关系均值:与 relationMatrix 同口径的衰减 valence,仅计认识玩家的人 ---- [] 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) [] 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) [] 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) // ---- 三种结局:由当时关系均值确定性决定(三方对照)---- [] member _.EndingThresholdsSeparateThreeOutcomes () = Assert.AreEqual(Occupation.Story.Warm, Occupation.Story.endingOf 0.5f) Assert.AreEqual(Occupation.Story.Warm, Occupation.Story.endingOf 0.3f) Assert.AreEqual(Occupation.Story.Plain, Occupation.Story.endingOf 0.1f) Assert.AreEqual(Occupation.Story.Plain, Occupation.Story.endingOf 0.0f) Assert.AreEqual(Occupation.Story.Distant, Occupation.Story.endingOf -0.1f) [] 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(warm, Occupation.Story.endingTextOf kind Occupation.Story.Warm) [] 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(3, warmState.StoryStage) Assert.AreEqual(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(3, distantState.StoryStage) Assert.AreEqual(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(3, plainState.StoryStage) Assert.AreEqual(Occupation.Story.endingTextOf kind Occupation.Story.Plain, plainSummary.Value) [] 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(firstState, secondState) Assert.AreEqual(firstSummary, secondSummary) // ---- §3 身份条目 + 存档持久化 ---- [] member _.IdentitySummariesUsePlainChronicleStyle () = Assert.AreEqual("以耕田为生", Occupation.Story.identitySummaryOf Occupation.Farmer) Assert.AreEqual("以打渔为生", Occupation.Story.identitySummaryOf Occupation.Fisher) Assert.AreEqual("以贩货为生", Occupation.Story.identitySummaryOf Occupation.Peddler) Assert.AreEqual("以读书为生", Occupation.Story.identitySummaryOf Occupation.Scholar) [] member _.StoryAnnalRoundTripsThroughTheV3Save () = let kind = Occupation.Fisher let startDay = Occupation.Story.startDayOf StoryHarness.seed kind let world = StoryHarness.worldAtTick (StoryHarness.dayTick (startDay + 2L)) let _, summary = Occupation.Story.onDialogue StoryHarness.seed (StoryHarness.stateOf kind 2) world let entry = { Tick = world.Tick; Kind = StoryAnnal; Summary = summary.Value } let worldWithAnnal = Sim.appendAnnal entry world let text = WorldSave.saveWith (Some (StoryHarness.stateOf kind 3)) worldWithAnnal match WorldSave.load text with | Ok loaded -> Assert.AreEqual(worldWithAnnal.Annals, loaded.Annals) // 存 → 读 → 存 逐字节一致 Assert.AreEqual(text, WorldSave.saveWith (Some (StoryHarness.stateOf kind 3)) loaded) | Error message -> Assert.Fail(message) [] member _.StoryStageRestoresFromTheV3OccupationSegment () = StoryHarness.withTempSave (fun path -> let state = StoryHarness.stateOf Occupation.Peddler 2 WorldSave.saveToFileWith (Some state) path (StoryHarness.worldAtTick 0L) match WorldSave.loadFromFileWith path with | Ok (_, Some loaded) -> Assert.AreEqual(2, loaded.StoryStage) | Ok (_, None) -> Assert.Fail("v3 occupation state expected") | Error message -> Assert.Fail(message)) [] member _.NoOccupationSaveStaysFreeOfStoryTokens () = // 无职业 v2 存档永不产生 story token,v1/v2 字节口径不变 let world = StoryHarness.worldAtTick 0L let text = WorldSave.save world Assert.IsFalse(text.Contains("story")) Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V2|"))