diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-29 08:09:12 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-29 08:09:12 +0800 |
| commit | 247d0bc083dffc648f043176eabf9bd4036f19e7 (patch) | |
| tree | 78c11e2f923c7b9322b64dc81ccae86fc5bbceea | |
| parent | cf6672ee1d6cf7013472f3bd91e68990f23750e8 (diff) | |
| download | living-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.txt | 30 | ||||
| -rw-r--r-- | docs/evidence/p66-verification.txt | 57 | ||||
| -rw-r--r-- | scripts/p66-target-report.fsx | 87 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P66TaskTargetTests.fs | 195 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 17 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/WorldBootstrap.fs | 39 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Occupation.fs | 74 |
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%; |
