diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-28 22:38:52 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-28 22:38:52 +0800 |
| commit | aa3baa953ea5c4f99f5f23cb3eef9400338b61ec (patch) | |
| tree | 641759916bcf736ea91af48f798a0f7a3a406c99 /src/LivingVillage.Desktop.Tests/P51ProfessionSliceTests.fs | |
| parent | 3aa7221453afec24757b0c7e4d352f69af8b395b (diff) | |
| download | living-village-aa3baa953ea5c4f99f5f23cb3eef9400338b61ec.tar.gz | |
p51: 职业系统实现首切片——开局钱/体力/背包侧车 + 当日任务/跨日刷新 + StartNewGameWith 读回(design §2.4-1)
Diffstat (limited to 'src/LivingVillage.Desktop.Tests/P51ProfessionSliceTests.fs')
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P51ProfessionSliceTests.fs | 138 |
1 files changed, 138 insertions, 0 deletions
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)) |
