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) // ---- 对话分支倾向(design §5 / P-d):村民对玩家职业的确定性回应偏差 ---- // 全部为纯函数重映射:基底仍走 Sim.responseFor 的人格口径,偏差不引入随机。 // 无职业(None)= 原回应逐字节不变。 // 规则(各自只作用于签名意图,其余意图原样穿透): // 农夫 AskHelp:Refused → Helpful(老实人家,求助不落空); // 渔夫 SmallTalk:Reserved → Friendly(聊的是天气鱼汛,冷场变热络); // 货郎 SmallTalk:Reserved → Bargaining(三句不离买卖,更易进讨价还价); // 书生 Joke:完全覆盖为 Reserved(读过书的人讲笑话,众人反而拘谨)—— // 与 personality 相反时采用完全覆盖语义而非概率偏向,确定性可测(design §5 反差感)。 let private remapResponse (kind: Kind) (intent: Sim.DialogueIntent) (baseResponse: Sim.DialogueResponse) : Sim.DialogueResponse = match kind, intent, baseResponse with | Farmer, Sim.AskHelp, Sim.Refused -> Sim.Helpful | Fisher, Sim.SmallTalk, Sim.Reserved -> Sim.Friendly | Peddler, Sim.SmallTalk, Sim.Reserved -> Sim.Bargaining | Scholar, Sim.Joke, _ -> Sim.Reserved | _ -> baseResponse let biasResponse (kind: Kind option) (intent: Sim.DialogueIntent) (personality: Sim.Personality) : Sim.DialogueResponse = let baseResponse = Sim.responseFor intent personality match kind with | None -> baseResponse | Some kind -> remapResponse kind intent baseResponse // ---- 轻剧情线(design §5 / P-d):三段式 接任务 → 走访 → 收束 ---- // 纯年鉴/对话驱动:每次玩家对话至多推进一段,产出只有(StoryStage 推进 + 年鉴摘要文本), // 由调用方写入世界。全部输入 = (seed, 世界 tick, 关系状态, 职业状态),纯函数、确定性。 // 开线日沿用每日任务的 (seed, profession, day) splitmix 哈希模式。 module Story = type Ending = | Warm | Plain | Distant /// 剧情线种子:与每日任务种子同源异值,两两不撞(见测试)。 let storySeedOf (kind: Kind) : uint64 = match kind with | Farmer -> 0x5555555555555555UL | Fisher -> 0x6666666666666666UL | Peddler -> 0x7777777777777777UL | Scholar -> 0x8888888888888888UL let lineNameOf (kind: Kind) : string = match kind with | Farmer -> "雨水与年成" | Fisher -> "水位与收成" | Peddler -> "新货的路子" | Scholar -> "庙前的争论" /// StoryStage 语义:0 未开始 / 1 已接任务 / 2 走访中 / 3 已收束。 let stageNameOf (stage: int) : string = match stage with | 1 -> "已接任务" | 2 -> "走访中" | 3 -> "已收束" | _ -> "未开始" /// 开线日 = hash(seed ^ storySeed, day 0) mod 3 + 2,落在 [2, 4]。 let startDayOf (seed: uint64) (kind: Kind) : int64 = (int64 (taskHash seed (storySeedOf kind) 0L % 3UL)) + 2L /// 玩家与村民的关系均值:与 Sim.relationMatrix 同口径(衰减 valence), /// 但只统计认识玩家的村民(有 Chatted/Dialogue 记忆者),无一认识 = 0(中性)。 let relationMeanToPlayer (world: Sim.World) : float32 = let player = Sim.playerId let mutable total = 0.0f let mutable known = 0 for npc in world.Npcs do let mutable valence = 0.0f let mutable touched = false for e in npc.Mind.Memory do if e.Tick <= world.Tick then let involvesPlayer = match e.Kind with | Sim.Chatted partner -> partner = player | Sim.Dialogue (actor, _, _) -> actor = player | _ -> false if involvesPlayer then touched <- true valence <- valence + e.Valence * Sim.relationDecayWeight world.Tick e.Tick if touched then known <- known + 1 total <- total + valence if known = 0 then 0.0f else total / float32 known /// 结局分档:均值 ≥ 0.3 热络;[0, 0.3) 平常;< 0 淡漠。纯阈值,无随机。 let endingOf (mean: float32) : Ending = if mean >= 0.3f then Warm elif mean >= 0.0f then Plain else Distant let endingTextOf (kind: Kind) (ending: Ending) : string = match kind, ending with | Farmer, Warm -> "雨水与年成有了结果:乡邻都说雨水匀,今年差不了" | Farmer, Plain -> "雨水与年成有了结果:雨水尚可,年成将就,照旧耕作" | Farmer, Distant -> "雨水与年成有了结果:问不出准信,年成只能看天" | Fisher, Warm -> "水位与收成有了结果:乡邻说得真切,水位稳了,收成坏不了" | Fisher, Plain -> "水位与收成有了结果:众说平常,水位尚可,收成将就" | Fisher, Distant -> "水位与收成有了结果:问了几家都淡淡的,水情还得自己看" | Peddler, Warm -> "新货的路子有了结果:乡邻热心指路,新货不愁卖" | Peddler, Plain -> "新货的路子有了结果:说法平常,先小量试卖" | Peddler, Distant -> "新货的路子有了结果:各家都淡淡的,路子还得自己闯" | Scholar, Warm -> "庙前的争论有了结果:各说各理,事有分晓" | Scholar, Plain -> "庙前的争论有了结果:众说纷纭,暂无定论" | Scholar, Distant -> "庙前的争论有了结果:无人理会,先记在册上" /// §3 身份条目:开局写入年鉴(如「以打渔为生」),不带主语、沿年鉴短句风格。 let identitySummaryOf (kind: Kind) : string = match kind with | Farmer -> "以耕田为生" | Fisher -> "以打渔为生" | Peddler -> "以贩货为生" | Scholar -> "以读书为生" /// 用一次玩家对话推进剧情线:同输入两次结果一致。 /// 返回(新状态, 应写入年鉴的摘要文本 option);无门控命中时原样返回。 /// 结局文本在收束瞬间按当时关系均值定格,由调用方连同世界存档(年鉴快照)持久化。 let onDialogue (seed: uint64) (state: State) (world: Sim.World) : State * string option = let kind = state.Profile.Kind let day = dayIndexOf world.Tick let startDay = startDayOf seed kind match state.StoryStage with | 0 when day >= startDay -> { state with StoryStage = 1 }, Some (sprintf "接了件事:%s,得去村里多问问" (lineNameOf kind)) | 1 when day >= startDay + 1L -> { state with StoryStage = 2 }, Some "走访了几户人家,改日再问" | 2 when day >= startDay + 2L -> let ending = endingOf (relationMeanToPlayer world) { state with StoryStage = 3 }, Some (endingTextOf kind ending) | _ -> state, None