summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/LivingVillage.Kernel.Tests/DailyTaskTests.fs191
-rw-r--r--src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj1
-rw-r--r--src/LivingVillage.Kernel/Occupation.fs118
3 files changed, 310 insertions, 0 deletions
diff --git a/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs b/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs
new file mode 100644
index 0000000..12bdf68
--- /dev/null
+++ b/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs
@@ -0,0 +1,191 @@
+namespace LivingVillage.Kernel.Tests
+
+open System
+open System.IO
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Kernel
+open LivingVillage.Kernel.Sim
+
+module private DailyTaskHarness =
+
+ let taskOf (template: Occupation.TaskTemplateId) (state: Occupation.TaskState) : Occupation.DailyTask =
+ { TemplateId = template
+ TargetNpc = None
+ TargetTile = None
+ OfferedTick = 0L
+ DueTick = Sim.ticksPerDay - 1L
+ State = state }
+
+ let accepted (template: Occupation.TaskTemplateId) : Occupation.DailyTask =
+ Occupation.acceptTask (taskOf template Occupation.Offered)
+
+[<TestClass>]
+type DailyTaskTests () =
+
+ // ---- §3 生成:hash(seed ^ occupationSeed, dayIndex) mod |pool|(splitmix 同源)----
+
+ [<TestMethod>]
+ member _.TaskHashFollowsSplitmixOverSeedXorOccupationSeed () =
+ Assert.AreEqual<uint64>(0xF7E9F3F88CC04AD6UL, Occupation.taskHash 42UL 7UL 0L)
+ Assert.AreEqual<uint64>(0xFB624BD98F2BE6C4UL, Occupation.taskHash 42UL 7UL 1L)
+ Assert.AreEqual<uint64>(0x87FC3F1DAC740225UL, Occupation.taskHash 42UL 7UL 2L)
+ Assert.AreEqual<uint64>(0xDA258F1151868B40UL, Occupation.taskHash 42UL 7UL 3L)
+ Assert.AreEqual<uint64>(0x02D114B7ABDD7A1FUL, Occupation.taskHash 4242UL 99UL 0L)
+
+ [<TestMethod>]
+ member _.FarmerAndFisherPoolsAreThreeTemplatesEach () =
+ 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)
+
+ [<TestMethod>]
+ member _.GeneratedTasksStayInPoolAndRepeatDeterministically () =
+ for seed, occ in [ (42UL, 7UL); (7UL, 42UL); (123456789UL, 555UL) ] do
+ for kind in [ Occupation.Farmer; Occupation.Fisher ] do
+ let pool = Occupation.taskPoolOf kind
+ for day in 0L .. 299L do
+ let task = (Occupation.dailyTaskOf seed occ day kind).Value
+ Assert.IsTrue(pool |> List.contains task.TemplateId, $"day {day} escaped the pool")
+ let again = (Occupation.dailyTaskOf seed occ day kind).Value
+ Assert.AreEqual<Occupation.DailyTask>(task, again)
+ let distinct =
+ [ 0L .. 299L ]
+ |> List.map (fun day -> (Occupation.dailyTaskOf seed occ day kind).Value.TemplateId)
+ |> Set.ofList
+ 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)
+
+ [<TestMethod>]
+ member _.TaskWindowCoversExactlyOneDayStartingOffered () =
+ let task = (Occupation.dailyTaskOf 42UL 7UL 3L Occupation.Farmer).Value
+ Assert.AreEqual<int64>(3L * Sim.ticksPerDay, task.OfferedTick)
+ Assert.AreEqual<int64>(4L * Sim.ticksPerDay - 1L, task.DueTick)
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Offered, task.State)
+ Assert.AreEqual<int64>(3L, Occupation.dayIndexOf (3L * Sim.ticksPerDay))
+ Assert.AreEqual<int64>(3L, Occupation.dayIndexOf (4L * Sim.ticksPerDay - 1L))
+
+ [<TestMethod>]
+ member _.TaskTemplateChineseNamesMatchDesignTable () =
+ Assert.AreEqual<string>("送粮", Occupation.taskNameOf Occupation.DeliverGrain)
+ Assert.AreEqual<string>("帮工", Occupation.taskNameOf Occupation.HelpWork)
+ Assert.AreEqual<string>("翻土", Occupation.taskNameOf Occupation.TillSoil)
+ Assert.AreEqual<string>("夜捕", Occupation.taskNameOf Occupation.NightCatch)
+ Assert.AreEqual<string>("卖渔", Occupation.taskNameOf Occupation.SellFish)
+ Assert.AreEqual<string>("询价", Occupation.taskNameOf Occupation.MarketInquiry)
+
+ // ---- §3 状态机 Offered|Active|Done|Failed ----
+
+ [<TestMethod>]
+ member _.AcceptMovesOfferedToActiveOnly () =
+ let offered = DailyTaskHarness.taskOf Occupation.HelpWork Occupation.Offered
+ let active = Occupation.acceptTask offered
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Active, active.State)
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Active, (Occupation.acceptTask active).State)
+ let finished = { active with State = Occupation.Done }
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Done, (Occupation.acceptTask finished).State)
+ let failed = { active with State = Occupation.Failed }
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Failed, (Occupation.acceptTask failed).State)
+
+ [<TestMethod>]
+ member _.SellFishCompletesOnlyOnPlayerFishSales () =
+ let active = DailyTaskHarness.accepted Occupation.SellFish
+ Assert.AreEqual<Occupation.TaskState>(
+ Occupation.Done,
+ (Occupation.applySignal (Occupation.Traded(playerId, Fish, 1)) active).State)
+ Assert.AreEqual<Occupation.TaskState>(
+ Occupation.Active,
+ (Occupation.applySignal (Occupation.Traded(playerId, Spice, 1)) active).State)
+ Assert.AreEqual<Occupation.TaskState>(
+ Occupation.Active,
+ (Occupation.applySignal (Occupation.Traded(NpcId 2, Fish, 4)) active).State)
+ let offered = DailyTaskHarness.taskOf Occupation.SellFish Occupation.Offered
+ Assert.AreEqual<Occupation.TaskState>(
+ Occupation.Offered,
+ (Occupation.applySignal (Occupation.Traded(playerId, Fish, 1)) offered).State)
+
+ [<TestMethod>]
+ member _.DeliverGrainCompletesOnlyOnPlayerFoodGiveaways () =
+ let active = DailyTaskHarness.accepted Occupation.DeliverGrain
+ Assert.AreEqual<Occupation.TaskState>(
+ Occupation.Done,
+ (Occupation.applySignal (Occupation.Traded(playerId, Food, 3)) active).State)
+ Assert.AreEqual<Occupation.TaskState>(
+ Occupation.Active,
+ (Occupation.applySignal (Occupation.Traded(playerId, Fish, 1)) active).State)
+ Assert.AreEqual<Occupation.TaskState>(
+ Occupation.Active,
+ (Occupation.applySignal (Occupation.Traded(NpcId 5, Food, 1)) active).State)
+
+ [<TestMethod>]
+ member _.HelpWorkAndTillSoilRespectOptionalTargets () =
+ let wildcard = DailyTaskHarness.accepted Occupation.HelpWork
+ Assert.AreEqual<Occupation.TaskState>(
+ Occupation.Done,
+ (Occupation.applySignal (Occupation.Dialogued(NpcId 4)) wildcard).State)
+ let targeted =
+ { DailyTaskHarness.accepted Occupation.HelpWork with TargetNpc = Some(NpcId 2) }
+ Assert.AreEqual<Occupation.TaskState>(
+ Occupation.Done,
+ (Occupation.applySignal (Occupation.Dialogued(NpcId 2)) targeted).State)
+ Assert.AreEqual<Occupation.TaskState>(
+ Occupation.Active,
+ (Occupation.applySignal (Occupation.Dialogued(NpcId 4)) targeted).State)
+ let untilled = DailyTaskHarness.accepted Occupation.TillSoil
+ Assert.AreEqual<Occupation.TaskState>(
+ Occupation.Done,
+ (Occupation.applySignal (Occupation.ArrivedAt(1, 1)) untilled).State)
+ let plotTilling =
+ { DailyTaskHarness.accepted Occupation.TillSoil with TargetTile = Some(52, 38) }
+ Assert.AreEqual<Occupation.TaskState>(
+ Occupation.Done,
+ (Occupation.applySignal (Occupation.ArrivedAt(52, 38)) plotTilling).State)
+ Assert.AreEqual<Occupation.TaskState>(
+ Occupation.Active,
+ (Occupation.applySignal (Occupation.ArrivedAt(10, 10)) plotTilling).State)
+
+ [<TestMethod>]
+ member _.NightCatchAndMarketInquiryCompleteOnTheirSignals () =
+ let active = DailyTaskHarness.accepted Occupation.NightCatch
+ Assert.AreEqual<Occupation.TaskState>(
+ Occupation.Done,
+ (Occupation.applySignal Occupation.NightAtWater active).State)
+ Assert.AreEqual<Occupation.TaskState>(
+ Occupation.Active,
+ (Occupation.applySignal (Occupation.Dialogued(NpcId 0)) active).State)
+ let inquiring = DailyTaskHarness.accepted Occupation.MarketInquiry
+ Assert.AreEqual<Occupation.TaskState>(
+ Occupation.Done,
+ (Occupation.applySignal (Occupation.Dialogued(NpcId 3)) inquiring).State)
+ Assert.AreEqual<Occupation.TaskState>(
+ Occupation.Active,
+ (Occupation.applySignal Occupation.NightAtWater inquiring).State)
+
+ [<TestMethod>]
+ member _.DoneAndFailedAreTerminalStates () =
+ let finished = { DailyTaskHarness.accepted Occupation.SellFish with State = Occupation.Done }
+ Assert.AreEqual<Occupation.TaskState>(
+ Occupation.Done,
+ (Occupation.applySignal (Occupation.Traded(playerId, Fish, 1)) finished).State)
+ let failed = { DailyTaskHarness.accepted Occupation.SellFish with State = Occupation.Failed }
+ Assert.AreEqual<Occupation.TaskState>(
+ Occupation.Failed,
+ (Occupation.applySignal (Occupation.Traded(playerId, Fish, 1)) failed).State)
+
+ [<TestMethod>]
+ member _.OverdueTasksFailAtDayBoundaryButNotBefore () =
+ let active = DailyTaskHarness.accepted Occupation.SellFish
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Active, (Occupation.expireAt active.DueTick active).State)
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Failed, (Occupation.expireAt (active.DueTick + 1L) active).State)
+ let offered = DailyTaskHarness.taskOf Occupation.SellFish Occupation.Offered
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Failed, (Occupation.expireAt (offered.DueTick + 1L) offered).State)
+ let finished = { active with State = Occupation.Done }
+ Assert.AreEqual<Occupation.TaskState>(Occupation.Done, (Occupation.expireAt (finished.DueTick + 1L) finished).State)
diff --git a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj
index f7a50d5..c11e064 100644
--- a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj
+++ b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj
@@ -12,6 +12,7 @@
<Compile Include="TradeTests.fs" />
<Compile Include="M5Tests.fs" />
<Compile Include="M6aTests.fs" />
+ <Compile Include="DailyTaskTests.fs" />
<Compile Include="PerformanceTests.fs" />
</ItemGroup>
diff --git a/src/LivingVillage.Kernel/Occupation.fs b/src/LivingVillage.Kernel/Occupation.fs
index 28008f5..e0507ae 100644
--- a/src/LivingVillage.Kernel/Occupation.fs
+++ b/src/LivingVillage.Kernel/Occupation.fs
@@ -51,3 +51,121 @@ module Occupation =
let stateOf (kind: Kind) : State =
{ Profile = profileOf kind; TaskToken = 0; StoryStage = 0 }
+
+ // ---- 每日任务(design §3):Kernel 侧纯函数片,不进 Sim.step 数值路径 ----
+
+ type TaskState =
+ | Offered
+ | Active
+ | Done
+ | Failed
+
+ /// 首期任务池 = 农夫3(送粮/帮工/翻土)+ 渔夫3(夜捕/卖渔/询价),design §3 表。
+ type TaskTemplateId =
+ | DeliverGrain
+ | HelpWork
+ | TillSoil
+ | NightCatch
+ | SellFish
+ | MarketInquiry
+
+ let taskNameOf (template: TaskTemplateId) : string =
+ match template with
+ | DeliverGrain -> "送粮"
+ | HelpWork -> "帮工"
+ | TillSoil -> "翻土"
+ | NightCatch -> "夜捕"
+ | SellFish -> "卖渔"
+ | MarketInquiry -> "询价"
+
+ /// 货郎/书生模板留后续单,当前为空池 → 不生成每日任务。
+ let taskPoolOf (kind: Kind) : TaskTemplateId list =
+ match kind with
+ | Farmer -> [ DeliverGrain; HelpWork; TillSoil ]
+ | Fisher -> [ NightCatch; SellFish; MarketInquiry ]
+ | Peddler
+ | Scholar -> []
+
+ 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);ArrivedAt 为到达瓦片;
+ /// NightAtWater 由调用方把 isNight + 水边判定折叠后喂入。Sim.step 零感知。
+ type TaskSignal =
+ | Dialogued of Sim.NpcId
+ | Traded of Sim.NpcId * Sim.ItemKind * int
+ | ArrivedAt of int * int
+ | 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
+ | _ -> 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 }