summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop.Tests/P51ProfessionSliceTests.fs
blob: c65c05922bec8fb306a0be6b5cced9659a41c17d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
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))