summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Kernel/Occupation.fs
blob: 2168c4ea7968f156887b5493bb66b530d23cd1ba (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
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)