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)仍逐字节一致且无职业。 [] 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 [] 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(money, world.Avatar.Mind.Needs.Money) Assert.AreEqual(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(defaultNeeds.Money, noneWorld.Avatar.Mind.Needs.Money) Assert.AreEqual(defaultNeeds.Energy, noneWorld.Avatar.Mind.Needs.Energy) Assert.AreEqual(None, noneState) [] member _.BackpackQuantityReadsThroughSidecar () = let state = Occupation.stateOf Occupation.Peddler Assert.AreEqual(6, Occupation.backpackQuantity Sim.Food state) Assert.AreEqual(4, Occupation.backpackQuantity Sim.Spice state) Assert.AreEqual(0, Occupation.backpackQuantity Sim.Scroll state) [] 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(day0, state.Today) Assert.AreEqual(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(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(expected, refreshed.Today) Assert.AreEqual(Occupation.Offered, refreshed.Today.Value.State) Assert.AreNotEqual(state.Today, refreshed.Today) [] member _.StartNewGameWithProfessionIsReadableFromMenuAndWorld () = let menu = MenuState.openOccupationSelect (MenuState.create SimulationControl.initial false) Assert.AreEqual(OccupationSelect, menu.Page) let fisherItem = MenuState.move Down (MenuState.move Down menu) Assert.AreEqual(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.Fisher, kind) match state with | Some s -> Assert.AreEqual(Occupation.Fisher, s.Profile.Kind) Assert.AreEqual( 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) [] 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(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(peddlerState.Today, s.Today) Assert.AreEqual(WorldSave.saveWith state world, WorldSave.saveWith occupation restored) | None -> Assert.Fail("v3 must carry the occupation sidecar") | Error message -> Assert.Fail(message))