summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop.Tests/P66TaskTargetTests.fs
blob: 214b50dfb5d3772a1ebe379115068c96eb809b25 (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
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
namespace LivingVillage.Desktop.Tests

open System
open System.IO
open Microsoft.VisualStudio.TestTools.UnitTesting
open LivingVillage.Kernel
open LivingVillage.Kernel.Sim
open LivingVillage.Desktop

/// P66(职业收尾切片:每日任务目标具体化 TargetNpc/TargetTile)。
/// 设计文档 §2.4-3:当日任务的目标从恒 None(通配)改为按 hash 在**可达集合**内定点。
/// 口径:仅对既有 `satisfiesCompletion` 会读取目标的模板定点(帮工 → 指定村民 TargetNpc;
/// 翻土 → 自家地块 TargetTile),其余模板目标保持 None(不新增完成语义);
/// 空候选集合 → None(保留历史通配行为)。
[<TestClass>]
type P66TaskTargetTests () =

    let seed = 42UL

    let worldWith (occupation: Occupation.State option) : World =
        WorldBootstrap.initialWorldWithOccupation false seed Sim.npcCount occupation

    let targets (world: World) : Occupation.TaskTargets = WorldBootstrap.reachableTargets world

    let stateAt (world: World) (kind: Occupation.Kind) (day: int64) : Occupation.State =
        WorldBootstrap.occupationStateForWorld seed kind (day * Sim.ticksPerDay + 10L) world

    let findDayWith (world: World) (kind: Occupation.Kind) (template: Occupation.TaskTemplateId) : int64 =
        let rec loop day =
            if day > 299L then
                Assert.Fail(sprintf "300 天内未抽到 %A" template)
                -1L
            else
                let task = (stateAt world kind day).Today.Value
                if task.TemplateId = template then day else loop (day + 1L)
        loop 0L

    let assertTargetsReachable (world: World) (state: Occupation.State) : unit =
        match state.Today with
        | None -> Assert.Fail("职业当日任务不应为空")
        | Some task ->
            let reachable = targets world
            match task.TemplateId with
            | Occupation.HelpWork ->
                Assert.IsTrue(task.TargetNpc.IsSome, "帮工应定点到具体村民")
                Assert.IsTrue(
                    reachable.Npcs |> List.contains task.TargetNpc.Value,
                    sprintf "帮工目标 %A 不在可达 NPC 集合内" task.TargetNpc.Value)
                Assert.IsTrue(task.TargetTile.IsNone, "帮工不应带瓦片目标")
            | Occupation.TillSoil ->
                Assert.IsTrue(task.TargetTile.IsSome, "翻土应定点到具体瓦片")
                Assert.IsTrue(
                    reachable.Tiles |> List.contains task.TargetTile.Value,
                    sprintf "翻土目标 %A 不在可达瓦片集合内" task.TargetTile.Value)
                Assert.IsTrue(task.TargetNpc.IsNone, "翻土不应带 NPC 目标")
            | _ ->
                Assert.IsTrue(task.TargetNpc.IsNone, sprintf "%A 不应带 NPC 目标" task.TemplateId)
                Assert.IsTrue(task.TargetTile.IsNone, sprintf "%A 不应带瓦片目标" task.TemplateId)

    [<TestMethod>]
    member _.SameSeedAndKindProduceIdenticalConcreteTargets () =
        for kind in Occupation.all do
            let world = worldWith (Some(Occupation.stateOf kind))
            for day in 0L .. 29L do
                let first = stateAt world kind day
                let second = stateAt world kind day
                // 同输入两次逐字节一致(结构相等覆盖 TargetNpc/TargetTile)。
                Assert.AreEqual<Occupation.State>(first, second)
                assertTargetsReachable world first
        // 独立重建的同 seed 世界得到相同定点。
        let rebuilt = worldWith (Some(Occupation.stateOf Occupation.Farmer))
        for day in 0L .. 29L do
            Assert.AreEqual<Occupation.State>(stateAt rebuilt Occupation.Farmer day, stateAt (worldWith (Some(Occupation.stateOf Occupation.Farmer))) Occupation.Farmer day)

    [<TestMethod>]
    member _.TargetsAlwaysFallInsideReachableSetsAcrossSeeds () =
        for s in [ 0UL; 1UL; 7UL; 42UL; 123456789UL; 0xFFFFFFFFUL ] do
            let world = WorldBootstrap.initialWorldWithOccupation false s Sim.npcCount None
            for kind in Occupation.all do
                for day in 0L .. 59L do
                    let state = WorldBootstrap.occupationStateForWorld s kind (day * Sim.ticksPerDay + 5L) world
                    assertTargetsReachable world state

    [<TestMethod>]
    member _.CandidatePickingIsOrderIndependentAndDeterministic () =
        let npcA = Occupation.targetNpcOf seed 7UL 3L [ NpcId 5; NpcId 1; NpcId 9 ]
        let npcB = Occupation.targetNpcOf seed 7UL 3L [ NpcId 9; NpcId 5; NpcId 1 ]
        Assert.AreEqual<Sim.NpcId option>(npcA, npcB)
        Assert.IsTrue(npcA.IsSome)
        Assert.AreEqual<Sim.NpcId option>(npcA, Occupation.targetNpcOf seed 7UL 3L [ NpcId 5; NpcId 1; NpcId 9 ])
        let tileA = Occupation.targetTileOf seed 7UL 3L [ (9, 2); (1, 5) ]
        let tileB = Occupation.targetTileOf seed 7UL 3L [ (1, 5); (9, 2) ]
        Assert.AreEqual<(int * int) option>(tileA, tileB)
        Assert.IsTrue(tileA.IsSome)
        // 空候选 → None(保留通配,不臆造目标)。
        Assert.AreEqual<Sim.NpcId option>(None, Occupation.targetNpcOf seed 7UL 3L [])
        Assert.AreEqual<(int * int) option>(None, Occupation.targetTileOf seed 7UL 3L [])

    [<TestMethod>]
    member _.LegacyWildcardPathAndNoOccupationWorldsStayUnchanged () =
        // 历史口径 dailyTaskOf:目标恒 None(既有调用点/测试逐字节不变)。
        for kind in Occupation.all do
            for day in 0L .. 29L do
                let occupationSeed = Occupation.occupationSeedOf kind
                let legacy = (Occupation.dailyTaskOf seed occupationSeed day kind).Value
                Assert.IsTrue(legacy.TargetNpc.IsNone)
                Assert.IsTrue(legacy.TargetTile.IsNone)
                Assert.AreEqual<Occupation.DailyTask>(
                    legacy,
                    (Occupation.dailyTaskOfWith { Npcs = []; Tiles = [] } seed occupationSeed day kind).Value)
        // 无职业世界不产生任务(保持旧行为)。
        let noOccupationWorld = worldWith None
        Assert.IsFalse(
            (Occupation.dailyTaskOf seed (Occupation.occupationSeedOf Occupation.Farmer) 0L Occupation.Farmer).Value
                |> fun task -> task.TargetNpc.IsSome || task.TargetTile.IsSome)
        // 无职业存档仍是 v2 且与 saveWith None 逐字节一致。
        let v2 = WorldSave.save noOccupationWorld
        Assert.IsTrue(v2.StartsWith("LV_WORLD_SAVE_V2"))
        Assert.AreEqual<string>(v2, WorldSave.saveWith None noOccupationWorld)

    [<TestMethod>]
    member _.ConcreteHelpWorkTargetGatesCompletionThroughExistingSemantics () =
        let world = worldWith (Some(Occupation.stateOf Occupation.Farmer))
        let day = findDayWith world Occupation.Farmer Occupation.HelpWork
        let task = (stateAt world Occupation.Farmer day).Today.Value
        let target = task.TargetNpc.Value
        let other = targets world |> fun t -> t.Npcs |> List.find (fun id -> id <> target)
        let active = Occupation.acceptTask task
        Assert.AreEqual<Occupation.TaskState>(
            Occupation.Done,
            (Occupation.applySignal (Occupation.Dialogued target) active).State)
        Assert.AreEqual<Occupation.TaskState>(
            Occupation.Active,
            (Occupation.applySignal (Occupation.Dialogued other) active).State)
        Assert.AreEqual<Occupation.TaskState>(
            Occupation.Active,
            (Occupation.applySignal Occupation.NightAtWater active).State)

    [<TestMethod>]
    member _.ConcreteTillSoilTargetGatesCompletionThroughExistingSemantics () =
        let world = worldWith (Some(Occupation.stateOf Occupation.Farmer))
        let day = findDayWith world Occupation.Farmer Occupation.TillSoil
        let task = (stateAt world Occupation.Farmer day).Today.Value
        let target = task.TargetTile.Value
        let other =
            targets world
            |> fun t -> t.Tiles |> List.tryFind (fun tile -> tile <> target) |> Option.defaultValue (fst target + 1, snd target)
        let active = Occupation.acceptTask task
        Assert.AreEqual<Occupation.TaskState>(
            Occupation.Done,
            (Occupation.applySignal (Occupation.ArrivedAt target) active).State)
        Assert.AreEqual<Occupation.TaskState>(
            Occupation.Active,
            (Occupation.applySignal (Occupation.ArrivedAt other) active).State)
        Assert.AreEqual<Occupation.TaskState>(
            Occupation.Active,
            (Occupation.applySignal (Occupation.Dialogued (NpcId 0)) active).State)

    [<TestMethod>]
    member _.WorldRefreshKeepsSameDayTargetsAndRepointsOnNewDay () =
        let world = worldWith (Some(Occupation.stateOf Occupation.Farmer))
        let day0 = stateAt world Occupation.Farmer 0L
        let sameDay = WorldBootstrap.refreshOccupationTodayWorld seed day0 (10L) world
        Assert.AreEqual<Occupation.DailyTask option>(day0.Today, sameDay.Today)
        let nextDayTick = Sim.ticksPerDay + 10L
        let nextDay = WorldBootstrap.refreshOccupationTodayWorld seed day0 nextDayTick world
        let expected = WorldBootstrap.occupationStateForWorld seed Occupation.Farmer nextDayTick world
        Assert.AreEqual<Occupation.DailyTask option>(expected.Today, nextDay.Today)
        Assert.IsNotNull(nextDay.Today)

    [<TestMethod>]
    member _.V3RoundTripPreservesConcreteTargetsAndV2StaysBaseline () =
        let world = worldWith (Some(Occupation.stateOf Occupation.Farmer))
        let day = findDayWith world Occupation.Farmer Occupation.HelpWork
        let state = stateAt world Occupation.Farmer day
        Assert.IsTrue(state.Today.Value.TargetNpc.IsSome)
        let path = Path.Combine(Path.GetTempPath(), "living-village-p66-" + Guid.NewGuid().ToString("N") + ".save")
        try
            WorldSave.saveToFileWith (Some state) path world
            let text = File.ReadAllText path
            Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V3"))
            // 同输入两次写盘逐字节一致。
            Assert.AreEqual<string>(text, WorldSave.saveWith (Some state) world)
            match WorldSave.loadFromFileWith path with
            | Ok (_, Some restored) ->
                Assert.AreEqual<Occupation.DailyTask option>(state.Today, restored.Today)
                Assert.AreEqual<Sim.NpcId option>(state.Today.Value.TargetNpc, restored.Today.Value.TargetNpc)
            | Ok (_, None) -> Assert.Fail("v3 必须读回职业侧车")
            | Error message -> Assert.Fail(message)
        finally
            if File.Exists path then File.Delete path
        // 无职业存档:v2 头 + 与 saveWith None 逐字节一致。
        let v2 = WorldSave.save world
        Assert.IsTrue(v2.StartsWith("LV_WORLD_SAVE_V2"))
        Assert.AreEqual<string>(v2, WorldSave.saveWith None world)