diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-20 21:38:46 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-20 21:38:46 +0800 |
| commit | c64779031d8459c44d78916ab6e38e80d2532a1c (patch) | |
| tree | 5cddbc68f3708ce58650ddd172483f5fe9e14fe0 /src | |
| parent | c5ff7685e5b3ac5dc46fce74ef1b302de09af1e6 (diff) | |
| download | living-village-c64779031d8459c44d78916ab6e38e80d2532a1c.tar.gz | |
feat(desktop): localize village presentation labels
[Change Nature]
- This commit adds the Chinese display layer for the desktop village prototype.
[New Capability]
- Menu, settings, controls, interaction panels, chronicle entries, trade results, day-night labels, and speed labels now use Chinese copy.
[Implementation]
- Keep simulation state, commands, and machine-readable status strings unchanged.
- Add focused formatter coverage and update affected presentation assertions.
[Impact]
- Desktop presentation behavior is localized without changing Kernel or simulation logic.
- Existing release build and test coverage remain green.
Diffstat (limited to 'src')
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/DesktopTests.fs | 2 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/M6aTests.fs | 8 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 76 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Interaction.fs | 171 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/M6Presentation.fs | 13 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/MenuState.fs | 48 |
6 files changed, 244 insertions, 74 deletions
diff --git a/src/LivingVillage.Desktop.Tests/DesktopTests.fs b/src/LivingVillage.Desktop.Tests/DesktopTests.fs index 650f757..1c175ce 100644 --- a/src/LivingVillage.Desktop.Tests/DesktopTests.fs +++ b/src/LivingVillage.Desktop.Tests/DesktopTests.fs @@ -22,7 +22,7 @@ type DesktopTests () = Assert.AreEqual<World>(world, next) Assert.AreEqual<M5Panel>(DialoguePanel, view.Panel) Assert.AreEqual<int>(6, view.Menu.Value.Options.Length) - Assert.IsTrue(M5Interaction.titleText view |> fun text -> text.Contains("SMALLTALK")) + Assert.IsTrue(M5Interaction.titleText view |> fun text -> text.Contains("闲聊")) [<TestMethod>] member _.SelectingAnIntentUpdatesWorldAndChronicle () = diff --git a/src/LivingVillage.Desktop.Tests/M6aTests.fs b/src/LivingVillage.Desktop.Tests/M6aTests.fs index 1cc29d4..04920d4 100644 --- a/src/LivingVillage.Desktop.Tests/M6aTests.fs +++ b/src/LivingVillage.Desktop.Tests/M6aTests.fs @@ -9,7 +9,7 @@ type M6aDesktopTests () = [<TestMethod>] member _.SpeedLabelsExposePauseAndThreeRunningModes () = - Assert.AreEqual<string>("PAUSED", M6Presentation.clockLabel { Paused = true; Speed = OneX }) - Assert.AreEqual<string>("1X", M6Presentation.clockLabel { Paused = false; Speed = OneX }) - Assert.AreEqual<string>("2X", M6Presentation.clockLabel { Paused = false; Speed = TwoX }) - Assert.AreEqual<string>("5X", M6Presentation.clockLabel { Paused = false; Speed = FiveX }) + Assert.AreEqual<string>("暂停", M6Presentation.clockLabel { Paused = true; Speed = OneX }) + Assert.AreEqual<string>("1倍速", M6Presentation.clockLabel { Paused = false; Speed = OneX }) + Assert.AreEqual<string>("2倍速", M6Presentation.clockLabel { Paused = false; Speed = TwoX }) + Assert.AreEqual<string>("5倍速", M6Presentation.clockLabel { Paused = false; Speed = FiveX }) diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs index b959d70..3abb064 100644 --- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs +++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs @@ -1,7 +1,10 @@ namespace LivingVillage.Desktop.Tests +open System open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel open LivingVillage.Kernel.Sim +open LivingVillage.Desktop open LivingVillage.Desktop.VillagePresentation [<TestClass>] @@ -100,3 +103,76 @@ type PrototypeTests () = Assert.AreEqual<string>("需求", stageOneCopy.Needs) Assert.AreEqual<string>("交易", stageOneCopy.Trade) Assert.AreEqual<string>("进入民居", stageOneCopy.EnterHome) + + [<TestMethod>] + member _.MenuControlsAndSettingsUseChineseDisplayCopy () = + let state = MenuState.create SimulationControl.initial false + let itemLabels = MenuState.items state |> List.map MenuState.itemLabel + let controlLabels = MenuState.controlRows |> List.map (fun row -> row.Action) + let settingsLabels = MenuState.settings |> List.map MenuState.settingsLabel + + Assert.IsTrue(itemLabels |> List.contains "开始新游戏") + Assert.IsTrue(itemLabels |> List.contains "读取存档") + Assert.IsTrue(itemLabels |> List.contains "设置") + Assert.IsTrue(itemLabels |> List.contains "退出") + Assert.AreEqual<string list>( + [ "移动" + "互动" + "对话" + "观察" + "需求" + "年鉴" + "关系" + "暂停" + "速度" + "保存" + "读取" + "菜单或关闭" + "选择" + "确认" ], + controlLabels) + Assert.AreEqual<string list>([ "1倍速"; "2倍速"; "5倍速"; "暂停" ], settingsLabels) + + [<TestMethod>] + member _.InteractionPanelsUseChineseLabelsAndExplicitTradeResults () = + let world = Sim.initialWorldN 42UL 4 + let nearby = + { world with + Avatar = { world.Avatar with Pos = world.Npcs.[0].Pos } } + let openedWorld, opened = M5Interaction.apply Interact nearby M5Interaction.initial + let title = M5Interaction.titleText opened + let needsWorld, needsView = M5Interaction.apply ToggleNeeds openedWorld opened + let needsText = String.concat "|" (M5Interaction.panelLines needsWorld needsView) + let _, chronicleView = M5Interaction.apply ShowChronicle nearby M5Interaction.initial + let chronicleText = String.concat "|" (M5Interaction.panelLines nearby chronicleView) + let request = + { Buyer = NpcId 0 + Seller = NpcId 1 + Item = Food + Quantity = 1 } + let quote = Sim.quotePrice request nearby + let successText = M5Interaction.tradeResultText request quote (Sim.trade request nearby) + let failureRequest = { request with Quantity = 0 } + let failureText = + M5Interaction.tradeResultText failureRequest (Sim.quotePrice failureRequest nearby) (Sim.trade failureRequest nearby) + + Assert.IsTrue(title.Contains("互动")) + Assert.IsTrue(title.Contains("闲聊")) + Assert.IsFalse(title.Contains("SMALLTALK")) + Assert.IsTrue(needsText.Contains("需求")) + Assert.IsTrue(needsText.Contains("饥饿")) + Assert.IsTrue(chronicleText.Contains("年鉴")) + Assert.IsTrue(chronicleText.Contains("暂无记录")) + Assert.IsTrue(successText.Contains("交易成功")) + Assert.IsTrue(successText.Contains("报价")) + Assert.IsTrue(failureText.Contains("交易失败")) + Assert.IsTrue(failureText.Contains("数量")) + + [<TestMethod>] + member _.DayNightAndSpeedUseChineseDisplayCopy () = + Assert.AreEqual<string>("白天", M6Presentation.dayNightLabel Day) + Assert.AreEqual<string>("夜晚", M6Presentation.dayNightLabel Night) + Assert.AreEqual<string>("暂停", M6Presentation.clockLabel { Paused = true; Speed = OneX }) + Assert.AreEqual<string>("1倍速", M6Presentation.clockLabel { Paused = false; Speed = OneX }) + Assert.AreEqual<string>("2倍速", M6Presentation.clockLabel { Paused = false; Speed = TwoX }) + Assert.AreEqual<string>("5倍速", M6Presentation.clockLabel { Paused = false; Speed = FiveX }) diff --git a/src/LivingVillage.Desktop/Interaction.fs b/src/LivingVillage.Desktop/Interaction.fs index 6f175d6..da78b2e 100644 --- a/src/LivingVillage.Desktop/Interaction.fs +++ b/src/LivingVillage.Desktop/Interaction.fs @@ -52,19 +52,50 @@ module M5Interaction = let private intentName (intent: DialogueIntent) : string = match intent with - | SmallTalk -> "SMALLTALK" - | AskHelp -> "ASK HELP" - | OfferTrade -> "OFFER TRADE" - | Joke -> "JOKE" - | Apologize -> "APOLOGIZE" - | Provoke -> "PROVOKE" + | SmallTalk -> "闲聊" + | AskHelp -> "求助" + | OfferTrade -> "提议交易" + | Joke -> "讲笑话" + | Apologize -> "道歉" + | Provoke -> "挑衅" let private needName (kind: NeedKind) : string = match kind with - | HungerNeed -> "HUNGER" - | EnergyNeed -> "ENERGY" - | SocialNeed -> "SOCIAL" - | MoneyNeed -> "MONEY" + | HungerNeed -> "饥饿" + | EnergyNeed -> "精力" + | SocialNeed -> "社交" + | MoneyNeed -> "金钱" + + let private itemName (item: ItemKind) : string = + match item with + | Food -> "食物" + + let private responseName (response: DialogueResponse) : string = + match response with + | Friendly -> "友好" + | Helpful -> "乐于助人" + | Bargaining -> "讨价还价" + | Amused -> "开心" + | Forgiving -> "原谅" + | Hostile -> "敌意" + | Reserved -> "保留" + | Refused -> "拒绝" + | Offended -> "恼怒" + + let private dialogueFailureName (failure: DialogueFailure) : string = + match failure with + | DialogueTargetNotFound -> "找不到目标" + | DialogueTargetOutOfRange -> "目标不在范围内" + | DialogueTargetUnavailable -> "目标暂时无法互动" + + let private tradeFailureName (failure: TradeFailure) : string = + match failure with + | BuyerNotFound -> "找不到买家" + | SellerNotFound -> "找不到卖家" + | SameParticipant -> "买家和卖家不能是同一人" + | InvalidQuantity -> "数量必须大于零" + | OutOfStock -> "库存不足" + | InsufficientFunds -> "资金不足" let private npcNumber (NpcId id) = id @@ -84,7 +115,7 @@ module M5Interaction = Chronicle = Sim.annalText next Status = sprintf "dialogue intent=%A response=%A" outcome.Intent outcome.Response } | DialogueRejected(failure, next) -> - next, { view with Status = sprintf "dialogue rejected: %A" failure } + next, { view with Status = sprintf "dialogue rejected: %s" (dialogueFailureName failure) } let apply (command: M5Command) (world: World) (view: M5View) : World * M5View = match command with @@ -127,6 +158,19 @@ module M5Interaction = Status = sprintf "chronicle entries=%d" world.Annals.Length } | ClosePanel -> world, { view with Panel = WorldPanel; Menu = None; Status = "panel closed" } + let tradeResultText (request: TradeRequest) (quote: float32 option) (result: TradeResult) : string = + match result with + | TradeSucceeded _ -> + match quote with + | Some unitPrice -> + sprintf "交易成功:%s ×%d,报价 %.2f,总价 %.2f" + (itemName request.Item) + request.Quantity + unitPrice + (unitPrice * float32 request.Quantity) + | None -> sprintf "交易成功:%s ×%d" (itemName request.Item) request.Quantity + | TradeRejected(failure, _) -> sprintf "交易失败:%s" (tradeFailureName failure) + let panelName (view: M5View) : string = match view.Panel with | WorldPanel -> "world" @@ -137,9 +181,9 @@ module M5Interaction = let titleText (view: M5View) : string = match view.Panel with - | WorldPanel -> "E INTERACT | Q OBSERVE | TAB NEEDS | C CHRONICLE | L RELATIONS" - | NeedsPanel -> "NEEDS | TAB CLOSE | Q OBSERVE | C CHRONICLE" - | ObservationPanel -> "OBSERVATION | TAB NEEDS | C CHRONICLE | ESC CLOSE" + | WorldPanel -> "E 互动 | Q 观察 | TAB 需求 | C 年鉴 | L 关系" + | NeedsPanel -> "需求 | TAB 关闭 | Q 观察 | C 年鉴" + | ObservationPanel -> "观察 | TAB 需求 | C 年鉴 | ESC 关闭" | DialoguePanel -> match view.Menu with | Some menu -> @@ -147,38 +191,83 @@ module M5Interaction = menu.Options |> List.mapi (fun index intent -> sprintf "%d %s" (index + 1) (intentName intent)) |> String.concat " | " - sprintf "DIALOGUE TARGET=%A | %s | ESC CLOSE" menu.Target options - | None -> "DIALOGUE RECORDED | E INTERACT | ESC CLOSE" - | ChroniclePanel -> "CHRONICLE | TAB NEEDS | Q OBSERVE | ESC CLOSE" + sprintf "互动:村民 %d | %s | ESC 关闭" (npcNumber menu.Target) options + | None -> "互动已记录 | E 互动 | ESC 关闭" + | ChroniclePanel -> "年鉴 | TAB 需求 | Q 观察 | ESC 关闭" + + let private statusLabel (status: string) : string = + match status with + | "ready" -> "准备就绪" + | "needs panel open" -> "需求面板已打开" + | "needs panel closed" -> "需求面板已关闭" + | "panel closed" -> "面板已关闭" + | value when value.StartsWith("dialogue target=") -> "请选择对话方式" + | value when value.StartsWith("dialogue intent=") -> "对话完成" + | value when value.StartsWith("dialogue rejected:") -> + let reason = value.Substring("dialogue rejected:".Length).Trim() + if String.IsNullOrWhiteSpace reason then "对话失败" else sprintf "对话失败:%s" reason + | value when value.StartsWith("observation") -> "观察完成" + | value when value.StartsWith("chronicle") -> "年鉴已打开" + | value when value.StartsWith("no dialogue") -> "当前没有可用对话" + | value when value.StartsWith("invalid dialogue") -> "对话选项无效" + | value when value.StartsWith("no nearby") -> "附近没有可互动村民" + | _ -> "状态已更新" + + let private annalLine (entry: AnnalEntry) : string = + match entry.Kind with + | DialogueAnnal outcome -> + sprintf "对话:村民 %d 对村民 %d 使用%s,回应%s" + (npcNumber outcome.Actor) + (npcNumber outcome.Target) + (intentName outcome.Intent) + (responseName outcome.Response) + | RumorAnnal rumor -> + sprintf "传闻:村民 %d 传给村民 %d,传播 %d 层" + (npcNumber rumor.Narrator) + (npcNumber rumor.Receiver) + rumor.Depth + | TradeAnnal event -> + match event.Kind with + | TradeEvent(buyer, seller, item, quantity, unitPrice) -> + sprintf "交易:村民 %d 从村民 %d 获得%s ×%d,单价 %.2f" + (npcNumber buyer) + (npcNumber seller) + (itemName item) + quantity + unitPrice + | _ -> "交易记录" - let private chronicleLines (text: string) : string list = - text.Split([| '\n' |], System.StringSplitOptions.RemoveEmptyEntries) |> Array.toList + let private chronicleLines (world: World) : string list = + world.Annals + |> List.rev + |> List.truncate 10 + |> List.map annalLine let panelLines (world: World) (view: M5View) : string list = - let status = if String.IsNullOrWhiteSpace view.Status then "READY" else view.Status.ToUpperInvariant() + let status = if String.IsNullOrWhiteSpace view.Status then "准备就绪" else statusLabel view.Status match view.Panel with | WorldPanel -> - [ "M5 VILLAGE" - "E INTERACT" - "Q OBSERVE" - "TAB NEEDS" - "C CHRONICLE" - "L RELATIONS" + [ "村庄" + "E 互动" + "Q 观察" + "TAB 需求" + "C 年鉴" + "L 关系" status ] | NeedsPanel -> match view.Needs with - | None -> [ "NEEDS"; "NO DATA"; status ] + | None -> [ "需求"; "暂无数据"; status ] | Some panel -> let topNeed = panel.Ranked |> List.tryHead - |> Option.map (fun score -> sprintf "TOP %s" (needName score.Kind)) - |> Option.defaultValue "TOP NONE" - [ "NEEDS" - sprintf "HUNGER %.0f" panel.Needs.Hunger - sprintf "ENERGY %.0f" panel.Needs.Energy - sprintf "SOCIAL %.0f" panel.Needs.Social - sprintf "MONEY %.0f" panel.Needs.Money + |> Option.map (fun score -> sprintf "最紧迫:%s" (needName score.Kind)) + |> Option.defaultValue "暂无紧迫需求" + [ "需求" + sprintf "饥饿 %.0f" panel.Needs.Hunger + sprintf "精力 %.0f" panel.Needs.Energy + sprintf "社交 %.0f" panel.Needs.Social + sprintf "金钱 %.0f" panel.Needs.Money topNeed status ] | ObservationPanel -> @@ -191,21 +280,21 @@ module M5Interaction = |> List.tryHead |> Option.map (fun score -> score.Urgency) |> Option.defaultValue 0.0f - [ sprintf "NPC %d U %.0f" (npcNumber observation.Id) urgency - sprintf "H %.0f E %.0f S %.0f M %.0f" + [ sprintf "村民 %d 紧迫度 %.0f" (npcNumber observation.Id) urgency + sprintf "饥饿 %.0f 精力 %.0f 社交 %.0f 金钱 %.0f" observation.Needs.Hunger observation.Needs.Energy observation.Needs.Social observation.Needs.Money ]) - "OBSERVATION" :: (if rows.IsEmpty then [ "NO NPC IN RANGE" ] else rows) @ [ status ] + "观察" :: (if rows.IsEmpty then [ "范围内暂无村民" ] else rows) @ [ status ] | DialoguePanel -> match view.Menu with - | None -> [ "DIALOGUE RECORDED"; status ] + | None -> [ "互动已记录"; status ] | Some menu -> let options = menu.Options |> List.mapi (fun index intent -> sprintf "%d %s" (index + 1) (intentName intent)) - [ sprintf "DIALOGUE NPC %d" (npcNumber menu.Target) ] @ options @ [ "ESC CLOSE"; status ] + [ sprintf "互动:村民 %d" (npcNumber menu.Target) ] @ options @ [ "ESC 关闭"; status ] | ChroniclePanel -> - let entries = chronicleLines view.Chronicle |> List.truncate 10 - "CHRONICLE" :: (if entries.IsEmpty then [ "NO ENTRIES" ] else entries) @ [ status ] + let entries = chronicleLines world + "年鉴" :: (if entries.IsEmpty then [ "暂无记录" ] else entries) @ [ status ] diff --git a/src/LivingVillage.Desktop/M6Presentation.fs b/src/LivingVillage.Desktop/M6Presentation.fs index 410f20a..b6c24fa 100644 --- a/src/LivingVillage.Desktop/M6Presentation.fs +++ b/src/LivingVillage.Desktop/M6Presentation.fs @@ -15,6 +15,11 @@ type M6RenderProfile = module M6Presentation = + let dayNightLabel mode : string = + match mode with + | Day -> "白天" + | Night -> "夜晚" + let profileAtTick (tick: int64) : M6RenderProfile = if Sim.isNightTick tick then { Mode = Night @@ -27,9 +32,9 @@ module M6Presentation = let clockLabel (control: SimulationControl) : string = if control.Paused then - "PAUSED" + "暂停" else match control.Speed with - | OneX -> "1X" - | TwoX -> "2X" - | FiveX -> "5X" + | OneX -> "1倍速" + | TwoX -> "2倍速" + | FiveX -> "5倍速" diff --git a/src/LivingVillage.Desktop/MenuState.fs b/src/LivingVillage.Desktop/MenuState.fs index 78d846c..452d233 100644 --- a/src/LivingVillage.Desktop/MenuState.fs +++ b/src/LivingVillage.Desktop/MenuState.fs @@ -55,20 +55,20 @@ module MenuState = [ OneXSetting; TwoXSetting; FiveXSetting; PauseSetting ] let controlRows : ControlRow list = - [ { Keys = "WASD"; Action = "MOVE" } - { Keys = "E"; Action = "INTERACT" } - { Keys = "1-6"; Action = "DIALOGUE" } - { Keys = "Q"; Action = "OBSERVE" } - { Keys = "TAB"; Action = "NEEDS" } - { Keys = "C"; Action = "CHRONICLE" } - { Keys = "L"; Action = "RELATIONS" } - { Keys = "P"; Action = "PAUSE" } - { Keys = "F1/F2/F3"; Action = "SPEED" } - { Keys = "F6"; Action = "SAVE" } - { Keys = "F7"; Action = "LOAD" } - { Keys = "ESC"; Action = "MENU OR CLOSE" } - { Keys = "UP/DOWN"; Action = "SELECT" } - { Keys = "ENTER"; Action = "CONFIRM" } ] + [ { Keys = "WASD"; Action = "移动" } + { Keys = "E"; Action = "互动" } + { Keys = "1-6"; Action = "对话" } + { Keys = "Q"; Action = "观察" } + { Keys = "TAB"; Action = "需求" } + { Keys = "C"; Action = "年鉴" } + { Keys = "L"; Action = "关系" } + { Keys = "P"; Action = "暂停" } + { Keys = "F1/F2/F3"; Action = "速度" } + { Keys = "F6"; Action = "保存" } + { Keys = "F7"; Action = "读取" } + { Keys = "ESC"; Action = "菜单或关闭" } + { Keys = "UP/DOWN"; Action = "选择" } + { Keys = "ENTER"; Action = "确认" } ] let create (simulationControl: SimulationControl) (hasCurrentWorld: bool) : MenuState = { Page = MainMenu @@ -86,19 +86,19 @@ module MenuState = let settingsLabel setting : string = match setting with - | OneXSetting -> "1X" - | TwoXSetting -> "2X" - | FiveXSetting -> "5X" - | PauseSetting -> "PAUSE" + | OneXSetting -> "1倍速" + | TwoXSetting -> "2倍速" + | FiveXSetting -> "5倍速" + | PauseSetting -> "暂停" let itemLabel item : string = match item with - | ContinueItem -> "CONTINUE" - | NewGameItem -> "NEW GAME" - | LoadItem -> "LOAD SAVE" - | SettingsItem -> "SETTINGS" - | ControlsItem -> "CONTROLS" - | ExitItem -> "QUIT" + | ContinueItem -> "继续游戏" + | NewGameItem -> "开始新游戏" + | LoadItem -> "读取存档" + | SettingsItem -> "设置" + | ControlsItem -> "操作说明" + | ExitItem -> "退出" let private optionsFor (state: MenuState) : int = match state.Page with |
