summaryrefslogtreecommitdiff
path: root/docs/design-professions.md
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-28 22:25:15 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-28 22:25:15 +0800
commita84c90fd70437b49f848f4d0607c3f72373a06ac (patch)
tree0c569c33d8acb65e916778ddf2cdc88200c42309 /docs/design-professions.md
parentd44c14555a0475974aeede83660346692a52f025 (diff)
downloadliving-village-a84c90fd70437b49f848f4d0607c3f72373a06ac.tar.gz
p50: 开局职业选择设计与验收文档 docs/design-professions.md(职业表/分层/存档兼容/自动化验收)
Diffstat (limited to 'docs/design-professions.md')
-rw-r--r--docs/design-professions.md123
1 files changed, 123 insertions, 0 deletions
diff --git a/docs/design-professions.md b/docs/design-professions.md
new file mode 100644
index 0000000..30e00bc
--- /dev/null
+++ b/docs/design-professions.md
@@ -0,0 +1,123 @@
+# 开局职业选择 · 设计与验收(professions)
+
+状态:**设计/验收文档(本单零代码,只落文档)。**
+归属:用户三大可玩性方向 #3「在现有社会模拟主线之上加入新玩法(开局职业选择 → 职业影响剧情与每日任务)」。
+权威实现:`src/LivingVillage.Kernel/Occupation.fs`(数据 + 纯函数规则)、`src/LivingVillage.Kernel/WorldSave.fs`(v3 侧车存档)、`src/LivingVillage.Desktop/{WorldBootstrap,MenuState,Interaction,Game,ChineseText}.fs`(接线 + UI/文案)。
+历史稿(保留参考,不覆盖):`docs/design/profession-system.md`(P37 首切片)、`docs/design/occupation-system-design.md`(P11–P15)、`docs/profession-system-design.md`(第一版草稿)。
+
+> 原则:**所有职业差异都落在既有机制上**(`Sim.Needs`、`Sim.Npc.Inventory`、`Sim.quotePrice`、`Sim.responseFor`、`Sim.InteractionEvent`、`Sim.Annals`、关系衰减),不新增平行运行时系统;职业是**玩家侧侧车状态**,`Sim.step` 数值路径零感知,故同 seed 世界不变、`WorldSave.save`(无职业)digest 不变。
+
+---
+
+## 1. 职业表(4 个 + 「暂不选择」)
+
+四职业枚举:`Occupation.Kind = Farmer | Fisher | Peddler | Scholar`(`Occupation.fs:9`)。「暂不选择」= `Occupation.Kind option = None`,等同旧行为(无职业、无任务、无偏置)。
+
+| 职业 | 一句话定位 | 开局资源差(实字段) | 每日任务钩子 | 剧情锚点 |
+|---|---|---|---|---|
+| **农夫** Farmer | 靠地吃饭,求稳互助 | 钱 60 / 体力 100 / Food×12 | 池 `[DeliverGrain 送粮; HelpWork 帮工; TillSoil 翻土]` | 线「雨水与年成」;启程年鉴「以耕田为生」;对话偏置:求助 `Refused→Helpful` |
+| **渔夫** Fisher | 看天看水,作息随水情 | 钱 45 / 体力 95 / Food×8 + Fish×3 | 池 `[NightCatch 夜捕; SellFish 卖渔; MarketInquiry 询价]` | 线「水位与收成」;「以打渔为生」;闲聊 `Reserved→Friendly` |
+| **货郎** Peddler | 走街串巷,贱买贵卖 | 钱 90 / 体力 100 / Food×6 + Spice×4 | 池 `[BuyGoods 收货; Resell 转卖]` | 线「新货的路子」;「以贩货为生」;闲聊 `Reserved→Bargaining`;报价 ±3.00% |
+| **书生** Scholar | 读书论理,旁观记事 | 钱 70 / 体力 100 / Food×10 + Scroll×2 | 池 `[ObserveNotes 观察记事; ReasonDebate 论理]` | 线「庙前的争论」;「以读书为生」;讲笑话一律 `Reserved`;报价 ±1.00% |
+| **暂不选择** `None` | 旧行为(不带职业) | 沿用世界默认(钱/体力/无职业物品) | 无当日任务 | 无剧情线;所有偏置恒 1.0 / 原回应 |
+
+**每个钩子映射到的既有机制(均已存在,不发明):**
+
+- 开局钱/体力:`Occupation.Profile.InitialMoney / InitialEnergy`(`Occupation.fs:32-44`),由 `WorldBootstrap.initialWorldWithPlacement` 写入 `Sim.Needs.Money / Energy`(`WorldBootstrap.fs:58-68`);`Sim.Needs` 见 `Sim.fs:26-30`。
+- 开局物品:`Occupation.Profile.InitialInventory`(`Occupation.fs:36`),物品枚举 `Sim.ItemKind = Food | Fish | Spice | Scroll`(`Sim.fs:40-44`)。**现状缺口**:无「玩家背包」字段,`InitialInventory` 目前仅在数据/测试层被断言,尚未落到世界;详见 §2「待实现」。
+- 每日任务:`Occupation.taskPoolOf / dailyTaskOf / refreshToday / acceptTask / advanceTask / expireAt / satisfiesCompletion`(`Occupation.fs`),完成信号来自既有 `Sim.InteractionEvent`(`DialogueEvent` / `TradeEvent`,`Sim.fs:119-121`),经 `Occupation.signalOfInteraction` 桥接(`Occupation.fs:210`);任务状态机 `TaskState = Offered | Active | Done | Failed`。Desktop 侧 `WorldBootstrap.refreshOccupationToday` 跨日刷新、`Interaction.fs` 出面板文案。
+- 剧情线:`Occupation.Story.onDialogue / startDayOf / relationMeanToPlayer / endingOf / endingTextOf / identitySummaryOf`(`Occupation.fs`),关系均值用 `Sim.relationDecayWeight` + `Mind.Memory` 的 `Chatted`/`Dialogue`(与 `Sim.relationMatrix` 同口径);产物写 `Sim.AnnalEntry`(`Kind = StoryAnnal`)经 `Sim.appendAnnal`(`Sim.fs:305`);三结局 热络/平常/淡漠。
+- 对话偏置:`Occupation.biasResponse`(基底 `Sim.responseFor intent personality`,`Sim.fs:772`),已接线 `Interaction.fs:308`。
+- 报价偏置:`Occupation.quoteBiasOf / biasedQuote`(4 位定点基点,钳 ±5%,`Occupation.fs:232-243`)作用在 `Sim.quotePrice` 输出(`Sim.fs:552`);**现状缺口**:Desktop 暂无玩家交易通道,尚未接线。
+- 存档:`WorldSave.saveWith / loadFromFileWith`(`WorldSave.fs:570/697`),侧车 `Occupation.State { Profile; TaskToken; Today; StoryStage }`(`Occupation.fs:197`),不进入 `World` 结构体。
+
+---
+
+## 2. 实现分层方案
+
+### 2.1 Kernel —— 数据 + deterministic 规则(`Sim.step` 数值路径不动)
+
+| 文件 | 职责 | 变更性质 |
+|---|---|---|
+| `src/LivingVillage.Kernel/Occupation.fs` | 职业枚举/档案、任务池与状态机、剧情线、报价/对话偏置,全部纯函数、禁 `System.Random`(用 `Rng.ofSeed` splitmix 同源) | 规则载体,**已存在** |
+| `src/LivingVillage.Kernel/WorldSave.fs` | v3 侧车 token(`LV_WORLD_SAVE_V3` + `occupationKind/taskState/storyStage`)、`saveWith/loadFromFileWith`;`save/load` 默认 `None`(v2) | **已存在** |
+| `src/LivingVillage.Kernel/Sim.fs` | **不改**。职业不写入 `World`;`Events/Rumors/Annals/Needs/Inventory` 结构不变 | 冻结 |
+| `src/LivingVillage.Kernel/Rng.fs` | 任务/剧情 seed 的 splitmix 来源(`Rng.ofSeed/nextUInt64`) | 复用 |
+
+确定性口径:任务 = `hash(seed ⊕ occupationSeedOf kind, dayIndex) mod |pool|`(`Occupation.taskHash`);开线日 = `hash(seed ⊕ storySeedOf kind, 0) mod 3 + 2 ∈ [2,4]`;无时钟、无并行随机。
+
+### 2.2 Desktop —— UI + 文案
+
+| 文件 | 职责 |
+|---|---|
+| `src/LivingVillage.Desktop/WorldBootstrap.fs` | `occupationStateFor`(新档当日任务)/`refreshOccupationToday`(跨日)/`initialWorldWithPlacement` 写入开局钱·体力 |
+| `src/LivingVillage.Desktop/MenuState.fs` | `OccupationSelect` 页、`occupationOptions`、`occupationLabel`(含「暂不选择」)、`StartNewGameWith kind` |
+| `src/LivingVillage.Desktop/Interaction.fs` | 任务面板文案(`taskNameOf`/`TaskState` 中文/`Story.lineNameOf`/`stageNameOf`/职业名)、对话偏置接线、剧情年鉴写入 |
+| `src/LivingVillage.Desktop/Game.fs` | `StartNewGame`、`saveToFileWith`/`loadFromFileWith` 接线、职业选择页绘制、P37 取证钩子 |
+| `src/LivingVillage.Desktop/ChineseText.fs` | 新增中文用字(「选择职业/暂不选择/农夫/渔夫/货郎/书生」等)入 CJK 图集 |
+
+### 2.3 存档兼容策略
+
+- **新字段默认值 = 无职业**:序列化入口 `WorldSave.saveWith None` 写 `LV_WORLD_SAVE_V2`;`WorldSave.save` = `saveWith None`,与旧版逐字节一致(`WorldSave.fs:687`)。
+- **旧档直接可读**:`loadFromFileWith` 读到 `V1`/`V2` 头 → 职业 `None`(旧行为),不吞档、不改写;v3 头按 token 重建 `Occupation.State`。
+- **digest 不变**:`PerformanceProbe.WorldDigestOfText` 剥版本头 + 边界 token,把 v1/v2/v3 归一到单一规范体;职业是侧车、不参与 `save`(None)体,故 `--performance-baseline` final_digest 仍为 `953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`。若未来要让 digest 反映职业,需单列「有意的口径切换」并同步 perf README,属独立单。
+
+### 2.4 待实现(供后续实现单,红→绿小步)
+
+1. **玩家背包落盘 + 开局物品生效**:现无玩家 Inventory;`InitialInventory` 未落地。方案建议:在 `Occupation.State` 增加玩家背包侧车(Kernel 纯数据),开局由 `WorldBootstrap` 写入,交易时读写;保持 `WorldSave.save`(None)digest 不变。
+2. **玩家交易通道接线报价偏置**:Desktop 玩家买/卖 → `Sim.quotePrice` → `Occupation.biasedQuote`;无职业恒等 1.0(`TradeBiasTests` 已锁此项)。
+3. **任务目标具体化**:`DailyTask.TargetNpc/TargetTile` 目前恒 `None`(通配);后续按 hash 在可达 NPC/瓦片内定点。
+4. **货郎「打听行情」接谣言**:用既有 `World.Rumors` 池,仅加文案/判定,不写新状态。
+5. **旧档→新档迁移 UI**:读档后无职业时是否提供补选(可选,非必需)。
+
+---
+
+## 3. 验收清单(可自动化断言)
+
+> 每条尽量走 Kernel 纯函数口径优先;新增实现单必须「先写测试(红)→ 实现(绿)」。
+
+**A. 确定性 / digest 不回归**
+- `dotnet run -c Release --project src/LivingVillage.Headless -- --performance-baseline`:3 次 `performance_sample` 的 `final_digest` 全等 `953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`,`performance_determinism=PASS`。
+- 无职业世界 `WorldSave.save` 与历史基线逐字节一致;`WorldDigestOfText` 对 v1/v2/v3(None) 同体同 digest。
+- `Sim.step` 数值路径不读职业(代码审查 + dump/关系/谣言输出与旧版一致)。
+
+**B. 旧档读取回归**
+- `P37ProfessionTests.LegacyAndNoProfessionSavesLoadAsNoOccupation`:v2 文本以 `LV_WORLD_SAVE_V2` 开头且读回 `occupation = None`;把 v2 头改写成 v1 后读回仍 `None`。
+- `loadFromFileWith` 对 v3 读回 `Some state`,round-trip `saveWith` 后文本稳定(`P37` 职业往返)。
+
+**C. 职业表数据(逐字段)**
+- `M6aTests`(Kernel):开局 `InitialInventory` 各区段数量(农 12;渔 8/3;货 6/4;书 10/2)。
+- `P37ProfessionTests.OpeningMoneyAndEnergyDifferPerProfession`:钱 60/45/90/70;渔夫体力 95,其余 100。
+- `P37ProfessionTests.OpeningInventoryBundleIsDefinedPerProfession`:四职业物品清单逐项相等。
+
+**D. 每日任务确定性**
+- `P37ProfessionTests.SameProfessionAndSeedProduceIdenticalTaskSequences`:同 (seed, 职业) 30 天序列两次逐项相等,且每天任务 ∈ `taskPoolOf`。
+- `P37ProfessionTests.DifferentProfessionsProduceDifferentTaskSequences`:渔夫与其余三职业序列不同。
+- `P37ProfessionTests.FisherFirstTaskIsFishingRelated`:渔夫开局当日任务名 ∈ {夜捕, 卖渔, 询价}。
+- `DailyTaskTests`:`signalOfInteraction` 只对玩家事件产信号;`acceptTask` Offered→Active;`applySignal` 仅 Active 命中;`expireAt` 逾期→Failed;终态不可复活。
+
+**E. 对话 / 报价偏置**
+- `TradeBiasTests`:`None` 恒 1.0 且 `biasedQuote None` 原样穿透;货郎买 0.97 / 卖 1.03;书生买 1.01 / 卖 1.01;同输入两次相等。
+- Interaction 接线断言:同日同对话,无职业回应与旧版逐字节一致;`biasResponse` 仅作用于签名意图。
+
+**F. 剧情线**
+- `P31StoryUiTests`:`StoryStage` 0→1→2→3 逐日推进;结局由 `relationMeanToPlayer` 阈值分档(≥0.3 热络 / [0,0.3) 平常 / <0 淡漠);开线日 ∈ [2,4];开局写 `StoryAnnal`「以…为生」;同输入两次一致、无随机。
+
+**G. 中文 UI 短语回归**
+- `P37ProfessionTests.OccupationUiLabelsAreAtlasBacked`:`选择职业 / 暂不选择 / 农夫 / 渔夫 / 货郎 / 书生` 均在 CJK 图集内、可渲染、无缺字方框。
+- `PrototypeTests.RequiredUiLabelsAreFullyCoveredByTheGlyphTable`:全量必需用字覆盖。
+- `M6bTests`:`MenuState.occupationLabel None = "暂不选择"`;职业选择页上下/确认导航正确(`OccupationSelectPageNavigatesAndEmitsChosenKind`)。
+
+**H. 全量绿**
+- `dotnet build LivingVillage.sln -c Release`:0 错误(既有 1 警告除外)。
+- `dotnet test src/LivingVillage.Kernel.Tests`(当前 119)+ `src/LivingVillage.Desktop.Tests`(当前 250)全绿。
+
+**后续实现单需新增(红先)**:§2.4-1 玩家背包应用与 digest 不变;§2.4-2 玩家交易偏置端到端;§2.4-3 目标具体化的 `TargetNpc/TargetTile` 断言。
+
+---
+
+## 4. 禁项与不做
+
+- 本单**不改任何 `src/` 代码**、不引外部素材、不动 HUD/对话框;只新增本文档并可 commit `docs`(不 push)。
+- 实现阶段亦不改 `Sim.step` 数值路径、不改地图/世界生成 seed 口径、不改 `performance-baseline` digest 口径。
+- 不做:NPC 职业化(职业只挂玩家侧)、技能树/等级/装备/数值化剧情树、匠人/船匠等新职业、付费/凭据类字段。