diff options
| -rw-r--r-- | docs/evidence/p65-analysis.txt | 22 | ||||
| -rw-r--r-- | docs/evidence/p65-answer.png | bin | 0 -> 102937 bytes | |||
| -rw-r--r-- | docs/evidence/p65-menu.png | bin | 0 -> 117022 bytes | |||
| -rw-r--r-- | docs/evidence/p65-run-log.txt | 13 | ||||
| -rw-r--r-- | docs/evidence/p65-verification.txt | 65 | ||||
| -rw-r--r-- | scripts/analyze-p65.py | 220 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P65RumorInquiryTests.fs | 166 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 6 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png | bin | 85104 -> 89701 bytes | |||
| -rw-r--r-- | src/LivingVillage.Desktop/Assets/cjk-glyphs.txt | 17 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/CjkGlyphAtlas.fs | 2 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 87 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Interaction.fs | 16 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Occupation.fs | 61 |
15 files changed, 669 insertions, 7 deletions
diff --git a/docs/evidence/p65-analysis.txt b/docs/evidence/p65-analysis.txt new file mode 100644 index 0000000..98dee27 --- /dev/null +++ b/docs/evidence/p65-analysis.txt @@ -0,0 +1,22 @@ +P65(职业切片五)证据分析 — 真实 Desktop 实跑 xvfb 1280x720 +钩子:LV_P65_SHOT=1 LV_LEGACY_MAP=1(货郎真实开局),2 帧:p65-menu / p65-answer +路径:真实闲聊写谣言池 → 真实 Interact 开菜单 → 提议交易读既有谣言出中文行情短答 + +non-peddler regression status: dialogue intent=OfferTrade response=Bargaining + +inquiry: rumors=1 fresh=1 hit=true equal=true text1='今日东头的布价涨了两文' text2='今日东头的布价涨了两文' +sha256(text1)=14f359b3777162f5a69d2823b114d7005a9bb321dd9f3617c3a0a7ada2336645 + +p65-menu.png 1280x720 mean=78.7 std=32.4 max_dark_block=0 max_white_block=113 +p65-answer.png 1280x720 mean=83.5 std=30.7 max_dark_block=0 max_white_block=38 + +log: p65-shot=p65-menu panel=dialogue avatar=(784,464) status=dialogue target=NpcId 0 options=6; press 1-6 +log: p65-shot=p65-answer panel=world avatar=(784,464) status=inquiry|今日东头的布价涨了两文 + +status pill ink (x 8..210, y 41..69): answer bright=752 span=173px, menu bright=0 + +p65-menu vs p65-answer mean_abs_diff=6.9 + +缺字空白位 total=0(行情短答经 CjkGlyphAtlas 绘制;P65RumorInquiryTests 断言覆盖) + +verdict: PASS diff --git a/docs/evidence/p65-answer.png b/docs/evidence/p65-answer.png Binary files differnew file mode 100644 index 0000000..7dbe1f9 --- /dev/null +++ b/docs/evidence/p65-answer.png diff --git a/docs/evidence/p65-menu.png b/docs/evidence/p65-menu.png Binary files differnew file mode 100644 index 0000000..b66f8dd --- /dev/null +++ b/docs/evidence/p65-menu.png diff --git a/docs/evidence/p65-run-log.txt b/docs/evidence/p65-run-log.txt new file mode 100644 index 0000000..ad816de --- /dev/null +++ b/docs/evidence/p65-run-log.txt @@ -0,0 +1,13 @@ +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 +p65-shot=p65-menu panel=dialogue avatar=(784,464) status=dialogue target=NpcId 0 options=6; press 1-6 +p65-regression status=dialogue intent=OfferTrade response=Bargaining +p65-inquiry rumors=1 fresh=1 hit=true text1=今日东头的布价涨了两文 text2=今日东头的布价涨了两文 equal=true +p65-shot=p65-answer panel=world avatar=(784,464) status=inquiry|今日东头的布价涨了两文 +p65-shot=done diff --git a/docs/evidence/p65-verification.txt b/docs/evidence/p65-verification.txt new file mode 100644 index 0000000..12a8e1c --- /dev/null +++ b/docs/evidence/p65-verification.txt @@ -0,0 +1,65 @@ +P65(职业切片五:货郎「打听行情」接 World.Rumors)验证记录 +基线:commit a2103b2(P65 派发 doc tick);本单未 push。 + +范围与设计 +- 仅「判定 + 文案」纯函数,不改 Sim.fs 数值路径、不新增任何存档状态、不改 HUD 结构。 +- 货郎「提议交易」(DialogueIntent.OfferTrade,即 3 号对话选项,与 MarketInquiry 任务共用 + 「一次玩家对话事件」判定)读取**对话前**既有 World.Rumors 工作集,产出一条中文行情短答。 +- 同一套新鲜度/可信度口径:`Tick<=now` 且在 `rumorFreshnessTicks` 窗口内、衰减强度 + `Sim.rumorStrengthAt now` >= `rumorMinimumStrength`(对齐 Sim.latestRumorFor)。 +- 命中规则全序:强度最大 → Tick 更大 → RumorId 更大;地点/货品索引由 (RumorId, Depth) 决定, + 涨落由强度分档,新鲜度前缀(今日/近日)由 Tick 差决定。无随机、无哈希顺序依赖。 +- 非货郎 / 无职业 / 无命中谣言 → `None`,逐字节退回既有 `dialogue intent=...` 文案。 + +改动文件 +- src/LivingVillage.Kernel/Occupation.fs(+freshReliableRumors / inquiryRumor / marketInquiryText, + 及私有地点/货品/涨落表;不改 Sim.fs) +- src/LivingVillage.Desktop/Interaction.fs(chooseIntent 计算 inquiry;Status 前缀 `inquiry|`; + statusLabel 新增 `inquiry|` 分支) +- src/LivingVillage.Desktop/CjkGlyphAtlas.fs + Assets/cjk-glyphs.txt + Assets/cjk-glyph-atlas.png + (真字补 17 字:东 头 西 北 巷 口 盐 巴 米 布 茶 柴 涨 两 文 落 些;生成式脚本重出,禁程序字形) +- src/LivingVillage.Desktop/Game.fs(LV_P65_SHOT 钩子,additive) +- scripts/analyze-p65.py(新) +- src/LivingVillage.Desktop.Tests/P65RumorInquiryTests.fs(新,6 例)+ .fsproj 挂载 +- src/LivingVillage.Desktop.Tests/PrototypeTests.fs(字形表 323→340、rowCount 21→22、height 504→528) +- docs/evidence/p65-{menu,answer}.png、p65-analysis.txt、p65-run-log.txt + +红先 +- 先建 P65RumorInquiryTests 引用未实现的 marketInquiryText/freshReliableRumors/inquiryRumor + → 编译红(FS0039,仅缺 API)。 + +构建/测试 +- dotnet build LivingVillage.sln -c Release → 0 错误,1 警告(P40ArtTests.fs FS0760,既有) +- dotnet test LivingVillage.sln -c Release --no-build → Desktop 320/320(314→+6),Kernel 119/119 +- 新例覆盖:命中既有谣言池出中文短答 / 同输入两次逐字符串一致 / 陈旧·微弱·空池均 None / + 仅货郎 OfferTrade 出短答 / 非货郎回归逐字节 + 货郎 status=inquiry| 透出 statusText / + save v2 头逐字节不变 + v3 round-trip 稳定 / 穷举地点·货品·涨落组合无缺字。 + +证据实跑(真实 Desktop + xvfb 1280x720) +LV_P65_SHOT=1 LV_LEGACY_MAP=1 xvfb-run -a -s "-screen 0 1280x720x24"(exit=0) +log: + new-game=ok seed=42 npcs=30 occupation=货郎 tick=0 + p65-shot=p65-menu panel=dialogue status=dialogue target=NpcId 0 options=6; press 1-6 + p65-regression status=dialogue intent=OfferTrade response=Bargaining + p65-inquiry rumors=1 fresh=1 hit=true text1=今日东头的布价涨了两文 text2=今日东头的布价涨了两文 equal=true + p65-shot=p65-answer panel=world status=inquiry|今日东头的布价涨了两文 + +scripts/analyze-p65.py → verdict: PASS + p65-menu 1280x720 mean=78.7 std=32.4 黑块=0 白块=0 + p65-answer 1280x720 mean=83.5 std=30.7 黑块=0 白块=0 + sha256(text1)=14f359b3777162f5a69d2823b114d7005a9bb321dd9f3617c3a0a7ada2336645 + 状态药丸带亮色文字:answer bright=752 span=173px(≈11 CJK@scale2=176px)/ menu bright=0 + 两帧 mean_abs_diff=6.9;缺字空白位=0 +check-no-white-blocks.py 两帧 → failures=0 + +确定性 +--performance-baseline ×3 → 953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C ×3,PASS + +存档兼容 +- save(None) 仍 `LV_WORLD_SAVE_V2` 且 = saveWith None(逐字节,单测断言)。 +- v3(含货郎 sidecar)写盘→loadFromFileWith round-trip:kind=peddler、背包条数稳定;未新增 sidecar 字段。 + +边界 +- 未改 Kernel/Sim.fs、HUD/对话框结构、地图 seed、外部素材;未 push。 +- 逐帧「字形是否清晰可辨」的 vision 复核本写入模型不支持图像输入(P60 G3),留 Hermes 侧; + 本单给出可量化像素判据(药丸带亮色文字像素+铺满宽度)与字形覆盖单测。 diff --git a/scripts/analyze-p65.py b/scripts/analyze-p65.py new file mode 100644 index 0000000..5251a0f --- /dev/null +++ b/scripts/analyze-p65.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""P65(职业切片五)证据分析:货郎「打听行情」(提议交易)读取既有 World.Rumors 池。 + +判据(沿用既有 analyze 脚本风格:读真实实跑日志 + 逐帧像素统计): + 1. 命中谣言数:`p65-inquiry rumors>=1 fresh>=1 hit=true`——货郎对话确实读到既有谣言池, + 且命中一条新鲜可用谣言(非伪造、非空池)。 + 2. 同输入两次一致:`text1 == text2` 且 `equal=true`;两串皆非空、以既有涨落词收尾, + 含「的」(地点/货品短答结构),并打印 sha256 以便复跑比对。 + 3. 非货郎回归逐字节:`p65-regression status=dialogue intent=OfferTrade response=Bargaining` + ——农夫跑同一条 OfferTrade,状态仍是旧 dialog 文案,无 `inquiry|` 前缀。 + 4. 两帧:`p65-menu`(panel=dialogue,菜单含「3 提议交易」可选)+ `p65-answer` + (panel=world,status=`inquiry|<中文行情短答>`)。 + 5. 行情短答确实画到 HUD:状态药丸带(x 8..210, y 41..69)内亮色文字像素, + 答题帧 >= 300 且横向铺满 >= 150px(≈11 个 CJK @ scale2 = 176px),菜单帧 ≈ 0。 + 6. 两帧均 1280x720、非空、无 64x64 纯白块、无 24x24 纯黑块,且两两不同。 + 7. 缺字空白位:中文经 CjkGlyphAtlas 绘制,P65RumorInquiryTests 已断言行情短答覆盖。 + (逐帧「字形是否清晰可辨」的 vision 复核由 Hermes 侧完成,非本脚本判据。) + +Usage: python3 scripts/analyze-p65.py <record-dir> <out-txt> +Exit 0 pass / 1 fail / 2 usage. +""" + +import hashlib +import re +import sys +from pathlib import Path + +import numpy as np +from PIL import Image + +REGRESSION_RE = re.compile(r"^p65-regression status=(.+)$", re.MULTILINE) +INQUIRY_RE = re.compile( + r"^p65-inquiry rumors=(\d+) fresh=(\d+) hit=(\w+) text1=(.*?) text2=(.*?) equal=(\w+)$", + re.MULTILINE, +) +SHOT_RE = re.compile( + r"^p65-shot=(\S+) panel=(\S+) avatar=\(([\-\d.]+),([\-\d.]+)\) status=(.*)$", + re.MULTILINE, +) +FRAMES = ["p65-menu", "p65-answer"] +LEGACY_REGRESSION = "dialogue intent=OfferTrade response=Bargaining" +MOVEMENTS = ("涨了两文", "涨了一文", "还稳当", "有些回落") + +PILL = (41, 69, 8, 210) # y0, y1, x0, x1 +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 pill_ink(img): + y0, y1, x0, x1 = PILL + band = img[y0:y1, x0:x1] + mask = (band[:, :, 0] > 170) & (band[:, :, 1] > 170) & (band[:, :, 2] > 170) + count = int(mask.sum()) + if count == 0: + return count, 0 + cols = np.where(mask.any(axis=0))[0] + span = int(cols.max() - cols.min() + 1) + return count, span + + +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") + failures = [] + lines = [ + "P65(职业切片五)证据分析 — 真实 Desktop 实跑 xvfb 1280x720", + "钩子:LV_P65_SHOT=1 LV_LEGACY_MAP=1(货郎真实开局),2 帧:p65-menu / p65-answer", + "路径:真实闲聊写谣言池 → 真实 Interact 开菜单 → 提议交易读既有谣言出中文行情短答", + "", + ] + + m = REGRESSION_RE.search(log) + if not m: + failures.append("log missing p65-regression") + lines.append("regression: <missing>") + else: + status = m.group(1).strip() + lines.append(f"non-peddler regression status: {status}") + if status != LEGACY_REGRESSION: + failures.append(f"non-peddler status {status!r} != legacy {LEGACY_REGRESSION!r}") + lines.append("") + + m = INQUIRY_RE.search(log) + if not m: + failures.append("log missing p65-inquiry") + else: + rumors, fresh, hit, text1, text2, equal = m.groups() + rumors, fresh = int(rumors), int(fresh) + lines.append( + f"inquiry: rumors={rumors} fresh={fresh} hit={hit} equal={equal} " + f"text1={text1!r} text2={text2!r}" + ) + lines.append(f"sha256(text1)={hashlib.sha256(text1.encode('utf-8')).hexdigest()}") + if rumors < 1: + failures.append(f"rumors {rumors} < 1") + if fresh < 1: + failures.append(f"fresh {fresh} < 1") + if hit != "true": + failures.append(f"hit {hit} != true") + if equal != "true" or text1 != text2: + failures.append("text1 != text2 (same input must be identical)") + if not text1: + failures.append("empty inquiry text") + if not text1.endswith(MOVEMENTS): + failures.append(f"text1 {text1!r} does not end with a movement word") + if "的" not in text1: + failures.append(f"text1 {text1!r} missing place/good separator") + lines.append("") + + shots = {mm.group(1): mm for mm in SHOT_RE.finditer(log)} + 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("") + + for name in ("p65-menu", "p65-answer"): + if name not in shots: + failures.append(f"log missing p65-shot={name}") + continue + lines.append("log: " + shots[name].group(0)) + if "p65-menu" in shots and shots["p65-menu"].group(2) != "dialogue": + failures.append("p65-menu panel != dialogue") + if "p65-answer" in shots: + answer_status = shots["p65-answer"].group(5) + if not answer_status.startswith("inquiry|"): + failures.append(f"p65-answer status {answer_status!r} lacks inquiry| prefix") + lines.append("") + + if "p65-menu" in stats and "p65-answer" in stats: + menu_ink, _ = pill_ink(stats["p65-menu"]) + answer_ink, answer_span = pill_ink(stats["p65-answer"]) + lines.append( + f"status pill ink (x {PILL[2]}..{PILL[3]}, y {PILL[0]}..{PILL[1]}): " + f"answer bright={answer_ink} span={answer_span}px, menu bright={menu_ink}" + ) + if answer_ink < 300: + failures.append(f"answer pill ink {answer_ink} < 300 (answer text not drawn)") + if answer_span < 150: + failures.append(f"answer pill ink span {answer_span} < 150px (text too short)") + if menu_ink > 20: + failures.append(f"menu pill ink {menu_ink} > 20 (menu frame should not show the pill)") + lines.append("") + + for i in range(len(FRAMES)): + for j in range(i + 1, len(FRAMES)): + a, b = FRAMES[i], FRAMES[j] + if a in stats and b in stats: + diff = float(np.abs(stats[a].astype(np.int32) - stats[b].astype(np.int32)).mean()) + lines.append(f"{a} vs {b} mean_abs_diff={diff:.1f}") + if diff < 2.0: + failures.append(f"{a} and {b} are near-identical") + lines.append("") + + lines.append("缺字空白位 total=0(行情短答经 CjkGlyphAtlas 绘制;P65RumorInquiryTests 断言覆盖)") + 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 9f60cce..5d195f7 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -37,6 +37,7 @@ <Compile Include="P61GlyphCoverageTests.fs" /> <Compile Include="P62EvidenceTests.fs" /> <Compile Include="P64EvidenceTests.fs" /> + <Compile Include="P65RumorInquiryTests.fs" /> <Compile Include="SampleTests.fs" /> </ItemGroup> diff --git a/src/LivingVillage.Desktop.Tests/P65RumorInquiryTests.fs b/src/LivingVillage.Desktop.Tests/P65RumorInquiryTests.fs new file mode 100644 index 0000000..21ad47b --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P65RumorInquiryTests.fs @@ -0,0 +1,166 @@ +namespace LivingVillage.Desktop.Tests + +open System +open System.IO +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim +open LivingVillage.Desktop + +/// P65 职业切片五:货郎「打听行情」(OfferTrade 意图)读取既有 Kernel `World.Rumors` 池, +/// 产出中文行情短答。全部为纯判定 + 文案,不新增状态、不改 `Sim.fs` 数值路径。 +/// 红先用例:`Occupation.marketInquiryText` / `freshReliableRumors` / `inquiryRumor` 尚未实现。 +[<TestClass>] +type P65RumorInquiryTests () = + + let readyNpc (npc: Npc) : Npc = + { npc with + Mind = + { npc.Mind with + Action = Wander + Target = actionTarget Wander + ActionAge = minActionTicks + EffectDone = true } } + + let rumor (id: int64) (tick: int64) (depth: int) (strength: float32) : RumorEvent = + { Id = RumorId id + Tick = tick + DayIndex = tick / ticksPerDay + OriginTick = tick + Source = NpcId 0 + Narrator = NpcId 0 + Receiver = NpcId 1 + Parent = None + Depth = depth + Strength = strength } + + let worldWithRumors (tick: int64) (rumors: RumorEvent list) : World = + let baseWorld = initialWorldN 42UL 4 + { baseWorld with + Tick = tick + Time = float tick * dtSeconds + Npcs = baseWorld.Npcs |> Array.map readyNpc + Rumors = rumors + RumorCount = rumors.Length + RumorOldestDay = + rumors + |> List.fold (fun acc entry -> min acc entry.DayIndex) Int64.MaxValue } + + let avatarAtNpc (world: World) : World = + { world with Avatar = { world.Avatar with Pos = world.Npcs.[0].Pos } } + + let peddlerAnswer (world: World) : string option = + Occupation.marketInquiryText (Some Occupation.Peddler) OfferTrade world + + [<TestMethod>] + member _.PeddlerOfferTradeReadsExistingRumorPoolAndAnswersInChinese () = + let tick = ticksPerDay + let world = worldWithRumors tick [ rumor 0L tick 0 1.0f ] + + Assert.AreEqual<int>(1, Occupation.freshReliableRumors world |> List.length) + Assert.IsTrue(Occupation.inquiryRumor world |> Option.isSome, "应命中一条新鲜可用谣言") + + match peddlerAnswer world with + | None -> Assert.Fail("货郎提议交易应产出行情短答") + | Some text -> + Assert.IsTrue(text.Length > 0, "行情短答不得为空") + Assert.IsTrue( + text |> Seq.forall CjkGlyphAtlas.contains, + sprintf "行情短答含缺字:%s" text) + + // 穷举全部地点/货品(id 索引 mod 6)与涨落分档(强度阈值)组合,断言无缺字。 + let strengths = [ 1.0f; 0.6f; 0.3f; 0.13f ] + for id in 0L .. 5L do + for depth in 0 .. 1 do + for strength in strengths do + match peddlerAnswer (worldWithRumors tick [ rumor id tick depth strength ]) with + | None -> Assert.Fail(sprintf "id=%d depth=%d strength=%.1f 应有短答" id depth strength) + | Some answer -> + Assert.IsTrue( + answer |> Seq.forall CjkGlyphAtlas.contains, + sprintf "行情短答含缺字:%s" answer) + + [<TestMethod>] + member _.SameInquiryInputProducesIdenticalText () = + let tick = ticksPerDay + let first = peddlerAnswer (worldWithRumors tick [ rumor 3L tick 1 0.6f ]) + let second = peddlerAnswer (worldWithRumors tick [ rumor 3L tick 1 0.6f ]) + let repeated = peddlerAnswer (worldWithRumors tick [ rumor 3L tick 1 0.6f ]) + + Assert.IsTrue(first.IsSome) + Assert.AreEqual<string option>(first, second) + Assert.AreEqual<string option>(first, repeated) + + [<TestMethod>] + member _.InquiryIgnoresStaleOrWeakOrMissingRumors () = + let tick = ticksPerDay * 10L + let stale = worldWithRumors tick [ rumor 0L (tick - rumorFreshnessTicks - 1L) 0 1.0f ] + let weak = worldWithRumors tick [ rumor 0L tick 0 0.01f ] + let empty = worldWithRumors tick [] + + for world in [ stale; weak; empty ] do + Assert.AreEqual<string option>(None, peddlerAnswer world) + + [<TestMethod>] + member _.OnlyPeddlerOfferTradeProducesInquiry () = + let tick = ticksPerDay + let world = worldWithRumors tick [ rumor 0L tick 0 1.0f ] + + Assert.AreEqual<string option>(None, Occupation.marketInquiryText (Some Occupation.Farmer) OfferTrade world) + Assert.AreEqual<string option>(None, Occupation.marketInquiryText (Some Occupation.Fisher) OfferTrade world) + Assert.AreEqual<string option>(None, Occupation.marketInquiryText (Some Occupation.Scholar) OfferTrade world) + Assert.AreEqual<string option>(None, Occupation.marketInquiryText None OfferTrade world) + Assert.AreEqual<string option>(None, Occupation.marketInquiryText (Some Occupation.Peddler) SmallTalk world) + Assert.AreEqual<string option>(None, Occupation.marketInquiryText (Some Occupation.Peddler) AskHelp world) + + [<TestMethod>] + member _.NonPeddlerDialogueStatusStaysByteIdenticalWhilePeddlerShowsInquiry () = + let tick = ticksPerDay + let baseWorld = worldWithRumors tick [ rumor 0L tick 0 1.0f ] |> avatarAtNpc + + let openAndAsk (task: Occupation.State option) = + let view = { M5Interaction.initial with Task = task; Seed = 42UL } + let afterInteract, opened = M5Interaction.apply M5Command.Interact baseWorld view + Assert.AreEqual<M5Panel>(DialoguePanel, opened.Panel) + let _, answered = M5Interaction.apply M5Command.Intent3 afterInteract opened + answered + + // 无职业 / 非货郎:状态逐字节等于既有 OfficeTrade 文案(旧行为回归)。 + Assert.AreEqual<string>( + "dialogue intent=OfferTrade response=Bargaining", + (openAndAsk None).Status) + Assert.AreEqual<string>( + "dialogue intent=OfferTrade response=Bargaining", + (openAndAsk (Some(Occupation.stateOf Occupation.Farmer))).Status) + + // 货郎:状态改为 inquiry|<中文行情短答>,并原样透出到情境状态条。 + let peddler = openAndAsk (Some(Occupation.stateOf Occupation.Peddler)) + Assert.IsTrue(peddler.Status.StartsWith("inquiry|"), peddler.Status) + let answer = peddler.Status.Substring("inquiry|".Length) + Assert.IsTrue(answer.Length > 0) + Assert.IsTrue(answer |> Seq.forall CjkGlyphAtlas.contains, answer) + Assert.AreEqual<string>(answer, M5Interaction.statusText peddler) + + [<TestMethod>] + member _.SaveV2HeaderUnchangedAndV3RoundTripStable () = + let tick = ticksPerDay + let world = worldWithRumors tick [ rumor 0L tick 0 1.0f ] + + let v2 = WorldSave.save world + Assert.IsTrue(v2.StartsWith("LV_WORLD_SAVE_V2")) + Assert.AreEqual<string>(v2, WorldSave.saveWith None world) + + let peddler = Occupation.stateOf Occupation.Peddler + let v3 = WorldSave.saveWith (Some peddler) world + Assert.IsTrue(v3.StartsWith("LV_WORLD_SAVE_V3")) + + let path = Path.Combine(Path.GetTempPath(), sprintf "lv-p65-%s.sav" (Guid.NewGuid().ToString("N"))) + try + WorldSave.saveToFileWith (Some peddler) path world + match WorldSave.loadFromFileWith path with + | Ok (_, Some loaded) -> + Assert.AreEqual<string>("peddler", Occupation.saveToken loaded.Profile.Kind) + Assert.AreEqual<int>(peddler.Backpack.Length, loaded.Backpack.Length) + | other -> Assert.Fail(sprintf "v3 round-trip failed: %A" other) + finally + if File.Exists path then File.Delete path diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs index da81f0e..a49cecd 100644 --- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs +++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs @@ -1096,14 +1096,14 @@ type PrototypeTests () = [<TestMethod>] member _.CjkGlyphAtlasHasAStableCharacterTableAndGeometry () = - Assert.AreEqual<int>(323, characters.Length) + Assert.AreEqual<int>(340, characters.Length) CollectionAssert.AreEqual(characters, characters |> Array.sort) Assert.AreEqual<int>(characters.Length, characters |> Array.distinct |> Array.length) Assert.AreEqual<int>(16, columns) Assert.AreEqual<int>(24, cellPixels) - Assert.AreEqual<int>(21, rowCount) + Assert.AreEqual<int>(22, rowCount) Assert.AreEqual<int>(384, pixelWidth) - Assert.AreEqual<int>(504, pixelHeight) + Assert.AreEqual<int>(528, pixelHeight) Assert.AreEqual<string>("Assets/cjk-glyph-atlas.png", assetRelativePath) Assert.AreEqual<string>("Assets/cjk-glyphs.txt", manifestRelativePath) diff --git a/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png Binary files differindex ed1bf37..69662c0 100644 --- a/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png +++ b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png diff --git a/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt b/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt index 0191fc0..5c37717 100644 --- a/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt +++ b/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt @@ -4,6 +4,8 @@ 不 与 业 +东 +两 中 为 主 @@ -16,6 +18,7 @@ 事 于 互 +些 交 人 今 @@ -58,6 +61,7 @@ 助 匀 包 +北 单 卖 南 @@ -67,6 +71,7 @@ 友 取 受 +口 只 可 各 @@ -88,6 +93,7 @@ 天 夫 失 +头 好 始 子 @@ -108,7 +114,10 @@ 差 己 已 +巴 +巷 币 +布 帮 常 平 @@ -155,6 +164,7 @@ 效 敌 数 +文 料 新 方 @@ -181,6 +191,7 @@ 板 林 果 +柴 标 格 档 @@ -193,6 +204,7 @@ 江 没 法 +涨 淡 清 渔 @@ -210,6 +222,7 @@ 留 白 的 +盐 目 看 真 @@ -224,6 +237,7 @@ 竹 笑 第 +米 粮 精 系 @@ -246,10 +260,13 @@ 能 自 范 +茶 获 菜 +落 衅 行 +西 观 认 讨 diff --git a/src/LivingVillage.Desktop/CjkGlyphAtlas.fs b/src/LivingVillage.Desktop/CjkGlyphAtlas.fs index 8b42596..ab03039 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 6b52225..862e1de 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -337,6 +337,12 @@ type LivingVillageGame() as this = let mutable p64PendingName = "" let mutable p64Pending = false let mutable p64MoveFrames = 0 + // P65 证据钩子(职业切片五):货郎真实开局 → 与村民真实闲聊写入既有 World.Rumors → + // 真实 Interact 打开对话菜单(拍菜单帧)→ 选「提议交易」询价,拍带中文行情短答的帧。 + let p65ShotMode = Environment.GetEnvironmentVariable("LV_P65_SHOT") = "1" + let mutable p65Step = 0 + let mutable p65PendingName = "" + let mutable p65Pending = false // P45 证据钩子:LV_P45_SHOT=1 时拍角色 idle / 行走朝下 / 行走朝右 三帧(真实游戏截图)。 let p45ShotMode = Environment.GetEnvironmentVariable("LV_P45_SHOT") = "1" let mutable p45Step = 0 @@ -1158,6 +1164,75 @@ type LivingVillageGame() as this = p64PendingName world.Tick world.Avatar.Pos.X world.Avatar.Pos.Y camera.X camera.Y m5View.HomeMode p64Step <- p64Step + 1 + /// P65 证据:货郎真实开局 → 闲聊写入既有谣言池 → 真实 Interact 开菜单 → 提议交易询价。 + member private this.PrepareP65Shot () = + 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 + let placeAtNpc () = + let npc = world.Npcs.[0] + world <- { world with Avatar = { world.Avatar with Pos = npc.Pos } } + if not p65Pending then + match p65Step with + | 0 -> + setHour 12.0 + placeAtNpc () + ask M5Command.Interact + this.CenterCamera() + p65PendingName <- "p65-menu" + p65Pending <- true + | 1 -> + // 真实闲聊一次:把玩家对话写入既有 World.Rumors(最新在前),供询价读取。 + ask M5Command.Intent1 + placeAtNpc () + ask M5Command.Interact + // 非货郎回归:同世界跑农夫 Interact + Intent3,应仍得旧 dialogue 文案(无 inquiry|)。 + let farmerView = + { M5Interaction.initial with + Task = Some(Occupation.stateOf Occupation.Farmer) + Seed = m5View.Seed } + let farmerWorld, farmerOpen = M5Interaction.apply M5Command.Interact world farmerView + let _, farmerAnswered = M5Interaction.apply M5Command.Intent3 farmerWorld farmerOpen + printfn "p65-regression status=%s" farmerAnswered.Status + // 同一世界两次查询:证明同输入逐字符串一致(禁随机)。 + let answer1 = Occupation.marketInquiryText (Some Occupation.Peddler) Sim.OfferTrade world + let answer2 = Occupation.marketInquiryText (Some Occupation.Peddler) Sim.OfferTrade world + printfn + "p65-inquiry rumors=%d fresh=%d hit=%b text1=%s text2=%s equal=%b" + world.Rumors.Length + (Occupation.freshReliableRumors world |> List.length) + (Occupation.inquiryRumor world |> Option.isSome) + (answer1 |> Option.defaultValue "<none>") + (answer2 |> Option.defaultValue "<none>") + (answer1 = answer2) + p65Step <- p65Step + 1 + | 2 -> + ask M5Command.Intent3 + p65PendingName <- "p65-answer" + p65Pending <- true + | _ -> + printfn "p65-shot=done" + this.Exit() + + /// P65 证据:落盘并打印帧名/面板/状态,供 analyze 核验「货郎 status=inquiry|...」。 + member private this.CaptureP65Shot () = + if p65Pending then + p65Pending <- false + System.IO.Directory.CreateDirectory recordDirectory |> ignore + this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p65PendingName) + printfn + "p65-shot=%s panel=%s avatar=(%.0f,%.0f) status=%s" + p65PendingName + (M5Interaction.panelName m5View) + world.Avatar.Pos.X + world.Avatar.Pos.Y + m5View.Status + p65Step <- p65Step + 1 + member private this.PrepareP45Shot () = let baseTick = M6Presentation.resolveStartTick (Some 12.0) false let setTick (tick: int64) = @@ -2163,6 +2238,16 @@ type LivingVillageGame() as this = else this.DispatchMenuInput Confirm elif menu.Page = Playing then this.PrepareP64Shot() + elif p65ShotMode 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.Peddler) + if menu.Selected <> targetIndex then this.DispatchMenuInput Down + else this.DispatchMenuInput Confirm + elif menu.Page = Playing then + this.PrepareP65Shot() elif p45ShotMode then if menu.Page = MainMenu then this.DispatchMenuInput Confirm @@ -2639,6 +2724,8 @@ type LivingVillageGame() as this = this.CaptureP62Shot() if p64ShotMode && menu.Page = Playing then this.CaptureP64Shot() + if p65ShotMode && menu.Page = Playing then + this.CaptureP65Shot() 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 75575dd..ba792d1 100644 --- a/src/LivingVillage.Desktop/Interaction.fs +++ b/src/LivingVillage.Desktop/Interaction.fs @@ -318,6 +318,12 @@ module M5Interaction = match view.Task with | Some task -> Occupation.biasResponse (Some task.Profile.Kind) | None -> Sim.responseFor + // P65:货郎打听行情读**对话前**既有谣言池,产出中文行情短答。 + // 非货郎 / 无职业 / 无命中谣言 → None,落回既有 dialogue 文案(旧行为逐字节不变)。 + let inquiry = + match view.Task with + | Some task -> Occupation.marketInquiryText (Some task.Profile.Kind) intent world + | None -> None match Sim.chooseDialogueWith respond menu.Target intent world with | DialogueSucceeded(outcome, next) -> // P-d 剧情线:每次玩家对话至多推进一段,摘要写年鉴(StoryAnnal)。 @@ -352,9 +358,12 @@ module M5Interaction = PromptTargetPos = None Task = advanced Status = - match taskMessage with - | Some message -> "task|" + message - | None -> sprintf "dialogue intent=%A response=%A" outcome.Intent outcome.Response + match inquiry with + | Some text -> "inquiry|" + text + | None -> + 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, @@ -585,6 +594,7 @@ module M5Interaction = | value when value.StartsWith("chronicle") -> "年鉴已打开" | "task panel open" -> "任务面板已打开" | value when value.StartsWith("task|") -> value.Substring("task|".Length) + | value when value.StartsWith("inquiry|") -> value.Substring("inquiry|".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") -> "当前没有可用对话" diff --git a/src/LivingVillage.Kernel/Occupation.fs b/src/LivingVillage.Kernel/Occupation.fs index 66ed4f2..cb9e25b 100644 --- a/src/LivingVillage.Kernel/Occupation.fs +++ b/src/LivingVillage.Kernel/Occupation.fs @@ -362,6 +362,67 @@ module Occupation = | None -> baseResponse | Some kind -> remapResponse kind intent baseResponse + // ---- P65 职业切片五:货郎「打听行情」(OfferTrade 意图)读既有 World.Rumors 池 ---- + // 纯判定 + 文案:不新增任何状态、不动 Sim.fs 数值路径。行情短答只由既有谣言工作集 + // (玩家对话已写入,最新在前)按同一套新鲜度/可信度口径派生;同一世界同一次查询可重现。 + + let private inquiryPlaces = [| "东头"; "西头"; "南头"; "北头"; "桥头"; "巷口" |] + let private inquiryGoods = [| "盐巴"; "米价"; "鱼价"; "布价"; "茶价"; "柴价" |] + + /// 同一套口径(对齐 Sim.latestRumorFor):在新鲜窗口内、Tick 不晚于当前、 + /// 且衰减后强度不低于门槛的谣言,才算「可打听的行情」。无接收者过滤(打听面向全村)。 + let freshReliableRumors (world: Sim.World) : Sim.RumorEvent list = + world.Rumors + |> List.filter (fun rumor -> + world.Tick - rumor.Tick <= Sim.rumorFreshnessTicks + && rumor.Tick <= world.Tick + && Sim.rumorStrengthAt world.Tick rumor >= Sim.rumorMinimumStrength) + + /// 打听命中的目标谣言:衰减强度最大者;平手取 Tick 更大者,再平手取 RumorId 更大者。 + /// 全序比较保证同输入完全相同结果(无随机、无哈希遍历顺序依赖)。 + let inquiryRumor (world: Sim.World) : Sim.RumorEvent option = + let stronger (candidate: Sim.RumorEvent) (best: Sim.RumorEvent) = + let candidateStrength = Sim.rumorStrengthAt world.Tick candidate + let bestStrength = Sim.rumorStrengthAt world.Tick best + if candidateStrength > bestStrength then true + elif candidateStrength < bestStrength then false + elif candidate.Tick <> best.Tick then candidate.Tick > best.Tick + else candidate.Id > best.Id + + match freshReliableRumors world with + | [] -> None + | first :: rest -> + Some(rest |> List.fold (fun best candidate -> if stronger candidate best then candidate else best) first) + + let private movementOf (strength: float32) : string = + if strength >= 0.75f then "涨了两文" + elif strength >= 0.5f then "涨了一文" + elif strength >= 0.25f then "还稳当" + else "有些回落" + + /// 由命中谣言派生一条确定性行情短答:新鲜度前缀 + 地点 + 货品 + 涨落。 + /// 地点/货品索引只由 (RumorId, Depth) 决定,涨落由衰减强度分档,全程整数/纯函数。 + let private inquiryTextOf (world: Sim.World) (rumor: Sim.RumorEvent) : string = + let (Sim.RumorId id) = rumor.Id + let index = int ((id + int64 rumor.Depth) % 6L) + let place = inquiryPlaces.[index] + let good = inquiryGoods.[(index + 3) % 6] + let recency = if world.Tick - rumor.Tick < Sim.ticksPerDay then "今日" else "近日" + sprintf "%s%s的%s%s" recency place good (movementOf (Sim.rumorStrengthAt world.Tick rumor)) + + /// P65:货郎「提议交易」(= 打听行情/询价)时产出的行情短答。 + /// 仅 `Some Peddler + OfferTrade + 命中可用谣言` 返回 Some;其余组合 None, + /// 让非货郎/无职业路径逐字节退回既有 `dialogue intent=...` 文案。 + /// 与 `MarketInquiry` 当日任务共用同一「一次玩家对话事件」判定条件(见 satisfiesCompletion)。 + let marketInquiryText + (kind: Kind option) + (intent: Sim.DialogueIntent) + (world: Sim.World) + : string option = + match kind, intent with + | Some Peddler, Sim.OfferTrade -> inquiryRumor world |> Option.map (inquiryTextOf world) + | _ -> None + // ---- 轻剧情线(design §5 / P-d):三段式 接任务 → 走访 → 收束 ---- // 纯年鉴/对话驱动:每次玩家对话至多推进一段,产出只有(StoryStage 推进 + 年鉴摘要文本), // 由调用方写入世界。全部输入 = (seed, 世界 tick, 关系状态, 职业状态),纯函数、确定性。 |
