summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 23:03:16 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 23:03:16 +0800
commit6ce29c5e87d32e49dfe92b398fe45ef0b9de6cdf (patch)
tree859a902d27e069e0e087579b6ea38dec76dc4bf3 /src
parent11a2a1d8786a660d0f370a46f5ee0c69c108f823 (diff)
parent50875bb65cc4cdba92f6745d2430a92d85474872 (diff)
downloadliving-village-6ce29c5e87d32e49dfe92b398fe45ef0b9de6cdf.tar.gz
merge: P15 职业玩法收口——开局职业选择/货郎书生模板/CJK补字/全链路回归(独立复验全绿)
Diffstat (limited to 'src')
-rw-r--r--src/LivingVillage.Desktop.Tests/DesktopTests.fs119
-rw-r--r--src/LivingVillage.Desktop.Tests/M6bTests.fs30
-rw-r--r--src/LivingVillage.Desktop.Tests/PrototypeTests.fs6
-rw-r--r--src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.pngbin52827 -> 60594 bytes
-rw-r--r--src/LivingVillage.Desktop/Assets/cjk-glyphs.txt31
-rw-r--r--src/LivingVillage.Desktop/ChineseText.fs25
-rw-r--r--src/LivingVillage.Desktop/CjkGlyphAtlas.fs2
-rw-r--r--src/LivingVillage.Desktop/Game.fs38
-rw-r--r--src/LivingVillage.Desktop/MenuState.fs29
-rw-r--r--src/LivingVillage.Desktop/WorldBootstrap.fs17
-rw-r--r--src/LivingVillage.Kernel.Tests/DailyTaskTests.fs100
-rw-r--r--src/LivingVillage.Kernel/Occupation.fs61
-rw-r--r--src/LivingVillage.Kernel/WorldSave.fs8
13 files changed, 443 insertions, 23 deletions
diff --git a/src/LivingVillage.Desktop.Tests/DesktopTests.fs b/src/LivingVillage.Desktop.Tests/DesktopTests.fs
index 52443e9..00e445b 100644
--- a/src/LivingVillage.Desktop.Tests/DesktopTests.fs
+++ b/src/LivingVillage.Desktop.Tests/DesktopTests.fs
@@ -208,3 +208,122 @@ type TaskPanelTests () =
Assert.AreEqual<M5View>(dialogueView, afterTask)
let _, closed = M5Interaction.apply ClosePanel world { dialogueView with Panel = TaskPanel }
Assert.AreEqual<M5Panel>(WorldPanel, closed.Panel)
+
+// ---- P15 开局职业选择接入:bootstrap 职业状态 / 存档 round-trip / 面板联调 ----
+
+module private OccupationFlowHarness =
+
+ let withTempSave (action: string -> unit) =
+ let id = System.Guid.NewGuid().ToString("N")
+ let path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"living-village-p15-{id}.save")
+ try
+ action path
+ finally
+ if System.IO.File.Exists path then System.IO.File.Delete path
+
+[<TestClass>]
+type OccupationFlowTests () =
+
+ [<TestMethod>]
+ member _.BootstrapStampsOccupationMoneyEnergyAndTodaysOfferedTask () =
+ for kind in Occupation.all do
+ let state = WorldBootstrap.occupationStateFor 42UL kind 0L
+ let task = state.Today.Value
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Offered, task.State)
+ Assert.IsTrue(Occupation.taskPoolOf kind |> List.contains task.TemplateId)
+ Assert.AreEqual<Occupation.DailyTask>(
+ (Occupation.dailyTaskOf 42UL (Occupation.occupationSeedOf kind) 0L kind).Value,
+ task)
+ let world = WorldBootstrap.initialWorldWithOccupation true 42UL 4 (Some state)
+ Assert.AreEqual<float32>(float32 state.Profile.InitialMoney, world.Avatar.Mind.Needs.Money)
+ Assert.AreEqual<float32>(state.Profile.InitialEnergy, world.Avatar.Mind.Needs.Energy)
+
+ [<TestMethod>]
+ member _.ChosenOccupationAndTodaysTaskRoundTripThroughV3Save () =
+ for kind in Occupation.all do
+ let state = WorldBootstrap.occupationStateFor 42UL kind (3L * Sim.ticksPerDay + 120L)
+ let world = WorldBootstrap.initialWorldWithOccupation false 42UL 4 (Some state)
+ OccupationFlowHarness.withTempSave (fun path ->
+ WorldSave.saveToFileWith (Some state) path world
+ let text = System.IO.File.ReadAllText path
+ Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V3"), "chosen occupation must save as v3")
+ match WorldSave.loadFromFileWith path with
+ | Ok (restoredWorld, Some restoredState) ->
+ Assert.AreEqual<Occupation.Kind>(kind, restoredState.Profile.Kind)
+ Assert.AreEqual<Occupation.State>(state, restoredState)
+ Assert.AreEqual<string>(text, WorldSave.saveWith (Some restoredState) restoredWorld)
+ | Ok (_, None) -> Assert.Fail($"v3 occupation lost for {kind}")
+ | Error message -> Assert.Fail($"v3 round trip failed for {kind}: {message}"))
+
+ [<TestMethod>]
+ member _.TaskPanelShowsTheChosenOccupationsOfferedTask () =
+ let world = Sim.initialWorldN 42UL 4
+ for kind in Occupation.all do
+ let state = WorldBootstrap.occupationStateFor 42UL kind 0L
+ 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 (Occupation.nameOf kind), $"occupation name missing for {kind}")
+ Assert.IsTrue(
+ lines |> List.exists (fun line -> line.Contains(Occupation.taskNameOf state.Today.Value.TemplateId)),
+ $"task name missing for {kind}")
+ Assert.IsTrue(lines |> List.exists (fun line -> line.Contains("待接受")), $"offered state missing for {kind}")
+
+ [<TestMethod>]
+ member _.RefreshOccupationTodayKeepsSameDayAndRegeneratesForTheNewDay () =
+ let state = WorldBootstrap.occupationStateFor 42UL Occupation.Farmer 0L
+ Assert.AreEqual<Occupation.State>(state, WorldBootstrap.refreshOccupationToday 42UL state (Sim.ticksPerDay - 1L))
+ let day1 = WorldBootstrap.refreshOccupationToday 42UL state Sim.ticksPerDay
+ Assert.AreEqual<Occupation.DailyTask option>(
+ Some (Occupation.dailyTaskOf 42UL (Occupation.occupationSeedOf Occupation.Farmer) 1L Occupation.Farmer).Value,
+ day1.Today)
+
+ [<TestMethod>]
+ member _.ChosenOccupationFirstDayTaskReachesDoneThroughItsSignal () =
+ // 既有事件通道:交易/对话模板由 InteractionEvent 经 signalOfInteraction 推进;
+ // 到达瓦片/夜水边/观察由调用方折叠为信号(Sim.step 不感知)。
+ let eventOf (template: Occupation.TaskTemplateId) : Sim.InteractionEvent option =
+ match template with
+ | Occupation.DeliverGrain ->
+ Some { Tick = 0L; Kind = Sim.TradeEvent(NpcId 1, Sim.playerId, Food, 1, 1.0f) }
+ | Occupation.SellFish ->
+ Some { Tick = 0L; Kind = Sim.TradeEvent(NpcId 1, Sim.playerId, Fish, 1, 1.0f) }
+ | Occupation.Resell ->
+ Some { Tick = 0L; Kind = Sim.TradeEvent(NpcId 1, Sim.playerId, Spice, 1, 1.0f) }
+ | Occupation.BuyGoods ->
+ Some { Tick = 0L; Kind = Sim.TradeEvent(Sim.playerId, NpcId 1, Spice, 1, 1.0f) }
+ | Occupation.HelpWork
+ | Occupation.MarketInquiry
+ | Occupation.ReasonDebate ->
+ Some
+ { Tick = 0L
+ Kind =
+ Sim.DialogueEvent
+ { Tick = 0L
+ Actor = Sim.playerId
+ Target = NpcId 0
+ Intent = SmallTalk
+ Response = Friendly
+ Valence = 0.3f } }
+ | Occupation.TillSoil
+ | Occupation.NightCatch
+ | Occupation.ObserveNotes -> None
+ let foldedSignal (template: Occupation.TaskTemplateId) : Occupation.TaskSignal =
+ match template with
+ | Occupation.TillSoil -> Occupation.ArrivedAt(52, 38)
+ | Occupation.NightCatch -> Occupation.NightAtWater
+ | Occupation.ObserveNotes -> Occupation.Observed
+ | _ -> failwith "event-backed template"
+ for kind in Occupation.all do
+ let state = WorldBootstrap.occupationStateFor 42UL kind 0L
+ let offered = state.Today.Value
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Offered, offered.State)
+ Assert.IsTrue(Occupation.taskPoolOf kind |> List.contains offered.TemplateId)
+ let active = { state with Today = Some(Occupation.acceptTask offered) }
+ let completed =
+ match eventOf offered.TemplateId with
+ | Some event -> Occupation.advanceTask event active
+ | None ->
+ { active with
+ Today = active.Today |> Option.map (Occupation.applySignal (foldedSignal offered.TemplateId)) }
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Done, completed.Today.Value.State)
diff --git a/src/LivingVillage.Desktop.Tests/M6bTests.fs b/src/LivingVillage.Desktop.Tests/M6bTests.fs
index 6ae0634..5aea9bd 100644
--- a/src/LivingVillage.Desktop.Tests/M6bTests.fs
+++ b/src/LivingVillage.Desktop.Tests/M6bTests.fs
@@ -80,3 +80,33 @@ type M6bMenuTests () =
Assert.AreEqual<string option>(Some "missing save", failed.Error)
Assert.AreEqual<MenuPage>(MainMenu, returned.Page)
Assert.AreEqual<MenuCommand>(NoCommand, command)
+
+ // ---- P15 开局职业选择页 ----
+
+ [<TestMethod>]
+ member _.OccupationSelectOffersFourKindsThenNoneAndDefaultsToNone () =
+ let page = MenuState.openOccupationSelect (MenuState.create SimulationControl.initial false)
+
+ Assert.AreEqual<MenuPage>(OccupationSelect, page.Page)
+ Assert.AreEqual<Occupation.Kind option list>(
+ [ Some Occupation.Farmer; Some Occupation.Fisher; Some Occupation.Peddler; Some Occupation.Scholar; None ],
+ MenuState.occupationOptions)
+ Assert.AreEqual<Occupation.Kind option>(None, MenuState.selectedOccupation page)
+ Assert.AreEqual<string>("暂不选择", MenuState.occupationLabel None)
+ Assert.AreEqual<string>("农夫", MenuState.occupationLabel (Some Occupation.Farmer))
+ Assert.AreEqual<string>("书生", MenuState.occupationLabel (Some Occupation.Scholar))
+
+ [<TestMethod>]
+ member _.OccupationSelectConfirmEmitsChosenKindAndBackReturnsToMenu () =
+ let page = MenuState.openOccupationSelect (MenuState.create SimulationControl.initial false)
+ let _, defaultCommand = MenuState.update Confirm page
+ Assert.AreEqual<MenuCommand>(StartNewGameWith None, defaultCommand)
+
+ let picked = MenuState.move Up (MenuState.move Up page)
+ Assert.AreEqual<Occupation.Kind option>(Some Occupation.Peddler, MenuState.selectedOccupation picked)
+ let _, command = MenuState.update Confirm picked
+ Assert.AreEqual<MenuCommand>(StartNewGameWith (Some Occupation.Peddler), command)
+
+ let back, backCommand = MenuState.update Back page
+ Assert.AreEqual<MenuPage>(MainMenu, back.Page)
+ Assert.AreEqual<MenuCommand>(NoCommand, backCommand)
diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
index eeafb56..658e2e1 100644
--- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
+++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
@@ -945,14 +945,14 @@ type PrototypeTests () =
[<TestMethod>]
member _.CjkGlyphAtlasHasAStableCharacterTableAndGeometry () =
- Assert.AreEqual<int>(197, characters.Length)
+ Assert.AreEqual<int>(228, 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>(13, rowCount)
+ Assert.AreEqual<int>(15, rowCount)
Assert.AreEqual<int>(384, pixelWidth)
- Assert.AreEqual<int>(312, pixelHeight)
+ Assert.AreEqual<int>(360, 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
index 9678aeb..598c2bc 100644
--- a/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png
+++ b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png
Binary files differ
diff --git a/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt b/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt
index 331e2ec..cdfc550 100644
--- a/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt
+++ b/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt
@@ -1,16 +1,22 @@
×
一
不
+业
+中
主
乐
乡
+书
买
+事
于
互
交
人
+今
从
价
+任
传
体
作
@@ -21,12 +27,14 @@
入
关
内
+农
准
出
到
前
力
功
+务
动
助
单
@@ -35,18 +43,21 @@
原
友
取
+受
可
同
和
回
围
圃
+土
在
墙
备
夜
大
天
+夫
失
好
始
@@ -59,7 +70,9 @@
层
居
屋
+工
已
+帮
年
庄
库
@@ -69,6 +82,7 @@
式
当
录
+待
得
心
必
@@ -86,16 +100,20 @@
拒
择
挑
+捕
据
+接
提
播
操
+收
效
敌
数
新
方
无
+日
时
明
易
@@ -119,10 +137,13 @@
江
没
法
+渔
游
物
状
+理
瓦
+生
用
留
白
@@ -134,6 +155,7 @@
移
竹
笑
+粮
精
系
紧
@@ -143,37 +165,46 @@
绪
续
置
+翻
聊
+职
能
范
获
菜
衅
+行
观
认
讨
议
记
讲
+论
设
话
+询
说
请
读
谅
谈
败
+货
资
足
+转
近
返
还
进
迫
退
+送
选
速
道
+郎
量
金
鉴
diff --git a/src/LivingVillage.Desktop/ChineseText.fs b/src/LivingVillage.Desktop/ChineseText.fs
index d516432..82418e6 100644
--- a/src/LivingVillage.Desktop/ChineseText.fs
+++ b/src/LivingVillage.Desktop/ChineseText.fs
@@ -184,7 +184,30 @@ 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 fc19828..07895c8 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 6918e00..1b9508b 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -204,11 +204,18 @@ type LivingVillageGame() as this =
m5View <- M5Interaction.refreshPrompt world M5Interaction.initial
this.CenterCamera()
- member private this.StartNewGame() =
- world <- createInitialWorld ()
+ member private this.StartNewGame (occupation: Occupation.Kind option) =
+ let state =
+ occupation
+ |> Option.map (fun kind ->
+ let startTick = if daylightAutoplay then 6L * Sim.ticksPerDay / 24L else 0L
+ WorldBootstrap.occupationStateFor 42UL kind startTick)
+ world <- WorldBootstrap.initialWorldWithOccupation daylightAutoplay 42UL Sim.npcCount state
this.ResetWorldView()
+ m5View <- { m5View with Task = state }
menu <- menu |> MenuState.setCurrentWorld true |> MenuState.enterGame
- printfn $"new-game=ok seed=42 npcs={Sim.npcCount}"
+ let occupationName = occupation |> Option.map Occupation.nameOf |> Option.defaultValue "none"
+ printfn $"new-game=ok seed=42 npcs={Sim.npcCount} occupation={occupationName}"
member private this.SaveWorld (prefix: string) : bool =
try
@@ -247,7 +254,10 @@ type LivingVillageGame() as this =
| ContinueGame ->
menu <- menu |> MenuState.setCurrentWorld true |> MenuState.enterGame
printfn $"continue=ok tick={world.Tick}"
- | StartNewGame -> this.StartNewGame()
+ | StartNewGame ->
+ menu <- MenuState.openOccupationSelect menu
+ printfn "menu=occupation-select"
+ | StartNewGameWith occupation -> this.StartNewGame occupation
| LoadGame ->
match this.LoadWorld "load" with
| Ok _ ->
@@ -278,7 +288,9 @@ type LivingVillageGame() as this =
else None
match input with
| Some nextInput -> this.DispatchMenuInput nextInput
- | None when (autoplay || sampleMode) && not autoplayFlow && menu.Page = MainMenu && not menu.HasCurrentWorld ->
+ | None when (autoplay || sampleMode) && not autoplayFlow
+ && (menu.Page = MainMenu || menu.Page = OccupationSelect)
+ && not menu.HasCurrentWorld ->
autoplayFrames <- autoplayFrames + 1
if autoplayFrames >= 15 then
this.DispatchMenuInput Confirm
@@ -435,6 +447,12 @@ type LivingVillageGame() as this =
else
avatarMoving <- false
this.CenterCamera()
+ match m5View.Task with
+ | Some state ->
+ let refreshed = WorldBootstrap.refreshOccupationToday 42UL state world.Tick
+ if refreshed <> state then
+ m5View <- { m5View with Task = Some refreshed }
+ | None -> ()
m5View <- M5Interaction.refreshPrompt world m5View
member private this.RunAutoplayFlow() =
@@ -455,6 +473,9 @@ type LivingVillageGame() as this =
| 50 ->
printfn "flow=new-game"
flowStep <- 1
+ | 1 when menu.Page = OccupationSelect ->
+ this.DispatchMenuInput Confirm
+ printfn "flow=occupation=none"
| 1 when menu.Page = Playing ->
this.OpenMainMenu()
printfn "flow=return-menu"
@@ -619,6 +640,7 @@ type LivingVillageGame() as this =
let contentRows =
match menu.Page with
| MainMenu -> MenuState.items menu |> List.length
+ | OccupationSelect -> MenuState.occupationOptions.Length
| Settings -> MenuState.settings.Length
| Controls -> MenuState.controlRows.Length
| LoadError -> 4
@@ -696,6 +718,12 @@ type LivingVillageGame() as this =
MenuState.items menu
|> List.iteri (fun index item -> drawSelection index (MenuState.itemLabel item) (contentY + index * lineHeight))
drawText innerX (panelY + panelHeight - 40) mutedColor "UP DOWN 选择 ENTER 确认"
+ | OccupationSelect ->
+ drawText innerX headingY mutedColor "选择职业"
+ MenuState.occupationOptions
+ |> List.iteri (fun index occupation ->
+ drawSelection index (MenuState.occupationLabel occupation) (contentY + index * lineHeight))
+ drawText innerX (panelY + panelHeight - 40) mutedColor "UP DOWN 选择 ENTER 确认 ESC 返回"
| Settings ->
drawText innerX headingY mutedColor (ChineseText.menuHeading SettingsHeading)
MenuState.settings
diff --git a/src/LivingVillage.Desktop/MenuState.fs b/src/LivingVillage.Desktop/MenuState.fs
index 2848e22..619ac58 100644
--- a/src/LivingVillage.Desktop/MenuState.fs
+++ b/src/LivingVillage.Desktop/MenuState.fs
@@ -4,6 +4,7 @@ open LivingVillage.Kernel
type MenuPage =
| MainMenu
+ | OccupationSelect
| Settings
| Controls
| Playing
@@ -33,6 +34,7 @@ type MenuCommand =
| NoCommand
| ContinueGame
| StartNewGame
+ | StartNewGameWith of Occupation.Kind option
| LoadGame
| ApplySettings of SimulationControl
| ExitGame
@@ -100,9 +102,26 @@ module MenuState =
| ControlsItem -> "操作说明"
| ExitItem -> "退出"
+ /// 开局四选一 + 「暂不选择」(None = 旧行为,无职业)。
+ let occupationOptions : Occupation.Kind option list =
+ [ Some Occupation.Farmer
+ Some Occupation.Fisher
+ Some Occupation.Peddler
+ Some Occupation.Scholar
+ None ]
+
+ let occupationLabel (occupation: Occupation.Kind option) : string =
+ match occupation with
+ | Some kind -> Occupation.nameOf kind
+ | None -> "暂不选择"
+
+ let selectedOccupation (state: MenuState) : Occupation.Kind option =
+ occupationOptions |> List.item state.Selected
+
let private optionsFor (state: MenuState) : int =
match state.Page with
| MainMenu -> items state |> List.length
+ | OccupationSelect -> occupationOptions.Length
| Settings -> settings.Length
| _ -> 0
@@ -150,6 +169,14 @@ module MenuState =
let openSettings (state: MenuState) : MenuState =
{ state with Page = Settings; Selected = 0; ReturnPage = state.Page; Error = None }
+ /// 新档确认后进入职业选择页;默认选中「暂不选择」= 不写职业的旧行为。
+ let openOccupationSelect (state: MenuState) : MenuState =
+ { state with
+ Page = OccupationSelect
+ Selected = occupationOptions.Length - 1
+ ReturnPage = state.Page
+ Error = None }
+
let openControls (state: MenuState) : MenuState =
{ state with Page = Controls; Selected = 0; ReturnPage = state.Page; Error = None }
@@ -187,6 +214,7 @@ module MenuState =
| Back ->
match state.Page with
| MainMenu -> state, ExitGame
+ | OccupationSelect -> openMain state, NoCommand
| Settings
| Controls -> returnFromSubpage state, NoCommand
| LoadError -> openMain state, NoCommand
@@ -194,6 +222,7 @@ module MenuState =
| Confirm ->
match state.Page with
| MainMenu -> confirmMain state
+ | OccupationSelect -> state, StartNewGameWith (selectedOccupation state)
| Settings ->
let nextSettings = selectedControl state
{ state with Settings = nextSettings }, ApplySettings nextSettings
diff --git a/src/LivingVillage.Desktop/WorldBootstrap.fs b/src/LivingVillage.Desktop/WorldBootstrap.fs
index 0e8ea30..f8ba775 100644
--- a/src/LivingVillage.Desktop/WorldBootstrap.fs
+++ b/src/LivingVillage.Desktop/WorldBootstrap.fs
@@ -18,6 +18,23 @@ module WorldBootstrap =
let private daylightStartTick = 6L * Sim.ticksPerDay / 24L
+ /// 新档职业状态:按世界 seed 与职业种子生成 nowTick 所属日的 Offered 任务(design §3)。
+ let occupationStateFor (seed: uint64) (kind: Occupation.Kind) (nowTick: int64) : Occupation.State =
+ let baseState = Occupation.stateOf kind
+ { baseState with
+ Today = Occupation.dailyTaskOf seed (Occupation.occupationSeedOf kind) (Occupation.dayIndexOf nowTick) kind }
+
+ /// 跨日刷新当日任务;同日(含 Done/Failed 终态)原样保留。
+ let refreshOccupationToday (seed: uint64) (state: Occupation.State) (nowTick: int64) : Occupation.State =
+ { state with
+ Today =
+ Occupation.refreshToday
+ seed
+ (Occupation.occupationSeedOf state.Profile.Kind)
+ state.Profile.Kind
+ nowTick
+ state.Today }
+
let initialWorldWithOccupation (startAtDaylight: bool) (seed: uint64) (count: int) (occupation: Occupation.State option) : World =
if count < 0 || count > npcSpawnTiles.Length then
invalidArg (nameof count) "Desktop bootstrap supports at most 30 NPCs."
diff --git a/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs b/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs
index e784d83..0800ff8 100644
--- a/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs
+++ b/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs
@@ -50,20 +50,24 @@ type DailyTaskTests () =
Assert.AreEqual<uint64>(0x02D114B7ABDD7A1FUL, Occupation.taskHash 4242UL 99UL 0L)
[<TestMethod>]
- member _.FarmerAndFisherPoolsAreThreeTemplatesEach () =
+ member _.EveryOccupationPoolIsNonEmptyAndFarmFisherKeepThree () =
Assert.AreEqual(
[ Occupation.DeliverGrain; Occupation.HelpWork; Occupation.TillSoil ],
Occupation.taskPoolOf Occupation.Farmer)
Assert.AreEqual(
[ Occupation.NightCatch; Occupation.SellFish; Occupation.MarketInquiry ],
Occupation.taskPoolOf Occupation.Fisher)
- Assert.AreEqual<int>(0, Occupation.taskPoolOf Occupation.Peddler |> List.length)
- Assert.AreEqual<int>(0, Occupation.taskPoolOf Occupation.Scholar |> List.length)
+ Assert.AreEqual(
+ [ Occupation.BuyGoods; Occupation.Resell ],
+ Occupation.taskPoolOf Occupation.Peddler)
+ Assert.AreEqual(
+ [ Occupation.ObserveNotes; Occupation.ReasonDebate ],
+ Occupation.taskPoolOf Occupation.Scholar)
[<TestMethod>]
member _.GeneratedTasksStayInPoolAndRepeatDeterministically () =
for seed, occ in [ (42UL, 7UL); (7UL, 42UL); (123456789UL, 555UL) ] do
- for kind in [ Occupation.Farmer; Occupation.Fisher ] do
+ for kind in Occupation.all do
let pool = Occupation.taskPoolOf kind
for day in 0L .. 299L do
let task = (Occupation.dailyTaskOf seed occ day kind).Value
@@ -77,9 +81,17 @@ type DailyTaskTests () =
Assert.IsTrue(distinct.Count >= 2, "300 days must not pin a single template")
[<TestMethod>]
- member _.PeddlerAndScholarHaveNoDailyTaskYet () =
- Assert.AreEqual<Occupation.DailyTask option>(None, Occupation.dailyTaskOf 42UL 7UL 0L Occupation.Peddler)
- Assert.AreEqual<Occupation.DailyTask option>(None, Occupation.dailyTaskOf 42UL 7UL 0L Occupation.Scholar)
+ member _.PeddlerAndScholarGenerateTheirOwnDailyTasks () =
+ for kind in [ Occupation.Peddler; Occupation.Scholar ] do
+ for day in 0L .. 59L do
+ let task = (Occupation.dailyTaskOf 42UL 7UL day kind).Value
+ Assert.IsTrue(Occupation.taskPoolOf kind |> List.contains task.TemplateId)
+ Assert.AreEqual<Occupation.TaskTemplateId>(
+ Occupation.BuyGoods,
+ (Occupation.dailyTaskOf 42UL 7UL 0L Occupation.Peddler).Value.TemplateId)
+ Assert.AreEqual<Occupation.TaskTemplateId>(
+ Occupation.ObserveNotes,
+ (Occupation.dailyTaskOf 42UL 7UL 0L Occupation.Scholar).Value.TemplateId)
[<TestMethod>]
member _.TaskWindowCoversExactlyOneDayStartingOffered () =
@@ -98,6 +110,10 @@ type DailyTaskTests () =
Assert.AreEqual<string>("夜捕", Occupation.taskNameOf Occupation.NightCatch)
Assert.AreEqual<string>("卖渔", Occupation.taskNameOf Occupation.SellFish)
Assert.AreEqual<string>("询价", Occupation.taskNameOf Occupation.MarketInquiry)
+ Assert.AreEqual<string>("收货", Occupation.taskNameOf Occupation.BuyGoods)
+ Assert.AreEqual<string>("转卖", Occupation.taskNameOf Occupation.Resell)
+ Assert.AreEqual<string>("观察记事", Occupation.taskNameOf Occupation.ObserveNotes)
+ Assert.AreEqual<string>("论理", Occupation.taskNameOf Occupation.ReasonDebate)
// ---- §3 状态机 Offered|Active|Done|Failed ----
@@ -298,3 +314,73 @@ type DailyTaskTests () =
let state = { (Occupation.stateOf Occupation.Farmer) with Today = Some(DailyTaskHarness.taskForDay 9L) }
WorldSave.saveWith (Some state) world
Assert.AreEqual<string>(build (), build ())
+
+ // ---- P15:货郎/书生模板完成信号、事件通道桥、当日刷新 ----
+
+ [<TestMethod>]
+ member _.PeddlerAndScholarTemplatesCompleteOnTheirSignals () =
+ let buy = DailyTaskHarness.accepted Occupation.BuyGoods
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Done, (Occupation.applySignal (Occupation.Purchased(Food, 2)) buy).State)
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Active, (Occupation.applySignal (Occupation.Purchased(Food, 0)) buy).State)
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Active, (Occupation.applySignal (Occupation.Dialogued(NpcId 0)) buy).State)
+ let resell = DailyTaskHarness.accepted Occupation.Resell
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Done, (Occupation.applySignal (Occupation.Traded(playerId, Spice, 1)) resell).State)
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Active, (Occupation.applySignal (Occupation.Traded(NpcId 2, Spice, 1)) resell).State)
+ let observe = DailyTaskHarness.accepted Occupation.ObserveNotes
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Done, (Occupation.applySignal Occupation.Observed observe).State)
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Active, (Occupation.applySignal (Occupation.Dialogued(NpcId 0)) observe).State)
+ let reason = DailyTaskHarness.accepted Occupation.ReasonDebate
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Done, (Occupation.applySignal (Occupation.Dialogued(NpcId 3)) reason).State)
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Active, (Occupation.applySignal Occupation.Observed reason).State)
+
+ [<TestMethod>]
+ member _.SignalBridgeIgnoresNpcInitiatedEvents () =
+ let npcDialogue =
+ { Tick = 5L
+ Kind = DialogueEvent { Tick = 5L; Actor = NpcId 1; Target = NpcId 2; Intent = SmallTalk; Response = Friendly; Valence = 0.3f } }
+ Assert.AreEqual<Occupation.TaskSignal option>(None, Occupation.signalOfInteraction npcDialogue)
+ let npcTrade = { Tick = 5L; Kind = TradeEvent(NpcId 1, NpcId 2, Food, 1, 1.0f) }
+ Assert.AreEqual<Occupation.TaskSignal option>(None, Occupation.signalOfInteraction npcTrade)
+ let playerBuy = { Tick = 6L; Kind = TradeEvent(playerId, NpcId 2, Spice, 3, 2.0f) }
+ Assert.AreEqual<Occupation.TaskSignal option>(Some(Occupation.Purchased(Spice, 3)), Occupation.signalOfInteraction playerBuy)
+ let playerSell = { Tick = 7L; Kind = TradeEvent(NpcId 2, playerId, Fish, 4, 2.0f) }
+ Assert.AreEqual<Occupation.TaskSignal option>(Some(Occupation.Traded(playerId, Fish, 4)), Occupation.signalOfInteraction playerSell)
+
+ [<TestMethod>]
+ member _.PlayerDialogueEventThroughExistingChannelCompletesTheDaysTask () =
+ let world = Sim.initialWorldN 7UL 4
+ let nearFirst = { world with Avatar = { world.Avatar with Pos = world.Npcs.[0].Pos } }
+ match Sim.chooseDialogue (NpcId 0) SmallTalk nearFirst with
+ | DialogueRejected (failure, _) -> Assert.Fail($"dialogue rejected: {failure}")
+ | DialogueSucceeded (outcome, afterDialogue) ->
+ let event = afterDialogue.Events |> List.last
+ Assert.AreEqual<Sim.NpcId>(playerId, outcome.Actor)
+ Assert.AreEqual<Occupation.TaskSignal option>(Some(Occupation.Dialogued(NpcId 0)), Occupation.signalOfInteraction event)
+ let day =
+ [ 0L .. 60L ]
+ |> List.find (fun day ->
+ (Occupation.dailyTaskOf 42UL 7UL day Occupation.Farmer).Value.TemplateId = Occupation.HelpWork)
+ let offered = (Occupation.dailyTaskOf 42UL 7UL day Occupation.Farmer).Value
+ let notAccepted = Occupation.advanceTask event { (Occupation.stateOf Occupation.Farmer) with Today = Some offered }
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Offered, notAccepted.Today.Value.State)
+ let active = notAccepted.Today |> Option.map Occupation.acceptTask
+ let completed = Occupation.advanceTask event { notAccepted with Today = active }
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Done, completed.Today.Value.State)
+
+ [<TestMethod>]
+ member _.RefreshTodayKeepsSameDayFinalsAndRegeneratesOnDayChange () =
+ let day0 = (Occupation.dailyTaskOf 42UL 7UL 0L Occupation.Farmer).Value
+ Assert.AreEqual<Occupation.DailyTask option>(
+ Some day0,
+ Occupation.refreshToday 42UL 7UL Occupation.Farmer (Sim.ticksPerDay - 1L) (Some day0))
+ let day1 = (Occupation.dailyTaskOf 42UL 7UL 1L Occupation.Farmer).Value
+ Assert.AreEqual<Occupation.DailyTask option>(
+ Some day1,
+ Occupation.refreshToday 42UL 7UL Occupation.Farmer Sim.ticksPerDay (Some day0))
+ let finished = { day0 with State = Occupation.Done }
+ Assert.AreEqual<Occupation.DailyTask option>(
+ Some finished,
+ Occupation.refreshToday 42UL 7UL Occupation.Farmer 10L (Some finished))
+ Assert.AreEqual<Occupation.DailyTask option>(
+ Some day1,
+ Occupation.refreshToday 42UL 7UL Occupation.Farmer Sim.ticksPerDay None)
diff --git a/src/LivingVillage.Kernel/Occupation.fs b/src/LivingVillage.Kernel/Occupation.fs
index 5c75c81..2168c4e 100644
--- a/src/LivingVillage.Kernel/Occupation.fs
+++ b/src/LivingVillage.Kernel/Occupation.fs
@@ -51,7 +51,8 @@ module Occupation =
| Done
| Failed
- /// 首期任务池 = 农夫3(送粮/帮工/翻土)+ 渔夫3(夜捕/卖渔/询价),design §3 表。
+ /// 首期任务池 = 农夫3(送粮/帮工/翻土)+ 渔夫3(夜捕/卖渔/询价)
+ /// + P15 补齐货郎2(收货/转卖)+ 书生2(观察记事/论理),design §3 表。
type TaskTemplateId =
| DeliverGrain
| HelpWork
@@ -59,6 +60,10 @@ module Occupation =
| NightCatch
| SellFish
| MarketInquiry
+ | BuyGoods
+ | Resell
+ | ObserveNotes
+ | ReasonDebate
let taskNameOf (template: TaskTemplateId) : string =
match template with
@@ -68,14 +73,26 @@ module Occupation =
| NightCatch -> "夜捕"
| SellFish -> "卖渔"
| MarketInquiry -> "询价"
+ | BuyGoods -> "收货"
+ | Resell -> "转卖"
+ | ObserveNotes -> "观察记事"
+ | ReasonDebate -> "论理"
- /// 货郎/书生模板留后续单,当前为空池 → 不生成每日任务。
let taskPoolOf (kind: Kind) : TaskTemplateId list =
match kind with
| Farmer -> [ DeliverGrain; HelpWork; TillSoil ]
| Fisher -> [ NightCatch; SellFish; MarketInquiry ]
- | Peddler
- | Scholar -> []
+ | Peddler -> [ BuyGoods; Resell ]
+ | Scholar -> [ ObserveNotes; ReasonDebate ]
+
+ /// 每职业任务流种子:与世界 seed 异或后驱动 taskHash,使四职业的每日序列互不相同。
+ /// 纯常量(splitmix 同源),禁 System.Random。
+ let occupationSeedOf (kind: Kind) : uint64 =
+ match kind with
+ | Farmer -> 0x1111111111111111UL
+ | Fisher -> 0x2222222222222222UL
+ | Peddler -> 0x3333333333333333UL
+ | Scholar -> 0x4444444444444444UL
type DailyTask =
{ TemplateId: TaskTemplateId
@@ -87,12 +104,15 @@ module Occupation =
/// 完成判定信号(design §3:走既有事件通道)。
/// Dialogued 来自 DialogueEvent(chooseDialogue);Traded 来自 TradeEvent,
- /// 字段序 = 卖方/物品/数量(玩家递出即 seller = playerId);ArrivedAt 为到达瓦片;
+ /// 字段序 = 卖方/物品/数量(玩家递出即 seller = playerId);Purchased = 玩家买入
+ /// (TradeEvent 中 playerId 为 buyer);ArrivedAt 为到达瓦片;Observed 为观察面板;
/// NightAtWater 由调用方把 isNight + 水边判定折叠后喂入。Sim.step 零感知。
type TaskSignal =
| Dialogued of Sim.NpcId
| Traded of Sim.NpcId * Sim.ItemKind * int
+ | Purchased of Sim.ItemKind * int
| ArrivedAt of int * int
+ | Observed
| NightAtWater
/// TargetNpc/TargetTile 为 None 时视为通配(指定目标由后续 Desktop 接线单落地)。
@@ -110,6 +130,10 @@ module Occupation =
| NightCatch, NightAtWater -> true
| SellFish, Traded (seller, item, quantity) -> seller = Sim.playerId && item = Sim.Fish && quantity >= 1
| MarketInquiry, Dialogued _ -> true
+ | BuyGoods, Purchased (_, quantity) -> quantity >= 1
+ | Resell, Traded (seller, _, quantity) -> seller = Sim.playerId && quantity >= 1
+ | ObserveNotes, Observed -> true
+ | ReasonDebate, Dialogued _ -> true
| _ -> false
/// Offered → Active;其余状态原样返回(终态不可复活)。
@@ -145,7 +169,7 @@ module Occupation =
state <- next
out
- /// 当日任务生成:池内确定性抽 1 条;空池(货郎/书生首期)→ None。
+ /// 当日任务生成:池内确定性抽 1 条;空池 → None。
/// TargetNpc/TargetTile 首期留 None(通配),OfferedTick/DueTick 覆盖整日。
let dailyTaskOf (seed: uint64) (occupationSeed: uint64) (dayIndex: int64) (kind: Kind) : DailyTask option =
match taskPoolOf kind with
@@ -161,6 +185,13 @@ module Occupation =
DueTick = offered + Sim.ticksPerDay - 1L
State = Offered }
+ /// 当日任务刷新:已持任务的 OfferedTick 仍落在 nowTick 所属日时原样保留(含
+ /// Done/Failed 终态,保证当日面板与存档稳定);跨日才按当日重新生成 Offered。
+ let refreshToday (seed: uint64) (occupationSeed: uint64) (kind: Kind) (nowTick: int64) (today: DailyTask option) : DailyTask option =
+ match today with
+ | Some task when dayIndexOf task.OfferedTick = dayIndexOf nowTick -> Some task
+ | _ -> dailyTaskOf seed occupationSeed (dayIndexOf nowTick) kind
+
/// 挂在玩家侧的职业状态,随存档 v3 保存(design §2)。
/// Today = 当日任务(§3);TaskToken 保留为 v3 前缀占位扩展位(恒 0,暂未启用)。
type State =
@@ -172,6 +203,24 @@ module Occupation =
let stateOf (kind: Kind) : State =
{ Profile = profileOf kind; TaskToken = 0; Today = None; StoryStage = 0 }
+ // ---- 既有事件通道 → 任务信号桥(design §3:完成判定统一走 InteractionEvent)----
+
+ /// 仅玩家发起的对话/交易才产生任务信号;NPC 之间互动的副作用不派任务。
+ /// TradeEvent 字段序 = buyer/seller/item/quantity/price,玩家为卖方 → Traded,为买方 → Purchased。
+ let signalOfInteraction (event: Sim.InteractionEvent) : TaskSignal option =
+ match event.Kind with
+ | Sim.DialogueEvent outcome when outcome.Actor = Sim.playerId -> Some(Dialogued outcome.Target)
+ | Sim.TradeEvent (_, seller, item, quantity, _) when seller = Sim.playerId -> Some(Traded(seller, item, quantity))
+ | Sim.TradeEvent (buyer, _, item, quantity, _) when buyer = Sim.playerId -> Some(Purchased(item, quantity))
+ | _ -> None
+
+ /// 用一条既有事件推进当日任务(Offered 须先 acceptTask;终态不可复活)。
+ /// 无职业 / 无当日任务 / 非玩家事件时原样返回。
+ let advanceTask (event: Sim.InteractionEvent) (state: State) : State =
+ match state.Today, signalOfInteraction event with
+ | Some task, Some signal -> { state with Today = Some(applySignal signal task) }
+ | _ -> state
+
// ---- 报价偏置(design §2/§3):Kernel 纯函数旁路,不改 Sim.fs 数值口径 ----
// 4 位定点整数基点(1/10000)表达偏置,避免浮点漂移;结果钳在 [0.95, 1.05]。
// 货郎:买卖双向有利(买压价/卖抬价 ±3.00%);书生:解读行情小幅加成 ±1.00%;
diff --git a/src/LivingVillage.Kernel/WorldSave.fs b/src/LivingVillage.Kernel/WorldSave.fs
index 1ce1dcb..b327c6d 100644
--- a/src/LivingVillage.Kernel/WorldSave.fs
+++ b/src/LivingVillage.Kernel/WorldSave.fs
@@ -415,6 +415,10 @@ module WorldSave =
| Occupation.NightCatch -> add tokens "night-catch"
| Occupation.SellFish -> add tokens "sell-fish"
| Occupation.MarketInquiry -> add tokens "market-inquiry"
+ | Occupation.BuyGoods -> add tokens "buy-goods"
+ | Occupation.Resell -> add tokens "resell"
+ | Occupation.ObserveNotes -> add tokens "observe-notes"
+ | Occupation.ReasonDebate -> add tokens "reason-debate"
let private readTaskTemplateId (reader: TokenReader) label =
match reader.Take(label) with
@@ -424,6 +428,10 @@ module WorldSave =
| "night-catch" -> Occupation.NightCatch
| "sell-fish" -> Occupation.SellFish
| "market-inquiry" -> Occupation.MarketInquiry
+ | "buy-goods" -> Occupation.BuyGoods
+ | "resell" -> Occupation.Resell
+ | "observe-notes" -> Occupation.ObserveNotes
+ | "reason-debate" -> Occupation.ReasonDebate
| _ -> invalid (sprintf "invalid %s" label)
let private writeTaskState tokens state =