summaryrefslogtreecommitdiff
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
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 未动
-rw-r--r--docs/evidence/p66-analysis.txt30
-rw-r--r--docs/evidence/p66-verification.txt57
-rw-r--r--scripts/p66-target-report.fsx87
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P66TaskTargetTests.fs195
-rw-r--r--src/LivingVillage.Desktop/Game.fs17
-rw-r--r--src/LivingVillage.Desktop/WorldBootstrap.fs39
-rw-r--r--src/LivingVillage.Kernel/Occupation.fs74
8 files changed, 486 insertions, 14 deletions
diff --git a/docs/evidence/p66-analysis.txt b/docs/evidence/p66-analysis.txt
new file mode 100644
index 0000000..74a1c3a
--- /dev/null
+++ b/docs/evidence/p66-analysis.txt
@@ -0,0 +1,30 @@
+P66 每日任务目标具体化 — 证据报告
+seed=42 npcs=30 reachable_npcs=30 reachable_tiles=2 ((33,25),(34,25))
+
+ farmer day= 0 HelpWork TargetNpc=9
+ farmer day= 1 TillSoil TargetTile=(34,25)
+ farmer day= 2 TillSoil TargetTile=(34,25)
+ farmer day= 5 HelpWork TargetNpc=3
+ farmer day= 7 TillSoil TargetTile=(34,25)
+ farmer day= 9 TillSoil TargetTile=(33,25)
+ farmer day=10 TillSoil TargetTile=(34,25)
+ farmer day=11 HelpWork TargetNpc=10
+ farmer day=12 HelpWork TargetNpc=21
+ farmer day=15 HelpWork TargetNpc=10
+ farmer day=17 TillSoil TargetTile=(34,25)
+ farmer day=19 HelpWork TargetNpc=18
+ 农夫 60 天内定点模板命中=38
+ 渔夫 60 天内定点模板命中=0
+ 货郎 60 天内定点模板命中=0
+ 书生 60 天内定点模板命中=0
+
+统计(4 职业 × 60 天 = 240 条任务)
+ 定点 NPC(帮工) = 22
+ 定点瓦片(翻土) = 16
+ 保持通配(其余) = 202
+ 目标 ∈ 可达集合 = true
+ 同输入两次逐字节一致 = true
+
+历史口径 dailyTaskOf(空候选):TargetNpc=false TargetTile=false(应全 false)
+
+verdict: PASS
diff --git a/docs/evidence/p66-verification.txt b/docs/evidence/p66-verification.txt
new file mode 100644
index 0000000..8221027
--- /dev/null
+++ b/docs/evidence/p66-verification.txt
@@ -0,0 +1,57 @@
+P66(职业收尾切片:每日任务目标具体化 TargetNpc/TargetTile)验证记录
+基线:commit cf6672e(P66 补充限定记录)。本单未 push。
+
+范围与设计(design §2.4-3,仅此一条)
+- DailyTask 的 TargetNpc/TargetTile 从恒 None(通配)改为按 hash 在**可达集合**内定点。
+- 纯 Kernel 加函数 + Desktop 接线;禁改 Sim.fs/Sim.step(本单 `git diff -- src/LivingVillage.Kernel/Sim.fs` 为空)。
+- 可达集合由世界/样板场景派生(WorldBootstrap.reachableTargets):
+ - NPC = 世界中实际存在的村民 id(30 个开局 → 0..29)。
+ - 瓦片 = 样板场景菜地 VegetableGarden 的世界瓦片 (33,25)/(34,25)——设计口径「自家地块」,legacy/riverscape 一致。
+- 定点 = splitmix 同源的另一段流:`taskHash (seed ⊕ salt) occupationSeed dayIndex mod |候选|`,
+ 候选先排序(NPC 按 id、瓦片按 (x,y))以消除输入顺序影响;禁 System.Random。
+
+每条模板的定点影响(关键:不做超范围语义变更)
+- 帮工 HelpWork → **TargetNpc 定点**(既有 satisfiesCompletion 已读 `Some target -> target = npc`);
+ 现在玩家必须与指定村民对话才完成,非目标村民不完成(新单测断言)。
+- 翻土 TillSoil → **TargetTile 定点**(既有 `Some target -> target = (tileX,tileY)`);
+ 现在必须到达指定地块才完成(新单测断言)。
+- 其余 8 模板(送粮/夜捕/卖渔/询价/收货/转卖/观察记事/论理)的 satisfiesCompletion **不读目标**,
+ 故目标保持 None(通配),完成语义逐字节不变;不制造「有目标却不判定」的死数据。
+- 历史口径保留:`Occupation.dailyTaskOf` / `refreshToday` / `refreshState` / `WorldBootstrap.occupationStateFor`
+ / `refreshOccupationToday` 仍走空候选(目标恒 None),既有调用点/测试不受影响;
+ 定点走新增的 `dailyTaskOfWith` / `refreshTodayWith` / `refreshStateWith` /
+ `WorldBootstrap.occupationStateForWorld` / `refreshOccupationTodayWorld`(Game 真实开局与跨日刷新已切换)。
+
+改动文件
+- src/LivingVillage.Kernel/Occupation.fs(TaskTargets + targetNpcOf/targetTileOf + dailyTaskOfWith/refreshTodayWith/refreshStateWith;旧入口保留为通配包装)
+- src/LivingVillage.Desktop/WorldBootstrap.fs(reachableTargets + occupationStateForWorld/refreshOccupationTodayWorld)
+- src/LivingVillage.Desktop/Game.fs(StartNewGame、逐帧跨日刷新、P55 证据刷新、P31 剧情证据改用世界候选定点)
+- src/LivingVillage.Desktop.Tests/P66TaskTargetTests.fs(新,8 例)+ .fsproj 挂载
+- scripts/p66-target-report.fsx(新,可复跑证据生成)+ docs/evidence/p66-analysis.txt
+
+红先
+- 先建 P66TaskTargetTests.fs 引用未实现的 TaskTargets/reachableTargets/occupationStateForWorld/
+ refreshOccupationTodayWorld/dailyTaskOfWith → 暂存实现文件后编译红(FS0039/FS0041),恢复后转绿。
+
+构建/测试
+- dotnet build LivingVillage.sln -c Release → 0 错误,1 警告(P40ArtTests.fs FS0760,既有)
+- dotnet test src/LivingVillage.Desktop.Tests -c Release --no-build → 328/328(320→+8,P66TaskTargetTests)
+- dotnet test src/LivingVillage.Kernel.Tests -c Release --no-build → 119/119
+- P54ProfessionDailyTests / P55ProfessionRewardTests 全绿:二者手工构造的任务目标恒 None,
+ 定点不触碰其完成语义。
+
+证据(真实 Release 程序集,可复跑)
+- dotnet fsi scripts/p66-target-report.fsx → docs/evidence/p66-analysis.txt,verdict: PASS
+ - seed=42 / 30 NPC / 可达瓦片 (33,25),(34,25)
+ - 4 职业 × 60 天 = 240 条:帮工定点 NPC=22、翻土定点瓦片=16、其余保持通配=202
+ - 目标 ∈ 可达集合 = true;同输入两次逐字节一致 = true;历史 dailyTaskOf 目标全 None
+
+确定性 / 存档
+- dotnet run -c Release --project src/LivingVillage.Headless -- --performance-baseline ×3
+ → final_digest 全等 953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C,determinism=PASS
+- save(None) 仍 LV_WORLD_SAVE_V2 且 = saveWith None world(单测逐字节);v3(含定点目标)round-trip 稳定,
+ same-input 两次写盘文本逐字节一致。
+
+边界
+- 未改 Sim.fs/Sim.step、HUD/对话框结构、字形表、地图 seed、外部素材;未 push。
+- 未做 §2.4-5(旧档→新档迁移 UI)与目标在任务面板的中文展示(属后续独立切片;本单只落状态与判定)。
diff --git a/scripts/p66-target-report.fsx b/scripts/p66-target-report.fsx
new file mode 100644
index 0000000..e360775
--- /dev/null
+++ b/scripts/p66-target-report.fsx
@@ -0,0 +1,87 @@
+// P66(每日任务目标具体化)证据报告:按 hash 在可达 NPC/瓦片内定点的命中数字 + 同 seed 复现断言。
+// 运行:dotnet fsi scripts/p66-target-report.fsx > docs/evidence/p66-analysis.txt
+// 前置:已 dotnet build LivingVillage.sln -c Release。
+#r "../src/LivingVillage.Kernel/bin/Release/net8.0/LivingVillage.Kernel.dll"
+#r "../src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll"
+
+open System
+open LivingVillage.Kernel
+open LivingVillage.Kernel.Sim
+open LivingVillage.Desktop
+
+let seed = 42UL
+let world = WorldBootstrap.initialWorldWithOccupation false seed Sim.npcCount None
+let reachable = WorldBootstrap.reachableTargets world
+
+let npcLabel (NpcId id) = string id
+let tileLabel (x, y) = sprintf "(%d,%d)" x y
+
+let stateAt (kind: Occupation.Kind) (day: int64) =
+ WorldBootstrap.occupationStateForWorld seed kind (day * Sim.ticksPerDay + 10L) world
+
+printfn "P66 每日任务目标具体化 — 证据报告"
+printfn "seed=%d npcs=%d reachable_npcs=%d reachable_tiles=%d (%s)"
+ (int seed) world.Npcs.Length reachable.Npcs.Length reachable.Tiles.Length
+ (reachable.Tiles |> List.map tileLabel |> String.concat ",")
+printfn ""
+
+let mutable totalTasks = 0
+let mutable npcTargeted = 0
+let mutable tileTargeted = 0
+let mutable wildcard = 0
+let mutable membershipOk = true
+let mutable reproduceOk = true
+let mutable concreteSamples = 0
+
+for kind in Occupation.all do
+ let mutable hits = 0
+ for day in 0L .. 59L do
+ let state = stateAt kind day
+ match state.Today with
+ | None -> ()
+ | Some task ->
+ totalTasks <- totalTasks + 1
+ let again = (stateAt kind day).Today.Value
+ if again <> task then reproduceOk <- false
+ match task.TemplateId with
+ | Occupation.HelpWork ->
+ npcTargeted <- npcTargeted + 1
+ hits <- hits + 1
+ match task.TargetNpc with
+ | Some npc when List.contains npc reachable.Npcs ->
+ concreteSamples <- concreteSamples + 1
+ if concreteSamples <= 12 then
+ printfn " %-4s day=%2d HelpWork TargetNpc=%s" (Occupation.saveToken kind) day (npcLabel npc)
+ | _ -> membershipOk <- false
+ | Occupation.TillSoil ->
+ tileTargeted <- tileTargeted + 1
+ hits <- hits + 1
+ match task.TargetTile with
+ | Some tile when List.contains tile reachable.Tiles ->
+ concreteSamples <- concreteSamples + 1
+ if concreteSamples <= 12 then
+ printfn " %-4s day=%2d TillSoil TargetTile=%s" (Occupation.saveToken kind) day (tileLabel tile)
+ | _ -> membershipOk <- false
+ | _ ->
+ wildcard <- wildcard + 1
+ if task.TargetNpc.IsSome || task.TargetTile.IsSome then membershipOk <- false
+ printfn " %-8s 60 天内定点模板命中=%d" (Occupation.nameOf kind) hits
+
+printfn ""
+printfn "统计(4 职业 × 60 天 = %d 条任务)" totalTasks
+printfn " 定点 NPC(帮工) = %d" npcTargeted
+printfn " 定点瓦片(翻土) = %d" tileTargeted
+printfn " 保持通配(其余) = %d" wildcard
+printfn " 目标 ∈ 可达集合 = %b" membershipOk
+printfn " 同输入两次逐字节一致 = %b" reproduceOk
+printfn ""
+
+// 空候选 → None(保留历史通配口径)。
+let legacyNone =
+ (Occupation.dailyTaskOf seed (Occupation.occupationSeedOf Occupation.Farmer) 3L Occupation.Farmer).Value
+printfn "历史口径 dailyTaskOf(空候选):TargetNpc=%b TargetTile=%b(应全 false)"
+ legacyNone.TargetNpc.IsSome legacyNone.TargetTile.IsSome
+
+let verdict = if membershipOk && reproduceOk then "PASS" else "FAIL"
+printfn ""
+printfn "verdict: %s" verdict
diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
index 5d195f7..0e121d9 100644
--- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
+++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
@@ -38,6 +38,7 @@
<Compile Include="P62EvidenceTests.fs" />
<Compile Include="P64EvidenceTests.fs" />
<Compile Include="P65RumorInquiryTests.fs" />
+ <Compile Include="P66TaskTargetTests.fs" />
<Compile Include="SampleTests.fs" />
</ItemGroup>
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)
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index 862e1de..a0e6fc0 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -551,12 +551,17 @@ type LivingVillageGame() as this =
member private this.StartNewGame (occupation: Occupation.Kind option) =
let startTick = evidenceStartTick ()
- let state =
+ let baseState =
occupation
|> Option.map (fun kind -> WorldBootstrap.occupationStateFor 42UL kind startTick)
world <-
- initialWorldForMode state
+ initialWorldForMode baseState
|> withStartTick
+ // P66:用真实世界的可达候选集合给当日任务定点(世界只依赖 profile,故只需建一次)。
+ let state =
+ match occupation with
+ | Some kind -> Some(WorldBootstrap.occupationStateForWorld 42UL kind startTick world)
+ | None -> None
// §3 身份条目:带职业开局即写入年鉴,形成个人传记线
match state with
| Some occupationState ->
@@ -1665,7 +1670,7 @@ type LivingVillageGame() as this =
setDay3Noon ()
// 用真实跨日刷新把当日任务推进到第 3 日(seed 42/农夫 = 送粮)。
match m5View.Task with
- | Some state -> m5View <- { m5View with Task = Some(WorldBootstrap.refreshOccupationToday 42UL state world.Tick) }
+ | Some state -> m5View <- { m5View with Task = Some(WorldBootstrap.refreshOccupationTodayWorld 42UL state world.Tick world) }
| None -> ()
let npc = world.Npcs.[0]
world <- { world with Avatar = { world.Avatar with Pos = npc.Pos } }
@@ -2013,7 +2018,7 @@ type LivingVillageGame() as this =
this.CenterCamera()
match m5View.Task with
| Some state ->
- let refreshed = WorldBootstrap.refreshOccupationToday 42UL state world.Tick
+ let refreshed = WorldBootstrap.refreshOccupationTodayWorld 42UL state world.Tick world
if refreshed <> state then
m5View <- { m5View with Task = Some refreshed }
| None -> ()
@@ -2120,13 +2125,15 @@ type LivingVillageGame() as this =
let startTick = lineStartDay * Sim.ticksPerDay + 12L * Sim.ticksPerDay / 24L
let state = WorldBootstrap.occupationStateFor seed kind startTick
let world0 = WorldBootstrap.initialWorldWithOccupation false seed Sim.npcCount (Some state)
+ // P66:证据路径同样按真实世界候选定点当日任务(world0 只依赖 profile)。
+ let task0 = WorldBootstrap.occupationStateForWorld seed kind startTick world0
let world1 = { world0 with Tick = startTick; Time = float startTick * Sim.dtSeconds }
let world2 =
Sim.appendAnnal
{ Tick = startTick; Kind = StoryAnnal; Summary = Occupation.Story.identitySummaryOf kind }
world1
let mutable w = world2
- let mutable task = state
+ let mutable task = task0
for i in 0 .. stage - 1 do
let npc = w.Npcs.[i % w.Npcs.Length]
let wNear = { w with Avatar = { w.Avatar with Pos = npc.Pos } }
diff --git a/src/LivingVillage.Desktop/WorldBootstrap.fs b/src/LivingVillage.Desktop/WorldBootstrap.fs
index e75855f..e0ac037 100644
--- a/src/LivingVillage.Desktop/WorldBootstrap.fs
+++ b/src/LivingVillage.Desktop/WorldBootstrap.fs
@@ -29,6 +29,45 @@ module WorldBootstrap =
let refreshOccupationToday (seed: uint64) (state: Occupation.State) (nowTick: int64) : Occupation.State =
Occupation.refreshState seed (Occupation.occupationSeedOf state.Profile.Kind) state.Profile.Kind nowTick state
+ // ---- P66 任务目标具体化(design §2.4-3)----
+ // 可达候选集合完全由世界/样板场景派生;Kernel 只做纯 hash 定点。
+ /// 可达 NPC:世界中实际存在的村民 id(顺序稳定)。
+ /// 可达瓦片:样板场景的菜地(VegetableGarden)世界瓦片 —— 设计口径「自家地块」,
+ /// 跨 legacy/riverscape 一致且必在陆地上。
+ let reachableTargets (world: World) : Occupation.TaskTargets =
+ let npcs = world.Npcs |> Array.map (fun npc -> npc.Id) |> Array.toList
+ let tiles =
+ VillagePresentation.scene.Elements
+ |> List.choose (fun element ->
+ match element.Prop with
+ | VillagePresentation.VegetableGarden ->
+ let tile = VillagePresentation.worldTileForSceneTile element.Position
+ Some(tile.X, tile.Y)
+ | _ -> None)
+ { Npcs = npcs; Tiles = tiles }
+
+ /// 新档职业状态(P66):按真实世界的可达候选集合定点当日任务。
+ let occupationStateForWorld (seed: uint64) (kind: Occupation.Kind) (nowTick: int64) (world: World) : Occupation.State =
+ let baseState = Occupation.stateOf kind
+ { baseState with
+ Today =
+ Occupation.dailyTaskOfWith
+ (reachableTargets world)
+ seed
+ (Occupation.occupationSeedOf kind)
+ (Occupation.dayIndexOf nowTick)
+ kind }
+
+ /// 跨日刷新(P66):按真实世界的可达候选集合重算跨日当天任务;同日原样保留。
+ let refreshOccupationTodayWorld (seed: uint64) (state: Occupation.State) (nowTick: int64) (world: World) : Occupation.State =
+ Occupation.refreshStateWith
+ (reachableTargets world)
+ seed
+ (Occupation.occupationSeedOf state.Profile.Kind)
+ state.Profile.Kind
+ nowTick
+ state
+
/// 开局身份年鉴(design §3):带职业建世界后写入一条 `StoryAnnal`「以…为生」,
/// 形成个人传记线。Game 与测试共用同一口径。
let appendIdentityAnnal (state: Occupation.State) (world: World) : World =
diff --git a/src/LivingVillage.Kernel/Occupation.fs b/src/LivingVillage.Kernel/Occupation.fs
index cb9e25b..58b05ec 100644
--- a/src/LivingVillage.Kernel/Occupation.fs
+++ b/src/LivingVillage.Kernel/Occupation.fs
@@ -169,28 +169,72 @@ module Occupation =
state <- next
out
+ // ---- P66 任务目标具体化(design §2.4-3)----
+ // 候选集合由调用方注入(世界侧可达 NPC / 瓦片),Kernel 不感知地图/世界。
+ // 定点走 splitmix 同源的另一段流;空候选 → None(保持通配,不臆造)。
+ type TaskTargets =
+ { Npcs: Sim.NpcId list
+ Tiles: (int * int) list }
+
+ let private targetNpcSalt = 0x9E3779B97F4A7C15UL
+ let private targetTileSalt = 0xD1B54A32D192ED03UL
+
+ let private pickTargetIndex (salt: uint64) (seed: uint64) (occupationSeed: uint64) (dayIndex: int64) (count: int) : int =
+ int (taskHash (seed ^^^ salt) occupationSeed dayIndex % uint64 count)
+
+ /// 按 hash 从候选 NPC 内定点(先按 id 升序消除输入顺序影响);空集 → None(通配)。
+ let targetNpcOf (seed: uint64) (occupationSeed: uint64) (dayIndex: int64) (candidates: Sim.NpcId list) : Sim.NpcId option =
+ match candidates |> List.sortBy (fun (Sim.NpcId id) -> id) with
+ | [] -> None
+ | sorted -> Some sorted.[pickTargetIndex targetNpcSalt seed occupationSeed dayIndex sorted.Length]
+
+ /// 按 hash 从候选瓦片内定点(先按 (x,y) 升序消除输入顺序影响);空集 → None(通配)。
+ let targetTileOf (seed: uint64) (occupationSeed: uint64) (dayIndex: int64) (candidates: (int * int) list) : (int * int) option =
+ match candidates |> List.sort with
+ | [] -> None
+ | sorted -> Some sorted.[pickTargetIndex targetTileSalt seed occupationSeed dayIndex sorted.Length]
+
/// 当日任务生成:池内确定性抽 1 条;空池 → None。
- /// TargetNpc/TargetTile 首期留 None(通配),OfferedTick/DueTick 覆盖整日。
- let dailyTaskOf (seed: uint64) (occupationSeed: uint64) (dayIndex: int64) (kind: Kind) : DailyTask option =
+ /// 仅对既有 `satisfiesCompletion` 会读取目标的模板定点:帮工 → TargetNpc(指定村民),
+ /// 翻土 → TargetTile(自家地块);其余模板目标保持 None(不新增完成语义)。
+ /// 候选集合为空时目标为 None,落到既有通配行为。
+ let dailyTaskOfWith (targets: TaskTargets) (seed: uint64) (occupationSeed: uint64) (dayIndex: int64) (kind: Kind) : DailyTask option =
match taskPoolOf kind with
| [] -> None
| pool ->
let index = int (taskHash seed occupationSeed dayIndex % uint64 pool.Length)
+ let template = pool.[index]
+ let targetNpc =
+ match template with
+ | HelpWork -> targetNpcOf seed occupationSeed dayIndex targets.Npcs
+ | _ -> None
+ let targetTile =
+ match template with
+ | TillSoil -> targetTileOf seed occupationSeed dayIndex targets.Tiles
+ | _ -> None
let offered = dayIndex * Sim.ticksPerDay
Some
- { TemplateId = pool.[index]
- TargetNpc = None
- TargetTile = None
+ { TemplateId = template
+ TargetNpc = targetNpc
+ TargetTile = targetTile
OfferedTick = offered
DueTick = offered + Sim.ticksPerDay - 1L
State = Offered }
+ /// 历史口径:无候选集合 → 目标恒 None(通配)。既有调用点/测试逐字节不变。
+ let dailyTaskOf (seed: uint64) (occupationSeed: uint64) (dayIndex: int64) (kind: Kind) : DailyTask option =
+ dailyTaskOfWith { Npcs = []; Tiles = [] } seed occupationSeed dayIndex kind
+
/// 当日任务刷新:已持任务的 OfferedTick 仍落在 nowTick 所属日时原样保留(含
/// Done/Failed 终态,保证当日面板与存档稳定);跨日才按当日重新生成 Offered。
- let refreshToday (seed: uint64) (occupationSeed: uint64) (kind: Kind) (nowTick: int64) (today: DailyTask option) : DailyTask option =
+ let refreshTodayWith (targets: TaskTargets) (seed: uint64) (occupationSeed: uint64) (kind: Kind) (nowTick: int64) (today: DailyTask option) : DailyTask option =
match today with
| Some task when dayIndexOf task.OfferedTick = dayIndexOf nowTick -> Some task
- | _ -> dailyTaskOf seed occupationSeed (dayIndexOf nowTick) kind
+ | _ -> dailyTaskOfWith targets seed occupationSeed (dayIndexOf nowTick) kind
+
+ /// 历史口径:无候选集合刷新(目标恒 None)。
+ let refreshToday (seed: uint64) (occupationSeed: uint64) (kind: Kind) (nowTick: int64) (today: DailyTask option) : DailyTask option =
+ refreshTodayWith { Npcs = []; Tiles = [] } seed occupationSeed kind nowTick today
/// 挂在玩家侧的职业状态,随存档 v3 保存(design §2)。
/// Today = 当日任务(§3);TaskToken 保留为 v3 前缀占位扩展位(恒 0,暂未启用)。
@@ -295,8 +339,16 @@ module Occupation =
/// 跨日刷新职业状态(P55):重算当日任务,并按「上一日是否已完成」保留/清零连续天数。
/// 同日刷新原样保留 Streak(含 Done 终态),避免每帧刷新把 streak 冲掉。
- let refreshState (seed: uint64) (occupationSeed: uint64) (kind: Kind) (nowTick: int64) (state: State) : State =
- let today = refreshToday seed occupationSeed kind nowTick state.Today
+ /// P66:`targets` 提供候选集合时,跨日重算的当日任务按候选定点;空候选 → 通配(历史口径)。
+ let refreshStateWith
+ (targets: TaskTargets)
+ (seed: uint64)
+ (occupationSeed: uint64)
+ (kind: Kind)
+ (nowTick: int64)
+ (state: State)
+ : State =
+ let today = refreshTodayWith targets seed occupationSeed kind nowTick state.Today
let sameDay =
match state.Today with
| Some task -> dayIndexOf task.OfferedTick = dayIndexOf nowTick
@@ -310,6 +362,10 @@ module Occupation =
| None -> false
{ state with Today = today; Streak = (if continuing then state.Streak else 0) }
+ /// 历史口径:无候选集合刷新(目标恒 None,逐字节不变)。
+ let refreshState (seed: uint64) (occupationSeed: uint64) (kind: Kind) (nowTick: int64) (state: State) : State =
+ refreshStateWith { Npcs = []; Tiles = [] } seed occupationSeed kind nowTick state
+
// ---- 报价偏置(design §2/§3):Kernel 纯函数旁路,不改 Sim.fs 数值口径 ----
// 4 位定点整数基点(1/10000)表达偏置,避免浮点漂移;结果钳在 [0.95, 1.05]。
// 货郎:买卖双向有利(买压价/卖抬价 ±3.00%);书生:解读行情小幅加成 ±1.00%;