summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop.Tests/P51ProfessionSliceTests.fs
diff options
context:
space:
mode:
Diffstat (limited to 'src/LivingVillage.Desktop.Tests/P51ProfessionSliceTests.fs')
-rw-r--r--src/LivingVillage.Desktop.Tests/P51ProfessionSliceTests.fs138
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))