diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 16:24:19 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 16:24:19 +0800 |
| commit | 0539bba17c260f6a4b51d3cefddac0ff448c19bc (patch) | |
| tree | 49fbc9861f79206738a4614d1172535652edb2f4 | |
| parent | 6c2f180cc7c8315e32d88cbe5d44928d495fe473 (diff) | |
| download | living-village-0539bba17c260f6a4b51d3cefddac0ff448c19bc.tar.gz | |
feat(kernel): P31 step2 story stage state machine
- Occupation.Story: three-segment arc per occupation (accept task -> visit
households -> conclude), driven one stage per player dialogue with day
gates derived from the same splitmix pattern as the daily task pool
(start day = hash(seed ^ storySeed, day 0) mod 3 + 2, range [2,4]).
- relationMeanToPlayer: decayed valence mean over acquainted NPCs only
(same decay formula as Sim.relationMatrix); no acquaintances = 0.
- Three endings (warm/plain/distant) chosen by deterministic thresholds
(>=0.3 / >=0.0 / <0); ending text is fixed at the concluding dialogue.
- onDialogue returns (state, summary text option) for the caller to write
into the yearbook; StoryStage itself is unchanged (0..3) and already
roundtrips through the existing v3 occupation segment.
- 14 new kernel tests (101 -> 115); performance digest unchanged.
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/OccupationStoryTests.fs | 230 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Occupation.fs | 103 |
3 files changed, 334 insertions, 0 deletions
diff --git a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj index 6924012..1a36ea7 100644 --- a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj +++ b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj @@ -15,6 +15,7 @@ <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/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) diff --git a/src/LivingVillage.Kernel/Occupation.fs b/src/LivingVillage.Kernel/Occupation.fs index 8ce5060..00cd2e9 100644 --- a/src/LivingVillage.Kernel/Occupation.fs +++ b/src/LivingVillage.Kernel/Occupation.fs @@ -272,3 +272,106 @@ module Occupation = match kind with | None -> baseResponse | Some kind -> remapResponse kind intent baseResponse + + // ---- 轻剧情线(design §5 / P-d):三段式 接任务 → 走访 → 收束 ---- + // 纯年鉴/对话驱动:每次玩家对话至多推进一段,产出只有(StoryStage 推进 + 年鉴摘要文本), + // 由调用方写入世界。全部输入 = (seed, 世界 tick, 关系状态, 职业状态),纯函数、确定性。 + // 开线日沿用每日任务的 (seed, profession, day) splitmix 哈希模式。 + module Story = + + type Ending = + | Warm + | Plain + | Distant + + /// 剧情线种子:与每日任务种子同源异值,两两不撞(见测试)。 + let storySeedOf (kind: Kind) : uint64 = + match kind with + | Farmer -> 0x5555555555555555UL + | Fisher -> 0x6666666666666666UL + | Peddler -> 0x7777777777777777UL + | Scholar -> 0x8888888888888888UL + + let lineNameOf (kind: Kind) : string = + match kind with + | Farmer -> "雨水与年成" + | Fisher -> "水位与收成" + | Peddler -> "新货的路子" + | Scholar -> "庙前的争论" + + /// StoryStage 语义:0 未开始 / 1 已接任务 / 2 走访中 / 3 已收束。 + let stageNameOf (stage: int) : string = + match stage with + | 1 -> "已接任务" + | 2 -> "走访中" + | 3 -> "已收束" + | _ -> "未开始" + + /// 开线日 = hash(seed ^ storySeed, day 0) mod 3 + 2,落在 [2, 4]。 + let startDayOf (seed: uint64) (kind: Kind) : int64 = + (int64 (taskHash seed (storySeedOf kind) 0L % 3UL)) + 2L + + /// 玩家与村民的关系均值:与 Sim.relationMatrix 同口径(衰减 valence), + /// 但只统计认识玩家的村民(有 Chatted/Dialogue 记忆者),无一认识 = 0(中性)。 + let relationMeanToPlayer (world: Sim.World) : float32 = + let player = Sim.playerId + let mutable total = 0.0f + let mutable known = 0 + for npc in world.Npcs do + let mutable valence = 0.0f + let mutable touched = false + for e in npc.Mind.Memory do + if e.Tick <= world.Tick then + let involvesPlayer = + match e.Kind with + | Sim.Chatted partner -> partner = player + | Sim.Dialogue (actor, _, _) -> actor = player + | _ -> false + if involvesPlayer then + touched <- true + valence <- valence + e.Valence * Sim.relationDecayWeight world.Tick e.Tick + if touched then + known <- known + 1 + total <- total + valence + if known = 0 then 0.0f else total / float32 known + + /// 结局分档:均值 ≥ 0.3 热络;[0, 0.3) 平常;< 0 淡漠。纯阈值,无随机。 + let endingOf (mean: float32) : Ending = + if mean >= 0.3f then Warm + elif mean >= 0.0f then Plain + else Distant + + let endingTextOf (kind: Kind) (ending: Ending) : string = + match kind, ending with + | Farmer, Warm -> "雨水与年成有了结果:乡邻都说雨水匀,今年差不了。" + | Farmer, Plain -> "雨水与年成有了结果:雨水尚可,年成将就,照旧耕作。" + | Farmer, Distant -> "雨水与年成有了结果:问不出准信,年成只能看天。" + | Fisher, Warm -> "水位与收成有了结果:乡邻说得真切,水位稳了,收成坏不了。" + | Fisher, Plain -> "水位与收成有了结果:众说平常,水位尚可,收成将就。" + | Fisher, Distant -> "水位与收成有了结果:问了几家都淡淡的,水情还得自己看。" + | Peddler, Warm -> "新货的路子有了结果:乡邻热心指路,新货不愁卖。" + | Peddler, Plain -> "新货的路子有了结果:说法平常,先小量试卖。" + | Peddler, Distant -> "新货的路子有了结果:各家都淡淡的,路子还得自己闯。" + | Scholar, Warm -> "庙前的争论有了结果:各说各理,事有分晓。" + | Scholar, Plain -> "庙前的争论有了结果:众说纷纭,暂无定论。" + | Scholar, Distant -> "庙前的争论有了结果:无人理会,先记在册上。" + + /// 用一次玩家对话推进剧情线:同输入两次结果一致。 + /// 返回(新状态, 应写入年鉴的摘要文本 option);无门控命中时原样返回。 + /// 结局文本在收束瞬间按当时关系均值定格,由调用方连同世界存档(年鉴快照)持久化。 + let onDialogue (seed: uint64) (state: State) (world: Sim.World) : State * string option = + let kind = state.Profile.Kind + let day = dayIndexOf world.Tick + let startDay = startDayOf seed kind + match state.StoryStage with + | 0 when day >= startDay -> + { state with StoryStage = 1 }, + Some (sprintf "接了件事:%s,得去村里多问问。" (lineNameOf kind)) + | 1 when day >= startDay + 1L -> + { state with StoryStage = 2 }, + Some "走访了几户人家,改日再问。" + | 2 when day >= startDay + 2L -> + let ending = endingOf (relationMeanToPlayer world) + { state with StoryStage = 3 }, + Some (endingTextOf kind ending) + | _ -> state, None |
