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(夜捕/卖渔/询价) /// + P15 补齐货郎2(收货/转卖)+ 书生2(观察记事/论理),design §3 表。 type TaskTemplateId = | DeliverGrain | HelpWork | TillSoil | NightCatch | SellFish | MarketInquiry | BuyGoods | Resell | ObserveNotes | ReasonDebate let taskNameOf (template: TaskTemplateId) : string = match template with | DeliverGrain -> "送粮" | HelpWork -> "帮工" | TillSoil -> "翻土" | 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 -> [ 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 TargetNpc: Sim.NpcId option TargetTile: (int * int) option OfferedTick: int64 DueTick: int64 State: TaskState } /// 完成判定信号(design §3:走既有事件通道)。 /// Dialogued 来自 DialogueEvent(chooseDialogue);Traded 来自 TradeEvent, /// 字段序 = 卖方/物品/数量(玩家递出即 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 接线单落地)。 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 | BuyGoods, Purchased (_, quantity) -> quantity >= 1 | Resell, Traded (seller, _, quantity) -> seller = Sim.playerId && quantity >= 1 | ObserveNotes, Observed -> true | ReasonDebate, 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 } /// 当日任务刷新:已持任务的 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 = { Profile: Profile TaskToken: int Today: DailyTask option StoryStage: int } 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%; // 农夫/渔夫/无职业:恒 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)