diff options
| -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 | 10 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Interaction.fs | 36 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/OccupationStoryTests.fs | 53 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Occupation.fs | 36 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Sim.fs | 2 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/WorldSave.fs | 2 |
11 files changed, 226 insertions, 26 deletions
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..ea5272b 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -383,6 +383,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 diff --git a/src/LivingVillage.Desktop/Interaction.fs b/src/LivingVillage.Desktop/Interaction.fs index dd372ef..bfe26c8 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 diff --git a/src/LivingVillage.Kernel.Tests/OccupationStoryTests.fs b/src/LivingVillage.Kernel.Tests/OccupationStoryTests.fs index 80bc9a4..d204ca6 100644 --- a/src/LivingVillage.Kernel.Tests/OccupationStoryTests.fs +++ b/src/LivingVillage.Kernel.Tests/OccupationStoryTests.fs @@ -8,6 +8,14 @@ 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 } @@ -94,7 +102,7 @@ type OccupationStoryTests () = 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.AreEqual<string option>(Some "走访了几户人家,改日再问", summaries |> List.rev |> List.item 1) Assert.IsTrue( summaries |> List.choose id @@ -228,3 +236,46 @@ type OccupationStoryTests () = 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 00cd2e9..c207395 100644 --- a/src/LivingVillage.Kernel/Occupation.fs +++ b/src/LivingVillage.Kernel/Occupation.fs @@ -343,18 +343,26 @@ module Occupation = 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 -> "庙前的争论有了结果:无人理会,先记在册上。" + | 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);无门控命中时原样返回。 @@ -366,10 +374,10 @@ module Occupation = match state.StoryStage with | 0 when day >= startDay -> { state with StoryStage = 1 }, - Some (sprintf "接了件事:%s,得去村里多问问。" (lineNameOf kind)) + Some (sprintf "接了件事:%s,得去村里多问问" (lineNameOf kind)) | 1 when day >= startDay + 1L -> { state with StoryStage = 2 }, - Some "走访了几户人家,改日再问。" + Some "走访了几户人家,改日再问" | 2 when day >= startDay + 2L -> let ending = endingOf (relationMeanToPlayer world) { state with StoryStage = 3 }, diff --git a/src/LivingVillage.Kernel/Sim.fs b/src/LivingVillage.Kernel/Sim.fs index 348a60e..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 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) = |
