summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Kernel/Occupation.fs
blob: c2073959ecc9d04d02a77d2cce0d6dd4c15b5bdc (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
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