summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj1
-rw-r--r--src/LivingVillage.Kernel.Tests/OccupationStoryTests.fs230
-rw-r--r--src/LivingVillage.Kernel/Occupation.fs103
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