summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/evidence/p55-analysis.txt16
-rw-r--r--scripts/analyze-p55.py142
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P55ProfessionRewardTests.fs223
-rw-r--r--src/LivingVillage.Desktop/Game.fs90
-rw-r--r--src/LivingVillage.Desktop/Interaction.fs4
-rw-r--r--src/LivingVillage.Desktop/PlayerTrade.fs28
-rw-r--r--src/LivingVillage.Desktop/TaskRuntime.fs36
-rw-r--r--src/LivingVillage.Desktop/WorldBootstrap.fs10
-rw-r--r--src/LivingVillage.Kernel/Occupation.fs88
-rw-r--r--src/LivingVillage.Kernel/WorldSave.fs18
11 files changed, 612 insertions, 44 deletions
diff --git a/docs/evidence/p55-analysis.txt b/docs/evidence/p55-analysis.txt
new file mode 100644
index 0000000..19bff74
--- /dev/null
+++ b/docs/evidence/p55-analysis.txt
@@ -0,0 +1,16 @@
+P55 profession daily-task reward + streak analysis (LV_P55_SHOT=1)
+
+p55-task-offered: template=送粮 state=offered streak=0 panel=task status=task panel open bag=[Food=12]
+p55-task-active: template=送粮 state=active streak=0 panel=task status=task|已接受任务:送粮 bag=[Food=12]
+p55-reward: template=送粮 state=done streak=1 panel=world status=trade ok|卖出食物 ×1,单价 10.01|任务完成:送粮 +食物×1 bag=[Food=12]
+p55-task-done: template=送粮 state=done streak=1 panel=task status=task panel open bag=[Food=12]
+
+frame p55-task-offered: 1280x720 std=28.9
+frame p55-task-active: 1280x720 std=28.7
+frame p55-reward: 1280x720 std=28.5
+frame p55-task-done: 1280x720 std=28.7
+
+task panel vs world delta = 2.57 (>= 1.0)
+backpack Food start = 12, after reward = 12
+
+verdict: PASS
diff --git a/scripts/analyze-p55.py b/scripts/analyze-p55.py
new file mode 100644
index 0000000..2664a68
--- /dev/null
+++ b/scripts/analyze-p55.py
@@ -0,0 +1,142 @@
+#!/usr/bin/env python3
+"""P55 profession daily-task reward + streak analysis.
+
+Reads the `LV_P55_SHOT=1` frame sequence + <record-dir>/log.txt and writes
+docs/evidence/p55-analysis.txt with per-frame measured behaviour:
+
+ 1. sequence from the real game log: offered -> active -> reward -> done, for
+ the day-3 farmer task 送粮 (DeliverGrain);
+ 2. the reward is the design-doc completion item only ("+食物×1"), the task
+ reaches Done only after the real sell signal, and the streak goes 0 -> 1;
+ 3. the backpack Food stays whole across the flow (sold 1, rewarded 1 back),
+ and the reward is surfaced in the short Chinese HUD feedback.
+
+Usage:
+ python3 scripts/analyze-p55.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"p55-shot=(\S+) template=(\S+) state=(\S+) streak=(\d+) panel=(\S+) status=(.*?) bag=\[([^\]]*)\]"
+)
+FRAMES = ["p55-task-offered", "p55-task-active", "p55-reward", "p55-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 = ["P55 profession daily-task reward + streak analysis (LV_P55_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)] = {
+ "template": match.group(2),
+ "state": match.group(3),
+ "streak": int(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']} streak={record['streak']} "
+ f"panel={record['panel']} status={record['status']} bag=[{record['bag']}]"
+ )
+
+ lines.append("")
+ if frames["p55-task-offered"]["state"] != "offered":
+ return fail(lines, out_txt, "the daily task must start Offered (visible in the task panel)")
+ if frames["p55-task-offered"]["template"] != "送粮":
+ return fail(lines, out_txt, "seed 42 / farmer / day 3 must draw the 送粮 template")
+ if frames["p55-task-active"]["state"] != "active" or frames["p55-task-active"]["streak"] != 0:
+ return fail(lines, out_txt, "accepting must move the task Offered -> Active without touching streak")
+ reward = frames["p55-reward"]
+ if reward["state"] != "done":
+ return fail(lines, out_txt, "the real sell signal must complete the task")
+ if "任务完成:送粮 +食物×1" not in reward["status"]:
+ return fail(lines, out_txt, "the design-doc item reward must appear in the short Chinese feedback")
+ if reward["streak"] != 1:
+ return fail(lines, out_txt, "completing the day must bump the streak to 1")
+ if frames["p55-task-done"]["state"] != "done" or frames["p55-task-done"]["streak"] != 1:
+ return fail(lines, out_txt, "the task and streak must stay Done/1 in the panel")
+
+ 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["p55-task-offered"] - images["p55-reward"]).mean())
+ food_reward = bag_food(reward["bag"])
+ food_start = bag_food(frames["p55-task-offered"]["bag"])
+ lines += [
+ "",
+ f"task panel vs world delta = {panel_delta:.2f} (>= {PANEL_DIFF_MIN})",
+ f"backpack Food start = {food_start}, after reward = {food_reward}",
+ "",
+ ]
+ if panel_delta < PANEL_DIFF_MIN:
+ return fail(lines, out_txt, "task panel frame does not differ from the world frame")
+ if food_reward != food_start:
+ return fail(lines, out_txt, "Food must stay whole (sold 1, rewarded 1 back) across the reward flow")
+
+ 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 ade22be..a3c1e45 100644
--- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
+++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
@@ -32,6 +32,7 @@
<Compile Include="P52ProfessionTradeTests.fs" />
<Compile Include="P53InteractionPromptTests.fs" />
<Compile Include="P54ProfessionDailyTests.fs" />
+ <Compile Include="P55ProfessionRewardTests.fs" />
<Compile Include="SampleTests.fs" />
</ItemGroup>
diff --git a/src/LivingVillage.Desktop.Tests/P55ProfessionRewardTests.fs b/src/LivingVillage.Desktop.Tests/P55ProfessionRewardTests.fs
new file mode 100644
index 0000000..353f728
--- /dev/null
+++ b/src/LivingVillage.Desktop.Tests/P55ProfessionRewardTests.fs
@@ -0,0 +1,223 @@
+namespace LivingVillage.Desktop.Tests
+
+open System
+open System.IO
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Kernel
+open LivingVillage.Kernel.Sim
+open LivingVillage.Desktop
+
+/// P55 职业切片四:当日任务经济闭环——设计口径报酬入账 + 连续完成天数。
+///
+/// 报酬只发设计文档已写明的**完成结果物品**(`docs/design/occupation-system-design.md`
+/// §3 任务表:夜捕 得 Fish×2;送粮 交给村民 Food×1),其余模板无写明物品 → 不发、只留
+/// 既有年鉴/关系反馈;禁止发明金额或新物品。连续天数按日历日累计,v3 持久化。
+[<TestClass>]
+type P55ProfessionRewardTests () =
+
+ let taskOn day template taskState : Occupation.DailyTask =
+ { TemplateId = template
+ TargetNpc = None
+ TargetTile = None
+ OfferedTick = day * Sim.ticksPerDay
+ DueTick = day * Sim.ticksPerDay + Sim.ticksPerDay - 1L
+ State = taskState }
+
+ let fisherNightCatch taskState : Occupation.State =
+ { Occupation.stateOf Occupation.Fisher with Today = Some(taskOn 0L Occupation.NightCatch taskState) }
+
+ let farmerDeliverGrain taskState : Occupation.State =
+ { Occupation.stateOf Occupation.Farmer with Today = Some(taskOn 0L Occupation.DeliverGrain taskState) }
+
+ let farmerHelpWork taskState : Occupation.State =
+ { Occupation.stateOf Occupation.Farmer with Today = Some(taskOn 0L Occupation.HelpWork taskState) }
+
+ let tradeEvent buyer seller item quantity : InteractionEvent =
+ { Tick = 10L; Kind = TradeEvent(buyer, seller, item, quantity, 10.0f) }
+
+ let dialogueEvent actor target : InteractionEvent =
+ { Tick = 10L
+ Kind =
+ DialogueEvent
+ { Tick = 10L
+ Actor = actor
+ Target = target
+ Intent = SmallTalk
+ Response = Friendly
+ Valence = 0.1f } }
+
+ let signalFor (template: Occupation.TaskTemplateId) : Occupation.TaskSignal =
+ match template with
+ | Occupation.NightCatch -> Occupation.NightAtWater
+ | Occupation.ObserveNotes -> Occupation.Observed
+ | Occupation.TillSoil -> Occupation.ArrivedAt(2, 2)
+ | Occupation.DeliverGrain -> Occupation.Traded(playerId, Food, 1)
+ | Occupation.SellFish -> Occupation.Traded(playerId, Fish, 1)
+ | Occupation.Resell -> Occupation.Traded(playerId, Spice, 1)
+ | Occupation.BuyGoods -> Occupation.Purchased(Food, 1)
+ | Occupation.HelpWork
+ | Occupation.MarketInquiry
+ | Occupation.ReasonDebate -> Occupation.Dialogued playerId
+
+ let quantity (item: ItemKind) (state: Occupation.State) = Occupation.backpackQuantity item state
+
+ [<TestMethod>]
+ member _.RewardTableOnlyCarriesDesignWrittenItems () =
+ Assert.AreEqual<(ItemKind * int) option>(Some(Fish, 2), Occupation.rewardOf Occupation.NightCatch)
+ Assert.AreEqual<(ItemKind * int) option>(Some(Food, 1), Occupation.rewardOf Occupation.DeliverGrain)
+ for template in
+ [ Occupation.HelpWork; Occupation.TillSoil; Occupation.SellFish; Occupation.MarketInquiry
+ Occupation.BuyGoods; Occupation.Resell; Occupation.ObserveNotes; Occupation.ReasonDebate ] do
+ Assert.AreEqual<(ItemKind * int) option>(None, Occupation.rewardOf template, sprintf "%A 无写明物品" template)
+
+ [<TestMethod>]
+ member _.NightCatchCompletionGrantsFishAndBumpsStreak () =
+ let before = fisherNightCatch Occupation.Active
+ let after, reward = Occupation.applySignalToState Occupation.NightAtWater before
+ Assert.AreEqual<(ItemKind * int) option>(Some(Fish, 2), reward)
+ Assert.AreEqual<int>(quantity Fish before + 2, quantity Fish after)
+ Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Done, after.Today |> Option.map (fun t -> t.State))
+ Assert.AreEqual<int>(1, after.Streak)
+
+ [<TestMethod>]
+ member _.DeliverGrainCompletionGrantsFoodAndBumpsStreak () =
+ let before = farmerDeliverGrain Occupation.Active
+ let sold, reward = Occupation.advanceTaskWithReward (tradeEvent (NpcId 1) playerId Food 2) before
+ Assert.AreEqual<(ItemKind * int) option>(Some(Food, 1), reward)
+ Assert.AreEqual<int>(quantity Food before + 1, quantity Food sold)
+ Assert.AreEqual<int>(1, sold.Streak)
+
+ [<TestMethod>]
+ member _.UnwrittenRewardStillCountsStreakWithoutItems () =
+ let before = farmerHelpWork Occupation.Active
+ let after, reward = Occupation.advanceTaskWithReward (dialogueEvent playerId (NpcId 1)) before
+ Assert.AreEqual<(ItemKind * int) option>(None, reward)
+ Assert.AreEqual<int>(before.Backpack |> List.sumBy (fun (_, n) -> n), after.Backpack |> List.sumBy (fun (_, n) -> n))
+ Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Done, after.Today |> Option.map (fun t -> t.State))
+ Assert.AreEqual<int>(1, after.Streak)
+
+ [<TestMethod>]
+ member _.CompletionNeedsTheMatchingSignalAndOfferedDoesNotPay () =
+ // 不相干信号:不动背包、不加 streak。
+ let active = fisherNightCatch Occupation.Active
+ let stalled, noReward = Occupation.applySignalToState (Occupation.ArrivedAt(1, 1)) active
+ Assert.AreEqual<(ItemKind * int) option>(None, noReward)
+ Assert.AreEqual<int>(0, stalled.Streak)
+ Assert.AreEqual<int>(quantity Fish active, quantity Fish stalled)
+ // Offered 未接受:完成信号不结算。
+ let offered = fisherNightCatch Occupation.Offered
+ let stillOffered, offeredReward = Occupation.applySignalToState Occupation.NightAtWater offered
+ Assert.AreEqual<(ItemKind * int) option>(None, offeredReward)
+ Assert.AreEqual<int>(0, stillOffered.Streak)
+
+ [<TestMethod>]
+ member _.TaskRuntimeSurfacesRewardInShortChineseFeedback () =
+ let after, message = TaskRuntime.advanceWithSignal Occupation.NightAtWater (fisherNightCatch Occupation.Active)
+ Assert.AreEqual<string option>(Some "任务完成:夜捕 +鱼×2", message)
+ Assert.AreEqual<int>(1, after.Streak)
+ let _, deliverMessage = TaskRuntime.advanceWithEvent (tradeEvent (NpcId 1) playerId Food 1) (farmerDeliverGrain Occupation.Active)
+ Assert.AreEqual<string option>(Some "任务完成:送粮 +食物×1", deliverMessage)
+ let _, helpMessage = TaskRuntime.advanceWithEvent (dialogueEvent playerId (NpcId 1)) (farmerHelpWork Occupation.Active)
+ Assert.AreEqual<string option>(Some "任务完成:帮工", helpMessage)
+
+ [<TestMethod>]
+ member _.SameDayRefreshKeepsStreakAndCrossDayCarriesOnlyAfterCompletion () =
+ // 同日刷新:不动 streak。
+ let completed = { fisherNightCatch Occupation.Done with Streak = 1 }
+ let sameDay = Occupation.refreshState 42UL (Occupation.occupationSeedOf Occupation.Fisher) Occupation.Fisher 10L completed
+ Assert.AreEqual<int>(1, sameDay.Streak)
+ // 跨日且上一日已完成 → 保留 streak。
+ let nextDayTick = Sim.ticksPerDay + 10L
+ let nextDay = Occupation.refreshState 42UL (Occupation.occupationSeedOf Occupation.Fisher) Occupation.Fisher nextDayTick completed
+ Assert.AreEqual<int>(1, nextDay.Streak)
+ Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Offered, nextDay.Today |> Option.map (fun t -> t.State))
+ // 跨日且上一日未完成 → 清零。
+ let missed = { fisherNightCatch Occupation.Active with Streak = 3 }
+ let afterMiss = Occupation.refreshState 42UL (Occupation.occupationSeedOf Occupation.Fisher) Occupation.Fisher nextDayTick missed
+ Assert.AreEqual<int>(0, afterMiss.Streak)
+
+ [<TestMethod>]
+ member _.StreakAccumulatesAcrossConsecutiveCompletedDays () =
+ let mutable state = Occupation.stateOf Occupation.Fisher
+ for day in 0L .. 2L do
+ let tick = day * Sim.ticksPerDay + 10L
+ state <- Occupation.refreshState 42UL (Occupation.occupationSeedOf Occupation.Fisher) Occupation.Fisher tick state
+ state <- { state with Today = state.Today |> Option.map Occupation.acceptTask }
+ let signal = signalFor state.Today.Value.TemplateId
+ let next, _ = Occupation.applySignalToState signal state
+ state <- next
+ Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Done, state.Today |> Option.map (fun t -> t.State))
+ Assert.AreEqual<int>(int day + 1, state.Streak)
+
+ [<TestMethod>]
+ member _.SkippedDayResetsStreakBackToZero () =
+ let mutable state = Occupation.stateOf Occupation.Fisher
+ // 第 0、1 天连续完成。
+ for day in 0L .. 1L do
+ let tick = day * Sim.ticksPerDay + 10L
+ state <- Occupation.refreshState 42UL (Occupation.occupationSeedOf Occupation.Fisher) Occupation.Fisher tick state
+ state <- { state with Today = state.Today |> Option.map Occupation.acceptTask }
+ state <- fst (Occupation.applySignalToState (signalFor state.Today.Value.TemplateId) state)
+ Assert.AreEqual<int>(2, state.Streak)
+ // 第 2 天不做 → 进入第 3 天刷新时归零。
+ state <- Occupation.refreshState 42UL (Occupation.occupationSeedOf Occupation.Fisher) Occupation.Fisher (3L * Sim.ticksPerDay + 10L) state
+ Assert.AreEqual<int>(0, state.Streak)
+
+ [<TestMethod>]
+ member _.WorldBootstrapRefreshKeepsCompletedStreakOnNewDay () =
+ let state = { Occupation.stateOf Occupation.Farmer with Today = Some(taskOn 0L Occupation.HelpWork Occupation.Done); Streak = 2 }
+ let refreshed = WorldBootstrap.refreshOccupationToday 42UL state (Sim.ticksPerDay + 5L)
+ Assert.AreEqual<int>(2, refreshed.Streak)
+ Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Offered, refreshed.Today |> Option.map (fun t -> t.State))
+
+ [<TestMethod>]
+ member _.V3RoundTripPersistsStreakAndRewardedBackpack () =
+ let world = Sim.initialWorldN 42UL 4
+ let state =
+ { Occupation.stateOf Occupation.Fisher with
+ Today = Some(taskOn 0L Occupation.NightCatch Occupation.Done)
+ Streak = 4
+ Backpack = [ Food, 8; Fish, 5 ] }
+ let path = Path.Combine(Path.GetTempPath(), "living-village-p55-" + Guid.NewGuid().ToString("N") + ".save")
+ try
+ WorldSave.saveToFileWith (Some state) path world
+ match WorldSave.loadFromFileWith path with
+ | Ok (_, Some restored) ->
+ Assert.AreEqual<int>(4, restored.Streak)
+ Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Done, restored.Today |> Option.map (fun t -> t.State))
+ Assert.AreEqual<int>(5, quantity Fish restored)
+ // 再次保存与首次逐字节一致(round-trip 稳定)。
+ let text = File.ReadAllText path
+ Assert.AreEqual<string>(text, WorldSave.saveWith (Some restored) world)
+ | Ok (_, None) -> Assert.Fail("v3 必须读回职业侧车")
+ | Error message -> Assert.Fail(message)
+ finally
+ if File.Exists path then File.Delete path
+
+ [<TestMethod>]
+ member _.V3WithoutStreakLoadsAsZeroAndSaveNoneStaysV2 () =
+ let world = Sim.initialWorldN 42UL 4
+ let state = { Occupation.stateOf Occupation.Farmer with Today = Some(taskOn 0L Occupation.HelpWork Occupation.Done) }
+ let withFile = Path.Combine(Path.GetTempPath(), "living-village-p55-" + Guid.NewGuid().ToString("N") + ".save")
+ try
+ WorldSave.saveToFileWith (Some state) withFile world
+ match WorldSave.loadFromFileWith withFile with
+ | Ok (_, Some restored) -> Assert.AreEqual<int>(0, restored.Streak)
+ | Ok (_, None) -> Assert.Fail("v3 必须读回职业侧车")
+ | Error message -> Assert.Fail(message)
+ finally
+ if File.Exists withFile then File.Delete withFile
+ // 无职业存档仍是 v2 且与 saveWith None 逐字节一致。
+ let v2 = WorldSave.save world
+ Assert.IsTrue(v2.StartsWith("LV_WORLD_SAVE_V2"))
+ Assert.AreEqual<string>(v2, WorldSave.saveWith None world)
+ match WorldSave.load v2 with
+ | Ok restored -> Assert.AreEqual<string>(v2, WorldSave.save restored)
+ | Error message -> Assert.Fail(message)
+
+ [<TestMethod>]
+ member _.ObserveCompletionHasNoRewardSuffix () =
+ let state = { Occupation.stateOf Occupation.Scholar with Today = Some(taskOn 0L Occupation.ObserveNotes Occupation.Active) }
+ let after, message = TaskRuntime.advanceWithSignal Occupation.Observed state
+ Assert.AreEqual<string option>(Some "任务完成:观察记事", message)
+ Assert.AreEqual<int>(1, after.Streak)
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index 284c246..8162351 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -373,6 +373,13 @@ type LivingVillageGame() as this =
let mutable p54Step = 0
let mutable p54PendingName = ""
let mutable p54Pending = false
+ // P55 证据钩子:LV_P55_SHOT=1 时以农夫真实开局,跳到第 3 日(当日任务=送粮),
+ // 走「面板(待接受) → 接受 → 真实卖出 Food(送粮完成,设计口径 +食物×1)→ 面板(已完成)」,
+ // 打印 streak/背包/反馈,供 analyze 脚本核验报酬入账与连续天数。
+ let p55ShotMode = Environment.GetEnvironmentVariable("LV_P55_SHOT") = "1"
+ let mutable p55Step = 0
+ let mutable p55PendingName = ""
+ let mutable p55Pending = false
let mutable autoplayFrames = 0
let mutable flowStep = 0
let mutable flowHold = 0
@@ -1307,6 +1314,77 @@ type LivingVillageGame() as this =
p54PendingName taskText (M5Interaction.panelName m5View) m5View.Status bag
p54Step <- p54Step + 1
+ /// P55 证据:跳到第 3 日(送粮)→ 面板(待接受) → 接受 → 真实卖出 Food(完成 +食物×1)。
+ member private this.PrepareP55Shot () =
+ let setDay3Noon () =
+ let tick = 3L * Sim.ticksPerDay + M6Presentation.resolveStartTick (Some 12.0) 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 p55Pending then
+ match p55Step with
+ | 0 ->
+ setDay3Noon ()
+ // 用真实跨日刷新把当日任务推进到第 3 日(seed 42/农夫 = 送粮)。
+ match m5View.Task with
+ | Some state -> m5View <- { m5View with Task = Some(WorldBootstrap.refreshOccupationToday 42UL state world.Tick) }
+ | None -> ()
+ let npc = world.Npcs.[0]
+ world <- { world with Avatar = { world.Avatar with Pos = npc.Pos } }
+ ask M5Command.ShowTaskPanel
+ this.CenterCamera()
+ p55PendingName <- "p55-task-offered"
+ p55Pending <- true
+ | 1 ->
+ ask M5Command.AcceptTask
+ p55PendingName <- "p55-task-active"
+ p55Pending <- true
+ | 2 ->
+ ask M5Command.ClosePanel
+ ask M5Command.Interact
+ ask M5Command.SellToTarget
+ p55PendingName <- "p55-reward"
+ p55Pending <- true
+ | 3 ->
+ ask M5Command.ShowTaskPanel
+ p55PendingName <- "p55-task-done"
+ p55Pending <- true
+ | _ ->
+ printfn "p55-shot=done"
+ this.Exit()
+
+ /// P55 证据:落盘并打印模板/状态/streak/背包/反馈。
+ member private this.CaptureP55Shot () =
+ if p55Pending then
+ p55Pending <- false
+ System.IO.Directory.CreateDirectory recordDirectory |> ignore
+ this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p55PendingName)
+ let stateText (state: Occupation.TaskState) =
+ match state with
+ | Occupation.Offered -> "offered"
+ | Occupation.Active -> "active"
+ | Occupation.Done -> "done"
+ | Occupation.Failed -> "failed"
+ let templateText, taskState, streak =
+ match m5View.Task with
+ | None -> "none", "none", 0
+ | Some state ->
+ (match state.Today with
+ | Some task -> Occupation.taskNameOf task.TemplateId, stateText task.State, state.Streak
+ | None -> "none", "none", state.Streak)
+ let bag =
+ match m5View.Task with
+ | None -> "-"
+ | Some state ->
+ state.Backpack
+ |> List.map (fun (item, quantity) -> sprintf "%A=%d" item quantity)
+ |> String.concat ";"
+ printfn "p55-shot=%s template=%s state=%s streak=%d panel=%s status=%s bag=[%s]"
+ p55PendingName templateText taskState streak (M5Interaction.panelName m5View) m5View.Status bag
+ p55Step <- p55Step + 1
+
/// P53 证据:真实录制提示出现/消失序列(远处 → 靠近 → 互动 → 关闭+离开)。
member private this.PrepareP53Recording () =
let setHour (hour: float) =
@@ -1844,6 +1922,16 @@ type LivingVillageGame() as this =
else this.DispatchMenuInput Confirm
elif menu.Page = Playing then
this.PrepareP54Shot()
+ elif p55ShotMode then
+ if menu.Page = MainMenu then
+ this.DispatchMenuInput Confirm
+ elif menu.Page = OccupationSelect then
+ let targetIndex =
+ MenuState.occupationOptions |> List.findIndex (fun option -> option = Some Occupation.Farmer)
+ if menu.Selected <> targetIndex then this.DispatchMenuInput Down
+ else this.DispatchMenuInput Confirm
+ elif menu.Page = Playing then
+ this.PrepareP55Shot()
elif menu.Page = Playing then
this.UpdatePlaying gameTime kb pressed pressedAny
elif menuShotMode then
@@ -2263,6 +2351,8 @@ type LivingVillageGame() as this =
this.CaptureP53Recording()
if p54ShotMode && menu.Page = Playing then
this.CaptureP54Shot()
+ if p55ShotMode && menu.Page = Playing then
+ this.CaptureP55Shot()
/// 启动画面:分层天色 + 屋脊/水面剪影 + 标题淡入上浮 + 跳过提示,全部为帧计数纯函数。
member private this.DrawSplash() =
diff --git a/src/LivingVillage.Desktop/Interaction.fs b/src/LivingVillage.Desktop/Interaction.fs
index 68f90d7..75575dd 100644
--- a/src/LivingVillage.Desktop/Interaction.fs
+++ b/src/LivingVillage.Desktop/Interaction.fs
@@ -442,8 +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
+ // P54/P55:交易完成当日任务时把(含奖励的)短中文反馈一并带出。
+ let taskMessage = TaskRuntime.completedMessageWithReward view.Task outcome.Occupation outcome.Reward
outcome.World,
{ view with
Panel = WorldPanel
diff --git a/src/LivingVillage.Desktop/PlayerTrade.fs b/src/LivingVillage.Desktop/PlayerTrade.fs
index 540445d..407536c 100644
--- a/src/LivingVillage.Desktop/PlayerTrade.fs
+++ b/src/LivingVillage.Desktop/PlayerTrade.fs
@@ -23,15 +23,12 @@ module PlayerTrade =
Succeeded: bool
/// 成交时的职业偏置后单位价(失败为 None)。
UnitPrice: float32 option
+ /// P55:本次交易若完成当日任务,设计口径发放的物品奖励。
+ Reward: (ItemKind * int) option
/// 面向 HUD 的短中文反馈。
Message: string }
- let private itemName (item: ItemKind) : string =
- match item with
- | Food -> "食物"
- | Fish -> "鱼"
- | Spice -> "香料"
- | Scroll -> "卷轴"
+ let private itemName (item: ItemKind) : string = Occupation.itemNameOf item
let private quantityOf (item: ItemKind) (inventory: Map<ItemKind, int>) : int =
inventory |> Map.tryFind item |> Option.defaultValue 0
@@ -112,12 +109,13 @@ module PlayerTrade =
| _ -> 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
+ /// P54/P55:交易事件同时推进当日任务(Offered 须先接受;判定走 Kernel 纯函数),
+ /// 并把任务的物品奖励一并返回。
+ let private advanceOccupation (event: InteractionEvent) (occupation: Occupation.State) : Occupation.State * (ItemKind * int) option =
+ Occupation.advanceTaskWithReward event occupation
let private failure (state: Occupation.State option) (world: World) (message: string) : Outcome =
- { World = world; Occupation = state; Succeeded = false; UnitPrice = None; Message = message }
+ { World = world; Occupation = state; Succeeded = false; UnitPrice = None; Reward = None; Message = message }
/// 玩家向 `counterparty` 买入 `quantity` 件 `item`。
let buy
@@ -163,13 +161,14 @@ module PlayerTrade =
|> (fun w -> { w with Avatar = { w.Avatar with Mind = avatarMind } })
|> replaceNpc updatedSeller
|> recordTrade tradeEvent
- let nextOccupation =
- { occupation with Backpack = changeBackpack item quantity occupation.Backpack }
+ let nextOccupation, reward =
+ ({ occupation with Backpack = changeBackpack item quantity occupation.Backpack })
|> advanceOccupation tradeEvent
{ World = nextWorld
Occupation = Some nextOccupation
Succeeded = true
UnitPrice = Some unitPrice
+ Reward = reward
Message = sprintf "买入%s ×%d,单价 %.2f" (itemName item) quantity unitPrice }
/// 玩家向 `counterparty` 卖出 `quantity` 件 `item`(须在背包内)。
@@ -216,13 +215,14 @@ module PlayerTrade =
|> (fun w -> { w with Avatar = { w.Avatar with Mind = avatarMind } })
|> replaceNpc updatedBuyer
|> recordTrade tradeEvent
- let nextOccupation =
- { occupation with Backpack = changeBackpack item -quantity occupation.Backpack }
+ let nextOccupation, reward =
+ ({ occupation with Backpack = changeBackpack item -quantity occupation.Backpack })
|> advanceOccupation tradeEvent
{ World = nextWorld
Occupation = Some nextOccupation
Succeeded = true
UnitPrice = Some unitPrice
+ Reward = reward
Message = sprintf "卖出%s ×%d,单价 %.2f" (itemName item) quantity unitPrice }
/// HUD 入口:向 `counterparty` 买 1 件食物。
diff --git a/src/LivingVillage.Desktop/TaskRuntime.fs b/src/LivingVillage.Desktop/TaskRuntime.fs
index 75675aa..efc57f3 100644
--- a/src/LivingVillage.Desktop/TaskRuntime.fs
+++ b/src/LivingVillage.Desktop/TaskRuntime.fs
@@ -9,10 +9,21 @@ open LivingVillage.Kernel
/// (其内部是 `satisfiesCompletion`),绝不按日历索引硬编码计数。
module TaskRuntime =
- let private completionMessage (before: Occupation.DailyTask option) (after: Occupation.DailyTask option) : string option =
+ /// 完成短中文反馈:有设计口径物品奖励时带「+物品×数量」。
+ let completionText (task: Occupation.DailyTask) (reward: (Sim.ItemKind * int) option) : string =
+ let headline = sprintf "任务完成:%s" (Occupation.taskNameOf task.TemplateId)
+ match reward with
+ | Some(item, quantity) -> sprintf "%s +%s×%d" headline (Occupation.itemNameOf item) quantity
+ | None -> headline
+
+ let private completionMessage
+ (before: Occupation.DailyTask option)
+ (after: Occupation.DailyTask option)
+ (reward: (Sim.ItemKind * int) 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))
+ Some(completionText current reward)
| _ -> None
/// 接受当日任务(Offered → Active);无任务 / 已在进行 / 终态时无变化、无反馈。
@@ -23,19 +34,17 @@ module TaskRuntime =
Some(sprintf "已接受任务:%s" (Occupation.taskNameOf task.TemplateId))
| _ -> state, None
- /// 用一条既有世界事件推进当日任务;Active → Done 时返回短中文反馈。
+ /// 用一条既有世界事件推进当日任务;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 next, reward = Occupation.advanceTaskWithReward event state
+ next, completionMessage before next.Today reward
/// 用一条已折叠的任务信号(到达瓦片 / 观察 / 夜在水边)推进当日任务。
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 before = state.Today
+ let next, reward = Occupation.applySignalToState signal state
+ next, completionMessage before next.Today reward
/// 折叠多条信号(后面的不覆盖已得到的完成反馈)。
let foldSignals (signals: Occupation.TaskSignal list) (state: Occupation.State) : Occupation.State * string option =
@@ -58,7 +67,12 @@ module TaskRuntime =
if isNight && nearWater then yield Occupation.NightAtWater ]
/// 比较两份职业状态,返回本次推进是否让当日任务完成(供交易等路径复用)。
- let completedMessage (before: Occupation.State option) (after: Occupation.State option) : string option =
+ let completedMessageWithReward
+ (before: Occupation.State option)
+ (after: Occupation.State option)
+ (reward: (Sim.ItemKind * int) option)
+ : string option =
completionMessage
(before |> Option.bind (fun state -> state.Today))
(after |> Option.bind (fun state -> state.Today))
+ reward
diff --git a/src/LivingVillage.Desktop/WorldBootstrap.fs b/src/LivingVillage.Desktop/WorldBootstrap.fs
index 7be24a7..e75855f 100644
--- a/src/LivingVillage.Desktop/WorldBootstrap.fs
+++ b/src/LivingVillage.Desktop/WorldBootstrap.fs
@@ -25,15 +25,9 @@ module WorldBootstrap =
Today = Occupation.dailyTaskOf seed (Occupation.occupationSeedOf kind) (Occupation.dayIndexOf nowTick) kind }
/// 跨日刷新当日任务;同日(含 Done/Failed 终态)原样保留。
+ /// P55:经 Kernel `refreshState`,跨日时按上一日是否已完成保留/清零连续天数。
let refreshOccupationToday (seed: uint64) (state: Occupation.State) (nowTick: int64) : Occupation.State =
- { state with
- Today =
- Occupation.refreshToday
- seed
- (Occupation.occupationSeedOf state.Profile.Kind)
- state.Profile.Kind
- nowTick
- state.Today }
+ Occupation.refreshState seed (Occupation.occupationSeedOf state.Profile.Kind) state.Profile.Kind nowTick state
/// 开局身份年鉴(design §3):带职业建世界后写入一条 `StoryAnnal`「以…为生」,
/// 形成个人传记线。Game 与测试共用同一口径。
diff --git a/src/LivingVillage.Kernel/Occupation.fs b/src/LivingVillage.Kernel/Occupation.fs
index 45ef428..66ed4f2 100644
--- a/src/LivingVillage.Kernel/Occupation.fs
+++ b/src/LivingVillage.Kernel/Occupation.fs
@@ -201,19 +201,75 @@ module Occupation =
TaskToken: int
Today: DailyTask option
StoryStage: int
- Backpack: (Sim.ItemKind * int) list }
+ Backpack: (Sim.ItemKind * int) list
+ /// 连续完成天数(P55):当日任务 Done 时 +1;跨日刷新时若上一日已完成则保留,否则清零。
+ Streak: int }
let stateOf (kind: Kind) : State =
{ Profile = profileOf kind
TaskToken = 0
Today = None
StoryStage = 0
- Backpack = (profileOf kind).InitialInventory }
+ Backpack = (profileOf kind).InitialInventory
+ Streak = 0 }
/// 背包内某物品数量(纯函数;无则 0)。
let backpackQuantity (item: Sim.ItemKind) (state: State) : int =
state.Backpack |> List.sumBy (fun (kind, quantity) -> if kind = item then quantity else 0)
+ /// 物品中文名(HUD 短反馈共用;与 PlayerTrade 口径一致)。
+ let itemNameOf (item: Sim.ItemKind) : string =
+ match item with
+ | Sim.Food -> "食物"
+ | Sim.Fish -> "鱼"
+ | Sim.Spice -> "香料"
+ | Sim.Scroll -> "卷轴"
+
+ /// 背包发放物品:同物品合并数量,新物品追加尾部(保持声明顺序,稳定序列化)。
+ let grantItem (item: Sim.ItemKind) (quantity: int) (backpack: (Sim.ItemKind * int) list) : (Sim.ItemKind * int) list =
+ if quantity <= 0 then
+ backpack
+ else
+ let mutable found = false
+ let updated =
+ backpack
+ |> List.map (fun (kind, current) ->
+ if kind = item then
+ found <- true
+ kind, current + quantity
+ else
+ kind, current)
+ if found then updated else updated @ [ item, quantity ]
+
+ // ---- P55 经济闭环:设计口径报酬 + 连续完成天数 ----
+ // 报酬 = `docs/design/occupation-system-design.md` §3 任务表已写明的**完成结果物品**
+ // (仅两处有物品+数量:夜捕「得 Fish×2」、送粮「交给村民 Food×1」);其余模板无写明物品
+ // → 不发、只保留既有年鉴/关系反馈。禁止发明金额或新物品。
+
+ /// 完成任务的设计口径物品奖励(无写明 → None)。
+ let rewardOf (template: TaskTemplateId) : (Sim.ItemKind * int) option =
+ match template with
+ | NightCatch -> Some(Sim.Fish, 2)
+ | DeliverGrain -> Some(Sim.Food, 1)
+ | _ -> None
+
+ /// 对职业状态施加任务信号:Active 命中完成 → Done + Streak+1 + 发放设计写明物品。
+ /// 返回(新状态, 本次发放的物品奖励 option)。
+ let applySignalToState (signal: TaskSignal) (state: State) : State * (Sim.ItemKind * int) option =
+ match state.Today with
+ | Some task when task.State = Active && satisfiesCompletion task signal ->
+ let reward = rewardOf task.TemplateId
+ let backpack =
+ match reward with
+ | Some(item, quantity) -> grantItem item quantity state.Backpack
+ | None -> state.Backpack
+ { state with
+ Today = Some { task with State = Done }
+ Streak = state.Streak + 1
+ Backpack = backpack },
+ reward
+ | _ -> state, None
+
// ---- 既有事件通道 → 任务信号桥(design §3:完成判定统一走 InteractionEvent)----
/// 仅玩家发起的对话/交易才产生任务信号;NPC 之间互动的副作用不派任务。
@@ -225,12 +281,34 @@ module Occupation =
| Sim.TradeEvent (buyer, _, item, quantity, _) when buyer = Sim.playerId -> Some(Purchased(item, quantity))
| _ -> None
+ /// 用一条既有事件推进当日任务并返回物品奖励(Offered 须先 acceptTask;终态不可复活)。
+ /// 无职业 / 无当日任务 / 非玩家事件时原样返回。
+ let advanceTaskWithReward (event: Sim.InteractionEvent) (state: State) : State * (Sim.ItemKind * int) option =
+ match signalOfInteraction event with
+ | Some signal -> applySignalToState signal state
+ | None -> state, 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
+ fst (advanceTaskWithReward event state)
+
+ /// 跨日刷新职业状态(P55):重算当日任务,并按「上一日是否已完成」保留/清零连续天数。
+ /// 同日刷新原样保留 Streak(含 Done 终态),避免每帧刷新把 streak 冲掉。
+ let refreshState (seed: uint64) (occupationSeed: uint64) (kind: Kind) (nowTick: int64) (state: State) : State =
+ let today = refreshToday seed occupationSeed kind nowTick state.Today
+ let sameDay =
+ match state.Today with
+ | Some task -> dayIndexOf task.OfferedTick = dayIndexOf nowTick
+ | None -> false
+ let todayDay = dayIndexOf nowTick
+ let continuing =
+ match state.Today with
+ | Some task ->
+ // 同日刷新保留;跨日时仅当上一日「紧邻且已完成」才保留(跳过多日 = 漏完成 → 清零)。
+ sameDay || (task.State = Done && dayIndexOf task.OfferedTick + 1L = todayDay)
+ | None -> false
+ { state with Today = today; Streak = (if continuing then state.Streak else 0) }
// ---- 报价偏置(design §2/§3):Kernel 纯函数旁路,不改 Sim.fs 数值口径 ----
// 4 位定点整数基点(1/10000)表达偏置,避免浮点漂移;结果钳在 [0.95, 1.05]。
diff --git a/src/LivingVillage.Kernel/WorldSave.fs b/src/LivingVillage.Kernel/WorldSave.fs
index 3e11810..29c8d18 100644
--- a/src/LivingVillage.Kernel/WorldSave.fs
+++ b/src/LivingVillage.Kernel/WorldSave.fs
@@ -617,6 +617,11 @@ module WorldSave =
| Some task -> writeDailyTask tokens task
| None -> ()
writeBackpack tokens state.Backpack
+ // P55:连续完成天数仅在 > 0 时写入尾段,旧 v3(无此段)读回默认 0,
+ // 且缺省(无 streak)存档的文本布局与 P51/P52 完全一致。
+ if state.Streak > 0 then
+ add tokens "streak"
+ addInt tokens state.Streak
| None -> ()
String.Join("|", tokens)
@@ -660,7 +665,8 @@ module WorldSave =
TaskToken = task
Today = None
StoryStage = stage
- Backpack = (Occupation.profileOf kind).InitialInventory })
+ Backpack = (Occupation.profileOf kind).InitialInventory
+ Streak = 0 })
| _ -> ()
let tick = readInt64 reader "tick"
let time = readFloat reader "time"
@@ -679,20 +685,24 @@ module WorldSave =
let tail =
let mutable task : Occupation.DailyTask option = None
let mutable backpack : (ItemKind * int) list option = None
+ let mutable streak = 0
while reader.Remaining > 0 do
match reader.Take("occupation.tail") with
| "task-today" -> task <- Some(readDailyTask reader)
| "backpack" -> backpack <- Some(readBackpack reader)
+ | "streak" -> streak <- readInt reader "occupation.streak"
| _ -> invalid "trailing save data"
- task, backpack
+ task, backpack, streak
+ let tailTask, tailBackpack, tailStreak = tail
if reader.Remaining <> 0 then
invalid "trailing save data"
let occupation =
occupationOption
|> Option.map (fun state ->
{ state with
- Today = fst tail
- Backpack = snd tail |> Option.defaultValue state.Backpack })
+ Today = tailTask
+ Backpack = tailBackpack |> Option.defaultValue state.Backpack
+ Streak = tailStreak })
Ok
({ Tick = tick
Time = time