From f8830705272cfa2c877cd0b984f61d1fa7a77ec2 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 18:40:17 +0800 Subject: feat(occupation): P11 每日任务生成纯函数 + TaskState 状态机(Kernel 侧) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Occupation.fs 增补:TaskState(Offered|Active|Done|Failed)、TaskTemplateId 首期池=农夫3(送粮/帮工/翻土)+渔夫3(夜捕/卖渔/询价)、DailyTask 记录(设计 §2/§3)。生成 = taskHash(seed ^ occupationSeed, dayIndex) mod |pool|,与 Rng.fs splitmix 同源(禁 System.Random),输出只依赖三元组;taskHash 以 5 个 splitmix 已知向量钉死。完成判定 = satisfiesCompletion 纯谓词,信号 TaskSignal(Dialogued/Traded/ArrivedAt/NightAtWater) 全部来自既有事件通道(DialogueEvent/TradeEvent/到达瓦片/夜+水边),TargetNpc/TargetTile None=通配;acceptTask/applySignal/expireAt 状态机纯函数,Sim.step 数值路径零改动。货郎/书生空池 → dailyTaskOf = None。13 例测试:splitmix 向量、池约束、逐日确定性、窗口=整日、中文名、状态机转移、六模板正负完成信号、终态不可复活、逾期判负。 --- src/LivingVillage.Kernel.Tests/DailyTaskTests.fs | 191 +++++++++++++++++++++ .../LivingVillage.Kernel.Tests.fsproj | 1 + src/LivingVillage.Kernel/Occupation.fs | 118 +++++++++++++ 3 files changed, 310 insertions(+) create mode 100644 src/LivingVillage.Kernel.Tests/DailyTaskTests.fs diff --git a/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs b/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs new file mode 100644 index 0000000..12bdf68 --- /dev/null +++ b/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs @@ -0,0 +1,191 @@ +namespace LivingVillage.Kernel.Tests + +open System +open System.IO +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim + +module private DailyTaskHarness = + + let taskOf (template: Occupation.TaskTemplateId) (state: Occupation.TaskState) : Occupation.DailyTask = + { TemplateId = template + TargetNpc = None + TargetTile = None + OfferedTick = 0L + DueTick = Sim.ticksPerDay - 1L + State = state } + + let accepted (template: Occupation.TaskTemplateId) : Occupation.DailyTask = + Occupation.acceptTask (taskOf template Occupation.Offered) + +[] +type DailyTaskTests () = + + // ---- §3 生成:hash(seed ^ occupationSeed, dayIndex) mod |pool|(splitmix 同源)---- + + [] + member _.TaskHashFollowsSplitmixOverSeedXorOccupationSeed () = + Assert.AreEqual(0xF7E9F3F88CC04AD6UL, Occupation.taskHash 42UL 7UL 0L) + Assert.AreEqual(0xFB624BD98F2BE6C4UL, Occupation.taskHash 42UL 7UL 1L) + Assert.AreEqual(0x87FC3F1DAC740225UL, Occupation.taskHash 42UL 7UL 2L) + Assert.AreEqual(0xDA258F1151868B40UL, Occupation.taskHash 42UL 7UL 3L) + Assert.AreEqual(0x02D114B7ABDD7A1FUL, Occupation.taskHash 4242UL 99UL 0L) + + [] + member _.FarmerAndFisherPoolsAreThreeTemplatesEach () = + Assert.AreEqual( + [ Occupation.DeliverGrain; Occupation.HelpWork; Occupation.TillSoil ], + Occupation.taskPoolOf Occupation.Farmer) + Assert.AreEqual( + [ Occupation.NightCatch; Occupation.SellFish; Occupation.MarketInquiry ], + Occupation.taskPoolOf Occupation.Fisher) + Assert.AreEqual(0, Occupation.taskPoolOf Occupation.Peddler |> List.length) + Assert.AreEqual(0, Occupation.taskPoolOf Occupation.Scholar |> List.length) + + [] + member _.GeneratedTasksStayInPoolAndRepeatDeterministically () = + for seed, occ in [ (42UL, 7UL); (7UL, 42UL); (123456789UL, 555UL) ] do + for kind in [ Occupation.Farmer; Occupation.Fisher ] do + let pool = Occupation.taskPoolOf kind + for day in 0L .. 299L do + let task = (Occupation.dailyTaskOf seed occ day kind).Value + Assert.IsTrue(pool |> List.contains task.TemplateId, $"day {day} escaped the pool") + let again = (Occupation.dailyTaskOf seed occ day kind).Value + Assert.AreEqual(task, again) + let distinct = + [ 0L .. 299L ] + |> List.map (fun day -> (Occupation.dailyTaskOf seed occ day kind).Value.TemplateId) + |> Set.ofList + Assert.IsTrue(distinct.Count >= 2, "300 days must not pin a single template") + + [] + member _.PeddlerAndScholarHaveNoDailyTaskYet () = + Assert.AreEqual(None, Occupation.dailyTaskOf 42UL 7UL 0L Occupation.Peddler) + Assert.AreEqual(None, Occupation.dailyTaskOf 42UL 7UL 0L Occupation.Scholar) + + [] + member _.TaskWindowCoversExactlyOneDayStartingOffered () = + let task = (Occupation.dailyTaskOf 42UL 7UL 3L Occupation.Farmer).Value + Assert.AreEqual(3L * Sim.ticksPerDay, task.OfferedTick) + Assert.AreEqual(4L * Sim.ticksPerDay - 1L, task.DueTick) + Assert.AreEqual(Occupation.Offered, task.State) + Assert.AreEqual(3L, Occupation.dayIndexOf (3L * Sim.ticksPerDay)) + Assert.AreEqual(3L, Occupation.dayIndexOf (4L * Sim.ticksPerDay - 1L)) + + [] + member _.TaskTemplateChineseNamesMatchDesignTable () = + Assert.AreEqual("送粮", Occupation.taskNameOf Occupation.DeliverGrain) + Assert.AreEqual("帮工", Occupation.taskNameOf Occupation.HelpWork) + Assert.AreEqual("翻土", Occupation.taskNameOf Occupation.TillSoil) + Assert.AreEqual("夜捕", Occupation.taskNameOf Occupation.NightCatch) + Assert.AreEqual("卖渔", Occupation.taskNameOf Occupation.SellFish) + Assert.AreEqual("询价", Occupation.taskNameOf Occupation.MarketInquiry) + + // ---- §3 状态机 Offered|Active|Done|Failed ---- + + [] + member _.AcceptMovesOfferedToActiveOnly () = + let offered = DailyTaskHarness.taskOf Occupation.HelpWork Occupation.Offered + let active = Occupation.acceptTask offered + Assert.AreEqual(Occupation.Active, active.State) + Assert.AreEqual(Occupation.Active, (Occupation.acceptTask active).State) + let finished = { active with State = Occupation.Done } + Assert.AreEqual(Occupation.Done, (Occupation.acceptTask finished).State) + let failed = { active with State = Occupation.Failed } + Assert.AreEqual(Occupation.Failed, (Occupation.acceptTask failed).State) + + [] + member _.SellFishCompletesOnlyOnPlayerFishSales () = + let active = DailyTaskHarness.accepted Occupation.SellFish + Assert.AreEqual( + Occupation.Done, + (Occupation.applySignal (Occupation.Traded(playerId, Fish, 1)) active).State) + Assert.AreEqual( + Occupation.Active, + (Occupation.applySignal (Occupation.Traded(playerId, Spice, 1)) active).State) + Assert.AreEqual( + Occupation.Active, + (Occupation.applySignal (Occupation.Traded(NpcId 2, Fish, 4)) active).State) + let offered = DailyTaskHarness.taskOf Occupation.SellFish Occupation.Offered + Assert.AreEqual( + Occupation.Offered, + (Occupation.applySignal (Occupation.Traded(playerId, Fish, 1)) offered).State) + + [] + member _.DeliverGrainCompletesOnlyOnPlayerFoodGiveaways () = + let active = DailyTaskHarness.accepted Occupation.DeliverGrain + Assert.AreEqual( + Occupation.Done, + (Occupation.applySignal (Occupation.Traded(playerId, Food, 3)) active).State) + Assert.AreEqual( + Occupation.Active, + (Occupation.applySignal (Occupation.Traded(playerId, Fish, 1)) active).State) + Assert.AreEqual( + Occupation.Active, + (Occupation.applySignal (Occupation.Traded(NpcId 5, Food, 1)) active).State) + + [] + member _.HelpWorkAndTillSoilRespectOptionalTargets () = + let wildcard = DailyTaskHarness.accepted Occupation.HelpWork + Assert.AreEqual( + Occupation.Done, + (Occupation.applySignal (Occupation.Dialogued(NpcId 4)) wildcard).State) + let targeted = + { DailyTaskHarness.accepted Occupation.HelpWork with TargetNpc = Some(NpcId 2) } + Assert.AreEqual( + Occupation.Done, + (Occupation.applySignal (Occupation.Dialogued(NpcId 2)) targeted).State) + Assert.AreEqual( + Occupation.Active, + (Occupation.applySignal (Occupation.Dialogued(NpcId 4)) targeted).State) + let untilled = DailyTaskHarness.accepted Occupation.TillSoil + Assert.AreEqual( + Occupation.Done, + (Occupation.applySignal (Occupation.ArrivedAt(1, 1)) untilled).State) + let plotTilling = + { DailyTaskHarness.accepted Occupation.TillSoil with TargetTile = Some(52, 38) } + Assert.AreEqual( + Occupation.Done, + (Occupation.applySignal (Occupation.ArrivedAt(52, 38)) plotTilling).State) + Assert.AreEqual( + Occupation.Active, + (Occupation.applySignal (Occupation.ArrivedAt(10, 10)) plotTilling).State) + + [] + member _.NightCatchAndMarketInquiryCompleteOnTheirSignals () = + let active = DailyTaskHarness.accepted Occupation.NightCatch + Assert.AreEqual( + Occupation.Done, + (Occupation.applySignal Occupation.NightAtWater active).State) + Assert.AreEqual( + Occupation.Active, + (Occupation.applySignal (Occupation.Dialogued(NpcId 0)) active).State) + let inquiring = DailyTaskHarness.accepted Occupation.MarketInquiry + Assert.AreEqual( + Occupation.Done, + (Occupation.applySignal (Occupation.Dialogued(NpcId 3)) inquiring).State) + Assert.AreEqual( + Occupation.Active, + (Occupation.applySignal Occupation.NightAtWater inquiring).State) + + [] + member _.DoneAndFailedAreTerminalStates () = + let finished = { DailyTaskHarness.accepted Occupation.SellFish with State = Occupation.Done } + Assert.AreEqual( + Occupation.Done, + (Occupation.applySignal (Occupation.Traded(playerId, Fish, 1)) finished).State) + let failed = { DailyTaskHarness.accepted Occupation.SellFish with State = Occupation.Failed } + Assert.AreEqual( + Occupation.Failed, + (Occupation.applySignal (Occupation.Traded(playerId, Fish, 1)) failed).State) + + [] + member _.OverdueTasksFailAtDayBoundaryButNotBefore () = + let active = DailyTaskHarness.accepted Occupation.SellFish + Assert.AreEqual(Occupation.Active, (Occupation.expireAt active.DueTick active).State) + Assert.AreEqual(Occupation.Failed, (Occupation.expireAt (active.DueTick + 1L) active).State) + let offered = DailyTaskHarness.taskOf Occupation.SellFish Occupation.Offered + Assert.AreEqual(Occupation.Failed, (Occupation.expireAt (offered.DueTick + 1L) offered).State) + let finished = { active with State = Occupation.Done } + Assert.AreEqual(Occupation.Done, (Occupation.expireAt (finished.DueTick + 1L) finished).State) diff --git a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj index f7a50d5..c11e064 100644 --- a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj +++ b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj @@ -12,6 +12,7 @@ + diff --git a/src/LivingVillage.Kernel/Occupation.fs b/src/LivingVillage.Kernel/Occupation.fs index 28008f5..e0507ae 100644 --- a/src/LivingVillage.Kernel/Occupation.fs +++ b/src/LivingVillage.Kernel/Occupation.fs @@ -51,3 +51,121 @@ module Occupation = let stateOf (kind: Kind) : State = { Profile = profileOf kind; TaskToken = 0; StoryStage = 0 } + + // ---- 每日任务(design §3):Kernel 侧纯函数片,不进 Sim.step 数值路径 ---- + + type TaskState = + | Offered + | Active + | Done + | Failed + + /// 首期任务池 = 农夫3(送粮/帮工/翻土)+ 渔夫3(夜捕/卖渔/询价),design §3 表。 + type TaskTemplateId = + | DeliverGrain + | HelpWork + | TillSoil + | NightCatch + | SellFish + | MarketInquiry + + let taskNameOf (template: TaskTemplateId) : string = + match template with + | DeliverGrain -> "送粮" + | HelpWork -> "帮工" + | TillSoil -> "翻土" + | NightCatch -> "夜捕" + | SellFish -> "卖渔" + | MarketInquiry -> "询价" + + /// 货郎/书生模板留后续单,当前为空池 → 不生成每日任务。 + let taskPoolOf (kind: Kind) : TaskTemplateId list = + match kind with + | Farmer -> [ DeliverGrain; HelpWork; TillSoil ] + | Fisher -> [ NightCatch; SellFish; MarketInquiry ] + | Peddler + | Scholar -> [] + + type DailyTask = + { TemplateId: TaskTemplateId + TargetNpc: Sim.NpcId option + TargetTile: (int * int) option + OfferedTick: int64 + DueTick: int64 + State: TaskState } + + /// 完成判定信号(design §3:走既有事件通道)。 + /// Dialogued 来自 DialogueEvent(chooseDialogue);Traded 来自 TradeEvent, + /// 字段序 = 卖方/物品/数量(玩家递出即 seller = playerId);ArrivedAt 为到达瓦片; + /// NightAtWater 由调用方把 isNight + 水边判定折叠后喂入。Sim.step 零感知。 + type TaskSignal = + | Dialogued of Sim.NpcId + | Traded of Sim.NpcId * Sim.ItemKind * int + | ArrivedAt of int * int + | NightAtWater + + /// TargetNpc/TargetTile 为 None 时视为通配(指定目标由后续 Desktop 接线单落地)。 + let satisfiesCompletion (task: DailyTask) (signal: TaskSignal) : bool = + match task.TemplateId, signal with + | DeliverGrain, Traded (seller, item, quantity) -> seller = Sim.playerId && item = Sim.Food && quantity >= 1 + | HelpWork, Dialogued npc -> + match task.TargetNpc with + | Some target -> target = npc + | None -> true + | TillSoil, ArrivedAt (tileX, tileY) -> + match task.TargetTile with + | Some target -> target = (tileX, tileY) + | None -> true + | NightCatch, NightAtWater -> true + | SellFish, Traded (seller, item, quantity) -> seller = Sim.playerId && item = Sim.Fish && quantity >= 1 + | MarketInquiry, Dialogued _ -> true + | _ -> false + + /// Offered → Active;其余状态原样返回(终态不可复活)。 + let acceptTask (task: DailyTask) : DailyTask = + if task.State = Offered then { task with State = Active } else task + + /// 仅 Active 下命中完成信号才置 Done;Offered 须先 accept,Done/Failed 终态原样返回。 + let applySignal (signal: TaskSignal) (task: DailyTask) : DailyTask = + if task.State = Active && satisfiesCompletion task signal then + { task with State = Done } + else + task + + /// 逾期判负:DueTick 之后仍未完成 → Failed;Done/Failed 原样返回。 + let expireAt (nowTick: int64) (task: DailyTask) : DailyTask = + if nowTick > task.DueTick && task.State <> Done && task.State <> Failed then + { task with State = Failed } + else + task + + let dayIndexOf (tick: int64) : int64 = tick / Sim.ticksPerDay + + /// hash(seed ^ occupationSeed, dayIndex) —— 与 Rng.fs splitmix 同源(禁 System.Random)。 + /// 输出只依赖三元组,与调用顺序无关:seed 异或 occupationSeed 后连续推进 + /// dayIndex+1 次 splitmix,末次输出即当日哈希。 + let taskHash (seed: uint64) (occupationSeed: uint64) (dayIndex: int64) : uint64 = + let mutable state = Rng.ofSeed (seed ^^^ occupationSeed) + let mutable out = 0UL + if dayIndex >= 0L then + for _ in 1L .. dayIndex + 1L do + let value, next = Rng.nextUInt64 state + out <- value + state <- next + out + + /// 当日任务生成:池内确定性抽 1 条;空池(货郎/书生首期)→ None。 + /// TargetNpc/TargetTile 首期留 None(通配),OfferedTick/DueTick 覆盖整日。 + let dailyTaskOf (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 offered = dayIndex * Sim.ticksPerDay + Some + { TemplateId = pool.[index] + TargetNpc = None + TargetTile = None + OfferedTick = offered + DueTick = offered + Sim.ticksPerDay - 1L + State = Offered } -- cgit v1.2.3