From 247d0bc083dffc648f043176eabf9bd4036f19e7 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 29 Sep 2026 08:09:12 +0800 Subject: p66: 每日任务目标具体化 TargetNpc/TargetTile 定点 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 未动 --- .../LivingVillage.Desktop.Tests.fsproj | 1 + .../P66TaskTargetTests.fs | 195 +++++++++++++++++++++ src/LivingVillage.Desktop/Game.fs | 17 +- src/LivingVillage.Desktop/WorldBootstrap.fs | 39 +++++ src/LivingVillage.Kernel/Occupation.fs | 74 +++++++- 5 files changed, 312 insertions(+), 14 deletions(-) create mode 100644 src/LivingVillage.Desktop.Tests/P66TaskTargetTests.fs (limited to 'src') 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 @@ + 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(保留历史通配行为)。 +[] +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) + + [] + 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(first, second) + assertTargetsReachable world first + // 独立重建的同 seed 世界得到相同定点。 + let rebuilt = worldWith (Some(Occupation.stateOf Occupation.Farmer)) + for day in 0L .. 29L do + Assert.AreEqual(stateAt rebuilt Occupation.Farmer day, stateAt (worldWith (Some(Occupation.stateOf Occupation.Farmer))) Occupation.Farmer day) + + [] + 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 + + [] + 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(npcA, npcB) + Assert.IsTrue(npcA.IsSome) + Assert.AreEqual(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(None, Occupation.targetNpcOf seed 7UL 3L []) + Assert.AreEqual<(int * int) option>(None, Occupation.targetTileOf seed 7UL 3L []) + + [] + 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( + 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(v2, WorldSave.saveWith None noOccupationWorld) + + [] + 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.Done, + (Occupation.applySignal (Occupation.Dialogued target) active).State) + Assert.AreEqual( + Occupation.Active, + (Occupation.applySignal (Occupation.Dialogued other) active).State) + Assert.AreEqual( + Occupation.Active, + (Occupation.applySignal Occupation.NightAtWater active).State) + + [] + 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.Done, + (Occupation.applySignal (Occupation.ArrivedAt target) active).State) + Assert.AreEqual( + Occupation.Active, + (Occupation.applySignal (Occupation.ArrivedAt other) active).State) + Assert.AreEqual( + Occupation.Active, + (Occupation.applySignal (Occupation.Dialogued (NpcId 0)) active).State) + + [] + 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(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(expected.Today, nextDay.Today) + Assert.IsNotNull(nextDay.Today) + + [] + 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(text, WorldSave.saveWith (Some state) world) + match WorldSave.loadFromFileWith path with + | Ok (_, Some restored) -> + Assert.AreEqual(state.Today, restored.Today) + Assert.AreEqual(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(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%; -- cgit v1.2.3