diff options
| -rw-r--r-- | docs/维护说明.md | 47 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P31StoryUiTests.fs | 105 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 6 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png | bin | 63292 -> 80906 bytes | |||
| -rw-r--r-- | src/LivingVillage.Desktop/Assets/cjk-glyphs.txt | 69 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/ChineseText.fs | 36 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/CjkGlyphAtlas.fs | 2 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 91 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Interaction.fs | 42 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj | 2 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/OccupationBiasTests.fs | 146 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/OccupationStoryTests.fs | 281 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Occupation.fs | 142 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Sim.fs | 21 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/WorldSave.fs | 2 |
16 files changed, 976 insertions, 17 deletions
diff --git a/docs/维护说明.md b/docs/维护说明.md index e234283..202d3b2 100644 --- a/docs/维护说明.md +++ b/docs/维护说明.md @@ -603,6 +603,53 @@ dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll - **遗留**:散落农舍与乡村树丛为纯布局/美术层,不与 NPC 数值、任务系统耦合;默认 64x48/256x192 判定不进入新分支、不消费额外 RNG,观感不变。 +## P31:职业系统 P-d——对话分支倾向 + 四条轻剧情线 + +- **① 对话分支倾向(design §5)**:`Occupation.biasResponse (kind option) (intent) (personality)` + 是 `Sim.responseFor` 之上的纯函数重映射,无职业(None)逐字节等于原输出。规则各自只作用于 + 签名意图:农夫 AskHelp `Refused->Helpful`;渔夫 SmallTalk `Reserved->Friendly`(聊天气鱼汛, + 冷场变热络);货郎 SmallTalk `Reserved->Bargaining`(三句不离买卖);书生 Joke **完全覆盖** + 为 `Reserved`(与 personality 相反时取完全覆盖语义而非概率偏向,禁随机,design §5 反差感)。 + 接线:`Sim.responseFor` 改为公开(同一纯函数,原口径不变);新增 + `Sim.chooseDialogueWith respond target intent world`(`chooseDialogue` 保留为 identity 包装, + 既有行为逐字节不变);Desktop `M5Interaction.chooseIntent` 按玩家职业传 `biasResponse`, + 偏差后的回应照常进 DialogueOutcome/双向记忆/年鉴(`Sim.appendAnnal` 改公开,语义不变)。 +- **② 轻剧情线状态机**:`Occupation.Story`,每职业三段式 接任务→走访→收束,`StoryStage` + 语义 `0 未开始 / 1 已接任务 / 2 走访中 / 3 已收束`(v3 occupation 段早已序列化该字段, + 读取路径原样恢复,存档段格式未动)。每次玩家对话至多推进一段:开线日 + `startDayOf seed kind = hash(seed ^ storySeed, day 0) mod 3 + 2 ∈ [2,4]`(沿每日任务的 + splitmix `(seed, profession, day)` 哈希模式,story 种子与任务种子两两不撞);`1→2`、`2→3` + 分别要求 `day >= startDay+1`、`startDay+2`。终局由 `relationMeanToPlayer`(与 + `Sim.relationMatrix` 同口径的衰减 valence,只计认识玩家的村民;无一认识 = 0)过纯阈值 + `≥0.3 Warm / [0,0.3) Plain / <0 Distant` 定格三种结局文案,写入年鉴快照后不再随关系衰减改变。 + 四条线:农夫「雨水与年成」、渔夫「水位与收成」、货郎「新货的路子」、书生「庙前的争论」。 + (设计稿 §5 的「药童」在 P-a 落地时以「农夫」为第四职业合入 main,故第四线写农夫题材, + 其余三线名照设计稿。) +- **③ 年鉴条目**:`Sim.AnnalKind` 新增 `StoryAnnal`(文案落 `Summary`),v3 存档以新 token + `story` 序列化;v1/v2(无职业)永不产生该 token,字节口径不变;v3 存→读→存逐字节一致 + (有测试)。§3「身份条目」此前未落地,本单补齐:带职业开局由 `StartNewGame` 写入 + `以耕田为生 / 以打渔为生 / 以贩货为生 / 以读书为生`(年鉴短句风格,不带主语)。 +- **④ 显示层收口(最小展示)**:任务面板(T 键)在职业名/每日任务下新增一行 + `剧情线:<线名>,<段名>`;职业选择页选中职业时提示 `剧情线:<线名>`;年鉴面板按既有 + `annalLine` 渲染 `StoryAnnal.Summary` 原文。M5View 新增 `Seed` 字段(与开局种子同源), + 供剧情线开线日哈希。 +- **⑤ 中文图集**:新增文案(四线名、四段名、身份条目、接任务/走访/12 条结局文案)全部进入 + `ChineseText.requiredUiLabels`;`cjk-glyphs.txt` 与 `CjkGlyphAtlas.characterTable` 同步 + 238 -> **307** 字(384x480,行数 15 -> 20),`scripts/generate-cjk-glyph-atlas.sh` 确定性 + 再生成 PNG;`CjkGlyphAtlasHasAStableCharacterTableAndGeometry` 计数/行数/尺寸断言同步更新。 +- **回归**:Kernel **92 -> 119**(偏差 9 + 剧情线 14 + 存档/身份 4)、Desktop **156 -> 160** + (面板剧情行 2、M5 对话接线 2)全绿;Release build 0 警告 0 错误; + `final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 多次一致; + **64x48=6042052、256x192=1284656962 锁定不变**(`DefaultSizeChecksumsArePinned`)。 + M3 相关文件零改动;无职业世界(Occupation=None)全部走 identity/原路径,模拟数值语义 + 未触碰(Sim.fs 仅 visibility 公开 + chooseDialogue 重构为 identity 包装,行为逐字节等价)。 +- **取证** `/tmp/opencode/lv-p31/art/`(gitignore 不入库,真实 Xvfb 帧非概念图): + `p31-story-{farmer,fisher,peddler,scholar}-stage2.png`(1280x720 各 1 张,任务面板含 + 剧情线行 + 年鉴身份条目)。复现命令: + `timeout 120 xvfb-run -a -s "-screen 0 1280x720x24" env LV_STORY_SHOT=1 LV_STORY_OCCUPATION=fisher LV_STORY_STAGE=2 LV_RECORD_DIR=/tmp/opencode/lv-p31/art dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll`。 +- **范围外**:设计稿 §5 的「视频证据」以 4 张真实渲染截图替代(本单验收清单口径); + 药童线以农夫线替代(见 ②);NPC 职业化、补选职业、剧情线多线并行仍为设计边界外。 + ## 已验证命令 ```bash diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj index a0c98b0..d4fa1f5 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -10,6 +10,7 @@ <Compile Include="M6aTests.fs" /> <Compile Include="M6bTests.fs" /> <Compile Include="P16UiTests.fs" /> + <Compile Include="P31StoryUiTests.fs" /> <Compile Include="PrototypeTests.fs" /> <Compile Include="MapGenTests.fs" /> <Compile Include="PerformanceSummaryTests.fs" /> diff --git a/src/LivingVillage.Desktop.Tests/P31StoryUiTests.fs b/src/LivingVillage.Desktop.Tests/P31StoryUiTests.fs new file mode 100644 index 0000000..562794d --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P31StoryUiTests.fs @@ -0,0 +1,105 @@ +namespace LivingVillage.Desktop.Tests + +open System +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim +open LivingVillage.Desktop + +module private P31Harness = + + let atNpc (id: int) (world: World) : World = + let npc = world.Npcs.[id] + { world with Avatar = { world.Avatar with Pos = npc.Pos } } + + let nearWorld () : World = Sim.initialWorldN 42UL 4 |> atNpc 0 + + let npcIndex (NpcId id) : int = id + +[<TestClass>] +type P31StoryUiTests () = + + // ---- 任务面板:剧情线名 + 进度段名 ---- + + [<TestMethod>] + member _.TaskPanelShowsTheStorylineNameAndStage () = + let world = Sim.initialWorldN 42UL 4 + let state = { Occupation.stateOf Occupation.Fisher with StoryStage = 2 } + let view = { M5Interaction.initial with Task = Some state } + let _, taskView = M5Interaction.apply ShowTaskPanel world view + let lines = M5Interaction.panelLines world taskView + Assert.IsTrue(lines |> List.contains "剧情线:水位与收成,走访中", $"lines={lines}") + + [<TestMethod>] + member _.TaskPanelShowsConcludedStageAndNoOccupationStaysStoryFree () = + let world = Sim.initialWorldN 42UL 4 + let concluded = { Occupation.stateOf Occupation.Scholar with StoryStage = 3 } + let view = { M5Interaction.initial with Task = Some concluded } + let _, taskView = M5Interaction.apply ShowTaskPanel world view + Assert.IsTrue( + M5Interaction.panelLines world taskView + |> List.contains "剧情线:庙前的争论,已收束") + let _, noneView = M5Interaction.apply ShowTaskPanel world M5Interaction.initial + Assert.IsFalse( + M5Interaction.panelLines world noneView + |> List.exists (fun line -> line.Contains("剧情线"))) + + // ---- 对话接线:回应偏差进记忆 + 剧情线推进 + 年鉴写入 ---- + + [<TestMethod>] + member _.DialogueThroughM5AppliesBiasAndAdvancesTheStory () = + let world = { P31Harness.nearWorld () with Tick = 5L * Sim.ticksPerDay } + let state = Occupation.stateOf Occupation.Fisher + let view = { M5Interaction.initial with Task = Some state; Seed = 42UL } + let personality = world.Npcs.[0].Mind.Personality + let openedWorld, openedView = M5Interaction.apply Interact world view + match openedView.Menu with + | None -> Assert.Fail("dialogue menu should open next to a villager") + | Some menu -> + let finalWorld, finalView = M5Interaction.apply Intent1 openedWorld openedView + // 剧情线推进一段(tick 第 5 日 ≥ 开线日) + let task = finalView.Task.Value + Assert.AreEqual<int>(1, task.StoryStage) + // 年鉴写入剧情条目 + Assert.IsTrue( + finalWorld.Annals + |> List.exists (fun entry -> + match entry.Kind with + | StoryAnnal -> entry.Summary.Contains("接了件事") + | _ -> false)) + // 回应偏差可观测:偏差后的回应与纯函数口径一致 + let biased = Occupation.biasResponse (Some Occupation.Fisher) SmallTalk personality + let unbiased = Sim.responseFor SmallTalk personality + if biased <> unbiased then + Assert.IsTrue( + finalWorld.Annals + |> List.exists (fun entry -> + match entry.Kind with + | DialogueAnnal outcome -> outcome.Response = biased + | _ -> false), + "the biased response must be the one recorded") + + [<TestMethod>] + member _.NoOccupationDialogueKeepsTheHistoricResponsePath () = + let world = { P31Harness.nearWorld () with Tick = 5L * Sim.ticksPerDay } + let view = M5Interaction.initial + let openedWorld, openedView = M5Interaction.apply Interact world view + match openedView.Menu with + | None -> Assert.Fail("dialogue menu should open next to a villager") + | Some menu -> + let personality = openedWorld.Npcs.[P31Harness.npcIndex menu.Target].Mind.Personality + let finalWorld, _ = M5Interaction.apply Intent1 openedWorld openedView + let expected = Sim.responseFor SmallTalk personality + Assert.IsTrue( + finalWorld.Npcs.[0].Mind.Memory + |> List.exists (fun memory -> + match memory.Kind with + | Dialogue (_, SmallTalk, response) -> response = expected + | _ -> false)) + // 无职业不产生剧情条目,剧情线状态不存在 + Assert.IsFalse( + finalWorld.Annals + |> List.exists (fun entry -> + match entry.Kind with + | StoryAnnal -> true + | _ -> false)) diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs index 7b35837..2de0996 100644 --- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs +++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs @@ -1094,14 +1094,14 @@ type PrototypeTests () = [<TestMethod>] member _.CjkGlyphAtlasHasAStableCharacterTableAndGeometry () = - Assert.AreEqual<int>(238, characters.Length) + Assert.AreEqual<int>(307, characters.Length) CollectionAssert.AreEqual(characters, characters |> Array.sort) Assert.AreEqual<int>(characters.Length, characters |> Array.distinct |> Array.length) Assert.AreEqual<int>(16, columns) Assert.AreEqual<int>(24, cellPixels) - Assert.AreEqual<int>(15, rowCount) + Assert.AreEqual<int>(20, rowCount) Assert.AreEqual<int>(384, pixelWidth) - Assert.AreEqual<int>(360, pixelHeight) + Assert.AreEqual<int>(480, pixelHeight) Assert.AreEqual<string>("Assets/cjk-glyph-atlas.png", assetRelativePath) Assert.AreEqual<string>("Assets/cjk-glyphs.txt", manifestRelativePath) diff --git a/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png Binary files differindex af83cd7..25d317b 100644 --- a/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png +++ b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png diff --git a/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt b/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt index 701200b..1b9bf67 100644 --- a/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt +++ b/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt @@ -1,13 +1,18 @@ × 一 +上 不 +与 业 中 +为 主 乐 乡 书 买 +了 +争 事 于 互 @@ -15,36 +20,53 @@ 人 今 从 +以 +件 价 任 +众 +会 传 +位 体 作 使 保 +信 倍 停 +先 入 关 内 +册 +再 农 准 +几 出 +分 +切 到 前 +剧 力 功 务 动 助 +匀 单 卖 南 原 +去 友 取 受 +只 可 +各 同 和 回 @@ -52,8 +74,10 @@ 圃 土 在 +坏 墙 备 +多 夜 大 天 @@ -61,23 +85,33 @@ 失 好 始 +子 存 完 +定 家 察 对 +将 +小 +尚 就 层 居 屋 工 +差 +己 已 币 帮 +常 +平 年 庄 库 应 +庙 度 开 式 @@ -91,15 +125,19 @@ 怒 总 恼 +情 +愁 意 戏 成 或 +户 打 找 报 拒 择 +指 挑 捕 据 @@ -108,6 +146,7 @@ 播 操 收 +改 效 敌 数 @@ -115,11 +154,13 @@ 方 无 日 +旧 时 明 昏 易 是 +晓 晚 晨 景 @@ -127,9 +168,12 @@ 更 最 有 +未 村 +束 板 林 +果 标 格 档 @@ -142,23 +186,31 @@ 江 没 法 +淡 清 渔 游 +热 +照 物 状 理 瓦 生 用 +田 留 白 +的 目 +看 +真 石 确 社 离 移 +稳 空 竹 笑 @@ -166,6 +218,10 @@ 精 系 紧 +纭 +纷 +线 +结 给 绝 继 @@ -173,9 +229,11 @@ 续 置 翻 +耕 聊 职 能 +自 范 获 菜 @@ -189,6 +247,8 @@ 讲 论 设 +访 +试 话 询 说 @@ -198,8 +258,11 @@ 谈 败 货 +贩 资 +走 足 +路 跳 转 过 @@ -213,17 +276,23 @@ 选 速 道 +邻 郎 +都 +里 量 金 鉴 钱 门 闭 +问 +闯 闲 间 闻 附 +雨 零 需 面 diff --git a/src/LivingVillage.Desktop/ChineseText.fs b/src/LivingVillage.Desktop/ChineseText.fs index 52e124d..9789ae7 100644 --- a/src/LivingVillage.Desktop/ChineseText.fs +++ b/src/LivingVillage.Desktop/ChineseText.fs @@ -217,7 +217,41 @@ module ChineseText = "收货" "转卖" "观察记事" - "论理" ] + "论理" + "剧情线" + "雨水与年成" + "水位与收成" + "新货的路子" + "庙前的争论" + "未开始" + "已接任务" + "走访中" + "已收束" + "耕田" + "打渔" + "贩货" + "读书" + "以耕田为生" + "以打渔为生" + "以贩货为生" + "以读书为生" + "接了件事:雨水与年成,得去村里多问问" + "接了件事:水位与收成,得去村里多问问" + "接了件事:新货的路子,得去村里多问问" + "接了件事:庙前的争论,得去村里多问问" + "走访了几户人家,改日再问" + "雨水与年成有了结果:乡邻都说雨水匀,今年差不了" + "雨水与年成有了结果:雨水尚可,年成将就,照旧耕作" + "雨水与年成有了结果:问不出准信,年成只能看天" + "水位与收成有了结果:乡邻说得真切,水位稳了,收成坏不了" + "水位与收成有了结果:众说平常,水位尚可,收成将就" + "水位与收成有了结果:问了几家都淡淡的,水情还得自己看" + "新货的路子有了结果:乡邻热心指路,新货不愁卖" + "新货的路子有了结果:说法平常,先小量试卖" + "新货的路子有了结果:各家都淡淡的,路子还得自己闯" + "庙前的争论有了结果:各说各理,事有分晓" + "庙前的争论有了结果:众说纷纭,暂无定论" + "庙前的争论有了结果:无人理会,先记在册上" ] let layoutText (maxWidth: int) (supported: Set<char>) (text: string) : TextLayout = let width = max 1 maxWidth diff --git a/src/LivingVillage.Desktop/CjkGlyphAtlas.fs b/src/LivingVillage.Desktop/CjkGlyphAtlas.fs index b6e6947..01d6a59 100644 --- a/src/LivingVillage.Desktop/CjkGlyphAtlas.fs +++ b/src/LivingVillage.Desktop/CjkGlyphAtlas.fs @@ -11,7 +11,7 @@ module CjkGlyphAtlas = // Keep this table sorted and stable so the generated PNG has a reproducible layout. let private characterTable = - "×一不业中主乐乡书买事于互交人今从价任传体作使保倍停入关内农准出到前力功务动助单卖南原友取受可同和回围圃土在墙备夜大天夫失好始存完家察对就层居屋工已币帮年庄库应度开式当录待得心必态怒总恼意戏成或打找报拒择挑捕据接提播操收效敌数新方无日时明昏易是晚晨景暂更最有村板林标格档桥歉段民水求江没法清渔游物状理瓦生用留白目石确社离移空竹笑粮精系紧给绝继绪续置翻聊职能范获菜衅行观认讨议记讲论设话询说请读谅谈败货资足跳转过近返还进迫退送选速道郎量金鉴钱门闭闲间闻附零需面项须食饥饿黄黛,:" + "×一上不与业中为主乐乡书买了争事于互交人今从以件价任众会传位体作使保信倍停先入关内册再农准几出分切到前剧力功务动助匀单卖南原去友取受只可各同和回围圃土在坏墙备多夜大天夫失好始子存完定家察对将小尚就层居屋工差己已币帮常平年庄库应庙度开式当录待得心必态怒总恼情愁意戏成或户打找报拒择指挑捕据接提播操收改效敌数新方无日旧时明昏易是晓晚晨景暂更最有未村束板林果标格档桥歉段民水求江没法淡清渔游热照物状理瓦生用田留白的目看真石确社离移稳空竹笑粮精系紧纭纷线结给绝继绪续置翻耕聊职能自范获菜衅行观认讨议记讲论设访试话询说请读谅谈败货贩资走足路跳转过近返还进迫退送选速道邻郎都里量金鉴钱门闭问闯闲间闻附雨零需面项须食饥饿黄黛,:" let characters = characterTable.ToCharArray() let rowCount = (characters.Length + columns - 1) / columns diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 3a9a004..c68cf6a 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -256,6 +256,23 @@ type LivingVillageGame() as this = let mutable splashFrame = 0L let mutable menuEntranceFrame = 0L let mutable menuShotFrame = 0L + // P31 证据钩子:LV_STORY_SHOT=1 时以指定职业开局、经真实对话管线推进剧情线后 + // 打开任务面板截图,拍完即退出。LV_STORY_OCCUPATION=farmer|fisher|peddler|scholar, + // LV_STORY_STAGE=0..3(默认 2),输出到 LV_RECORD_DIR/p31-story-<职业>-stage<N>.png。 + let storyShotMode = Environment.GetEnvironmentVariable("LV_STORY_SHOT") = "1" + let storyShotKind = + match Environment.GetEnvironmentVariable("LV_STORY_OCCUPATION") with + | "fisher" -> Occupation.Fisher + | "peddler" -> Occupation.Peddler + | "scholar" -> Occupation.Scholar + | _ -> Occupation.Farmer + let storyShotStage = + match Environment.GetEnvironmentVariable("LV_STORY_STAGE") with + | null | "" -> 2 + | v -> match Int32.TryParse v with (true, value) -> max 0 (min 3 value) | _ -> 2 + let mutable storyShotPrepared = false + let mutable storyShotFrame = 0L + let mutable storyShotSaved = false let mutable autoplayFrames = 0 let mutable flowStep = 0 let mutable flowHold = 0 @@ -383,6 +400,16 @@ type LivingVillageGame() as this = world <- initialWorldForMode state |> withStartTick + // §3 身份条目:带职业开局即写入年鉴,形成个人传记线 + match state with + | Some occupationState -> + world <- + Sim.appendAnnal + { Tick = world.Tick + Kind = StoryAnnal + Summary = Occupation.Story.identitySummaryOf occupationState.Profile.Kind } + world + | None -> () this.ResetWorldView() m5View <- { m5View with Task = state } menu <- menu |> MenuState.setCurrentWorld true |> MenuState.enterGame @@ -740,6 +767,44 @@ type LivingVillageGame() as this = if not (isNull pixel) then pixel.Dispose() if not (isNull spriteBatch) then spriteBatch.Dispose() + /// P31 证据:以真实开局函数建世界,用真实对话管线(bias + onDialogue)推进剧情线, + /// 再打开任务面板——与玩家操作走同一套 Kernel 纯函数,仅脚本化输入。 + member private this.RunStoryShotSetup () = + let seed = 42UL + let kind = storyShotKind + let stage = storyShotStage + let lineStartDay = Occupation.Story.startDayOf seed kind + int64 (max 0 (stage - 1)) + let startTick = lineStartDay * Sim.ticksPerDay + 12L * Sim.ticksPerDay / 24L + let state = WorldBootstrap.occupationStateFor seed kind startTick + let world0 = WorldBootstrap.initialWorldWithOccupation false seed Sim.npcCount (Some state) + let world1 = { world0 with Tick = startTick; Time = float startTick * Sim.dtSeconds } + let world2 = + Sim.appendAnnal + { Tick = startTick; Kind = StoryAnnal; Summary = Occupation.Story.identitySummaryOf kind } + world1 + let mutable w = world2 + let mutable task = state + for i in 0 .. stage - 1 do + let npc = w.Npcs.[i % w.Npcs.Length] + let wNear = { w with Avatar = { w.Avatar with Pos = npc.Pos } } + let respond = Occupation.biasResponse (Some kind) + match Sim.chooseDialogueWith respond npc.Id SmallTalk wNear with + | DialogueSucceeded (_, next) -> + let nextState, summary = Occupation.Story.onDialogue seed task next + w <- + match summary with + | Some text -> Sim.appendAnnal { Tick = next.Tick; Kind = StoryAnnal; Summary = text } next + | None -> next + task <- nextState + | DialogueRejected (failure, _) -> printfn "story-shot dialogue rejected: %A" failure + world <- w + m5View <- { M5Interaction.initial with Task = Some task; Seed = seed } + let _, view = M5Interaction.apply ShowTaskPanel world m5View + m5View <- view + menu <- menu |> MenuState.setCurrentWorld true |> MenuState.enterGame + printfn "story-shot=prepared occupation=%s stage=%d tick=%d annals=%d" + (Occupation.saveToken kind) stage world.Tick world.Annals.Length + override this.Update(gameTime: GameTime) = let kb = Keyboard.GetState() let pressed (key: Keys) = kb.IsKeyDown(key) && not (prevKb.IsKeyDown(key)) @@ -752,7 +817,10 @@ type LivingVillageGame() as this = menuEntranceFrame <- 0L printfn (if skip then "splash=skip frame=%d" else "splash=done frame=%d") splashFrame else - if autoplayFlow then + if storyShotMode && not storyShotPrepared then + storyShotPrepared <- true + this.RunStoryShotSetup() + elif autoplayFlow then this.RunAutoplayFlow() elif menu.Page = Playing then this.UpdatePlaying gameTime kb pressed pressedAny @@ -1117,6 +1185,18 @@ type LivingVillageGame() as this = this.SaveBackBuffer(sprintf "%s/%s%04d.png" recordDirectory recordName recordIndex) if menuShotMode then this.CaptureMenuShot() + if storyShotMode && storyShotPrepared then + storyShotFrame <- storyShotFrame + 1L + if not storyShotSaved && storyShotFrame = 20L then + storyShotSaved <- true + System.IO.Directory.CreateDirectory recordDirectory |> ignore + this.SaveBackBuffer( + sprintf "%s/p31-story-%s-stage%d.png" + recordDirectory + (Occupation.saveToken storyShotKind) + storyShotStage) + printfn "story-shot=saved occupation=%s stage=%d" (Occupation.saveToken storyShotKind) storyShotStage + this.Exit() /// 启动画面:分层天色 + 屋脊/水面剪影 + 标题淡入上浮 + 跳过提示,全部为帧计数纯函数。 member private this.DrawSplash() = @@ -1264,6 +1344,15 @@ type LivingVillageGame() as this = MenuState.occupationOptions |> List.iteri (fun index occupation -> drawSelection index (MenuState.occupationLabel occupation) (contentY + index * lineHeight)) + // P-d:选中职业时提示其轻剧情线(仅最小展示,复用既有选择页机制)。 + (match MenuState.selectedOccupation menu with + | Some kind -> + drawText + innerX + (contentY + MenuState.occupationOptions.Length * lineHeight) + mutedColor + (sprintf "剧情线:%s" (Occupation.Story.lineNameOf kind)) + | None -> ()) drawText innerX (panelY + panelHeight - 40) mutedColor "UP DOWN 选择 ENTER 确认 ESC 返回" | Settings -> drawText innerX headingY mutedColor (ChineseText.menuHeading SettingsHeading) diff --git a/src/LivingVillage.Desktop/Interaction.fs b/src/LivingVillage.Desktop/Interaction.fs index dd372ef..9aceff6 100644 --- a/src/LivingVillage.Desktop/Interaction.fs +++ b/src/LivingVillage.Desktop/Interaction.fs @@ -49,7 +49,9 @@ type M5View = HomeMode: HomeMode Prompt: string option PromptTargetPos: Vec2 option - Task: Occupation.State option } + Task: Occupation.State option + /// 世界种子(与 Game.fs 的开局种子同源),供剧情线开线日哈希使用。 + Seed: uint64 } module InteractionResolver = @@ -211,7 +213,8 @@ module M5Interaction = HomeMode = Outside Prompt = None PromptTargetPos = None - Task = None } + Task = None + Seed = 42UL } let worldInputAllowed (view: M5View) : bool = view.Panel = WorldPanel @@ -299,17 +302,37 @@ module M5Interaction = match List.tryItem index menu.Options with | None -> world, { view with Status = "invalid dialogue option" } | Some intent -> - match Sim.chooseDialogue menu.Target intent world with + // P-d:回应经职业偏差纯函数;无职业走既有 identity 路径(逐字节不变)。 + let respond = + match view.Task with + | Some task -> Occupation.biasResponse (Some task.Profile.Kind) + | None -> Sim.responseFor + match Sim.chooseDialogueWith respond menu.Target intent world with | DialogueSucceeded(outcome, next) -> - next, + // P-d 剧情线:每次玩家对话至多推进一段,摘要写年鉴(StoryAnnal)。 + let storyWorld, storyTask = + match view.Task with + | Some task -> + let nextState, summary = Occupation.Story.onDialogue view.Seed task next + let withAnnal = + match summary with + | Some text -> + Sim.appendAnnal + { Tick = next.Tick; Kind = StoryAnnal; Summary = text } + next + | None -> next + withAnnal, Some nextState + | None -> next, None + storyWorld, { view with Panel = WorldPanel Menu = None - Chronicle = Sim.annalText next + Chronicle = Sim.annalText storyWorld Prompt = None PromptTargetPos = None + Task = storyTask Status = sprintf "dialogue intent=%A response=%A" outcome.Intent outcome.Response - StatusTick = Some next.Tick } + StatusTick = Some storyWorld.Tick } | DialogueRejected(failure, next) -> next, { view with @@ -536,6 +559,7 @@ module M5Interaction = quantity unitPrice | _ -> "交易记录" + | StoryAnnal -> entry.Summary let private chronicleLines (world: World) : string list = world.Annals @@ -608,4 +632,8 @@ module M5Interaction = | None -> [ "今日暂无任务" ] | Some task -> [ sprintf "%s:%s" (Occupation.taskNameOf task.TemplateId) (taskStateText task.State) ] - Occupation.nameOf occupationState.Profile.Kind :: taskLines @ [ status ] + let storyLine = + sprintf "剧情线:%s,%s" + (Occupation.Story.lineNameOf occupationState.Profile.Kind) + (Occupation.Story.stageNameOf occupationState.StoryStage) + Occupation.nameOf occupationState.Profile.Kind :: taskLines @ [ storyLine ] @ [ status ] 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|")) diff --git a/src/LivingVillage.Kernel/Occupation.fs b/src/LivingVillage.Kernel/Occupation.fs index 2168c4e..c207395 100644 --- a/src/LivingVillage.Kernel/Occupation.fs +++ b/src/LivingVillage.Kernel/Occupation.fs @@ -241,3 +241,145 @@ module Occupation = /// Sim.quotePrice 的输出经职业偏置映射;None(无报价)原样穿透。 let biasedQuote (kind: Kind option) (playerIsBuyer: bool) (baseQuote: float32 option) : float32 option = baseQuote |> Option.map (fun price -> price * quoteBiasOf kind playerIsBuyer) + + // ---- 对话分支倾向(design §5 / P-d):村民对玩家职业的确定性回应偏差 ---- + // 全部为纯函数重映射:基底仍走 Sim.responseFor 的人格口径,偏差不引入随机。 + // 无职业(None)= 原回应逐字节不变。 + // 规则(各自只作用于签名意图,其余意图原样穿透): + // 农夫 AskHelp:Refused → Helpful(老实人家,求助不落空); + // 渔夫 SmallTalk:Reserved → Friendly(聊的是天气鱼汛,冷场变热络); + // 货郎 SmallTalk:Reserved → Bargaining(三句不离买卖,更易进讨价还价); + // 书生 Joke:完全覆盖为 Reserved(读过书的人讲笑话,众人反而拘谨)—— + // 与 personality 相反时采用完全覆盖语义而非概率偏向,确定性可测(design §5 反差感)。 + let private remapResponse + (kind: Kind) + (intent: Sim.DialogueIntent) + (baseResponse: Sim.DialogueResponse) + : Sim.DialogueResponse = + match kind, intent, baseResponse with + | Farmer, Sim.AskHelp, Sim.Refused -> Sim.Helpful + | Fisher, Sim.SmallTalk, Sim.Reserved -> Sim.Friendly + | Peddler, Sim.SmallTalk, Sim.Reserved -> Sim.Bargaining + | Scholar, Sim.Joke, _ -> Sim.Reserved + | _ -> baseResponse + + let biasResponse + (kind: Kind option) + (intent: Sim.DialogueIntent) + (personality: Sim.Personality) + : Sim.DialogueResponse = + let baseResponse = Sim.responseFor intent personality + 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 -> "庙前的争论有了结果:无人理会,先记在册上" + + /// §3 身份条目:开局写入年鉴(如「以打渔为生」),不带主语、沿年鉴短句风格。 + let identitySummaryOf (kind: Kind) : string = + match kind with + | Farmer -> "以耕田为生" + | Fisher -> "以打渔为生" + | Peddler -> "以贩货为生" + | Scholar -> "以读书为生" + + /// 用一次玩家对话推进剧情线:同输入两次结果一致。 + /// 返回(新状态, 应写入年鉴的摘要文本 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 diff --git a/src/LivingVillage.Kernel/Sim.fs b/src/LivingVillage.Kernel/Sim.fs index 5c4e107..0ead9d5 100644 --- a/src/LivingVillage.Kernel/Sim.fs +++ b/src/LivingVillage.Kernel/Sim.fs @@ -153,6 +153,8 @@ module Sim = | DialogueAnnal of DialogueOutcome | RumorAnnal of RumorEvent | TradeAnnal of InteractionEvent + /// P-d 剧情线/身份条目:文案直接落在 Summary(存档 token "story",仅带职业的 v3 存档产生)。 + | StoryAnnal type AnnalEntry = { Tick: int64 @@ -299,7 +301,8 @@ module Sim = let clamp (v: float32) (lo: float32) (hi: float32) : float32 = if v < lo then lo elif v > hi then hi else v - let private appendAnnal (entry: AnnalEntry) (world: World) : World = + /// P-d 起对调用方公开(剧情线年鉴写入复用同一裁剪口径,语义不变)。 + let appendAnnal (entry: AnnalEntry) (world: World) : World = { world with Annals = (entry :: world.Annals) |> List.truncate maxAnnalEntries } let hungerDecayPerTick = 0.0012f @@ -765,7 +768,8 @@ module Sim = { Target = target Options = dialogueOptions }) - let private responseFor (intent: DialogueIntent) (personality: Personality) : DialogueResponse = + /// 纯人格回应函数;P-d 起对调用方公开,供 Occupation.biasResponse 复用原口径。 + let responseFor (intent: DialogueIntent) (personality: Personality) : DialogueResponse = match intent with | SmallTalk -> if personality.Extraversion >= 0.5f then Friendly else Reserved | AskHelp -> if personality.Honesty >= 0.4f then Helpful else Refused @@ -782,7 +786,12 @@ module Sim = sprintf "rumor tick=%d source=%A narrator=%A receiver=%A depth=%d" rumor.Tick rumor.Source rumor.Narrator rumor.Receiver rumor.Depth - let chooseDialogue (target: NpcId) (intent: DialogueIntent) (world: World) : DialogueResult = + let chooseDialogueWith + (respond: DialogueIntent -> Personality -> DialogueResponse) + (target: NpcId) + (intent: DialogueIntent) + (world: World) + : DialogueResult = match findNpcIndex target world.Npcs with | None -> DialogueRejected(DialogueTargetNotFound, world) | Some targetIndex -> @@ -794,7 +803,7 @@ module Sim = elif not (dialogueAvailable targetNpc) then DialogueRejected(DialogueTargetUnavailable, world) else - let response = responseFor intent targetNpc.Mind.Personality + let response = respond intent targetNpc.Mind.Personality let outcome = { Tick = world.Tick Actor = playerId @@ -842,6 +851,10 @@ module Sim = withRumorAnnal DialogueSucceeded(outcome, withDialogueAnnal) + /// 既有入口:无职业偏差的人格回应路径(identity,与历史行为逐字节一致)。 + let chooseDialogue (target: NpcId) (intent: DialogueIntent) (world: World) : DialogueResult = + chooseDialogueWith responseFor target intent world + let annalText (world: World) : string = let rows = world.Annals diff --git a/src/LivingVillage.Kernel/WorldSave.fs b/src/LivingVillage.Kernel/WorldSave.fs index b3bdfa9..7f724a3 100644 --- a/src/LivingVillage.Kernel/WorldSave.fs +++ b/src/LivingVillage.Kernel/WorldSave.fs @@ -390,12 +390,14 @@ module WorldSave = | TradeAnnal event -> add tokens "trade" writeInteraction tokens event + | StoryAnnal -> add tokens "story" let private readAnnalKind (reader: TokenReader) label = match reader.Take(label) with | "dialogue" -> DialogueAnnal(readDialogueOutcome reader (label + ".outcome")) | "rumor" -> RumorAnnal(readRumor reader (label + ".rumor")) | "trade" -> TradeAnnal(readInteraction reader (label + ".event")) + | "story" -> StoryAnnal | _ -> invalid (sprintf "invalid %s" label) let private writeAnnal tokens (value: AnnalEntry) = |
