summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Kernel
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 18:40:17 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 18:40:17 +0800
commitf8830705272cfa2c877cd0b984f61d1fa7a77ec2 (patch)
tree80afb54d4972915db413128917128f17a7d3458c /src/LivingVillage.Kernel
parentcf214889490a62ba4fe84fb0055bc2133776e8a2 (diff)
downloadliving-village-f8830705272cfa2c877cd0b984f61d1fa7a77ec2.tar.gz
feat(occupation): P11 每日任务生成纯函数 + TaskState 状态机(Kernel 侧)
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 向量、池约束、逐日确定性、窗口=整日、中文名、状态机转移、六模板正负完成信号、终态不可复活、逾期判负。
Diffstat (limited to 'src/LivingVillage.Kernel')
-rw-r--r--src/LivingVillage.Kernel/Occupation.fs118
1 files changed, 118 insertions, 0 deletions
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 }