summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Kernel.Tests
diff options
context:
space:
mode:
Diffstat (limited to 'src/LivingVillage.Kernel.Tests')
-rw-r--r--src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj2
-rw-r--r--src/LivingVillage.Kernel.Tests/OccupationBiasTests.fs146
-rw-r--r--src/LivingVillage.Kernel.Tests/OccupationStoryTests.fs281
3 files changed, 429 insertions, 0 deletions
diff --git a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj
index dea8314..1a36ea7 100644
--- a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj
+++ b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj
@@ -14,6 +14,8 @@
<Compile Include="M6aTests.fs" />
<Compile Include="DailyTaskTests.fs" />
<Compile Include="TradeBiasTests.fs" />
+ <Compile Include="OccupationBiasTests.fs" />
+ <Compile Include="OccupationStoryTests.fs" />
<Compile Include="PerformanceTests.fs" />
</ItemGroup>
diff --git a/src/LivingVillage.Kernel.Tests/OccupationBiasTests.fs b/src/LivingVillage.Kernel.Tests/OccupationBiasTests.fs
new file mode 100644
index 0000000..76e02f3
--- /dev/null
+++ b/src/LivingVillage.Kernel.Tests/OccupationBiasTests.fs
@@ -0,0 +1,146 @@
+namespace LivingVillage.Kernel.Tests
+
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Kernel
+open LivingVillage.Kernel.Sim
+
+module private BiasHarness =
+
+ /// 覆盖 responseFor 全部分支的两个人格样本:
+ /// 外向+诚实(E>=0.5, H>=0.4)与 内向+不诚实(E<0.5, H<0.4)。
+ let outgoing = { Drive = 0.5f; Aggression = 0.2f; Extraversion = 0.8f; Honesty = 0.9f; Greed = 0.3f }
+ let withdrawn = { Drive = 0.5f; Aggression = 0.2f; Extraversion = 0.2f; Honesty = 0.1f; Greed = 0.3f }
+ let grumpy = { Drive = 0.5f; Aggression = 0.8f; Extraversion = 0.2f; Honesty = 0.1f; Greed = 0.3f }
+
+ let atNpc (id: int) (world: World) : World =
+ let npc = world.Npcs.[id]
+ { world with Avatar = { world.Avatar with Pos = npc.Pos } }
+
+[<TestClass>]
+type OccupationBiasTests () =
+
+ // ---- 无职业 = 既有 responseFor 输出逐字节不变 ----
+
+ [<TestMethod>]
+ member _.NoOccupationReproducesTheHistoricResponsesForEveryIntent () =
+ for intent in [ SmallTalk; AskHelp; OfferTrade; Joke; Apologize; Provoke ] do
+ for personality in [ BiasHarness.outgoing; BiasHarness.withdrawn; BiasHarness.grumpy ] do
+ Assert.IsTrue(
+ Sim.responseFor intent personality = Occupation.biasResponse None intent personality,
+ $"no-occupation bias must be identity for {intent}")
+
+ // ---- 农夫 AskHelp:Refused → Helpful(求助更友好)----
+
+ [<TestMethod>]
+ member _.FarmerAskHelpTurnsRefusalIntoHelpfulness () =
+ // 不诚实村民对常人 Refused,对农夫给 Helpful
+ Assert.AreEqual<DialogueResponse>(Refused, Sim.responseFor AskHelp BiasHarness.withdrawn)
+ Assert.AreEqual<DialogueResponse>(Helpful, Occupation.biasResponse (Some Occupation.Farmer) AskHelp BiasHarness.withdrawn)
+ // 本就 Helpful 的路径不变
+ Assert.AreEqual<DialogueResponse>(Helpful, Sim.responseFor AskHelp BiasHarness.outgoing)
+ Assert.AreEqual<DialogueResponse>(Helpful, Occupation.biasResponse (Some Occupation.Farmer) AskHelp BiasHarness.outgoing)
+
+ // ---- 渔夫 SmallTalk:Reserved → Friendly(聊天气鱼汛,冷场变热络)----
+
+ [<TestMethod>]
+ member _.FisherSmallTalkWarmsUpAReservedVillager () =
+ Assert.AreEqual<DialogueResponse>(Reserved, Sim.responseFor SmallTalk BiasHarness.withdrawn)
+ Assert.AreEqual<DialogueResponse>(Friendly, Occupation.biasResponse (Some Occupation.Fisher) SmallTalk BiasHarness.withdrawn)
+ Assert.AreEqual<DialogueResponse>(Friendly, Sim.responseFor SmallTalk BiasHarness.outgoing)
+ Assert.AreEqual<DialogueResponse>(Friendly, Occupation.biasResponse (Some Occupation.Fisher) SmallTalk BiasHarness.outgoing)
+
+ // ---- 货郎 SmallTalk:Reserved → Bargaining(三句不离买卖,更易进讨价还价)----
+
+ [<TestMethod>]
+ member _.PeddlerSmallTalkSlidesIntoBargaining () =
+ Assert.AreEqual<DialogueResponse>(Reserved, Sim.responseFor SmallTalk BiasHarness.withdrawn)
+ Assert.AreEqual<DialogueResponse>(Bargaining, Occupation.biasResponse (Some Occupation.Peddler) SmallTalk BiasHarness.withdrawn)
+ // 外向村民本就 Friendly,不被货郎偏差改写
+ Assert.AreEqual<DialogueResponse>(Friendly, Occupation.biasResponse (Some Occupation.Peddler) SmallTalk BiasHarness.outgoing)
+
+ // ---- 书生 Joke:完全覆盖为 Reserved(与 personality 相反的确定性偏差)----
+ // 语义说明:书生讲笑话走「完全覆盖」——无论村民性格如何,回应一律 Reserved。
+ // 刻意不做概率偏向(禁随机);反差感即设计意图(design §5)。
+
+ [<TestMethod>]
+ member _.ScholarJokeIsAlwaysReservedRegardlessOfPersonality () =
+ Assert.AreEqual<DialogueResponse>(Amused, Sim.responseFor Joke BiasHarness.outgoing)
+ Assert.AreEqual<DialogueResponse>(Reserved, Sim.responseFor Joke BiasHarness.withdrawn)
+ for personality in [ BiasHarness.outgoing; BiasHarness.withdrawn; BiasHarness.grumpy ] do
+ Assert.AreEqual<DialogueResponse>(
+ Reserved,
+ Occupation.biasResponse (Some Occupation.Scholar) Joke personality)
+
+ // ---- 偏差只作用于各自的签名意图,其余意图原样穿透 ----
+
+ [<TestMethod>]
+ member _.BiasOnlyAppliesToTheSignatureIntents () =
+ // 农夫:仅 AskHelp 受偏差
+ for intent in [ SmallTalk; OfferTrade; Joke; Apologize; Provoke ] do
+ for personality in [ BiasHarness.outgoing; BiasHarness.withdrawn ] do
+ Assert.AreEqual<DialogueResponse>(
+ Sim.responseFor intent personality,
+ Occupation.biasResponse (Some Occupation.Farmer) intent personality)
+ // 渔夫:仅 SmallTalk 受偏差
+ for intent in [ AskHelp; OfferTrade; Joke; Apologize; Provoke ] do
+ for personality in [ BiasHarness.outgoing; BiasHarness.withdrawn ] do
+ Assert.AreEqual<DialogueResponse>(
+ Sim.responseFor intent personality,
+ Occupation.biasResponse (Some Occupation.Fisher) intent personality)
+ // 货郎:仅 SmallTalk 受偏差
+ for intent in [ AskHelp; OfferTrade; Joke; Apologize; Provoke ] do
+ for personality in [ BiasHarness.outgoing; BiasHarness.withdrawn ] do
+ Assert.AreEqual<DialogueResponse>(
+ Sim.responseFor intent personality,
+ Occupation.biasResponse (Some Occupation.Peddler) intent personality)
+ // 书生:仅 Joke 受偏差
+ for intent in [ SmallTalk; AskHelp; OfferTrade; Apologize; Provoke ] do
+ for personality in [ BiasHarness.outgoing; BiasHarness.withdrawn ] do
+ Assert.AreEqual<DialogueResponse>(
+ Sim.responseFor intent personality,
+ Occupation.biasResponse (Some Occupation.Scholar) intent personality)
+
+ // ---- 确定性:同输入两次调用结果一致 ----
+
+ [<TestMethod>]
+ member _.BiasIsDeterministicForTheSameInputs () =
+ for kind in Occupation.all do
+ for intent in [ SmallTalk; AskHelp; OfferTrade; Joke; Apologize; Provoke ] do
+ for personality in [ BiasHarness.outgoing; BiasHarness.withdrawn; BiasHarness.grumpy ] do
+ let first = Occupation.biasResponse (Some kind) intent personality
+ let second = Occupation.biasResponse (Some kind) intent personality
+ Assert.AreEqual<DialogueResponse>(first, second)
+
+ // ---- 与 chooseDialogueWith 复合:偏差写进 DialogueOutcome/记忆/年鉴 ----
+
+ [<TestMethod>]
+ member _.ChooseDialogueWithIdentityMatchesTheHistoricPath () =
+ let world = Sim.initialWorldN 42UL 4 |> BiasHarness.atNpc 0
+ let npc = world.Npcs.[0]
+ let identityOutcome =
+ match Sim.chooseDialogueWith Sim.responseFor npc.Id AskHelp world with
+ | DialogueSucceeded (outcome, _) -> outcome
+ | DialogueRejected (failure, _) -> Assert.Fail($"rejected: {failure}"); Unchecked.defaultof<DialogueOutcome>
+ Assert.AreEqual<DialogueResponse>(Sim.responseFor AskHelp npc.Mind.Personality, identityOutcome.Response)
+
+ [<TestMethod>]
+ member _.ChooseDialogueWithBiasRecordsTheBiasedResponse () =
+ let world = Sim.initialWorldN 42UL 4 |> BiasHarness.atNpc 0
+ let npc = world.Npcs.[0]
+ let biasedRespond = Occupation.biasResponse (Some Occupation.Farmer)
+ let response, outcomeWorld =
+ match Sim.chooseDialogueWith biasedRespond npc.Id AskHelp world with
+ | DialogueSucceeded (outcome, next) ->
+ Assert.AreEqual<DialogueResponse>(
+ Occupation.biasResponse (Some Occupation.Farmer) AskHelp npc.Mind.Personality,
+ outcome.Response)
+ outcome.Response, next
+ | DialogueRejected (failure, _) -> Assert.Fail($"rejected: {failure}"); Unchecked.defaultof<_>, Unchecked.defaultof<World>
+ // 目标村民记忆里记录的是偏差后的回应
+ let remembered =
+ outcomeWorld.Npcs.[0].Mind.Memory
+ |> List.tryPick (fun memory ->
+ match memory.Kind with
+ | Dialogue (_, _, response) -> Some response
+ | _ -> None)
+ Assert.AreEqual<DialogueResponse option>(Some response, remembered)
diff --git a/src/LivingVillage.Kernel.Tests/OccupationStoryTests.fs b/src/LivingVillage.Kernel.Tests/OccupationStoryTests.fs
new file mode 100644
index 0000000..d204ca6
--- /dev/null
+++ b/src/LivingVillage.Kernel.Tests/OccupationStoryTests.fs
@@ -0,0 +1,281 @@
+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) }
+
+[<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)
+
+ // ---- §3 身份条目 + 存档持久化 ----
+
+ [<TestMethod>]
+ member _.IdentitySummariesUsePlainChronicleStyle () =
+ Assert.AreEqual<string>("以耕田为生", Occupation.Story.identitySummaryOf Occupation.Farmer)
+ Assert.AreEqual<string>("以打渔为生", Occupation.Story.identitySummaryOf Occupation.Fisher)
+ Assert.AreEqual<string>("以贩货为生", Occupation.Story.identitySummaryOf Occupation.Peddler)
+ Assert.AreEqual<string>("以读书为生", Occupation.Story.identitySummaryOf Occupation.Scholar)
+
+ [<TestMethod>]
+ 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<string>(text, WorldSave.saveWith (Some (StoryHarness.stateOf kind 3)) loaded)
+ | Error message -> Assert.Fail(message)
+
+ [<TestMethod>]
+ 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<int>(2, loaded.StoryStage)
+ | Ok (_, None) -> Assert.Fail("v3 occupation state expected")
+ | Error message -> Assert.Fail(message))
+
+ [<TestMethod>]
+ 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|"))