diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 22:55:01 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 22:55:01 +0800 |
| commit | 0222501162d18447aa162e9c53e000bc42ccbbd9 (patch) | |
| tree | d3f1c8f6bf7c0e88158664b188061c0b7fd9f74c | |
| parent | 34c7ebc9ed8b6295ec109c177f75ba0803cc04f2 (diff) | |
| download | living-village-0222501162d18447aa162e9c53e000bc42ccbbd9.tar.gz | |
feat(occupation): P15 补齐货郎/书生各 2 条每日任务模板 + 既有事件通道信号桥(Kernel 纯函数 TDD)
- TaskTemplateId 扩出 BuyGoods(收货)/Resell(转卖)/ObserveNotes(观察记事)/ReasonDebate(论理),
taskPoolOf 货郎=[收货,转卖]、书生=[观察记事,论理],四职业池均非空。
- TaskSignal 增 Purchased(玩家买入)/Observed(观察),satisfiesCompletion 给出四模板完成判定;
新增 occupationSeedOf/refreshToday(跨日才重生成,同日含 Done/Failed 终态保留)。
- signalOfInteraction/advanceTask:仅 playerId 发起的 DialogueEvent/TradeEvent 产生信号,
NPC 互动不派任务;Offered 须先 acceptTask,终态不可复活。WorldSave 模板 token 双向同步。
- 测试 83 全绿(新增 6:池非空、货郎书生逐日池内确定、模板中文名、完成信号正负、
事件桥含玩家买/卖与 NPC 忽略、既有 DialogueEvent→accept→Done 全链路、refreshToday 逐日)。
- 不动 Sim.step 数值路径;旧档 v3 模板 token 仅新增合法值,未知模板仍拒绝。
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/DailyTaskTests.fs | 100 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Occupation.fs | 61 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/WorldSave.fs | 8 |
3 files changed, 156 insertions, 13 deletions
diff --git a/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs b/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs index e784d83..0800ff8 100644 --- a/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs +++ b/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs @@ -50,20 +50,24 @@ type DailyTaskTests () = Assert.AreEqual<uint64>(0x02D114B7ABDD7A1FUL, Occupation.taskHash 4242UL 99UL 0L) [<TestMethod>] - member _.FarmerAndFisherPoolsAreThreeTemplatesEach () = + member _.EveryOccupationPoolIsNonEmptyAndFarmFisherKeepThree () = 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<int>(0, Occupation.taskPoolOf Occupation.Peddler |> List.length) - Assert.AreEqual<int>(0, Occupation.taskPoolOf Occupation.Scholar |> List.length) + Assert.AreEqual( + [ Occupation.BuyGoods; Occupation.Resell ], + Occupation.taskPoolOf Occupation.Peddler) + Assert.AreEqual( + [ Occupation.ObserveNotes; Occupation.ReasonDebate ], + Occupation.taskPoolOf Occupation.Scholar) [<TestMethod>] member _.GeneratedTasksStayInPoolAndRepeatDeterministically () = for seed, occ in [ (42UL, 7UL); (7UL, 42UL); (123456789UL, 555UL) ] do - for kind in [ Occupation.Farmer; Occupation.Fisher ] do + for kind in Occupation.all do let pool = Occupation.taskPoolOf kind for day in 0L .. 299L do let task = (Occupation.dailyTaskOf seed occ day kind).Value @@ -77,9 +81,17 @@ type DailyTaskTests () = Assert.IsTrue(distinct.Count >= 2, "300 days must not pin a single template") [<TestMethod>] - member _.PeddlerAndScholarHaveNoDailyTaskYet () = - Assert.AreEqual<Occupation.DailyTask option>(None, Occupation.dailyTaskOf 42UL 7UL 0L Occupation.Peddler) - Assert.AreEqual<Occupation.DailyTask option>(None, Occupation.dailyTaskOf 42UL 7UL 0L Occupation.Scholar) + member _.PeddlerAndScholarGenerateTheirOwnDailyTasks () = + for kind in [ Occupation.Peddler; Occupation.Scholar ] do + for day in 0L .. 59L do + let task = (Occupation.dailyTaskOf 42UL 7UL day kind).Value + Assert.IsTrue(Occupation.taskPoolOf kind |> List.contains task.TemplateId) + Assert.AreEqual<Occupation.TaskTemplateId>( + Occupation.BuyGoods, + (Occupation.dailyTaskOf 42UL 7UL 0L Occupation.Peddler).Value.TemplateId) + Assert.AreEqual<Occupation.TaskTemplateId>( + Occupation.ObserveNotes, + (Occupation.dailyTaskOf 42UL 7UL 0L Occupation.Scholar).Value.TemplateId) [<TestMethod>] member _.TaskWindowCoversExactlyOneDayStartingOffered () = @@ -98,6 +110,10 @@ type DailyTaskTests () = Assert.AreEqual<string>("夜捕", Occupation.taskNameOf Occupation.NightCatch) Assert.AreEqual<string>("卖渔", Occupation.taskNameOf Occupation.SellFish) Assert.AreEqual<string>("询价", Occupation.taskNameOf Occupation.MarketInquiry) + Assert.AreEqual<string>("收货", Occupation.taskNameOf Occupation.BuyGoods) + Assert.AreEqual<string>("转卖", Occupation.taskNameOf Occupation.Resell) + Assert.AreEqual<string>("观察记事", Occupation.taskNameOf Occupation.ObserveNotes) + Assert.AreEqual<string>("论理", Occupation.taskNameOf Occupation.ReasonDebate) // ---- §3 状态机 Offered|Active|Done|Failed ---- @@ -298,3 +314,73 @@ type DailyTaskTests () = let state = { (Occupation.stateOf Occupation.Farmer) with Today = Some(DailyTaskHarness.taskForDay 9L) } WorldSave.saveWith (Some state) world Assert.AreEqual<string>(build (), build ()) + + // ---- P15:货郎/书生模板完成信号、事件通道桥、当日刷新 ---- + + [<TestMethod>] + member _.PeddlerAndScholarTemplatesCompleteOnTheirSignals () = + let buy = DailyTaskHarness.accepted Occupation.BuyGoods + Assert.AreEqual<Occupation.TaskState>(Occupation.Done, (Occupation.applySignal (Occupation.Purchased(Food, 2)) buy).State) + Assert.AreEqual<Occupation.TaskState>(Occupation.Active, (Occupation.applySignal (Occupation.Purchased(Food, 0)) buy).State) + Assert.AreEqual<Occupation.TaskState>(Occupation.Active, (Occupation.applySignal (Occupation.Dialogued(NpcId 0)) buy).State) + let resell = DailyTaskHarness.accepted Occupation.Resell + Assert.AreEqual<Occupation.TaskState>(Occupation.Done, (Occupation.applySignal (Occupation.Traded(playerId, Spice, 1)) resell).State) + Assert.AreEqual<Occupation.TaskState>(Occupation.Active, (Occupation.applySignal (Occupation.Traded(NpcId 2, Spice, 1)) resell).State) + let observe = DailyTaskHarness.accepted Occupation.ObserveNotes + Assert.AreEqual<Occupation.TaskState>(Occupation.Done, (Occupation.applySignal Occupation.Observed observe).State) + Assert.AreEqual<Occupation.TaskState>(Occupation.Active, (Occupation.applySignal (Occupation.Dialogued(NpcId 0)) observe).State) + let reason = DailyTaskHarness.accepted Occupation.ReasonDebate + Assert.AreEqual<Occupation.TaskState>(Occupation.Done, (Occupation.applySignal (Occupation.Dialogued(NpcId 3)) reason).State) + Assert.AreEqual<Occupation.TaskState>(Occupation.Active, (Occupation.applySignal Occupation.Observed reason).State) + + [<TestMethod>] + member _.SignalBridgeIgnoresNpcInitiatedEvents () = + let npcDialogue = + { Tick = 5L + Kind = DialogueEvent { Tick = 5L; Actor = NpcId 1; Target = NpcId 2; Intent = SmallTalk; Response = Friendly; Valence = 0.3f } } + Assert.AreEqual<Occupation.TaskSignal option>(None, Occupation.signalOfInteraction npcDialogue) + let npcTrade = { Tick = 5L; Kind = TradeEvent(NpcId 1, NpcId 2, Food, 1, 1.0f) } + Assert.AreEqual<Occupation.TaskSignal option>(None, Occupation.signalOfInteraction npcTrade) + let playerBuy = { Tick = 6L; Kind = TradeEvent(playerId, NpcId 2, Spice, 3, 2.0f) } + Assert.AreEqual<Occupation.TaskSignal option>(Some(Occupation.Purchased(Spice, 3)), Occupation.signalOfInteraction playerBuy) + let playerSell = { Tick = 7L; Kind = TradeEvent(NpcId 2, playerId, Fish, 4, 2.0f) } + Assert.AreEqual<Occupation.TaskSignal option>(Some(Occupation.Traded(playerId, Fish, 4)), Occupation.signalOfInteraction playerSell) + + [<TestMethod>] + member _.PlayerDialogueEventThroughExistingChannelCompletesTheDaysTask () = + let world = Sim.initialWorldN 7UL 4 + let nearFirst = { world with Avatar = { world.Avatar with Pos = world.Npcs.[0].Pos } } + match Sim.chooseDialogue (NpcId 0) SmallTalk nearFirst with + | DialogueRejected (failure, _) -> Assert.Fail($"dialogue rejected: {failure}") + | DialogueSucceeded (outcome, afterDialogue) -> + let event = afterDialogue.Events |> List.last + Assert.AreEqual<Sim.NpcId>(playerId, outcome.Actor) + Assert.AreEqual<Occupation.TaskSignal option>(Some(Occupation.Dialogued(NpcId 0)), Occupation.signalOfInteraction event) + let day = + [ 0L .. 60L ] + |> List.find (fun day -> + (Occupation.dailyTaskOf 42UL 7UL day Occupation.Farmer).Value.TemplateId = Occupation.HelpWork) + let offered = (Occupation.dailyTaskOf 42UL 7UL day Occupation.Farmer).Value + let notAccepted = Occupation.advanceTask event { (Occupation.stateOf Occupation.Farmer) with Today = Some offered } + Assert.AreEqual<Occupation.TaskState>(Occupation.Offered, notAccepted.Today.Value.State) + let active = notAccepted.Today |> Option.map Occupation.acceptTask + let completed = Occupation.advanceTask event { notAccepted with Today = active } + Assert.AreEqual<Occupation.TaskState>(Occupation.Done, completed.Today.Value.State) + + [<TestMethod>] + member _.RefreshTodayKeepsSameDayFinalsAndRegeneratesOnDayChange () = + let day0 = (Occupation.dailyTaskOf 42UL 7UL 0L Occupation.Farmer).Value + Assert.AreEqual<Occupation.DailyTask option>( + Some day0, + Occupation.refreshToday 42UL 7UL Occupation.Farmer (Sim.ticksPerDay - 1L) (Some day0)) + let day1 = (Occupation.dailyTaskOf 42UL 7UL 1L Occupation.Farmer).Value + Assert.AreEqual<Occupation.DailyTask option>( + Some day1, + Occupation.refreshToday 42UL 7UL Occupation.Farmer Sim.ticksPerDay (Some day0)) + let finished = { day0 with State = Occupation.Done } + Assert.AreEqual<Occupation.DailyTask option>( + Some finished, + Occupation.refreshToday 42UL 7UL Occupation.Farmer 10L (Some finished)) + Assert.AreEqual<Occupation.DailyTask option>( + Some day1, + Occupation.refreshToday 42UL 7UL Occupation.Farmer Sim.ticksPerDay None) diff --git a/src/LivingVillage.Kernel/Occupation.fs b/src/LivingVillage.Kernel/Occupation.fs index 5c75c81..2168c4e 100644 --- a/src/LivingVillage.Kernel/Occupation.fs +++ b/src/LivingVillage.Kernel/Occupation.fs @@ -51,7 +51,8 @@ module Occupation = | Done | Failed - /// 首期任务池 = 农夫3(送粮/帮工/翻土)+ 渔夫3(夜捕/卖渔/询价),design §3 表。 + /// 首期任务池 = 农夫3(送粮/帮工/翻土)+ 渔夫3(夜捕/卖渔/询价) + /// + P15 补齐货郎2(收货/转卖)+ 书生2(观察记事/论理),design §3 表。 type TaskTemplateId = | DeliverGrain | HelpWork @@ -59,6 +60,10 @@ module Occupation = | NightCatch | SellFish | MarketInquiry + | BuyGoods + | Resell + | ObserveNotes + | ReasonDebate let taskNameOf (template: TaskTemplateId) : string = match template with @@ -68,14 +73,26 @@ module Occupation = | NightCatch -> "夜捕" | SellFish -> "卖渔" | MarketInquiry -> "询价" + | BuyGoods -> "收货" + | Resell -> "转卖" + | ObserveNotes -> "观察记事" + | ReasonDebate -> "论理" - /// 货郎/书生模板留后续单,当前为空池 → 不生成每日任务。 let taskPoolOf (kind: Kind) : TaskTemplateId list = match kind with | Farmer -> [ DeliverGrain; HelpWork; TillSoil ] | Fisher -> [ NightCatch; SellFish; MarketInquiry ] - | Peddler - | Scholar -> [] + | Peddler -> [ BuyGoods; Resell ] + | Scholar -> [ ObserveNotes; ReasonDebate ] + + /// 每职业任务流种子:与世界 seed 异或后驱动 taskHash,使四职业的每日序列互不相同。 + /// 纯常量(splitmix 同源),禁 System.Random。 + let occupationSeedOf (kind: Kind) : uint64 = + match kind with + | Farmer -> 0x1111111111111111UL + | Fisher -> 0x2222222222222222UL + | Peddler -> 0x3333333333333333UL + | Scholar -> 0x4444444444444444UL type DailyTask = { TemplateId: TaskTemplateId @@ -87,12 +104,15 @@ module Occupation = /// 完成判定信号(design §3:走既有事件通道)。 /// Dialogued 来自 DialogueEvent(chooseDialogue);Traded 来自 TradeEvent, - /// 字段序 = 卖方/物品/数量(玩家递出即 seller = playerId);ArrivedAt 为到达瓦片; + /// 字段序 = 卖方/物品/数量(玩家递出即 seller = playerId);Purchased = 玩家买入 + /// (TradeEvent 中 playerId 为 buyer);ArrivedAt 为到达瓦片;Observed 为观察面板; /// NightAtWater 由调用方把 isNight + 水边判定折叠后喂入。Sim.step 零感知。 type TaskSignal = | Dialogued of Sim.NpcId | Traded of Sim.NpcId * Sim.ItemKind * int + | Purchased of Sim.ItemKind * int | ArrivedAt of int * int + | Observed | NightAtWater /// TargetNpc/TargetTile 为 None 时视为通配(指定目标由后续 Desktop 接线单落地)。 @@ -110,6 +130,10 @@ module Occupation = | NightCatch, NightAtWater -> true | SellFish, Traded (seller, item, quantity) -> seller = Sim.playerId && item = Sim.Fish && quantity >= 1 | MarketInquiry, Dialogued _ -> true + | BuyGoods, Purchased (_, quantity) -> quantity >= 1 + | Resell, Traded (seller, _, quantity) -> seller = Sim.playerId && quantity >= 1 + | ObserveNotes, Observed -> true + | ReasonDebate, Dialogued _ -> true | _ -> false /// Offered → Active;其余状态原样返回(终态不可复活)。 @@ -145,7 +169,7 @@ module Occupation = state <- next out - /// 当日任务生成:池内确定性抽 1 条;空池(货郎/书生首期)→ None。 + /// 当日任务生成:池内确定性抽 1 条;空池 → None。 /// TargetNpc/TargetTile 首期留 None(通配),OfferedTick/DueTick 覆盖整日。 let dailyTaskOf (seed: uint64) (occupationSeed: uint64) (dayIndex: int64) (kind: Kind) : DailyTask option = match taskPoolOf kind with @@ -161,6 +185,13 @@ module Occupation = DueTick = offered + Sim.ticksPerDay - 1L State = Offered } + /// 当日任务刷新:已持任务的 OfferedTick 仍落在 nowTick 所属日时原样保留(含 + /// Done/Failed 终态,保证当日面板与存档稳定);跨日才按当日重新生成 Offered。 + let refreshToday (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 + /// 挂在玩家侧的职业状态,随存档 v3 保存(design §2)。 /// Today = 当日任务(§3);TaskToken 保留为 v3 前缀占位扩展位(恒 0,暂未启用)。 type State = @@ -172,6 +203,24 @@ module Occupation = let stateOf (kind: Kind) : State = { Profile = profileOf kind; TaskToken = 0; Today = None; StoryStage = 0 } + // ---- 既有事件通道 → 任务信号桥(design §3:完成判定统一走 InteractionEvent)---- + + /// 仅玩家发起的对话/交易才产生任务信号;NPC 之间互动的副作用不派任务。 + /// TradeEvent 字段序 = buyer/seller/item/quantity/price,玩家为卖方 → Traded,为买方 → Purchased。 + let signalOfInteraction (event: Sim.InteractionEvent) : TaskSignal option = + match event.Kind with + | Sim.DialogueEvent outcome when outcome.Actor = Sim.playerId -> Some(Dialogued outcome.Target) + | Sim.TradeEvent (_, seller, item, quantity, _) when seller = Sim.playerId -> Some(Traded(seller, item, quantity)) + | Sim.TradeEvent (buyer, _, item, quantity, _) when buyer = Sim.playerId -> Some(Purchased(item, quantity)) + | _ -> None + + /// 用一条既有事件推进当日任务(Offered 须先 acceptTask;终态不可复活)。 + /// 无职业 / 无当日任务 / 非玩家事件时原样返回。 + let advanceTask (event: Sim.InteractionEvent) (state: State) : State = + match state.Today, signalOfInteraction event with + | Some task, Some signal -> { state with Today = Some(applySignal signal task) } + | _ -> state + // ---- 报价偏置(design §2/§3):Kernel 纯函数旁路,不改 Sim.fs 数值口径 ---- // 4 位定点整数基点(1/10000)表达偏置,避免浮点漂移;结果钳在 [0.95, 1.05]。 // 货郎:买卖双向有利(买压价/卖抬价 ±3.00%);书生:解读行情小幅加成 ±1.00%; diff --git a/src/LivingVillage.Kernel/WorldSave.fs b/src/LivingVillage.Kernel/WorldSave.fs index 1ce1dcb..b327c6d 100644 --- a/src/LivingVillage.Kernel/WorldSave.fs +++ b/src/LivingVillage.Kernel/WorldSave.fs @@ -415,6 +415,10 @@ module WorldSave = | Occupation.NightCatch -> add tokens "night-catch" | Occupation.SellFish -> add tokens "sell-fish" | Occupation.MarketInquiry -> add tokens "market-inquiry" + | Occupation.BuyGoods -> add tokens "buy-goods" + | Occupation.Resell -> add tokens "resell" + | Occupation.ObserveNotes -> add tokens "observe-notes" + | Occupation.ReasonDebate -> add tokens "reason-debate" let private readTaskTemplateId (reader: TokenReader) label = match reader.Take(label) with @@ -424,6 +428,10 @@ module WorldSave = | "night-catch" -> Occupation.NightCatch | "sell-fish" -> Occupation.SellFish | "market-inquiry" -> Occupation.MarketInquiry + | "buy-goods" -> Occupation.BuyGoods + | "resell" -> Occupation.Resell + | "observe-notes" -> Occupation.ObserveNotes + | "reason-debate" -> Occupation.ReasonDebate | _ -> invalid (sprintf "invalid %s" label) let private writeTaskState tokens state = |
