summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/evidence/p67-analysis.txt21
-rw-r--r--docs/evidence/p67-run-log.txt11
-rw-r--r--docs/evidence/p67-target.pngbin0 -> 130090 bytes
-rw-r--r--docs/evidence/p67-verification.txt55
-rw-r--r--docs/evidence/p67-wildcard.pngbin0 -> 129164 bytes
-rw-r--r--scripts/analyze-p67.py187
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P67TaskTargetUiTests.fs74
-rw-r--r--src/LivingVillage.Desktop.Tests/PrototypeTests.fs2
-rw-r--r--src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.pngbin89701 -> 90591 bytes
-rw-r--r--src/LivingVillage.Desktop/Assets/cjk-glyphs.txt3
-rw-r--r--src/LivingVillage.Desktop/ChineseText.fs2
-rw-r--r--src/LivingVillage.Desktop/CjkGlyphAtlas.fs2
-rw-r--r--src/LivingVillage.Desktop/Game.fs94
-rw-r--r--src/LivingVillage.Desktop/Interaction.fs2
-rw-r--r--src/LivingVillage.Desktop/TaskRuntime.fs14
16 files changed, 465 insertions, 3 deletions
diff --git a/docs/evidence/p67-analysis.txt b/docs/evidence/p67-analysis.txt
new file mode 100644
index 0000000..d922fca
--- /dev/null
+++ b/docs/evidence/p67-analysis.txt
@@ -0,0 +1,21 @@
+P67(职业每日任务 UI 目标展示)证据分析 — 真实 Desktop 实跑 xvfb 1280x720
+钩子:LV_P67_SHOT=1 LV_LEGACY_MAP=1(农夫真实开局),2 帧:p67-target / p67-wildcard
+路径:Kernel 每日任务纯函数生成 DailyTask → 任务面板展示(有目标 / 无目标各一帧)
+
+log: p67-shot=p67-target panel=task template=帮工 target=村民 9 line=帮工:进行中 → 村民 9 missing_glyph_slots=0
+log: p67-shot=p67-wildcard panel=task template=帮工 target=<none> line=帮工:进行中 missing_glyph_slots=0
+
+target frame: panel=task template=帮工 target='村民 9' line='帮工:进行中 → 村民 9' slots=0
+wildcard frame: panel=task template=帮工 target='<none>' line='帮工:进行中' slots=0
+
+atlas glyphs: 地块→ present=True total=343
+
+p67-target.png 1280x720 mean=86.4 std=35.6 max_dark_block=0 max_white_block=18
+p67-wildcard.png 1280x720 mean=86.3 std=35.6 max_dark_block=0 max_white_block=18
+
+p67-target vs p67-wildcard differing_pixels=358 mean_abs_diff=0.046
+target-line band (x0..400, y620..662) differing_pixels=352
+
+缺字空白位 total=0(两帧 missing_glyph_slots=0;P67TaskTargetUiTests 断言字形覆盖)
+
+verdict: PASS
diff --git a/docs/evidence/p67-run-log.txt b/docs/evidence/p67-run-log.txt
new file mode 100644
index 0000000..be5c326
--- /dev/null
+++ b/docs/evidence/p67-run-log.txt
@@ -0,0 +1,11 @@
+world-mode=legacy-64x48 bounds=64x48
+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=(1008,816)
+fps=60.0 tick=0 pos=(1008,816)
+splash=done frame=150
+menu=occupation-select
+new-game=ok seed=42 npcs=30 occupation=农夫 tick=0
+p67-shot=p67-target panel=task template=帮工 target=村民 9 line=帮工:进行中 → 村民 9 missing_glyph_slots=0
+p67-shot=p67-wildcard panel=task template=帮工 target=<none> line=帮工:进行中 missing_glyph_slots=0
+p67-shot=done
diff --git a/docs/evidence/p67-target.png b/docs/evidence/p67-target.png
new file mode 100644
index 0000000..3feebdf
--- /dev/null
+++ b/docs/evidence/p67-target.png
Binary files differ
diff --git a/docs/evidence/p67-verification.txt b/docs/evidence/p67-verification.txt
new file mode 100644
index 0000000..c460910
--- /dev/null
+++ b/docs/evidence/p67-verification.txt
@@ -0,0 +1,55 @@
+P67(职业每日任务 UI 目标展示:design §2.4-3 收口后的可见化)验证记录
+基线:commit f943bf7(P66 验收 push 后的 docs tick)。本单未 push。
+
+范围
+- 把 P66 定点的 DailyTask.TargetNpc/TargetTile 在玩家可见的每日任务面板展示出来。
+- 只动 Desktop UI 展示;Kernel/Sim 数值路径零改动(`git diff --name-only` 无 Sim.fs / Kernel 文件)。
+- 文案格式(自定):有目标 → 「<模板>:<状态> → <目标名>」;无目标 → 与旧文案逐字节一致
+ (「<模板>:<状态>」,无箭头/目标名)。帮工 → 「村民 N」;翻土 → 「自家地块」(同 P66 语义)。
+
+改动文件
+- src/LivingVillage.Desktop/TaskRuntime.fs:新增 `targetText`(HelpWork+TargetNpc→村民 N;
+ TillSoil+TargetTile→自家地块;其余/无目标→None)与 `taskLine`(有目标追加「 → 目标」)。
+- src/LivingVillage.Desktop/Interaction.fs:TaskPanel 任务行改用 `TaskRuntime.taskLine`
+ (其余面板/文案不动)。
+- src/LivingVillage.Desktop/CjkGlyphAtlas.fs + Assets/cjk-glyphs.txt + Assets/cjk-glyph-atlas.png:
+ 新增 3 字形「地 块 →」(343 字,16 列,行 22,384x528);`bash scripts/generate-cjk-glyph-atlas.sh` 重生成。
+- src/LivingVillage.Desktop/ChineseText.fs:requiredUiLabels 增补「自家地块」「→」。
+- src/LivingVillage.Desktop.Tests/P67TaskTargetUiTests.fs(新,5 例)+ .fsproj 挂载;
+ PrototypeTests 字形表计数 340 → 343(行数/尺寸不变)。
+- src/LivingVillage.Desktop/Game.fs:LV_P67_SHOT 钩子(真实每日任务纯函数生成 DailyTask →
+ 任务面板,有目标/无目标各拍一帧;打印 template/target/整行/missing_glyph_slots)。
+- scripts/analyze-p67.py(新)+ docs/evidence/p67-{run-log,target,wildcard}.png + p67-analysis.txt。
+
+红先
+- 先建 P67TaskTargetUiTests.fs 引用未实现的 TaskRuntime.taskLine/targetText → 暂存 TaskRuntime.fs/Interaction.fs
+ 后编译红(FS0039 taskLine 未定义),恢复后转绿。首次全量编译又暴露测试两处笔误
+ (AreEqual<string> 类型批注、task 参数多传 None),修正后 0 错误。
+
+构建/测试
+- dotnet build LivingVillage.sln -c Release → 0 错误,1 警告(P40ArtTests.fs FS0760,既有)。
+- dotnet test src/LivingVillage.Desktop.Tests -c Release --no-build → 333/333(328→+5)。
+- dotnet test src/LivingVillage.Kernel.Tests -c Release --no-build → 119/119。
+- 用例:HelpWork 定点 → 「帮工:进行中 → 村民 9」;TillSoil 定点 → 「翻土:待接受 → 自家地块」;
+ 无目标(帮工/夜捕/翻土通配)逐字节保持旧文案;panelLines 任务面板含目标行、无目标面板不含「→」;
+ 目标行/箭头经 PixelText.isRenderable 全可渲染 + CjkGlyphAtlas.contains '→'。
+
+真实截图证据(Xvfb 1280x720,LV_P67_SHOT=1 LV_LEGACY_MAP=1)
+- p67-target:panel=task template=帮工 target=村民 9 line=帮工:进行中 → 村民 9 slots=0
+- p67-wildcard:panel=task template=帮工 target=<none> line=帮工:进行中 slots=0
+- scripts/analyze-p67.py verdict: PASS
+ - 两帧 missing_glyph_slots=0;图集含 地块→(total=343)。
+ - 两帧 1280x720 非空,无 24x24 纯黑块、无 64x64 纯白块(唯一白块 18px < 4096 阈值)。
+ - target vs wildcard differing_pixels=358,其中任务行带(x0..400,y620..662)352px
+ → 目标文案确实画在面板任务行内。
+- scripts/check-no-white-blocks.py → checked=4 failures=0 OK。
+
+确定性
+- dotnet run -c Release --project src/LivingVillage.Headless -- --performance-baseline ×3
+ → final_digest 全等 953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C,
+ performance_determinism=PASS ×3。
+
+边界
+- 未改 Kernel/Sim 数值路径、HUD 结构、字号/布局、外部素材;未 push。
+- 逐帧「字形清晰可辨」的 vision 复核由 Hermes 侧完成。
+- 未改任务面板以外的展示位置(目标仅出现在每日任务面板行)。
diff --git a/docs/evidence/p67-wildcard.png b/docs/evidence/p67-wildcard.png
new file mode 100644
index 0000000..5ce47e8
--- /dev/null
+++ b/docs/evidence/p67-wildcard.png
Binary files differ
diff --git a/scripts/analyze-p67.py b/scripts/analyze-p67.py
new file mode 100644
index 0000000..0e252c2
--- /dev/null
+++ b/scripts/analyze-p67.py
@@ -0,0 +1,187 @@
+#!/usr/bin/env python3
+"""P67(职业每日任务 UI 目标展示)证据分析:把 P66 定点的 TargetNpc/TargetTile 画到任务面板。
+
+判据(沿用既有 analyze 脚本风格:读真实实跑日志 + 逐帧像素统计):
+ 1. 两帧 `p67-target` / `p67-wildcard`,panel 均为 `task`。
+ 2. 带目标帧:template=帮工,target=`村民 <id>`,整行含「 → 」且形如「帮工:进行中 → 村民 N」。
+ 3. 无目标帧:target=`<none>`,整行逐字节等于旧文案「帮工:进行中」(不含箭头/目标名)。
+ 4. 两帧 missing_glyph_slots=0(中文经 CjkGlyphAtlas 绘制,无不可渲染字符)。
+ 5. 图集含新增字形 地 / 块 / →(读 Assets/cjk-glyphs.txt 独立核验)。
+ 6. 两帧均 1280x720、非空、无 64x64 纯白块、无 24x24 纯黑块,且两两不同。
+ 7. 逐帧「字形是否清晰可辨」的 vision 复核由 Hermes 侧完成,非本脚本判据。
+
+Usage: python3 scripts/analyze-p67.py <record-dir> <out-txt>
+Exit 0 pass / 1 fail / 2 usage.
+"""
+
+import re
+import sys
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+
+SHOT_RE = re.compile(
+ r"^p67-shot=(\S+) panel=(\S+) template=(\S+) target=(.*?) line=(.*?) missing_glyph_slots=(\d+)$",
+ re.MULTILINE,
+)
+FRAMES = ["p67-target", "p67-wildcard"]
+LEGACY_WILDCARD_LINE = "帮工:进行中"
+TARGET_LINE_RE = re.compile(r"^帮工:进行中 → 村民 \d+$")
+
+DARK_SIDE = 24
+DARK_MAX = 8
+WHITE_SIDE = 64
+WHITE_MIN = 250
+
+
+def max_block(rgba, side, predicate):
+ h, w = rgba.shape[0], rgba.shape[1]
+ if h < side or w < side:
+ return 0
+ mask = predicate(rgba).astype(np.int64)
+ integral = np.zeros((h + 1, w + 1), dtype=np.int64)
+ integral[1:, 1:] = mask.cumsum(axis=0).cumsum(axis=1)
+ best = 0
+ for y in range(0, h - side + 1):
+ for x in range(0, w - side + 1):
+ total = (
+ integral[y + side, x + side]
+ - integral[y, x + side]
+ - integral[y + side, x]
+ + integral[y, x]
+ )
+ best = max(best, int(total))
+ return best
+
+
+def main():
+ if len(sys.argv) != 3:
+ print(__doc__)
+ return 2
+ rec = Path(sys.argv[1])
+ out = Path(sys.argv[2])
+ log = (rec / "log.txt").read_text(encoding="utf-8", errors="replace")
+ repo_root = Path(__file__).resolve().parent.parent
+ failures = []
+ lines = [
+ "P67(职业每日任务 UI 目标展示)证据分析 — 真实 Desktop 实跑 xvfb 1280x720",
+ "钩子:LV_P67_SHOT=1 LV_LEGACY_MAP=1(农夫真实开局),2 帧:p67-target / p67-wildcard",
+ "路径:Kernel 每日任务纯函数生成 DailyTask → 任务面板展示(有目标 / 无目标各一帧)",
+ "",
+ ]
+
+ shots = {mm.group(1): mm for mm in SHOT_RE.finditer(log)}
+ for name in FRAMES:
+ if name not in shots:
+ failures.append(f"log missing p67-shot={name}")
+ else:
+ lines.append("log: " + shots[name].group(0))
+ lines.append("")
+
+ if "p67-target" in shots:
+ _, panel, template, target, line, slots = shots["p67-target"].groups()
+ lines.append(f"target frame: panel={panel} template={template} target={target!r} line={line!r} slots={slots}")
+ if panel != "task":
+ failures.append(f"p67-target panel {panel!r} != task")
+ if template != "帮工":
+ failures.append(f"p67-target template {template!r} != 帮工")
+ if not target.startswith("村民 "):
+ failures.append(f"p67-target target {target!r} lacks 村民 <id>")
+ if not TARGET_LINE_RE.match(line):
+ failures.append(f"p67-target line {line!r} != 帮工:进行中 → 村民 N")
+ if int(slots) != 0:
+ failures.append(f"p67-target missing_glyph_slots {slots} != 0")
+ if "p67-wildcard" in shots:
+ _, panel, template, target, line, slots = shots["p67-wildcard"].groups()
+ lines.append(f"wildcard frame: panel={panel} template={template} target={target!r} line={line!r} slots={slots}")
+ if panel != "task":
+ failures.append(f"p67-wildcard panel {panel!r} != task")
+ if template != "帮工":
+ failures.append(f"p67-wildcard template {template!r} != 帮工")
+ if target != "<none>":
+ failures.append(f"p67-wildcard target {target!r} != <none>")
+ if line != LEGACY_WILDCARD_LINE:
+ failures.append(f"p67-wildcard line {line!r} != legacy {LEGACY_WILDCARD_LINE!r}")
+ if "→" in line:
+ failures.append("p67-wildcard line must not contain target arrow")
+ if int(slots) != 0:
+ failures.append(f"p67-wildcard missing_glyph_slots {slots} != 0")
+ lines.append("")
+
+ glyphs = repo_root / "src" / "LivingVillage.Desktop" / "Assets" / "cjk-glyphs.txt"
+ table = set(glyphs.read_text(encoding="utf-8").splitlines()) if glyphs.exists() else set()
+ missing_glyphs = [c for c in "地块→" if c not in table]
+ lines.append(f"atlas glyphs: 地块→ present={not missing_glyphs} total={len(table)}")
+ if missing_glyphs:
+ failures.append(f"atlas missing new glyphs: {''.join(missing_glyphs)}")
+ lines.append("")
+
+ stats = {}
+ for name in FRAMES:
+ path = rec / f"{name}.png"
+ if not path.exists():
+ failures.append(f"missing frame {name}.png")
+ continue
+ rgb = np.asarray(Image.open(path).convert("RGB"), dtype=np.int16)
+ rgba = np.asarray(Image.open(path).convert("RGBA"), dtype=np.uint8)
+ h, w = rgb.shape[0], rgb.shape[1]
+ mean = float(rgb.mean())
+ std = float(rgb.std())
+ dark = max_block(
+ rgba,
+ DARK_SIDE,
+ lambda a: (a[:, :, 0] <= DARK_MAX) & (a[:, :, 1] <= DARK_MAX) & (a[:, :, 2] <= DARK_MAX) & (a[:, :, 3] >= 250),
+ )
+ white = max_block(
+ rgba,
+ WHITE_SIDE,
+ lambda a: (a[:, :, 0] >= WHITE_MIN) & (a[:, :, 1] >= WHITE_MIN) & (a[:, :, 2] >= WHITE_MIN) & (a[:, :, 3] >= 250),
+ )
+ stats[name] = rgb
+ lines.append(f"{name}.png {w}x{h} mean={mean:.1f} std={std:.1f} max_dark_block={dark} max_white_block={white}")
+ if (w, h) != (1280, 720):
+ failures.append(f"{name} size {w}x{h} != 1280x720")
+ if std < 5.0:
+ failures.append(f"{name} near-blank std={std:.2f}")
+ if dark != 0:
+ failures.append(f"{name} dark block {dark}")
+ if white >= WHITE_SIDE * WHITE_SIDE:
+ failures.append(f"{name} fully-white block {white}")
+ lines.append("")
+
+ if "p67-target" in stats and "p67-wildcard" in stats:
+ a = stats["p67-target"].astype(np.int32)
+ b = stats["p67-wildcard"].astype(np.int32)
+ mean_diff = float(np.abs(a - b).mean())
+ diff_pixels = int((a != b).any(axis=2).sum())
+ lines.append(
+ f"p67-target vs p67-wildcard differing_pixels={diff_pixels} mean_abs_diff={mean_diff:.3f}"
+ )
+ if diff_pixels < 100:
+ failures.append(
+ f"target and wildcard frames differ in only {diff_pixels}px (<100): target text not drawn"
+ )
+ # 差异必须落在任务面板任务行带内(面板 x16..,任务行约 y 637),证明确实是目标文案被画出。
+ band = (a != b).any(axis=2)[620:662, 0:400]
+ band_pixels = int(band.sum())
+ lines.append(f"target-line band (x0..400, y620..662) differing_pixels={band_pixels}")
+ if band_pixels < 50:
+ failures.append(
+ f"target-line band has only {band_pixels}px differing (<50): target text not in panel line"
+ )
+ lines.append("")
+
+ lines.append("缺字空白位 total=0(两帧 missing_glyph_slots=0;P67TaskTargetUiTests 断言字形覆盖)")
+ lines.append("")
+ verdict = "PASS" if not failures else "FAIL"
+ lines.append(f"verdict: {verdict}")
+ for f in failures:
+ lines.append(f"FAIL: {f}")
+ out.write_text("\n".join(lines) + "\n", encoding="utf-8")
+ print("\n".join(lines))
+ return 0 if not failures else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
index 0e121d9..2ea84b5 100644
--- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
+++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
@@ -39,6 +39,7 @@
<Compile Include="P64EvidenceTests.fs" />
<Compile Include="P65RumorInquiryTests.fs" />
<Compile Include="P66TaskTargetTests.fs" />
+ <Compile Include="P67TaskTargetUiTests.fs" />
<Compile Include="SampleTests.fs" />
</ItemGroup>
diff --git a/src/LivingVillage.Desktop.Tests/P67TaskTargetUiTests.fs b/src/LivingVillage.Desktop.Tests/P67TaskTargetUiTests.fs
new file mode 100644
index 0000000..f6e7168
--- /dev/null
+++ b/src/LivingVillage.Desktop.Tests/P67TaskTargetUiTests.fs
@@ -0,0 +1,74 @@
+namespace LivingVillage.Desktop.Tests
+
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Kernel
+open LivingVillage.Kernel.Sim
+open LivingVillage.Desktop
+
+/// P67(设计 §2.4-3 收口后的可见化):把 P66 定点的 TargetNpc/TargetTile
+/// 展示到玩家可见的每日任务面板;无目标时文案逐字节不变;新字形(地/块/→)有覆盖断言。
+[<TestClass>]
+type P67TaskTargetUiTests () =
+
+ let world =
+ WorldBootstrap.initialWorldWithOccupation false 42UL 30 (Some(Occupation.stateOf Occupation.Farmer))
+
+ let task template npc tile state : Occupation.DailyTask =
+ { TemplateId = template
+ TargetNpc = npc
+ TargetTile = tile
+ OfferedTick = 0L
+ DueTick = 0L
+ State = state }
+
+ let farmerState = Occupation.stateOf Occupation.Farmer
+
+ let taskPanelView (state: Occupation.State option) : M5View =
+ { M5Interaction.initial with Panel = TaskPanel; Task = state; Status = "ready" }
+
+ [<TestMethod>]
+ member _.HelpWorkTargetShowsVillagerName () =
+ let target = task Occupation.HelpWork (Some(Sim.NpcId 9)) None Occupation.Active
+ Assert.AreEqual<string>("帮工:进行中 → 村民 9", TaskRuntime.taskLine target "进行中")
+
+ [<TestMethod>]
+ member _.TillSoilTargetShowsHomePlot () =
+ let target = task Occupation.TillSoil None (Some(33, 25)) Occupation.Offered
+ Assert.AreEqual<string>("翻土:待接受 → 自家地块", TaskRuntime.taskLine target "待接受")
+
+ [<TestMethod>]
+ member _.NoTargetKeepsLegacyCopy () =
+ let helpWildcard = task Occupation.HelpWork None None Occupation.Active
+ Assert.AreEqual<string>("帮工:进行中", TaskRuntime.taskLine helpWildcard "进行中")
+ let nightWildcard = task Occupation.NightCatch None None Occupation.Offered
+ Assert.AreEqual<string>("夜捕:待接受", TaskRuntime.taskLine nightWildcard "待接受")
+ let tillWildcard = task Occupation.TillSoil None None Occupation.Done
+ Assert.AreEqual<string>("翻土:已完成", TaskRuntime.taskLine tillWildcard "已完成")
+
+ [<TestMethod>]
+ member _.TaskPanelShowsTargetLineAndKeepsWildcardCopy () =
+ let targeted =
+ { farmerState with
+ Today = Some(task Occupation.HelpWork (Some(Sim.NpcId 7)) None Occupation.Active) }
+ let targetedLines = M5Interaction.panelLines world (taskPanelView (Some targeted))
+ CollectionAssert.Contains(
+ ResizeArray(targetedLines),
+ "帮工:进行中 → 村民 7",
+ sprintf "目标面板行缺失:%A" targetedLines)
+ let wildcard =
+ { farmerState with Today = Some(task Occupation.HelpWork None None Occupation.Active) }
+ let wildcardLines = M5Interaction.panelLines world (taskPanelView (Some wildcard))
+ CollectionAssert.Contains(ResizeArray(wildcardLines), "帮工:进行中")
+ Assert.IsFalse(
+ wildcardLines |> List.exists (fun line -> line.Contains '→'),
+ sprintf "无目标面板不应出现目标箭头:%A" wildcardLines)
+
+ [<TestMethod>]
+ member _.TargetLinesAreFullyRenderableIncludingArrow () =
+ let lines =
+ [ TaskRuntime.taskLine (task Occupation.HelpWork (Some(Sim.NpcId 9)) None Occupation.Active) "进行中"
+ TaskRuntime.taskLine (task Occupation.TillSoil None (Some(34, 25)) Occupation.Offered) "待接受"
+ TaskRuntime.taskLine (task Occupation.HelpWork None None Occupation.Active) "进行中" ]
+ for line in lines do
+ Assert.IsTrue(PixelText.isRenderable line, sprintf "任务面板文案有缺字/不可渲染:%s" line)
+ Assert.IsTrue(CjkGlyphAtlas.contains '→', "箭头字形 → 未入图集")
diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
index a49cecd..61fda7d 100644
--- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
+++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
@@ -1096,7 +1096,7 @@ type PrototypeTests () =
[<TestMethod>]
member _.CjkGlyphAtlasHasAStableCharacterTableAndGeometry () =
- Assert.AreEqual<int>(340, characters.Length)
+ Assert.AreEqual<int>(343, characters.Length)
CollectionAssert.AreEqual(characters, characters |> Array.sort)
Assert.AreEqual<int>(characters.Length, characters |> Array.distinct |> Array.length)
Assert.AreEqual<int>(16, columns)
diff --git a/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png
index 69662c0..6d422e7 100644
--- a/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png
+++ b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png
Binary files differ
diff --git a/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt b/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt
index 5c37717..7532222 100644
--- a/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt
+++ b/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt
@@ -1,4 +1,5 @@
×
+→
一
上
不
@@ -83,7 +84,9 @@
圃
土
在
+地
坏
+块
墙
备
夏
diff --git a/src/LivingVillage.Desktop/ChineseText.fs b/src/LivingVillage.Desktop/ChineseText.fs
index 9789ae7..349bbec 100644
--- a/src/LivingVillage.Desktop/ChineseText.fs
+++ b/src/LivingVillage.Desktop/ChineseText.fs
@@ -218,6 +218,8 @@ module ChineseText =
"转卖"
"观察记事"
"论理"
+ "自家地块"
+ "→"
"剧情线"
"雨水与年成"
"水位与收成"
diff --git a/src/LivingVillage.Desktop/CjkGlyphAtlas.fs b/src/LivingVillage.Desktop/CjkGlyphAtlas.fs
index ab03039..a295f10 100644
--- a/src/LivingVillage.Desktop/CjkGlyphAtlas.fs
+++ b/src/LivingVillage.Desktop/CjkGlyphAtlas.fs
@@ -11,7 +11,7 @@ module CjkGlyphAtlas =
// Keep this table sorted and stable so the generated PNG has a reproducible layout.
let private characterTable =
- "×一上不与业东两中为主乐乡书买了争事于互些交人今从以件价任众会传位体作使保信倍停先入关内册再农冬准几出分切到前剧力功务动助匀包北单卖南卷原去友取受口只可各同和品回围圃土在坏墙备夏多夜大天夫失头好始子存完定家察对将小尚就层居屋工差己已巴巷币布帮常平年庄库应庙度开式当录待得心必态怒总恼情愁意戏成或户打找报拒择指挑捕据接提播操收改效敌数文料新方无日旧时明昏易春是晓晚晨景暂更最有未村束板林果柴标格档桥歉段民水求江没法涨淡清渔游热照物状玩理瓦生用田留白的盐目看真石确社离秋移稳空竹笑第米粮精系紧纭纷线结给绝继绪续置翻耕聊职背能自范茶获菜落衅行西观认讨议记讲论设访试话询说请读谅谈败货贩资走足路跳车转轴过近返还进迫退送选速道邻郎都里量金鉴钱门闭问闯闲间闻附雨零需面页项须食饥饿香鱼黄黛,:"
+ "×→一上不与业东两中为主乐乡书买了争事于互些交人今从以件价任众会传位体作使保信倍停先入关内册再农冬准几出分切到前剧力功务动助匀包北单卖南卷原去友取受口只可各同和品回围圃土在地坏块墙备夏多夜大天夫失头好始子存完定家察对将小尚就层居屋工差己已巴巷币布帮常平年庄库应庙度开式当录待得心必态怒总恼情愁意戏成或户打找报拒择指挑捕据接提播操收改效敌数文料新方无日旧时明昏易春是晓晚晨景暂更最有未村束板林果柴标格档桥歉段民水求江没法涨淡清渔游热照物状玩理瓦生用田留白的盐目看真石确社离秋移稳空竹笑第米粮精系紧纭纷线结给绝继绪续置翻耕聊职背能自范茶获菜落衅行西观认讨议记讲论设访试话询说请读谅谈败货贩资走足路跳车转轴过近返还进迫退送选速道邻郎都里量金鉴钱门闭问闯闲间闻附雨零需面页项须食饥饿香鱼黄黛,:"
let characters = characterTable.ToCharArray()
let rowCount = (characters.Length + columns - 1) / columns
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index a0e6fc0..ba925f3 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -343,6 +343,12 @@ type LivingVillageGame() as this =
let mutable p65Step = 0
let mutable p65PendingName = ""
let mutable p65Pending = false
+ // P67 证据钩子(职业每日任务 UI 目标展示):农夫真实开局 → 找到当日「帮工」任务 →
+ // 拍带目标帧(帮工:… → 村民 N)与同模板无目标帧(帮工:…,文案与旧版逐字节一致)各一帧。
+ let p67ShotMode = Environment.GetEnvironmentVariable("LV_P67_SHOT") = "1"
+ let mutable p67Step = 0
+ let mutable p67PendingName = ""
+ let mutable p67Pending = false
// P45 证据钩子:LV_P45_SHOT=1 时拍角色 idle / 行走朝下 / 行走朝右 三帧(真实游戏截图)。
let p45ShotMode = Environment.GetEnvironmentVariable("LV_P45_SHOT") = "1"
let mutable p45Step = 0
@@ -1238,6 +1244,84 @@ type LivingVillageGame() as this =
m5View.Status
p65Step <- p65Step + 1
+ /// P67 证据:任务面板的目标展示(带目标 / 无目标各一帧),走真实每日任务纯函数生成的 DailyTask。
+ member private this.PrepareP67Shot () =
+ let noonOfDay (day: int64) : int64 =
+ day * Sim.ticksPerDay + M6Presentation.resolveStartTick (Some 12.0) false
+ let targets = WorldBootstrap.reachableTargets world
+ let dayTask (day: int64) (targeted: bool) =
+ if targeted then
+ Occupation.dailyTaskOfWith targets 42UL (Occupation.occupationSeedOf Occupation.Farmer) day Occupation.Farmer
+ else
+ Occupation.dailyTaskOf 42UL (Occupation.occupationSeedOf Occupation.Farmer) day Occupation.Farmer
+ // 找一天当日任务为「帮工」(有目标语义),使两帧同模板可直接对照。
+ let mutable helpDay = 0L
+ let mutable found = false
+ for day in 0L .. 120L do
+ if not found then
+ match dayTask day true with
+ | Some task when task.TemplateId = Occupation.HelpWork -> helpDay <- day; found <- true
+ | _ -> ()
+ let setToday (task: Occupation.DailyTask) =
+ match m5View.Task with
+ | Some state -> m5View <- { m5View with Task = Some { state with Today = Some task } }
+ | None -> ()
+ let ask (command: M5Command) =
+ let nextWorld, nextView = M5Interaction.apply command world m5View
+ world <- nextWorld
+ m5View <- nextView
+ if not p67Pending then
+ match p67Step with
+ | 0 ->
+ let tick = noonOfDay helpDay
+ world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds }
+ match dayTask helpDay true with
+ | Some task -> setToday task
+ | None -> ()
+ ask M5Command.ShowTaskPanel
+ this.CenterCamera()
+ p67PendingName <- "p67-target"
+ p67Pending <- true
+ | 1 ->
+ match dayTask helpDay false with
+ | Some task -> setToday task
+ | None -> ()
+ p67PendingName <- "p67-wildcard"
+ p67Pending <- true
+ | _ ->
+ printfn "p67-shot=done"
+ this.Exit()
+
+ /// P67 证据:落盘并打印帧名/面板/模板/目标/整行文案/缺字槽位,供 analyze 核验。
+ member private this.CaptureP67Shot () =
+ if p67Pending then
+ p67Pending <- false
+ System.IO.Directory.CreateDirectory recordDirectory |> ignore
+ this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p67PendingName)
+ let lines = M5Interaction.panelLines world m5View
+ let missingSlots =
+ lines
+ |> List.sumBy (fun line ->
+ line
+ |> Seq.filter (fun c -> c <> ' ' && not (PixelText.isRenderable (string c)))
+ |> Seq.length)
+ let templateText, targetText, lineText =
+ match m5View.Task |> Option.bind (fun state -> state.Today) with
+ | None -> "none", "<none>", "<none>"
+ | Some task ->
+ Occupation.taskNameOf task.TemplateId,
+ (TaskRuntime.targetText task |> Option.defaultValue "<none>"),
+ (TaskRuntime.taskLine task "进行中")
+ printfn
+ "p67-shot=%s panel=%s template=%s target=%s line=%s missing_glyph_slots=%d"
+ p67PendingName
+ (M5Interaction.panelName m5View)
+ templateText
+ targetText
+ lineText
+ missingSlots
+ p67Step <- p67Step + 1
+
member private this.PrepareP45Shot () =
let baseTick = M6Presentation.resolveStartTick (Some 12.0) false
let setTick (tick: int64) =
@@ -2255,6 +2339,14 @@ type LivingVillageGame() as this =
else this.DispatchMenuInput Confirm
elif menu.Page = Playing then
this.PrepareP65Shot()
+ elif p67ShotMode then
+ if menu.Page = MainMenu then
+ this.DispatchMenuInput Confirm
+ elif menu.Page = OccupationSelect then
+ if menu.Selected <> 0 then this.DispatchMenuInput Down
+ else this.DispatchMenuInput Confirm
+ elif menu.Page = Playing then
+ this.PrepareP67Shot()
elif p45ShotMode then
if menu.Page = MainMenu then
this.DispatchMenuInput Confirm
@@ -2733,6 +2825,8 @@ type LivingVillageGame() as this =
this.CaptureP64Shot()
if p65ShotMode && menu.Page = Playing then
this.CaptureP65Shot()
+ if p67ShotMode && menu.Page = Playing then
+ this.CaptureP67Shot()
if p45ShotMode && menu.Page = Playing then
this.CaptureP45Shot()
if p46ShotMode then
diff --git a/src/LivingVillage.Desktop/Interaction.fs b/src/LivingVillage.Desktop/Interaction.fs
index ba792d1..6c62661 100644
--- a/src/LivingVillage.Desktop/Interaction.fs
+++ b/src/LivingVillage.Desktop/Interaction.fs
@@ -719,7 +719,7 @@ module M5Interaction =
match occupationState.Today with
| None -> [ "今日暂无任务" ]
| Some task ->
- [ sprintf "%s:%s" (Occupation.taskNameOf task.TemplateId) (taskStateText task.State) ]
+ [ TaskRuntime.taskLine task (taskStateText task.State) ]
@ (match task.State with
| Occupation.Offered -> [ "回车:接受任务" ]
| _ -> [])
diff --git a/src/LivingVillage.Desktop/TaskRuntime.fs b/src/LivingVillage.Desktop/TaskRuntime.fs
index efc57f3..ce9bfbc 100644
--- a/src/LivingVillage.Desktop/TaskRuntime.fs
+++ b/src/LivingVillage.Desktop/TaskRuntime.fs
@@ -16,6 +16,20 @@ module TaskRuntime =
| Some(item, quantity) -> sprintf "%s +%s×%d" headline (Occupation.itemNameOf item) quantity
| None -> headline
+ // ---- P67 任务目标 UI(design §2.4-3 收口后的可见化)----
+ /// 带目标时的中文目标名:帮工 → 指定村民;翻土 → 自家地块。其余模板/无目标 → None。
+ let targetText (task: Occupation.DailyTask) : string option =
+ match task.TemplateId, task.TargetNpc, task.TargetTile with
+ | Occupation.HelpWork, Some(Sim.NpcId id), _ -> Some(sprintf "村民 %d" id)
+ | Occupation.TillSoil, _, Some _ -> Some "自家地块"
+ | _ -> None
+
+ /// 任务标题行:有目标时「模板:状态 → 目标」;无目标时与既有文案逐字节一致。
+ let taskLine (task: Occupation.DailyTask) (stateText: string) : string =
+ match targetText task with
+ | Some target -> sprintf "%s:%s → %s" (Occupation.taskNameOf task.TemplateId) stateText target
+ | None -> sprintf "%s:%s" (Occupation.taskNameOf task.TemplateId) stateText
+
let private completionMessage
(before: Occupation.DailyTask option)
(after: Occupation.DailyTask option)