summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/evidence/p54-daily-task.txt22
-rw-r--r--docs/evidence/p54-dialogue.pngbin0 -> 100567 bytes
-rw-r--r--docs/evidence/p54-shot-log.txt17
-rw-r--r--docs/evidence/p54-task-active.pngbin0 -> 114029 bytes
-rw-r--r--docs/evidence/p54-task-done.pngbin0 -> 113908 bytes
-rw-r--r--docs/evidence/p54-task-offered.pngbin0 -> 117667 bytes
-rw-r--r--scripts/analyze-p54.py159
-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
14 files changed, 699 insertions, 21 deletions
diff --git a/docs/evidence/p54-daily-task.txt b/docs/evidence/p54-daily-task.txt
new file mode 100644
index 0000000..a724158
--- /dev/null
+++ b/docs/evidence/p54-daily-task.txt
@@ -0,0 +1,22 @@
+P54 profession daily-task analysis (LV_P54_SHOT=1)
+
+p54-task-offered: template=帮工 state=offered panel=task status=task panel open bag=[Food=12]
+p54-task-active: template=帮工 state=active panel=task status=task|已接受任务:帮工 bag=[Food=12]
+p54-arrive: template=帮工 state=active panel=world status=panel closed bag=[Food=12]
+p54-dialogue: template=帮工 state=done panel=world status=task|任务完成:帮工 bag=[Food=12]
+p54-buy: template=帮工 state=done panel=world status=trade ok|买入食物 ×1,单价 10.46 bag=[Food=13]
+p54-sell: template=帮工 state=done panel=world status=trade ok|卖出食物 ×1,单价 10.09 bag=[Food=12]
+p54-task-done: template=帮工 state=done panel=task status=task panel open bag=[Food=12]
+
+frame p54-task-offered: 1280x720 std=33.5
+frame p54-task-active: 1280x720 std=33.3
+frame p54-arrive: 1280x720 std=32.9
+frame p54-dialogue: 1280x720 std=32.8
+frame p54-buy: 1280x720 std=32.9
+frame p54-sell: 1280x720 std=32.9
+frame p54-task-done: 1280x720 std=33.3
+
+task panel vs world delta = 1.86 (>= 1.0)
+backpack Food after buy = 13, after sell = 12
+
+verdict: PASS
diff --git a/docs/evidence/p54-dialogue.png b/docs/evidence/p54-dialogue.png
new file mode 100644
index 0000000..24d0510
--- /dev/null
+++ b/docs/evidence/p54-dialogue.png
Binary files differ
diff --git a/docs/evidence/p54-shot-log.txt b/docs/evidence/p54-shot-log.txt
new file mode 100644
index 0000000..1606c05
--- /dev/null
+++ b/docs/evidence/p54-shot-log.txt
@@ -0,0 +1,17 @@
+world-mode=riverscape bounds=256x192
+mode=keyboard seed=42
+controls=WASD move E interact 1-6 choose Q observe Tab needs C chronicle L relations P pause F1/F2/F3 speed F6 save F7 load Esc close/exit
+fps=61.0 tick=0 pos=(4112,3088)
+fps=60.0 tick=0 pos=(4112,3088)
+splash=done frame=150
+menu=occupation-select
+new-game=ok seed=42 npcs=30 occupation=农夫 tick=0
+p54-shot=p54-task-offered occupation=yes template=帮工 state=offered panel=task status=task panel open bag=[Food=12]
+p54-shot=p54-task-active occupation=yes template=帮工 state=active panel=task status=task|已接受任务:帮工 bag=[Food=12]
+fps=60.0 tick=2592000 pos=(3920,2896)
+p54-shot=p54-arrive occupation=yes template=帮工 state=active panel=world status=panel closed bag=[Food=12]
+p54-shot=p54-dialogue occupation=yes template=帮工 state=done panel=world status=task|任务完成:帮工 bag=[Food=12]
+p54-shot=p54-buy occupation=yes template=帮工 state=done panel=world status=trade ok|买入食物 ×1,单价 10.46 bag=[Food=13]
+p54-shot=p54-sell occupation=yes template=帮工 state=done panel=world status=trade ok|卖出食物 ×1,单价 10.09 bag=[Food=12]
+p54-shot=p54-task-done occupation=yes template=帮工 state=done panel=task status=task panel open bag=[Food=12]
+p54-shot=done
diff --git a/docs/evidence/p54-task-active.png b/docs/evidence/p54-task-active.png
new file mode 100644
index 0000000..d756aec
--- /dev/null
+++ b/docs/evidence/p54-task-active.png
Binary files differ
diff --git a/docs/evidence/p54-task-done.png b/docs/evidence/p54-task-done.png
new file mode 100644
index 0000000..64333fc
--- /dev/null
+++ b/docs/evidence/p54-task-done.png
Binary files differ
diff --git a/docs/evidence/p54-task-offered.png b/docs/evidence/p54-task-offered.png
new file mode 100644
index 0000000..3109e59
--- /dev/null
+++ b/docs/evidence/p54-task-offered.png
Binary files differ
diff --git a/scripts/analyze-p54.py b/scripts/analyze-p54.py
new file mode 100644
index 0000000..2539228
--- /dev/null
+++ b/scripts/analyze-p54.py
@@ -0,0 +1,159 @@
+#!/usr/bin/env python3
+"""P54 profession daily-task (visible + completable) analysis.
+
+Reads the `LV_P54_SHOT=1` frame sequence + <record-dir>/log.txt and writes
+docs/evidence/p54-daily-task.txt with per-frame measured behaviour:
+
+ 1. sequence from the real game log: offered -> active -> (arrive) ->
+ dialogue -> buy -> sell -> done, with the real task template/state;
+ 2. the daily task is Offered at first, becomes Active only after the accept
+ command, and reaches Done only after a *real* signal (here: a player
+ dialogue) — never by calendar/frame counting (still Active after the
+ environment-arrival frame);
+ 3. the task panel is a real overlay (differs from the world frames) and the
+ buy/sell frames show the real backpack delta (Food 12 -> 13 -> 12).
+
+Usage:
+ python3 scripts/analyze-p54.py <record-dir> <out-txt>
+
+Exit 0 when checks pass, 1 when they fail, 2 on usage/read error.
+"""
+
+import re
+import sys
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+
+SHOT_RE = re.compile(
+ r"p54-shot=(\S+) occupation=(\S+) template=(\S+) state=(\S+) panel=(\S+) status=(.*?) bag=\[([^\]]*)\]"
+)
+FRAMES = [
+ "p54-task-offered",
+ "p54-task-active",
+ "p54-arrive",
+ "p54-dialogue",
+ "p54-buy",
+ "p54-sell",
+ "p54-task-done",
+]
+PANEL_DIFF_MIN = 1.0
+
+
+def fail(lines, out_txt, message):
+ lines.append(f"FAIL: {message}")
+ lines.append("")
+ lines.append("verdict: FAIL")
+ text = "\n".join(lines) + "\n"
+ if out_txt is not None:
+ out_txt.parent.mkdir(parents=True, exist_ok=True)
+ out_txt.write_text(text, encoding="utf-8")
+ print(text)
+ return 1
+
+
+def load(path):
+ return np.asarray(Image.open(path).convert("RGB"), dtype=np.float64)
+
+
+def bag_food(bag):
+ for part in bag.split(";"):
+ if part.startswith("Food="):
+ return int(part.split("=")[1])
+ return 0
+
+
+def main(argv):
+ if len(argv) != 3:
+ sys.stderr.write(__doc__)
+ return 2
+ record_dir = Path(argv[1])
+ out_txt = Path(argv[2])
+ log_path = record_dir / "log.txt"
+ if not log_path.exists():
+ sys.stderr.write(f"missing {log_path}\n")
+ return 2
+
+ lines = ["P54 profession daily-task analysis (LV_P54_SHOT=1)", ""]
+ frames = {}
+ for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines():
+ match = SHOT_RE.search(line)
+ if match:
+ frames[match.group(1)] = {
+ "occupation": match.group(2),
+ "template": match.group(3),
+ "state": match.group(4),
+ "panel": match.group(5),
+ "status": match.group(6).strip(),
+ "bag": match.group(7),
+ }
+
+ for name in FRAMES:
+ if name not in frames:
+ return fail(lines, out_txt, f"missing log frame {name}")
+ record = frames[name]
+ lines.append(
+ f"{name}: template={record['template']} state={record['state']} "
+ f"panel={record['panel']} status={record['status']} bag=[{record['bag']}]"
+ )
+
+ lines.append("")
+ if frames["p54-task-offered"]["state"] != "offered":
+ return fail(lines, out_txt, "the daily task must start Offered (visible in the task panel)")
+ if frames["p54-task-offered"]["panel"] != "task":
+ return fail(lines, out_txt, "the offered frame must be the task panel")
+ if frames["p54-task-active"]["state"] != "active":
+ return fail(lines, out_txt, "accepting must move the task Offered -> Active")
+ if "已接受任务" not in frames["p54-task-active"]["status"]:
+ return fail(lines, out_txt, "accepting must surface a short Chinese feedback")
+ if frames["p54-arrive"]["state"] == "done":
+ return fail(lines, out_txt, "a non-matching environment signal must not complete the task")
+ if frames["p54-dialogue"]["state"] != "done":
+ return fail(lines, out_txt, "a real player dialogue must complete this dialogue task")
+ if "任务完成" not in frames["p54-dialogue"]["status"]:
+ return fail(lines, out_txt, "completion must surface a short Chinese 任务完成 feedback")
+ if frames["p54-task-done"]["state"] != "done":
+ return fail(lines, out_txt, "the task must stay Done in the panel")
+ if (
+ frames["p54-task-offered"]["state"] != "offered"
+ or frames["p54-task-active"]["state"] != "active"
+ or frames["p54-dialogue"]["state"] != "done"
+ ):
+ return fail(lines, out_txt, "task states must progress offered -> active -> done")
+
+ images = {}
+ for name in FRAMES:
+ path = record_dir / f"{name}.png"
+ if not path.exists():
+ return fail(lines, out_txt, f"missing frame {path}")
+ images[name] = load(path)
+ std = float(images[name].std())
+ lines.append(f"frame {name}: {images[name].shape[1]}x{images[name].shape[0]} std={std:.1f}")
+ if std < 5.0:
+ return fail(lines, out_txt, f"frame {name} looks blank (std {std:.1f})")
+
+ panel_delta = float(np.abs(images["p54-task-offered"] - images["p54-dialogue"]).mean())
+ food_after_buy = bag_food(frames["p54-buy"]["bag"])
+ food_after_sell = bag_food(frames["p54-sell"]["bag"])
+ lines += [
+ "",
+ f"task panel vs world delta = {panel_delta:.2f} (>= {PANEL_DIFF_MIN})",
+ f"backpack Food after buy = {food_after_buy}, after sell = {food_after_sell}",
+ "",
+ ]
+ if panel_delta < PANEL_DIFF_MIN:
+ return fail(lines, out_txt, "task panel frame does not differ from the world frame")
+ if food_after_buy != food_after_sell + 1 or food_after_sell <= 0:
+ return fail(lines, out_txt, "buyer backpack must rise then fall back across the real buy/sell")
+
+ lines.append("verdict: PASS")
+ text = "\n".join(lines) + "\n"
+ out_txt.parent.mkdir(parents=True, exist_ok=True)
+ out_txt.write_text(text, encoding="utf-8")
+ print(text)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))
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))