summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-20 21:38:46 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-20 21:38:46 +0800
commitc64779031d8459c44d78916ab6e38e80d2532a1c (patch)
tree5cddbc68f3708ce58650ddd172483f5fe9e14fe0
parentc5ff7685e5b3ac5dc46fce74ef1b302de09af1e6 (diff)
downloadliving-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.
-rw-r--r--src/LivingVillage.Desktop.Tests/DesktopTests.fs2
-rw-r--r--src/LivingVillage.Desktop.Tests/M6aTests.fs8
-rw-r--r--src/LivingVillage.Desktop.Tests/PrototypeTests.fs76
-rw-r--r--src/LivingVillage.Desktop/Interaction.fs171
-rw-r--r--src/LivingVillage.Desktop/M6Presentation.fs13
-rw-r--r--src/LivingVillage.Desktop/MenuState.fs48
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