diff options
| -rw-r--r-- | docs/evidence/p54-daily-task.txt | 22 | ||||
| -rw-r--r-- | docs/evidence/p54-dialogue.png | bin | 0 -> 100567 bytes | |||
| -rw-r--r-- | docs/evidence/p54-shot-log.txt | 17 | ||||
| -rw-r--r-- | docs/evidence/p54-task-active.png | bin | 0 -> 114029 bytes | |||
| -rw-r--r-- | docs/evidence/p54-task-done.png | bin | 0 -> 113908 bytes | |||
| -rw-r--r-- | docs/evidence/p54-task-offered.png | bin | 0 -> 117667 bytes | |||
| -rw-r--r-- | scripts/analyze-p54.py | 159 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P54ProfessionDailyTests.fs | 217 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 145 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Interaction.fs | 51 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/PlayerTrade.fs | 43 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/TaskRuntime.fs | 64 |
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 Binary files differnew file mode 100644 index 0000000..24d0510 --- /dev/null +++ b/docs/evidence/p54-dialogue.png 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 Binary files differnew file mode 100644 index 0000000..d756aec --- /dev/null +++ b/docs/evidence/p54-task-active.png diff --git a/docs/evidence/p54-task-done.png b/docs/evidence/p54-task-done.png Binary files differnew file mode 100644 index 0000000..64333fc --- /dev/null +++ b/docs/evidence/p54-task-done.png diff --git a/docs/evidence/p54-task-offered.png b/docs/evidence/p54-task-offered.png Binary files differnew file mode 100644 index 0000000..3109e59 --- /dev/null +++ b/docs/evidence/p54-task-offered.png 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)) |
