namespace LivingVillage.Kernel /// Occupation system first slice (design §1/§2, docs/design/occupation-system-design.md). /// Data lives beside the player only — NPCs are never occupied. /// Nothing here runs inside Sim.step; WorldBootstrap stamps the profile onto the /// world's avatar at creation time, which keeps the numeric state path untouched. module Occupation = type Kind = | Farmer | Fisher | Peddler | Scholar /// Chinese labels, shared for a later UI slice (农夫 / 渔夫 / 货郎 / 书生). let nameOf (kind: Kind) : string = match kind with | Farmer -> "农夫" | Fisher -> "渔夫" | Peddler -> "货郎" | Scholar -> "书生" let saveToken (kind: Kind) : string = match kind with | Farmer -> "farmer" | Fisher -> "fisher" | Peddler -> "peddler" | Scholar -> "scholar" let all : Kind list = [ Farmer; Fisher; Peddler; Scholar ] type Profile = { Kind: Kind InitialMoney: int InitialEnergy: float32 InitialInventory: (Sim.ItemKind * int) list } /// Initial values straight from the approved design §1 table. let profileOf (kind: Kind) : Profile = match kind with | Farmer -> { Kind = Farmer; InitialMoney = 60; InitialEnergy = 100.0f; InitialInventory = [ Sim.Food, 12 ] } | Fisher -> { Kind = Fisher; InitialMoney = 45; InitialEnergy = 95.0f; InitialInventory = [ Sim.Food, 8; Sim.Fish, 3 ] } | Peddler -> { Kind = Peddler; InitialMoney = 90; InitialEnergy = 100.0f; InitialInventory = [ Sim.Food, 6; Sim.Spice, 4 ] } | Scholar -> { Kind = Scholar; InitialMoney = 70; InitialEnergy = 100.0f; InitialInventory = [ Sim.Food, 10; Sim.Scroll, 2 ] } // ---- 每日任务(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 } /// 挂在玩家侧的职业状态,随存档 v3 保存(design §2)。 /// Today = 当日任务(§3);TaskToken 保留为 v3 前缀占位扩展位(恒 0,暂未启用)。 type State = { Profile: Profile TaskToken: int Today: DailyTask option StoryStage: int } let stateOf (kind: Kind) : State = { Profile = profileOf kind; TaskToken = 0; Today = None; StoryStage = 0 } // ---- 报价偏置(design §2/§3):Kernel 纯函数旁路,不改 Sim.fs 数值口径 ---- // 4 位定点整数基点(1/10000)表达偏置,避免浮点漂移;结果钳在 [0.95, 1.05]。 // 货郎:买卖双向有利(买压价/卖抬价 ±3.00%);书生:解读行情小幅加成 ±1.00%; // 农夫/渔夫/无职业:恒 1.0,与旧行为逐位一致。 let private peddlerTradeBasisPoints = 300 let private scholarTradeBasisPoints = 100 let quoteBiasBasisPoints (kind: Kind option) (playerIsBuyer: bool) : int = match kind with | Some Peddler -> if playerIsBuyer then -peddlerTradeBasisPoints else peddlerTradeBasisPoints | Some Scholar -> if playerIsBuyer then -scholarTradeBasisPoints else scholarTradeBasisPoints | _ -> 0 let quoteBiasOf (kind: Kind option) (playerIsBuyer: bool) : float32 = float32 (10000 + quoteBiasBasisPoints kind playerIsBuyer) / 10000.0f /// Sim.quotePrice 的输出经职业偏置映射;None(无报价)原样穿透。 let biasedQuote (kind: Kind option) (playerIsBuyer: bool) (baseQuote: float32 option) : float32 option = baseQuote |> Option.map (fun price -> price * quoteBiasOf kind playerIsBuyer)