summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-28 22:38:52 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-28 22:38:52 +0800
commitaa3baa953ea5c4f99f5f23cb3eef9400338b61ec (patch)
tree641759916bcf736ea91af48f798a0f7a3a406c99 /src
parent3aa7221453afec24757b0c7e4d352f69af8b395b (diff)
downloadliving-village-aa3baa953ea5c4f99f5f23cb3eef9400338b61ec.tar.gz
p51: 职业系统实现首切片——开局钱/体力/背包侧车 + 当日任务/跨日刷新 + StartNewGameWith 读回(design §2.4-1)
Diffstat (limited to 'src')
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P51ProfessionSliceTests.fs138
-rw-r--r--src/LivingVillage.Desktop/Game.fs7
-rw-r--r--src/LivingVillage.Desktop/WorldBootstrap.fs9
-rw-r--r--src/LivingVillage.Kernel.Tests/DailyTaskTests.fs8
-rw-r--r--src/LivingVillage.Kernel/Occupation.fs15
-rw-r--r--src/LivingVillage.Kernel/WorldSave.fs46
7 files changed, 207 insertions, 17 deletions
diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
index 2f698f0..0e81630 100644
--- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
+++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
@@ -28,6 +28,7 @@
<Compile Include="P46MenuAmbienceTests.fs" />
<Compile Include="P47SceneDetailTests.fs" />
<Compile Include="P48MapDigestTests.fs" />
+ <Compile Include="P51ProfessionSliceTests.fs" />
<Compile Include="SampleTests.fs" />
</ItemGroup>
diff --git a/src/LivingVillage.Desktop.Tests/P51ProfessionSliceTests.fs b/src/LivingVillage.Desktop.Tests/P51ProfessionSliceTests.fs
new file mode 100644
index 0000000..c65c059
--- /dev/null
+++ b/src/LivingVillage.Desktop.Tests/P51ProfessionSliceTests.fs
@@ -0,0 +1,138 @@
+namespace LivingVillage.Desktop.Tests
+
+open System
+open System.IO
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Kernel
+open LivingVillage.Kernel.Sim
+open LivingVillage.Desktop
+
+/// P51 职业系统实现首切片(docs/design-professions.md §1 / §2.4-1):
+/// (a) 开局钱/体力/背包侧车按所选职业写入,None 用世界默认;
+/// (b) 新档当日任务 = dailyTaskOf(kind),跨日 refreshOccupationToday 换新任务且 Offered;
+/// (c) StartNewGameWith kind 后菜单与世界可读回职业、当日任务与身份年鉴;
+/// (d) v3 含职业档 round-trip 读回一致,v2(None)仍逐字节一致且无职业。
+[<TestClass>]
+type P51ProfessionSliceTests () =
+
+ let seed = 42UL
+
+ let withTempSave (action: string -> unit) =
+ let suffix = Guid.NewGuid().ToString("N")
+ let path = Path.Combine(Path.GetTempPath(), "living-village-p51-" + suffix + ".save")
+ try
+ action path
+ finally
+ if File.Exists path then File.Delete path
+
+ /// 复刻 Game.StartNewGame 的纯函数路径:建世界(含职业钱/体力/背包/当日任务)+ 身份年鉴。
+ let buildWorld (kind: Occupation.Kind option) (startTick: int64) : World * Occupation.State option =
+ let state = kind |> Option.map (fun k -> WorldBootstrap.occupationStateFor seed k startTick)
+ let baseWorld = WorldBootstrap.initialWorldWithOccupation false seed 4 state
+ let world = { baseWorld with Tick = startTick; Time = float startTick * Sim.dtSeconds }
+ let world = state |> Option.fold (fun current s -> WorldBootstrap.appendIdentityAnnal s current) world
+ world, state
+
+ [<TestMethod>]
+ member _.OpeningResourcesFollowChosenProfession () =
+ let cases =
+ [ Occupation.Farmer, 60.0f, 100.0f, [ Sim.Food, 12 ]
+ Occupation.Fisher, 45.0f, 95.0f, [ Sim.Food, 8; Sim.Fish, 3 ]
+ Occupation.Peddler, 90.0f, 100.0f, [ Sim.Food, 6; Sim.Spice, 4 ]
+ Occupation.Scholar, 70.0f, 100.0f, [ Sim.Food, 10; Sim.Scroll, 2 ] ]
+ for (kind, money, energy, backpack) in cases do
+ let world, state = buildWorld (Some kind) 0L
+ Assert.AreEqual<float32>(money, world.Avatar.Mind.Needs.Money)
+ Assert.AreEqual<float32>(energy, world.Avatar.Mind.Needs.Energy)
+ match state with
+ | Some s ->
+ Assert.AreEqual<(Sim.ItemKind * int) list>(backpack, s.Backpack)
+ Assert.IsTrue((s.Backpack = (Occupation.profileOf kind).InitialInventory), "backpack = initial bundle")
+ | None -> Assert.Fail("occupation state must exist for a chosen profession")
+ // None = 世界默认(不覆盖钱/体力,无背包侧车)
+ let noneWorld, noneState = buildWorld None 0L
+ let defaultNeeds = (Sim.initialWorldN seed 4).Avatar.Mind.Needs
+ Assert.AreEqual<float32>(defaultNeeds.Money, noneWorld.Avatar.Mind.Needs.Money)
+ Assert.AreEqual<float32>(defaultNeeds.Energy, noneWorld.Avatar.Mind.Needs.Energy)
+ Assert.AreEqual<Occupation.State option>(None, noneState)
+
+ [<TestMethod>]
+ member _.BackpackQuantityReadsThroughSidecar () =
+ let state = Occupation.stateOf Occupation.Peddler
+ Assert.AreEqual<int>(6, Occupation.backpackQuantity Sim.Food state)
+ Assert.AreEqual<int>(4, Occupation.backpackQuantity Sim.Spice state)
+ Assert.AreEqual<int>(0, Occupation.backpackQuantity Sim.Scroll state)
+
+ [<TestMethod>]
+ member _.NewGameTodayTaskMatchesDailyTaskOfAndRefreshesAcrossDays () =
+ let kind = Occupation.Fisher
+ let state = WorldBootstrap.occupationStateFor seed kind 0L
+ let day0 = Occupation.dailyTaskOf seed (Occupation.occupationSeedOf kind) 0L kind
+ Assert.AreEqual<Occupation.DailyTask option>(day0, state.Today)
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Offered, state.Today.Value.State)
+ // 同日刷新保留原任务(含终态),不下发新任务
+ let finished =
+ { state with Today = state.Today |> Option.map (fun task -> { task with State = Occupation.Done }) }
+ let sameDay = WorldBootstrap.refreshOccupationToday seed finished 1000L
+ Assert.AreEqual<Occupation.DailyTask option>(finished.Today, sameDay.Today)
+ // 跨日刷新 = 当日 dailyTaskOf,状态 Offered,且与首日任务不同
+ let nextDay = 3L
+ let nextTick = nextDay * Sim.ticksPerDay + 123L
+ let refreshed = WorldBootstrap.refreshOccupationToday seed state nextTick
+ let expected = Occupation.dailyTaskOf seed (Occupation.occupationSeedOf kind) nextDay kind
+ Assert.AreEqual<Occupation.DailyTask option>(expected, refreshed.Today)
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Offered, refreshed.Today.Value.State)
+ Assert.AreNotEqual<Occupation.DailyTask option>(state.Today, refreshed.Today)
+
+ [<TestMethod>]
+ member _.StartNewGameWithProfessionIsReadableFromMenuAndWorld () =
+ let menu = MenuState.openOccupationSelect (MenuState.create SimulationControl.initial false)
+ Assert.AreEqual<MenuPage>(OccupationSelect, menu.Page)
+ let fisherItem = MenuState.move Down (MenuState.move Down menu)
+ Assert.AreEqual<Occupation.Kind option>(Some Occupation.Fisher, MenuState.selectedOccupation fisherItem)
+ let _, command = MenuState.update Confirm fisherItem
+ match command with
+ | StartNewGameWith (Some kind) ->
+ let world, state = buildWorld (Some kind) 0L
+ Assert.AreEqual<Occupation.Kind>(Occupation.Fisher, kind)
+ match state with
+ | Some s ->
+ Assert.AreEqual<Occupation.Kind>(Occupation.Fisher, s.Profile.Kind)
+ Assert.AreEqual<Occupation.DailyTask option>(
+ Occupation.dailyTaskOf seed (Occupation.occupationSeedOf Occupation.Fisher) 0L Occupation.Fisher,
+ s.Today)
+ Assert.AreEqual<(Sim.ItemKind * int) list>([ Sim.Food, 8; Sim.Fish, 3 ], s.Backpack)
+ Assert.IsTrue(
+ world.Annals
+ |> List.exists (fun entry ->
+ entry.Kind = Sim.StoryAnnal
+ && entry.Summary = Occupation.Story.identitySummaryOf Occupation.Fisher),
+ "identity annal must be readable back")
+ | None -> Assert.Fail("state must carry the chosen profession")
+ | other -> Assert.Fail(sprintf "expected StartNewGameWith(Some Fisher), got %A" other)
+
+ [<TestMethod>]
+ member _.V3SaveRoundTripsBackpackAndV2StaysOccupationFree () =
+ let world, state = buildWorld (Some Occupation.Peddler) 0L
+ let peddlerState =
+ match state with
+ | Some s -> s
+ | None -> failwith "expected occupation state"
+ // v2(None)逐字节一致且读回无职业
+ let v2 = WorldSave.save world
+ Assert.IsTrue(v2.StartsWith("LV_WORLD_SAVE_V2"))
+ match WorldSave.load v2 with
+ | Ok restored -> Assert.AreEqual<string>(v2, WorldSave.save restored)
+ | Error message -> Assert.Fail(message)
+ // v3 round-trip:背包与当日任务读回一致
+ withTempSave (fun path ->
+ WorldSave.saveToFileWith state path world
+ match WorldSave.loadFromFileWith path with
+ | Ok (restored, occupation) ->
+ match occupation with
+ | Some s ->
+ Assert.AreEqual<(Sim.ItemKind * int) list>(peddlerState.Backpack, s.Backpack)
+ Assert.AreEqual<Occupation.DailyTask option>(peddlerState.Today, s.Today)
+ Assert.AreEqual<string>(WorldSave.saveWith state world, WorldSave.saveWith occupation restored)
+ | None -> Assert.Fail("v3 must carry the occupation sidecar")
+ | Error message -> Assert.Fail(message))
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index 05afdc2..db39566 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -483,12 +483,7 @@ type LivingVillageGame() as this =
// §3 身份条目:带职业开局即写入年鉴,形成个人传记线
match state with
| Some occupationState ->
- world <-
- Sim.appendAnnal
- { Tick = world.Tick
- Kind = StoryAnnal
- Summary = Occupation.Story.identitySummaryOf occupationState.Profile.Kind }
- world
+ world <- WorldBootstrap.appendIdentityAnnal occupationState world
| None -> ()
this.ResetWorldView()
m5View <- { m5View with Task = state }
diff --git a/src/LivingVillage.Desktop/WorldBootstrap.fs b/src/LivingVillage.Desktop/WorldBootstrap.fs
index bf0ef65..7be24a7 100644
--- a/src/LivingVillage.Desktop/WorldBootstrap.fs
+++ b/src/LivingVillage.Desktop/WorldBootstrap.fs
@@ -35,6 +35,15 @@ module WorldBootstrap =
nowTick
state.Today }
+ /// 开局身份年鉴(design §3):带职业建世界后写入一条 `StoryAnnal`「以…为生」,
+ /// 形成个人传记线。Game 与测试共用同一口径。
+ let appendIdentityAnnal (state: Occupation.State) (world: World) : World =
+ Sim.appendAnnal
+ { Tick = world.Tick
+ Kind = Sim.StoryAnnal
+ Summary = Occupation.Story.identitySummaryOf state.Profile.Kind }
+ world
+
/// 以显式站位建世界:avatarTile 为玩家出生瓦片,npcTiles 为 NPC 出生瓦片序列。
/// 64x48 旧档与世界走 `avatarSpawnPosition` + `npcSpawnTiles` 的等价调用,字节口径不变。
let initialWorldWithPlacement
diff --git a/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs b/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs
index 0800ff8..95a0dd1 100644
--- a/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs
+++ b/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs
@@ -239,11 +239,14 @@ type DailyTaskTests () =
Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V3"), "v3 header expected")
let tokens = text.Split('|')
Assert.IsTrue(tokens |> Array.exists (fun token -> token = "task-today"), "task tail marker expected")
- Assert.AreEqual<string>("tile-none", Array.last tokens)
+ Assert.IsTrue(tokens |> Array.exists (fun token -> token = "backpack"), "backpack tail marker expected")
let markerIndex = tokens |> Array.findIndex (fun token -> token = "task-today")
+ let backpackIndex = tokens |> Array.findIndex (fun token -> token = "backpack")
let lastAnnalKindIndex =
tokens |> Array.findIndexBack (fun token -> token = "trade")
Assert.IsTrue(markerIndex > lastAnnalKindIndex, "task tail must follow the annals segment")
+ Assert.IsTrue(backpackIndex > markerIndex, "backpack tail must follow the task tail")
+ Assert.AreEqual<string>("3", Array.last tokens)
match WorldSave.loadFromFileWith path with
| Ok (restored, Some restoredState) ->
Assert.AreEqual<Occupation.State>(state, restoredState)
@@ -253,7 +256,7 @@ type DailyTaskTests () =
| Error message -> Assert.Fail($"v3 task round trip failed: {message}"))
[<TestMethod>]
- member _.SaveV3WithoutTaskKeepsOldTailFreeLayout () =
+ member _.SaveV3WithoutTaskCarriesOnlyBackpackTail () =
let world = DailyTaskHarness.worldWithAnnal ()
let state = Occupation.stateOf Occupation.Peddler
DailyTaskHarness.withTempSave (fun path ->
@@ -261,6 +264,7 @@ type DailyTaskTests () =
let text = File.ReadAllText path
Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V3"), "v3 header expected")
Assert.IsFalse(text.Contains("task-today"), "no-task v3 must not carry a task tail")
+ Assert.IsTrue(text.Contains("backpack"), "v3 must carry the backpack tail")
match WorldSave.loadFromFileWith path with
| Ok (restored, Some restoredState) ->
Assert.AreEqual<Occupation.DailyTask option>(None, restoredState.Today)
diff --git a/src/LivingVillage.Kernel/Occupation.fs b/src/LivingVillage.Kernel/Occupation.fs
index c207395..45ef428 100644
--- a/src/LivingVillage.Kernel/Occupation.fs
+++ b/src/LivingVillage.Kernel/Occupation.fs
@@ -194,14 +194,25 @@ module Occupation =
/// 挂在玩家侧的职业状态,随存档 v3 保存(design §2)。
/// Today = 当日任务(§3);TaskToken 保留为 v3 前缀占位扩展位(恒 0,暂未启用)。
+ /// Backpack = 玩家背包侧车(design §2.4-1):开局 = profile.InitialInventory 的纯数据副本,
+ /// 不进 `Sim.World`、不动 `Sim.step` 数值路径;键值对保持声明顺序以稳定序列化。
type State =
{ Profile: Profile
TaskToken: int
Today: DailyTask option
- StoryStage: int }
+ StoryStage: int
+ Backpack: (Sim.ItemKind * int) list }
let stateOf (kind: Kind) : State =
- { Profile = profileOf kind; TaskToken = 0; Today = None; StoryStage = 0 }
+ { Profile = profileOf kind
+ TaskToken = 0
+ Today = None
+ StoryStage = 0
+ Backpack = (profileOf kind).InitialInventory }
+
+ /// 背包内某物品数量(纯函数;无则 0)。
+ let backpackQuantity (item: Sim.ItemKind) (state: State) : int =
+ state.Backpack |> List.sumBy (fun (kind, quantity) -> if kind = item then quantity else 0)
// ---- 既有事件通道 → 任务信号桥(design §3:完成判定统一走 InteractionEvent)----
diff --git a/src/LivingVillage.Kernel/WorldSave.fs b/src/LivingVillage.Kernel/WorldSave.fs
index 7f724a3..3e11810 100644
--- a/src/LivingVillage.Kernel/WorldSave.fs
+++ b/src/LivingVillage.Kernel/WorldSave.fs
@@ -494,6 +494,24 @@ module WorldSave =
DueTick = dueTick
State = state }
+ /// v3 backpack 尾段(design §2.4-1):玩家背包侧车,按声明顺序写入;旧 v3(无此段)读回
+ /// 由 kind 的 `profileOf` 默认值补齐。
+ let private writeBackpack tokens (items: (ItemKind * int) list) =
+ add tokens "backpack"
+ addInt tokens items.Length
+ for item, quantity in items do
+ writeItemKind tokens item
+ addInt tokens quantity
+
+ let private readBackpack (reader: TokenReader) : (ItemKind * int) list =
+ let count = readCount reader "backpack.count"
+ [ for index in 0 .. count - 1 do
+ let item = readItemKind reader (sprintf "backpack[%d].item" index)
+ let quantity = readInt reader (sprintf "backpack[%d].quantity" index)
+ if quantity < 0 then
+ invalid (sprintf "invalid backpack[%d].quantity" index)
+ yield item, quantity ]
+
let private writeMind tokens (value: Mind) =
writeNeeds tokens value.Needs
writePersonality tokens value.Personality
@@ -598,6 +616,7 @@ module WorldSave =
match state.Today with
| Some task -> writeDailyTask tokens task
| None -> ()
+ writeBackpack tokens state.Backpack
| None -> ()
String.Join("|", tokens)
@@ -634,7 +653,14 @@ module WorldSave =
| "peddler" -> Some Occupation.Peddler
| "scholar" -> Some Occupation.Scholar
| _ -> None
- occupationOption <- kind |> Option.map (fun kind -> { Profile = Occupation.profileOf kind; TaskToken = task; Today = None; StoryStage = stage })
+ occupationOption <-
+ kind
+ |> Option.map (fun kind ->
+ { Profile = Occupation.profileOf kind
+ TaskToken = task
+ Today = None
+ StoryStage = stage
+ Backpack = (Occupation.profileOf kind).InitialInventory })
| _ -> ()
let tick = readInt64 reader "tick"
let time = readFloat reader "time"
@@ -650,17 +676,23 @@ module WorldSave =
let rumorStats = rumorWorkingSetStats rumors
let annalCount = readCount reader "annals"
let annals = List.init annalCount (fun index -> readAnnal reader (sprintf "annal[%d]" index))
- let todayTask =
- if reader.Remaining > 0 then
- match reader.Take("task.today") with
- | "task-today" -> Some(readDailyTask reader)
+ let tail =
+ let mutable task : Occupation.DailyTask option = None
+ let mutable backpack : (ItemKind * int) list option = None
+ while reader.Remaining > 0 do
+ match reader.Take("occupation.tail") with
+ | "task-today" -> task <- Some(readDailyTask reader)
+ | "backpack" -> backpack <- Some(readBackpack reader)
| _ -> invalid "trailing save data"
- else None
+ task, backpack
if reader.Remaining <> 0 then
invalid "trailing save data"
let occupation =
occupationOption
- |> Option.map (fun state -> { state with Today = todayTask })
+ |> Option.map (fun state ->
+ { state with
+ Today = fst tail
+ Backpack = snd tail |> Option.defaultValue state.Backpack })
Ok
({ Tick = tick
Time = time