summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-28 23:55:20 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-28 23:55:20 +0800
commit3669d9710e3783c325385a5648766b798d20ec77 (patch)
tree5fc618616b4a2eab252ea3bb1c32dd5b1f9ddc36 /src
parent6ea696b31ea342e43b61cf16aa1b3378a2bc68a3 (diff)
downloadliving-village-3669d9710e3783c325385a5648766b798d20ec77.tar.gz
p54: 当日任务玩家可见与可完成——Kernel 状态机接入真实事件/环境信号(接受/对话/交易/到达/夜水边),HUD 短中文反馈 + 任务面板接受提示 + v3 任务态往返与 save(None) 兼容 + LV_P54_SHOT 证据
Diffstat (limited to 'src')
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P54ProfessionDailyTests.fs217
-rw-r--r--src/LivingVillage.Desktop/Game.fs145
-rw-r--r--src/LivingVillage.Desktop/Interaction.fs51
-rw-r--r--src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj1
-rw-r--r--src/LivingVillage.Desktop/PlayerTrade.fs43
-rw-r--r--src/LivingVillage.Desktop/TaskRuntime.fs64
7 files changed, 501 insertions, 21 deletions
diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
index bda9932..ade22be 100644
--- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
+++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
@@ -31,6 +31,7 @@
<Compile Include="P51ProfessionSliceTests.fs" />
<Compile Include="P52ProfessionTradeTests.fs" />
<Compile Include="P53InteractionPromptTests.fs" />
+ <Compile Include="P54ProfessionDailyTests.fs" />
<Compile Include="SampleTests.fs" />
</ItemGroup>
diff --git a/src/LivingVillage.Desktop.Tests/P54ProfessionDailyTests.fs b/src/LivingVillage.Desktop.Tests/P54ProfessionDailyTests.fs
new file mode 100644
index 0000000..5d30921
--- /dev/null
+++ b/src/LivingVillage.Desktop.Tests/P54ProfessionDailyTests.fs
@@ -0,0 +1,217 @@
+namespace LivingVillage.Desktop.Tests
+
+open System
+open System.IO
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Kernel
+open LivingVillage.Kernel.Sim
+open LivingVillage.Desktop
+
+/// P54 职业切片三:当日任务 → 玩家可见与可完成。
+/// 完成度只由**真实世界事件/信号**驱动(`Occupation.applySignal`),不接受任何
+/// 按日历索引硬编码的计数;Offered 须先接受为 Active。
+[<TestClass>]
+type P54ProfessionDailyTests () =
+
+ let mkTask template taskState : Occupation.DailyTask =
+ { TemplateId = template
+ TargetNpc = None
+ TargetTile = None
+ OfferedTick = 0L
+ DueTick = Sim.ticksPerDay - 1L
+ State = taskState }
+
+ let stateWith template taskState : Occupation.State =
+ { Occupation.stateOf Occupation.Farmer with Today = Some(mkTask template taskState) }
+
+ let stateWithout : Occupation.State =
+ { Occupation.stateOf Occupation.Farmer with Today = None }
+
+ let dialogueEvent actor target : InteractionEvent =
+ { Tick = 10L
+ Kind =
+ DialogueEvent
+ { Tick = 10L
+ Actor = actor
+ Target = target
+ Intent = SmallTalk
+ Response = Friendly
+ Valence = 0.1f } }
+
+ let tradeEvent buyer seller item quantity : InteractionEvent =
+ { Tick = 10L
+ Kind = TradeEvent(buyer, seller, item, quantity, 10.0f) }
+
+ let taskStateOf (state: Occupation.State) =
+ state.Today |> Option.map (fun task -> task.State)
+
+ [<TestMethod>]
+ member _.AcceptOfferedTaskActivatesItWithFeedback () =
+ let activated, message = TaskRuntime.accept (stateWith Occupation.HelpWork Occupation.Offered)
+ Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Active, taskStateOf activated)
+ Assert.AreEqual<string option>(Some "已接受任务:帮工", message)
+ // 已在进行/终态:无变化、无反馈。
+ let active, activeMessage = TaskRuntime.accept (stateWith Occupation.HelpWork Occupation.Active)
+ Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Active, taskStateOf active)
+ Assert.AreEqual<string option>(None, activeMessage)
+ let none, noneMessage = TaskRuntime.accept stateWithout
+ Assert.AreEqual<Occupation.TaskState option>(None, taskStateOf none)
+ Assert.AreEqual<string option>(None, noneMessage)
+
+ [<TestMethod>]
+ member _.DialogueEventsCompleteActiveDialogueTasks () =
+ let complete template =
+ let state = stateWith template Occupation.Active
+ let next, message = TaskRuntime.advanceWithEvent (dialogueEvent playerId (NpcId 3)) state
+ Assert.IsTrue((taskStateOf next = Some Occupation.Done), sprintf "%A 应完成" template)
+ Assert.IsTrue(message.IsSome, sprintf "%A 完成应有短反馈" template)
+ message.Value
+ for template in [ Occupation.HelpWork; Occupation.MarketInquiry; Occupation.ReasonDebate ] do
+ let message = complete template
+ Assert.AreEqual<string>(sprintf "任务完成:%s" (Occupation.taskNameOf template), message)
+
+ [<TestMethod>]
+ member _.TradeEventsCompleteActiveTradeTasks () =
+ let state = stateWith Occupation.Resell Occupation.Active
+ let sold, soldMessage = TaskRuntime.advanceWithEvent (tradeEvent (NpcId 1) playerId Spice 1) state
+ Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Done, taskStateOf sold)
+ Assert.AreEqual<string option>(Some "任务完成:转卖", soldMessage)
+
+ let grain = stateWith Occupation.DeliverGrain Occupation.Active
+ Assert.AreEqual<Occupation.TaskState option>(
+ Some Occupation.Done,
+ taskStateOf (fst (TaskRuntime.advanceWithEvent (tradeEvent (NpcId 1) playerId Food 2) grain)))
+ let fish = stateWith Occupation.SellFish Occupation.Active
+ Assert.AreEqual<Occupation.TaskState option>(
+ Some Occupation.Done,
+ taskStateOf (fst (TaskRuntime.advanceWithEvent (tradeEvent (NpcId 1) playerId Fish 1) fish)))
+ let buy = stateWith Occupation.BuyGoods Occupation.Active
+ Assert.AreEqual<Occupation.TaskState option>(
+ Some Occupation.Done,
+ taskStateOf (fst (TaskRuntime.advanceWithEvent (tradeEvent playerId (NpcId 1) Food 1) buy)))
+
+ [<TestMethod>]
+ member _.EnvironmentSignalsOnlyFireOnRealChange () =
+ // 无基线不发到达信号(避免开局即完成翻土)。
+ Assert.AreEqual<Occupation.TaskSignal list>([], TaskRuntime.environmentSignals false false None (5, 5))
+ // 跨瓦片 → 到达瓦片。
+ Assert.AreEqual<Occupation.TaskSignal list>(
+ [ Occupation.ArrivedAt(6, 5) ],
+ TaskRuntime.environmentSignals false false (Some(5, 5)) (6, 5))
+ // 同瓦片不重复到达。
+ Assert.AreEqual<Occupation.TaskSignal list>([], TaskRuntime.environmentSignals false false (Some(6, 5)) (6, 5))
+ // 夜 + 水边 → 夜在水边。
+ Assert.AreEqual<Occupation.TaskSignal list>(
+ [ Occupation.NightAtWater ],
+ TaskRuntime.environmentSignals true true (Some(6, 5)) (6, 5))
+
+ [<TestMethod>]
+ member _.FoldedEnvironmentSignalsCompleteTheirTasks () =
+ let arrived = stateWith Occupation.TillSoil Occupation.Active
+ Assert.AreEqual<Occupation.TaskState option>(
+ Some Occupation.Done,
+ taskStateOf (fst (TaskRuntime.advanceWithSignal (Occupation.ArrivedAt(52, 38)) arrived)))
+ let night = stateWith Occupation.NightCatch Occupation.Active
+ Assert.AreEqual<Occupation.TaskState option>(
+ Some Occupation.Done,
+ taskStateOf (fst (TaskRuntime.advanceWithSignal Occupation.NightAtWater night)))
+ let observe = stateWith Occupation.ObserveNotes Occupation.Active
+ Assert.AreEqual<Occupation.TaskState option>(
+ Some Occupation.Done,
+ taskStateOf (fst (TaskRuntime.advanceWithSignal Occupation.Observed observe)))
+
+ [<TestMethod>]
+ member _.OfferedTaskNeedsAcceptBeforeItCanComplete () =
+ let offered = stateWith Occupation.HelpWork Occupation.Offered
+ let stillOffered, missed = TaskRuntime.advanceWithEvent (dialogueEvent playerId (NpcId 4)) offered
+ Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Offered, taskStateOf stillOffered)
+ Assert.AreEqual<string option>(None, missed)
+
+ let active, _ = TaskRuntime.accept offered
+ let done', message = TaskRuntime.advanceWithEvent (dialogueEvent playerId (NpcId 4)) active
+ Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Done, taskStateOf done')
+ Assert.IsTrue(message.IsSome)
+
+ [<TestMethod>]
+ member _.OnlyTheMatchingRealSignalCompletesAndNothingIsCalendarCounted () =
+ // 非玩家事件(NPC↔NPC)不得推进。
+ let active = stateWith Occupation.HelpWork Occupation.Active
+ let npcOnly, _ = TaskRuntime.advanceWithEvent (dialogueEvent (NpcId 1) (NpcId 2)) active
+ Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Active, taskStateOf npcOnly)
+ let npcTrade, _ = TaskRuntime.advanceWithEvent (tradeEvent (NpcId 1) (NpcId 2) Food 1) (stateWith Occupation.Resell Occupation.Active)
+ Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Active, taskStateOf npcTrade)
+ // 不匹配的信号不完成(观察任务不会被对话完成)。
+ let observe = stateWith Occupation.ObserveNotes Occupation.Active
+ let afterDialogue, _ = TaskRuntime.advanceWithEvent (dialogueEvent playerId (NpcId 1)) observe
+ Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Active, taskStateOf afterDialogue)
+ // 反复推进也不改变 OfferedTick/DueTick(无日历计数副作用)。
+ let before = (stateWith Occupation.HelpWork Occupation.Active).Today.Value
+ let after = (fst (TaskRuntime.advanceWithEvent (dialogueEvent playerId (NpcId 1)) active)).Today.Value
+ Assert.AreEqual<int64>(before.OfferedTick, after.OfferedTick)
+ Assert.AreEqual<int64>(before.DueTick, after.DueTick)
+
+ [<TestMethod>]
+ member _.AcceptTaskCommandActivatesTheDailyTaskThroughTheUi () =
+ let world = Sim.initialWorldN 42UL 4
+ let view = { M5Interaction.initial with Panel = TaskPanel; Task = Some(stateWith Occupation.HelpWork Occupation.Offered) }
+ let _, accepted = M5Interaction.apply AcceptTask world view
+ Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Active, taskStateOf accepted.Task.Value)
+ Assert.AreEqual<string>("已接受任务:帮工", M5Interaction.statusText accepted)
+
+ [<TestMethod>]
+ member _.ObserveCommandCompletesTheObserveNotesTask () =
+ let world = Sim.initialWorldN 42UL 4
+ let view = { M5Interaction.initial with Task = Some(stateWith Occupation.ObserveNotes Occupation.Active) }
+ let _, observed = M5Interaction.apply Observe world view
+ Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Done, taskStateOf observed.Task.Value)
+ Assert.AreEqual<string>("任务完成:观察记事", M5Interaction.statusText observed)
+
+ [<TestMethod>]
+ member _.DialogueThroughTheMenuCompletesTheHelpWorkTask () =
+ let world = Sim.initialWorldN 42UL 4
+ let npc = world.Npcs.[0]
+ let placed = { world with Avatar = { world.Avatar with Pos = npc.Pos } }
+ let seeded = { M5Interaction.initial with Task = Some(stateWith Occupation.HelpWork Occupation.Active) }
+ let _, opened = M5Interaction.apply M5Command.Interact placed seeded
+ Assert.AreEqual<M5Panel>(DialoguePanel, opened.Panel)
+ let _, replied = M5Interaction.apply Intent1 placed opened
+ Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Done, taskStateOf replied.Task.Value)
+ Assert.IsTrue((M5Interaction.statusText replied).StartsWith("任务完成"))
+
+ [<TestMethod>]
+ member _.PlayerTradeCompletesTheResellTask () =
+ let seed = 42UL
+ // 货郎背包含香料,才能卖出以完成「转卖」。
+ let state =
+ { Occupation.stateOf Occupation.Peddler with
+ Today = Some(mkTask Occupation.Resell Occupation.Active) }
+ let world = WorldBootstrap.initialWorldWithOccupation false seed 4 (Some state)
+ let npc = world.Npcs.[0].Id
+ let sell = PlayerTrade.sell (Some state) npc Spice 1 world
+ Assert.IsTrue(sell.Succeeded)
+ Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Done, taskStateOf sell.Occupation.Value)
+
+ [<TestMethod>]
+ member _.DailyTaskStatePersistsThroughV3RoundTripAndV2StaysCompatible () =
+ let world = Sim.initialWorldN 42UL 4
+ let state =
+ { Occupation.stateOf Occupation.Peddler with
+ Today = Some(mkTask Occupation.Resell Occupation.Done) }
+ // v2(None)保持与旧体一致、无任务侧车。
+ let v2 = WorldSave.save world
+ Assert.IsTrue(v2.StartsWith("LV_WORLD_SAVE_V2"))
+ match WorldSave.load v2 with
+ | Ok restored -> Assert.AreEqual<string>(v2, WorldSave.save restored)
+ | Error message -> Assert.Fail(message)
+ // v3 round-trip:任务模板 + 终态读回一致。
+ let path = Path.Combine(Path.GetTempPath(), "living-village-p54-" + Guid.NewGuid().ToString("N") + ".save")
+ try
+ WorldSave.saveToFileWith (Some state) path world
+ match WorldSave.loadFromFileWith path with
+ | Ok (_, Some restored) ->
+ Assert.AreEqual<Occupation.DailyTask option>(state.Today, restored.Today)
+ Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Done, taskStateOf restored)
+ | Ok (_, None) -> Assert.Fail("v3 must round-trip the occupation sidecar")
+ | Error message -> Assert.Fail(message)
+ finally
+ if File.Exists path then File.Delete path
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index 0cf996c..284c246 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -252,6 +252,8 @@ type LivingVillageGame() as this =
let mutable relationView = false
let mutable relationSnapshot: float32[,] option = None
let mutable m5View = M5Interaction.initial
+ // P54:记录上一帧玩家所在瓦片,用于折叠「到达瓦片」环境信号(仅在跨越瓦片时触发)。
+ let mutable lastAvatarTile : (int * int) option = None
let mutable simulationControl = startSimulationControl
let mutable legacyStepsPerFrame = (if sampleMode then None else initialLegacyStepsPerFrame)
let mutable menu = MenuState.create startSimulationControl false
@@ -357,6 +359,20 @@ type LivingVillageGame() as this =
let mutable p53Step = 0
let mutable p53PendingName = ""
let mutable p53Pending = false
+ // P54 证据钩子:LV_P54_SHOT=1 时以农夫真实开局,走「任务面板(待接受) → 接受 →
+ // 环境到达 + 对话 + 卖出三条真实信号」全流程,拍「待接受 / 进行中 / 完成」帧;
+ // 每步打印任务模板与状态,供 analyze 脚本核验 Offered→Active→Done 只由真实信号驱动。
+ // LV_P54_OCCUPATION=farmer|fisher|peddler|scholar(默认 farmer)。
+ let p54ShotMode = Environment.GetEnvironmentVariable("LV_P54_SHOT") = "1"
+ let p54Occupation =
+ match Environment.GetEnvironmentVariable("LV_P54_OCCUPATION") with
+ | "fisher" -> Occupation.Fisher
+ | "peddler" -> Occupation.Peddler
+ | "scholar" -> Occupation.Scholar
+ | _ -> Occupation.Farmer
+ let mutable p54Step = 0
+ let mutable p54PendingName = ""
+ let mutable p54Pending = false
let mutable autoplayFrames = 0
let mutable flowStep = 0
let mutable flowHold = 0
@@ -1198,6 +1214,99 @@ type LivingVillageGame() as this =
bag
p52Step <- p52Step + 1
+ /// P54 证据:农夫真实开局 → 任务面板(待接受) → 接受 → 环境到达 + 对话 + 买/卖
+ /// 四条真实信号,验证 Offered→Active→Done 只由真实事件驱动。
+ member private this.PrepareP54Shot () =
+ let setHour (hour: float) =
+ let tick = M6Presentation.resolveStartTick (Some hour) false
+ world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds }
+ let ask (command: M5Command) =
+ let nextWorld, nextView = M5Interaction.apply command world m5View
+ world <- nextWorld
+ m5View <- nextView
+ if not p54Pending then
+ match p54Step with
+ | 0 ->
+ setHour 12.0
+ let npc = world.Npcs.[0]
+ world <- { world with Avatar = { world.Avatar with Pos = npc.Pos } }
+ ask M5Command.ShowTaskPanel
+ this.CenterCamera()
+ p54PendingName <- "p54-task-offered"
+ p54Pending <- true
+ | 1 ->
+ ask M5Command.AcceptTask
+ p54PendingName <- "p54-task-active"
+ p54Pending <- true
+ | 2 ->
+ ask M5Command.ClosePanel
+ // 模拟跨入当前瓦片:以相邻瓦片为基线喂「到达瓦片」环境信号(真实折叠函数)。
+ let tile = (int (world.Avatar.Pos.X / float32 Sim.tilePixels), int (world.Avatar.Pos.Y / float32 Sim.tilePixels))
+ match m5View.Task with
+ | Some state ->
+ let previousTile = (fst tile + 1, snd tile)
+ let signals = TaskRuntime.environmentSignals false false (Some previousTile) tile
+ let advanced, message = TaskRuntime.foldSignals signals state
+ m5View <- { m5View with Task = Some advanced; Status = (match message with Some m -> "task|" + m | None -> m5View.Status) }
+ | None -> ()
+ this.CenterCamera()
+ p54PendingName <- "p54-arrive"
+ p54Pending <- true
+ | 3 ->
+ ask M5Command.Interact
+ ask M5Command.Intent1
+ p54PendingName <- "p54-dialogue"
+ p54Pending <- true
+ | 4 ->
+ ask M5Command.Interact
+ ask M5Command.BuyFromTarget
+ p54PendingName <- "p54-buy"
+ p54Pending <- true
+ | 5 ->
+ ask M5Command.Interact
+ ask M5Command.SellToTarget
+ p54PendingName <- "p54-sell"
+ p54Pending <- true
+ | 6 ->
+ ask M5Command.ShowTaskPanel
+ p54PendingName <- "p54-task-done"
+ p54Pending <- true
+ | _ ->
+ printfn "p54-shot=done"
+ this.Exit()
+
+ /// P54 证据:在 `Draw` 末尾落盘并打印任务模板/状态/反馈/背包。
+ member private this.CaptureP54Shot () =
+ if p54Pending then
+ p54Pending <- false
+ System.IO.Directory.CreateDirectory recordDirectory |> ignore
+ this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p54PendingName)
+ let stateName (state: Occupation.TaskState) =
+ match state with
+ | Occupation.Offered -> "offered"
+ | Occupation.Active -> "active"
+ | Occupation.Done -> "done"
+ | Occupation.Failed -> "failed"
+ let taskText =
+ match m5View.Task with
+ | None -> "occupation=none template=none state=none"
+ | Some state ->
+ match state.Today with
+ | None -> "occupation=yes template=none state=none"
+ | Some task ->
+ sprintf "occupation=yes template=%s state=%s"
+ (Occupation.taskNameOf task.TemplateId) (stateName task.State)
+ let bag =
+ match m5View.Task with
+ | None -> "-"
+ | Some state ->
+ state.Backpack
+ |> List.map (fun (item, quantity) -> sprintf "%A=%d" item quantity)
+ |> String.concat ";"
+ printfn "p54-shot=%s %s panel=%s status=%s bag=[%s]"
+ p54PendingName taskText (M5Interaction.panelName m5View) m5View.Status bag
+ p54Step <- p54Step + 1
+
/// P53 证据:真实录制提示出现/消失序列(远处 → 靠近 → 互动 → 关闭+离开)。
member private this.PrepareP53Recording () =
let setHour (hour: float) =
@@ -1439,6 +1548,7 @@ type LivingVillageGame() as this =
elif pressed Keys.Tab then Some ToggleNeeds
elif pressed Keys.C then Some ShowChronicle
elif pressed Keys.T then Some ShowTaskPanel
+ elif m5View.Panel = TaskPanel && pressedAny [ Keys.Enter; Keys.Space ] then Some AcceptTask
else None
match m5Command with
| Some command ->
@@ -1493,6 +1603,29 @@ type LivingVillageGame() as this =
if refreshed <> state then
m5View <- { m5View with Task = Some refreshed }
| None -> ()
+ // P54:把真实世界环境折叠成任务信号 —— 跨瓦片触发「到达瓦片」;
+ // 夜里站在水边触发「夜在水边」。全部走 Kernel 纯函数,不按日历计数。
+ match m5View.Task with
+ | Some state ->
+ let tileX = int (world.Avatar.Pos.X / float32 Sim.tilePixels)
+ let tileY = int (world.Avatar.Pos.Y / float32 Sim.tilePixels)
+ let isNight = (M6Presentation.profileAtTick world.Tick).Mode = Night
+ let nearWater =
+ match riverscapeMap with
+ | Some map ->
+ [ 1, 0; -1, 0; 0, 1; 0, -1 ]
+ |> List.exists (fun (dx, dy) -> SceneDetail.isWater map (tileX + dx) (tileY + dy))
+ | None -> false
+ let signals = TaskRuntime.environmentSignals isNight nearWater lastAvatarTile (tileX, tileY)
+ lastAvatarTile <- Some(tileX, tileY)
+ let advanced, message = TaskRuntime.foldSignals signals state
+ if advanced <> state then
+ m5View <-
+ { m5View with
+ Task = Some advanced
+ Status = (match message with Some text -> "task|" + text | None -> m5View.Status)
+ StatusTick = (match message with Some _ -> Some world.Tick | None -> m5View.StatusTick) }
+ | None -> ()
m5View <- M5Interaction.refreshPrompt world m5View
member private this.RunAutoplayFlow() =
@@ -1701,6 +1834,16 @@ type LivingVillageGame() as this =
else this.DispatchMenuInput Confirm
elif menu.Page = Playing then
this.PrepareP53Recording()
+ elif p54ShotMode then
+ if menu.Page = MainMenu then
+ this.DispatchMenuInput Confirm
+ elif menu.Page = OccupationSelect then
+ let targetIndex =
+ MenuState.occupationOptions |> List.findIndex (fun option -> option = Some p54Occupation)
+ if menu.Selected <> targetIndex then this.DispatchMenuInput Down
+ else this.DispatchMenuInput Confirm
+ elif menu.Page = Playing then
+ this.PrepareP54Shot()
elif menu.Page = Playing then
this.UpdatePlaying gameTime kb pressed pressedAny
elif menuShotMode then
@@ -2118,6 +2261,8 @@ type LivingVillageGame() as this =
this.CaptureP52Shot()
if p53RecordMode && menu.Page = Playing then
this.CaptureP53Recording()
+ if p54ShotMode && menu.Page = Playing then
+ this.CaptureP54Shot()
/// 启动画面:分层天色 + 屋脊/水面剪影 + 标题淡入上浮 + 跳过提示,全部为帧计数纯函数。
member private this.DrawSplash() =
diff --git a/src/LivingVillage.Desktop/Interaction.fs b/src/LivingVillage.Desktop/Interaction.fs
index 48bc3e7..68f90d7 100644
--- a/src/LivingVillage.Desktop/Interaction.fs
+++ b/src/LivingVillage.Desktop/Interaction.fs
@@ -34,6 +34,7 @@ type M5Command =
| Intent6
| BuyFromTarget
| SellToTarget
+ | AcceptTask
| Observe
| ToggleNeeds
| ShowChronicle
@@ -333,6 +334,15 @@ module M5Interaction =
| None -> next
withAnnal, Some nextState
| None -> next, None
+ // P54:玩家对话是真实事件(Actor=玩家),驱动对话类当日任务。
+ let advanced, taskMessage =
+ match storyTask with
+ | Some taskState ->
+ let next, message =
+ TaskRuntime.advanceWithEvent
+ { Tick = storyWorld.Tick; Kind = DialogueEvent outcome } taskState
+ Some next, message
+ | None -> None, None
storyWorld,
{ view with
Panel = WorldPanel
@@ -340,8 +350,11 @@ module M5Interaction =
Chronicle = Sim.annalText storyWorld
Prompt = None
PromptTargetPos = None
- Task = storyTask
- Status = sprintf "dialogue intent=%A response=%A" outcome.Intent outcome.Response
+ Task = advanced
+ Status =
+ match taskMessage with
+ | Some message -> "task|" + message
+ | None -> sprintf "dialogue intent=%A response=%A" outcome.Intent outcome.Response
StatusTick = Some storyWorld.Tick }
| DialogueRejected(failure, next) ->
next,
@@ -365,6 +378,7 @@ module M5Interaction =
| Intent6
| BuyFromTarget
| SellToTarget -> view.Panel = DialoguePanel
+ | AcceptTask -> view.Panel = TaskPanel
| Observe
| ToggleNeeds
| ShowChronicle
@@ -428,6 +442,8 @@ module M5Interaction =
let outcome =
if command = BuyFromTarget then PlayerTrade.buyFood view.Task menu.Target world
else PlayerTrade.sellFirst view.Task menu.Target world
+ // P54:交易完成当日任务时把短中文反馈一并带出。
+ let taskMessage = TaskRuntime.completedMessage view.Task outcome.Occupation
outcome.World,
{ view with
Panel = WorldPanel
@@ -435,17 +451,40 @@ module M5Interaction =
Prompt = None
PromptTargetPos = None
Task = outcome.Occupation
- Status = (if outcome.Succeeded then "trade ok|" else "trade fail|") + outcome.Message
+ Status =
+ (if outcome.Succeeded then "trade ok|" else "trade fail|")
+ + outcome.Message
+ + (match taskMessage with Some message -> "|" + message | None -> "")
StatusTick = Some outcome.World.Tick }
+ | AcceptTask ->
+ match view.Task with
+ | None -> world, { view with Status = "task|无职业任务" }
+ | Some occupationState ->
+ let next, message = TaskRuntime.accept occupationState
+ world,
+ { view with
+ Task = Some next
+ Status = "task|" + (message |> Option.defaultValue "当前任务无需接受") }
| Observe ->
let observations = Sim.observeVisible (observationBounds world) world
+ // P54:「观察」是真实动作,驱动观察记事类任务。
+ let task, taskMessage =
+ match view.Task with
+ | Some occupationState ->
+ let next, message = TaskRuntime.advanceWithSignal Occupation.Observed occupationState
+ Some next, message
+ | None -> None, None
world,
{ view with
Panel = ObservationPanel
Observations = observations
+ Task = task
Prompt = None
PromptTargetPos = None
- Status = sprintf "observation visible_npcs=%d" observations.Length }
+ Status =
+ match taskMessage with
+ | Some message -> "task|" + message
+ | None -> sprintf "observation visible_npcs=%d" observations.Length }
| ToggleNeeds ->
if view.Panel = NeedsPanel then
world, refreshPrompt world { view with Panel = WorldPanel; Needs = None; Status = "needs panel closed" }
@@ -545,6 +584,7 @@ module M5Interaction =
| value when value.StartsWith("observation") -> "观察完成"
| value when value.StartsWith("chronicle") -> "年鉴已打开"
| "task panel open" -> "任务面板已打开"
+ | value when value.StartsWith("task|") -> value.Substring("task|".Length)
| value when value.StartsWith("trade ok|") -> value.Substring("trade ok|".Length)
| value when value.StartsWith("trade fail|") -> "交易失败:" + value.Substring("trade fail|".Length)
| value when value.StartsWith("no dialogue") -> "当前没有可用对话"
@@ -670,6 +710,9 @@ module M5Interaction =
| None -> [ "今日暂无任务" ]
| Some task ->
[ sprintf "%s:%s" (Occupation.taskNameOf task.TemplateId) (taskStateText task.State) ]
+ @ (match task.State with
+ | Occupation.Offered -> [ "回车:接受任务" ]
+ | _ -> [])
let storyLine =
sprintf "剧情线:%s,%s"
(Occupation.Story.lineNameOf occupationState.Profile.Kind)
diff --git a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
index b3243e2..ab0a906 100644
--- a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
+++ b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
@@ -10,6 +10,7 @@
<Compile Include="ProceduralMap.fs" />
<Compile Include="MapGen.fs" />
<Compile Include="WorldBootstrap.fs" />
+ <Compile Include="TaskRuntime.fs" />
<Compile Include="PlayerTrade.fs" />
<Compile Include="CharacterArt.fs" />
<Compile Include="FloaterArt.fs" />
diff --git a/src/LivingVillage.Desktop/PlayerTrade.fs b/src/LivingVillage.Desktop/PlayerTrade.fs
index e1ea53e..540445d 100644
--- a/src/LivingVillage.Desktop/PlayerTrade.fs
+++ b/src/LivingVillage.Desktop/PlayerTrade.fs
@@ -100,25 +100,22 @@ module PlayerTrade =
let private replaceNpc (npc: Npc) (world: World) : World =
{ world with Npcs = world.Npcs |> Array.map (fun current -> if current.Id = npc.Id then npc else current) }
- let private recordTrade
- (buyer: NpcId)
- (seller: NpcId)
- (item: ItemKind)
- (quantity: int)
- (unitPrice: float32)
- (world: World)
- : World =
- let event =
- { Tick = world.Tick
- Kind = TradeEvent(buyer, seller, item, quantity, unitPrice) }
+ let private recordTrade (event: InteractionEvent) (world: World) : World =
let entry =
{ Tick = world.Tick
Kind = TradeAnnal event
Summary =
- sprintf "trade tick=%d buyer=%A seller=%A item=%A quantity=%d price=%.2f"
- world.Tick buyer seller item quantity unitPrice }
+ match event.Kind with
+ | TradeEvent(buyer, seller, item, quantity, unitPrice) ->
+ sprintf "trade tick=%d buyer=%A seller=%A item=%A quantity=%d price=%.2f"
+ world.Tick buyer seller item quantity unitPrice
+ | _ -> sprintf "trade tick=%d" world.Tick }
Sim.appendAnnal entry { world with Events = world.Events @ [ event ] }
+ /// P54:交易事件同时推进当日任务(Offered 须先接受;判定走 Kernel 纯函数)。
+ let private advanceOccupation (event: InteractionEvent) (occupation: Occupation.State) : Occupation.State =
+ Occupation.advanceTask event occupation
+
let private failure (state: Occupation.State option) (world: World) (message: string) : Outcome =
{ World = world; Occupation = state; Succeeded = false; UnitPrice = None; Message = message }
@@ -158,13 +155,19 @@ module PlayerTrade =
{ seller.Mind with
Needs = { seller.Mind.Needs with Money = seller.Mind.Needs.Money + total }
Memory = recordMemory world.Tick (Sold(playerId, item, quantity, unitPrice)) seller.Mind.Memory } }
+ let tradeEvent =
+ { Tick = world.Tick
+ Kind = TradeEvent(playerId, counterparty, item, quantity, unitPrice) }
let nextWorld =
world
|> (fun w -> { w with Avatar = { w.Avatar with Mind = avatarMind } })
|> replaceNpc updatedSeller
- |> recordTrade playerId counterparty item quantity unitPrice
+ |> recordTrade tradeEvent
+ let nextOccupation =
+ { occupation with Backpack = changeBackpack item quantity occupation.Backpack }
+ |> advanceOccupation tradeEvent
{ World = nextWorld
- Occupation = Some { occupation with Backpack = changeBackpack item quantity occupation.Backpack }
+ Occupation = Some nextOccupation
Succeeded = true
UnitPrice = Some unitPrice
Message = sprintf "买入%s ×%d,单价 %.2f" (itemName item) quantity unitPrice }
@@ -205,13 +208,19 @@ module PlayerTrade =
{ buyer.Mind with
Needs = { buyer.Mind.Needs with Money = buyer.Mind.Needs.Money - total }
Memory = recordMemory world.Tick (Bought(playerId, item, quantity, unitPrice)) buyer.Mind.Memory } }
+ let tradeEvent =
+ { Tick = world.Tick
+ Kind = TradeEvent(counterparty, playerId, item, quantity, unitPrice) }
let nextWorld =
world
|> (fun w -> { w with Avatar = { w.Avatar with Mind = avatarMind } })
|> replaceNpc updatedBuyer
- |> recordTrade counterparty playerId item quantity unitPrice
+ |> recordTrade tradeEvent
+ let nextOccupation =
+ { occupation with Backpack = changeBackpack item -quantity occupation.Backpack }
+ |> advanceOccupation tradeEvent
{ World = nextWorld
- Occupation = Some { occupation with Backpack = changeBackpack item -quantity occupation.Backpack }
+ Occupation = Some nextOccupation
Succeeded = true
UnitPrice = Some unitPrice
Message = sprintf "卖出%s ×%d,单价 %.2f" (itemName item) quantity unitPrice }
diff --git a/src/LivingVillage.Desktop/TaskRuntime.fs b/src/LivingVillage.Desktop/TaskRuntime.fs
new file mode 100644
index 0000000..75675aa
--- /dev/null
+++ b/src/LivingVillage.Desktop/TaskRuntime.fs
@@ -0,0 +1,64 @@
+namespace LivingVillage.Desktop
+
+open LivingVillage.Kernel
+
+/// P54 职业切片三:把 Kernel 的当日任务状态机接到 Desktop 运行时(玩家可见 / 可完成)。
+///
+/// 全部为纯函数:接受任务、按**真实世界事件/信号**推进、给出短中文反馈。
+/// 完成判定只复用 Kernel 既有 `Occupation.advanceTask` / `applySignal`
+/// (其内部是 `satisfiesCompletion`),绝不按日历索引硬编码计数。
+module TaskRuntime =
+
+ let private completionMessage (before: Occupation.DailyTask option) (after: Occupation.DailyTask option) : string option =
+ match before, after with
+ | Some previous, Some current when previous.State = Occupation.Active && current.State = Occupation.Done ->
+ Some(sprintf "任务完成:%s" (Occupation.taskNameOf current.TemplateId))
+ | _ -> None
+
+ /// 接受当日任务(Offered → Active);无任务 / 已在进行 / 终态时无变化、无反馈。
+ let accept (state: Occupation.State) : Occupation.State * string option =
+ match state.Today with
+ | Some task when task.State = Occupation.Offered ->
+ { state with Today = Some { task with State = Occupation.Active } },
+ Some(sprintf "已接受任务:%s" (Occupation.taskNameOf task.TemplateId))
+ | _ -> state, None
+
+ /// 用一条既有世界事件推进当日任务;Active → Done 时返回短中文反馈。
+ let advanceWithEvent (event: Sim.InteractionEvent) (state: Occupation.State) : Occupation.State * string option =
+ let before = state.Today
+ let next = Occupation.advanceTask event state
+ next, completionMessage before next.Today
+
+ /// 用一条已折叠的任务信号(到达瓦片 / 观察 / 夜在水边)推进当日任务。
+ let advanceWithSignal (signal: Occupation.TaskSignal) (state: Occupation.State) : Occupation.State * string option =
+ match state.Today with
+ | Some task ->
+ let after = Occupation.applySignal signal task
+ { state with Today = Some after }, completionMessage (Some task) (Some after)
+ | None -> state, None
+
+ /// 折叠多条信号(后面的不覆盖已得到的完成反馈)。
+ let foldSignals (signals: Occupation.TaskSignal list) (state: Occupation.State) : Occupation.State * string option =
+ signals
+ |> List.fold
+ (fun (current, found) signal ->
+ let next, message = advanceWithSignal signal current
+ next, (match found with Some _ -> found | None -> message))
+ (state, None)
+
+ /// 由真实世界环境折叠出的任务信号:跨瓦片 → 到达;夜 + 水边 → 夜在水边。
+ /// `previousTile = None`(尚未建立基线)时不产生到达信号,避免开局即误判完成。
+ let environmentSignals
+ (isNight: bool)
+ (nearWater: bool)
+ (previousTile: (int * int) option)
+ (tile: int * int)
+ : Occupation.TaskSignal list =
+ [ if previousTile.IsSome && previousTile.Value <> tile then yield Occupation.ArrivedAt tile
+ if isNight && nearWater then yield Occupation.NightAtWater ]
+
+ /// 比较两份职业状态,返回本次推进是否让当日任务完成(供交易等路径复用)。
+ let completedMessage (before: Occupation.State option) (after: Occupation.State option) : string option =
+ completionMessage
+ (before |> Option.bind (fun state -> state.Today))
+ (after |> Option.bind (fun state -> state.Today))