diff options
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P51ProfessionSliceTests.fs | 138 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 7 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/WorldBootstrap.fs | 9 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/DailyTaskTests.fs | 8 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Occupation.fs | 15 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/WorldSave.fs | 46 |
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 |
