summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop.Tests/P66TaskTargetTests.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-29 08:09:12 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-29 08:09:12 +0800
commit247d0bc083dffc648f043176eabf9bd4036f19e7 (patch)
tree78c11e2f923c7b9322b64dc81ccae86fc5bbceea /src/LivingVillage.Desktop.Tests/P66TaskTargetTests.fs
parentcf6672ee1d6cf7013472f3bd91e68990f23750e8 (diff)
downloadliving-village-247d0bc083dffc648f043176eabf9bd4036f19e7.tar.gz
p66: 每日任务目标具体化 TargetNpc/TargetTile 定点
- Kernel Occupation:TaskTargets + targetNpcOf/targetTileOf + dailyTaskOfWith/ refreshTodayWith/refreshStateWith(splitmix 同源定点,候选排序消除顺序依赖,禁随机) - 仅既有 satisfiesCompletion 会读目标的模板定点:帮工→TargetNpc、翻土→TargetTile; 其余 8 模板目标保持 None(不改完成语义) - Desktop WorldBootstrap:reachableTargets(世界 NPC + 样板菜地瓦片)+ occupationStateForWorld/refreshOccupationTodayWorld;Game 开局/跨日/证据路径切换 - 历史入口 dailyTaskOf/refreshState/occupationStateFor 保留为空候选通配包装(逐字节不变) - 红先 P66TaskTargetTests(8 例):同 seed 幂等、跨 seed 落在可达集合、候选顺序无关、 历史通配回归、帮工/翻土定点门控完成、跨日重定点、v3 round-trip + v2 基线 - scripts/p66-target-report.fsx + docs/evidence/p66-analysis.txt(PASS) - Desktop 328/328,Kernel 119/119;digest 953775FA…7F6C ×3 PASS;Sim.fs 未动
Diffstat (limited to 'src/LivingVillage.Desktop.Tests/P66TaskTargetTests.fs')
-rw-r--r--src/LivingVillage.Desktop.Tests/P66TaskTargetTests.fs195
1 files changed, 195 insertions, 0 deletions
diff --git a/src/LivingVillage.Desktop.Tests/P66TaskTargetTests.fs b/src/LivingVillage.Desktop.Tests/P66TaskTargetTests.fs
new file mode 100644
index 0000000..214b50d
--- /dev/null
+++ b/src/LivingVillage.Desktop.Tests/P66TaskTargetTests.fs
@@ -0,0 +1,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)