summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/LivingVillage.Kernel.Tests/DailyTaskTests.fs100
-rw-r--r--src/LivingVillage.Kernel/Occupation.fs61
-rw-r--r--src/LivingVillage.Kernel/WorldSave.fs8
3 files changed, 156 insertions, 13 deletions
diff --git a/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs b/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs
index e784d83..0800ff8 100644
--- a/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs
+++ b/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs
@@ -50,20 +50,24 @@ type DailyTaskTests () =
Assert.AreEqual<uint64>(0x02D114B7ABDD7A1FUL, Occupation.taskHash 4242UL 99UL 0L)
[<TestMethod>]
- member _.FarmerAndFisherPoolsAreThreeTemplatesEach () =
+ member _.EveryOccupationPoolIsNonEmptyAndFarmFisherKeepThree () =
Assert.AreEqual(
[ Occupation.DeliverGrain; Occupation.HelpWork; Occupation.TillSoil ],
Occupation.taskPoolOf Occupation.Farmer)
Assert.AreEqual(
[ Occupation.NightCatch; Occupation.SellFish; Occupation.MarketInquiry ],
Occupation.taskPoolOf Occupation.Fisher)
- Assert.AreEqual<int>(0, Occupation.taskPoolOf Occupation.Peddler |> List.length)
- Assert.AreEqual<int>(0, Occupation.taskPoolOf Occupation.Scholar |> List.length)
+ Assert.AreEqual(
+ [ Occupation.BuyGoods; Occupation.Resell ],
+ Occupation.taskPoolOf Occupation.Peddler)
+ Assert.AreEqual(
+ [ Occupation.ObserveNotes; Occupation.ReasonDebate ],
+ Occupation.taskPoolOf Occupation.Scholar)
[<TestMethod>]
member _.GeneratedTasksStayInPoolAndRepeatDeterministically () =
for seed, occ in [ (42UL, 7UL); (7UL, 42UL); (123456789UL, 555UL) ] do
- for kind in [ Occupation.Farmer; Occupation.Fisher ] do
+ for kind in Occupation.all do
let pool = Occupation.taskPoolOf kind
for day in 0L .. 299L do
let task = (Occupation.dailyTaskOf seed occ day kind).Value
@@ -77,9 +81,17 @@ type DailyTaskTests () =
Assert.IsTrue(distinct.Count >= 2, "300 days must not pin a single template")
[<TestMethod>]
- member _.PeddlerAndScholarHaveNoDailyTaskYet () =
- Assert.AreEqual<Occupation.DailyTask option>(None, Occupation.dailyTaskOf 42UL 7UL 0L Occupation.Peddler)
- Assert.AreEqual<Occupation.DailyTask option>(None, Occupation.dailyTaskOf 42UL 7UL 0L Occupation.Scholar)
+ member _.PeddlerAndScholarGenerateTheirOwnDailyTasks () =
+ for kind in [ Occupation.Peddler; Occupation.Scholar ] do
+ for day in 0L .. 59L do
+ let task = (Occupation.dailyTaskOf 42UL 7UL day kind).Value
+ Assert.IsTrue(Occupation.taskPoolOf kind |> List.contains task.TemplateId)
+ Assert.AreEqual<Occupation.TaskTemplateId>(
+ Occupation.BuyGoods,
+ (Occupation.dailyTaskOf 42UL 7UL 0L Occupation.Peddler).Value.TemplateId)
+ Assert.AreEqual<Occupation.TaskTemplateId>(
+ Occupation.ObserveNotes,
+ (Occupation.dailyTaskOf 42UL 7UL 0L Occupation.Scholar).Value.TemplateId)
[<TestMethod>]
member _.TaskWindowCoversExactlyOneDayStartingOffered () =
@@ -98,6 +110,10 @@ type DailyTaskTests () =
Assert.AreEqual<string>("夜捕", Occupation.taskNameOf Occupation.NightCatch)
Assert.AreEqual<string>("卖渔", Occupation.taskNameOf Occupation.SellFish)
Assert.AreEqual<string>("询价", Occupation.taskNameOf Occupation.MarketInquiry)
+ Assert.AreEqual<string>("收货", Occupation.taskNameOf Occupation.BuyGoods)
+ Assert.AreEqual<string>("转卖", Occupation.taskNameOf Occupation.Resell)
+ Assert.AreEqual<string>("观察记事", Occupation.taskNameOf Occupation.ObserveNotes)
+ Assert.AreEqual<string>("论理", Occupation.taskNameOf Occupation.ReasonDebate)
// ---- §3 状态机 Offered|Active|Done|Failed ----
@@ -298,3 +314,73 @@ type DailyTaskTests () =
let state = { (Occupation.stateOf Occupation.Farmer) with Today = Some(DailyTaskHarness.taskForDay 9L) }
WorldSave.saveWith (Some state) world
Assert.AreEqual<string>(build (), build ())
+
+ // ---- P15:货郎/书生模板完成信号、事件通道桥、当日刷新 ----
+
+ [<TestMethod>]
+ member _.PeddlerAndScholarTemplatesCompleteOnTheirSignals () =
+ let buy = DailyTaskHarness.accepted Occupation.BuyGoods
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Done, (Occupation.applySignal (Occupation.Purchased(Food, 2)) buy).State)
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Active, (Occupation.applySignal (Occupation.Purchased(Food, 0)) buy).State)
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Active, (Occupation.applySignal (Occupation.Dialogued(NpcId 0)) buy).State)
+ let resell = DailyTaskHarness.accepted Occupation.Resell
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Done, (Occupation.applySignal (Occupation.Traded(playerId, Spice, 1)) resell).State)
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Active, (Occupation.applySignal (Occupation.Traded(NpcId 2, Spice, 1)) resell).State)
+ let observe = DailyTaskHarness.accepted Occupation.ObserveNotes
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Done, (Occupation.applySignal Occupation.Observed observe).State)
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Active, (Occupation.applySignal (Occupation.Dialogued(NpcId 0)) observe).State)
+ let reason = DailyTaskHarness.accepted Occupation.ReasonDebate
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Done, (Occupation.applySignal (Occupation.Dialogued(NpcId 3)) reason).State)
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Active, (Occupation.applySignal Occupation.Observed reason).State)
+
+ [<TestMethod>]
+ member _.SignalBridgeIgnoresNpcInitiatedEvents () =
+ let npcDialogue =
+ { Tick = 5L
+ Kind = DialogueEvent { Tick = 5L; Actor = NpcId 1; Target = NpcId 2; Intent = SmallTalk; Response = Friendly; Valence = 0.3f } }
+ Assert.AreEqual<Occupation.TaskSignal option>(None, Occupation.signalOfInteraction npcDialogue)
+ let npcTrade = { Tick = 5L; Kind = TradeEvent(NpcId 1, NpcId 2, Food, 1, 1.0f) }
+ Assert.AreEqual<Occupation.TaskSignal option>(None, Occupation.signalOfInteraction npcTrade)
+ let playerBuy = { Tick = 6L; Kind = TradeEvent(playerId, NpcId 2, Spice, 3, 2.0f) }
+ Assert.AreEqual<Occupation.TaskSignal option>(Some(Occupation.Purchased(Spice, 3)), Occupation.signalOfInteraction playerBuy)
+ let playerSell = { Tick = 7L; Kind = TradeEvent(NpcId 2, playerId, Fish, 4, 2.0f) }
+ Assert.AreEqual<Occupation.TaskSignal option>(Some(Occupation.Traded(playerId, Fish, 4)), Occupation.signalOfInteraction playerSell)
+
+ [<TestMethod>]
+ member _.PlayerDialogueEventThroughExistingChannelCompletesTheDaysTask () =
+ let world = Sim.initialWorldN 7UL 4
+ let nearFirst = { world with Avatar = { world.Avatar with Pos = world.Npcs.[0].Pos } }
+ match Sim.chooseDialogue (NpcId 0) SmallTalk nearFirst with
+ | DialogueRejected (failure, _) -> Assert.Fail($"dialogue rejected: {failure}")
+ | DialogueSucceeded (outcome, afterDialogue) ->
+ let event = afterDialogue.Events |> List.last
+ Assert.AreEqual<Sim.NpcId>(playerId, outcome.Actor)
+ Assert.AreEqual<Occupation.TaskSignal option>(Some(Occupation.Dialogued(NpcId 0)), Occupation.signalOfInteraction event)
+ let day =
+ [ 0L .. 60L ]
+ |> List.find (fun day ->
+ (Occupation.dailyTaskOf 42UL 7UL day Occupation.Farmer).Value.TemplateId = Occupation.HelpWork)
+ let offered = (Occupation.dailyTaskOf 42UL 7UL day Occupation.Farmer).Value
+ let notAccepted = Occupation.advanceTask event { (Occupation.stateOf Occupation.Farmer) with Today = Some offered }
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Offered, notAccepted.Today.Value.State)
+ let active = notAccepted.Today |> Option.map Occupation.acceptTask
+ let completed = Occupation.advanceTask event { notAccepted with Today = active }
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Done, completed.Today.Value.State)
+
+ [<TestMethod>]
+ member _.RefreshTodayKeepsSameDayFinalsAndRegeneratesOnDayChange () =
+ let day0 = (Occupation.dailyTaskOf 42UL 7UL 0L Occupation.Farmer).Value
+ Assert.AreEqual<Occupation.DailyTask option>(
+ Some day0,
+ Occupation.refreshToday 42UL 7UL Occupation.Farmer (Sim.ticksPerDay - 1L) (Some day0))
+ let day1 = (Occupation.dailyTaskOf 42UL 7UL 1L Occupation.Farmer).Value
+ Assert.AreEqual<Occupation.DailyTask option>(
+ Some day1,
+ Occupation.refreshToday 42UL 7UL Occupation.Farmer Sim.ticksPerDay (Some day0))
+ let finished = { day0 with State = Occupation.Done }
+ Assert.AreEqual<Occupation.DailyTask option>(
+ Some finished,
+ Occupation.refreshToday 42UL 7UL Occupation.Farmer 10L (Some finished))
+ Assert.AreEqual<Occupation.DailyTask option>(
+ Some day1,
+ Occupation.refreshToday 42UL 7UL Occupation.Farmer Sim.ticksPerDay None)
diff --git a/src/LivingVillage.Kernel/Occupation.fs b/src/LivingVillage.Kernel/Occupation.fs
index 5c75c81..2168c4e 100644
--- a/src/LivingVillage.Kernel/Occupation.fs
+++ b/src/LivingVillage.Kernel/Occupation.fs
@@ -51,7 +51,8 @@ module Occupation =
| Done
| Failed
- /// 首期任务池 = 农夫3(送粮/帮工/翻土)+ 渔夫3(夜捕/卖渔/询价),design §3 表。
+ /// 首期任务池 = 农夫3(送粮/帮工/翻土)+ 渔夫3(夜捕/卖渔/询价)
+ /// + P15 补齐货郎2(收货/转卖)+ 书生2(观察记事/论理),design §3 表。
type TaskTemplateId =
| DeliverGrain
| HelpWork
@@ -59,6 +60,10 @@ module Occupation =
| NightCatch
| SellFish
| MarketInquiry
+ | BuyGoods
+ | Resell
+ | ObserveNotes
+ | ReasonDebate
let taskNameOf (template: TaskTemplateId) : string =
match template with
@@ -68,14 +73,26 @@ module Occupation =
| 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
- | Scholar -> []
+ | 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
@@ -87,12 +104,15 @@ module Occupation =
/// 完成判定信号(design §3:走既有事件通道)。
/// Dialogued 来自 DialogueEvent(chooseDialogue);Traded 来自 TradeEvent,
- /// 字段序 = 卖方/物品/数量(玩家递出即 seller = playerId);ArrivedAt 为到达瓦片;
+ /// 字段序 = 卖方/物品/数量(玩家递出即 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 接线单落地)。
@@ -110,6 +130,10 @@ module Occupation =
| 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;其余状态原样返回(终态不可复活)。
@@ -145,7 +169,7 @@ module Occupation =
state <- next
out
- /// 当日任务生成:池内确定性抽 1 条;空池(货郎/书生首期)→ None。
+ /// 当日任务生成:池内确定性抽 1 条;空池 → None。
/// TargetNpc/TargetTile 首期留 None(通配),OfferedTick/DueTick 覆盖整日。
let dailyTaskOf (seed: uint64) (occupationSeed: uint64) (dayIndex: int64) (kind: Kind) : DailyTask option =
match taskPoolOf kind with
@@ -161,6 +185,13 @@ module Occupation =
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 =
@@ -172,6 +203,24 @@ module Occupation =
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%;
diff --git a/src/LivingVillage.Kernel/WorldSave.fs b/src/LivingVillage.Kernel/WorldSave.fs
index 1ce1dcb..b327c6d 100644
--- a/src/LivingVillage.Kernel/WorldSave.fs
+++ b/src/LivingVillage.Kernel/WorldSave.fs
@@ -415,6 +415,10 @@ module WorldSave =
| Occupation.NightCatch -> add tokens "night-catch"
| Occupation.SellFish -> add tokens "sell-fish"
| Occupation.MarketInquiry -> add tokens "market-inquiry"
+ | Occupation.BuyGoods -> add tokens "buy-goods"
+ | Occupation.Resell -> add tokens "resell"
+ | Occupation.ObserveNotes -> add tokens "observe-notes"
+ | Occupation.ReasonDebate -> add tokens "reason-debate"
let private readTaskTemplateId (reader: TokenReader) label =
match reader.Take(label) with
@@ -424,6 +428,10 @@ module WorldSave =
| "night-catch" -> Occupation.NightCatch
| "sell-fish" -> Occupation.SellFish
| "market-inquiry" -> Occupation.MarketInquiry
+ | "buy-goods" -> Occupation.BuyGoods
+ | "resell" -> Occupation.Resell
+ | "observe-notes" -> Occupation.ObserveNotes
+ | "reason-debate" -> Occupation.ReasonDebate
| _ -> invalid (sprintf "invalid %s" label)
let private writeTaskState tokens state =