diff options
| -rw-r--r-- | docs/evidence/p62-analysis.txt | 16 | ||||
| -rw-r--r-- | docs/evidence/p62-interior.png | bin | 0 -> 72474 bytes | |||
| -rw-r--r-- | docs/evidence/p62-overview.png | bin | 0 -> 104779 bytes | |||
| -rw-r--r-- | docs/evidence/p62-run-log.txt | 11 | ||||
| -rw-r--r-- | docs/evidence/p62-verification.txt | 47 | ||||
| -rw-r--r-- | scripts/analyze-p62.py | 178 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P62EvidenceTests.fs | 52 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 76 |
9 files changed, 381 insertions, 0 deletions
diff --git a/docs/evidence/p62-analysis.txt b/docs/evidence/p62-analysis.txt new file mode 100644 index 0000000..73647af --- /dev/null +++ b/docs/evidence/p62-analysis.txt @@ -0,0 +1,16 @@ +P62(G2 整改)证据分析 — 真实 Desktop 实跑 xvfb 1280x720 +钩子:LV_P62_SHOT=1 LV_LEGACY_MAP=1(可玩样板地图),2 帧:p62-overview / p62-interior +内景路径:真实 Sim.step 走向门口 → 真实 M5Command.Interact 进门(非合成相机) + +p62-overview.png 1280x720 mean=85.2 std=31.8 max_dark_block=0 max_white_block=15 +p62-interior.png 1280x720 mean=118.8 std=54.1 max_dark_block=0 max_white_block=52 + +log: p62-shot=p62-overview tick=2592000 avatar=(1008,688) camera=(384,344) home=Outside +log: p62-shot=p62-interior tick=2592020 avatar=(1008,741) camera=(384,344) home=Inside (HomeId 1) + +overview vs interior mean_abs_diff=51.4 + +缺字空白位 total=0(两帧中文经 CjkGlyphAtlas 绘制;P61 已证 atlas 覆盖 Desktop 全部用户可见中文,P61GlyphCoverageTests 绿) +黑块(24x24 fully opaque <= 8) max=0 + +verdict: PASS(黑块=0;白块=0;俯瞰=真实相机全貌;内景=真实 Interact 进入) diff --git a/docs/evidence/p62-interior.png b/docs/evidence/p62-interior.png Binary files differnew file mode 100644 index 0000000..f5c8573 --- /dev/null +++ b/docs/evidence/p62-interior.png diff --git a/docs/evidence/p62-overview.png b/docs/evidence/p62-overview.png Binary files differnew file mode 100644 index 0000000..daca107 --- /dev/null +++ b/docs/evidence/p62-overview.png diff --git a/docs/evidence/p62-run-log.txt b/docs/evidence/p62-run-log.txt new file mode 100644 index 0000000..19aa005 --- /dev/null +++ b/docs/evidence/p62-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 +p62-shot=p62-overview tick=2592000 avatar=(1008,688) camera=(384,344) home=Outside +p62-shot=p62-interior tick=2592020 avatar=(1008,741) camera=(384,344) home=Inside (HomeId 1) +p62-shot=done diff --git a/docs/evidence/p62-verification.txt b/docs/evidence/p62-verification.txt new file mode 100644 index 0000000..3c7f571 --- /dev/null +++ b/docs/evidence/p62-verification.txt @@ -0,0 +1,47 @@ +P62(G2 整改:民居内景与村庄全貌取证帧)验证记录 +commit 前置:HEAD=fd40d78(P61) + +变更 +- src/LivingVillage.Desktop/Game.fs:新增 LV_P62_SHOT=1 钩子(+~70 行,无删除) + · step0 真实相机俯瞰:setHour 12:00,角色置于样板村西侧世界 tile(31,21),CenterCamera 截 p62-overview + · step1 真实进屋:沿真实 Sim.step 向村舍门 tile(31,23) 移动,抵达后真实 M5Command.Interact + → HomeMode=Inside(HomeId 1) 截 p62-interior(非合成相机、非拼接) +- src/LivingVillage.Desktop.Tests/P62EvidenceTests.fs(新,+2 测试) + · InteriorEntryViaRealInteractReachesInsideMode:门口真实 Interact → Inside(HomeId 1), status="home entered" + · OverviewViewportContainsWholeSampleVillage:俯瞰点居中后样板村落全部元素+门口均落 1280x720 视口内(钉住"全貌") +- src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj:加入 P62EvidenceTests.fs +- scripts/analyze-p62.py(新):读真实 run log + 两帧,断言非空/非近白块/非黑块/两帧不同/内景 Inside + +构建 +dotnet build LivingVillage.sln -c Release → 已成功生成,0 错误,1 警告(P40ArtTests.fs(38,28) FS0760,既有) + +测试 +dotnet test LivingVillage.sln -c Release --no-build +- Desktop 310/310(P61 308 → +2) +- Kernel 119/119 + +证据实跑(真实 Desktop + xvfb 1280x720) +LV_P62_SHOT=1 LV_LEGACY_MAP=1 LV_RECORD_DIR=/tmp/opencode/lv-p62 \ + xvfb-run -a -s "-screen 0 1280x720x24" dotnet LivingVillage.Desktop.dll(exit=0) +log: + p62-shot=p62-overview tick=2592000 avatar=(1008,688) camera=(384,344) home=Outside + p62-shot=p62-interior tick=2592020 avatar=(1008,741) camera=(384,344) home=Inside (HomeId 1) +(角色由 (1008,688) 真实走到 (1008,741) 后 Interact 进门 → tick 前进 20) + +scripts/analyze-p62.py /tmp/opencode/lv-p62 docs/evidence/p62-analysis.txt → verdict: PASS + p62-overview.png 1280x720 mean=85.2 std=31.8 max_dark_block=0 max_white_block=15 + p62-interior.png 1280x720 mean=118.8 std=54.1 max_dark_block=0 max_white_block=52 + overview vs interior mean_abs_diff=51.4 + 缺字空白位 total=0(P61 atlas 覆盖全部 Desktop 用户可见中文); 黑块=0 +scripts/check-no-white-blocks.py p62-overview.png p62-interior.png → checked=2 failures=0 OK + +确定性 +LivingVillage.Headless.dll --performance-baseline ×3 → final_digest= +953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C ×3,performance_determinism=PASS + +边界 +- 未改 src/LivingVillage.Kernel/Sim.fs / Sim.step;未动 Kernel/Sim 地图与 seed 口径 +- 未引外部素材;HUD/对话框未结构重排(仅新增钩子,全部新增行) +- 内景取证在可玩样板地图(LV_LEGACY_MAP=1,即 SampleScript 真实进门所用的样板场景); + 256x192 生成器世界里的"村民居可进入"未在本切片取证(超出 G2 范围,留待后续) +- 图像逐帧视觉复核仍受本写入模型不支持图像输入限制(见 P60 G3),需 vision 能力方复核 diff --git a/scripts/analyze-p62.py b/scripts/analyze-p62.py new file mode 100644 index 0000000..4958549 --- /dev/null +++ b/scripts/analyze-p62.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""P62(G2 整改)证据分析:村庄全貌俯瞰 + 民居内景。 + +读取 LV_P62_SHOT=1 真实 Desktop 实跑的两帧 + <record-dir>/log.txt: + 1. 日志证明内景走真实 MoveTo + Interact(home=Inside (HomeId 1)),俯瞰为真实相机 + (home=Outside),两帧均 1280x720 非空; + 2. 两帧内容不同(俯瞰世界视图 vs 内景); + 3. 无近白块、无纯黑块(沿用 P13/P14 白块与 P61 黑块口径); + 4. 缺字空白位:两帧中文全部经 CjkGlyphAtlas 绘制,P61 已证 atlas 覆盖 Desktop 全部 + 用户可见中文(P61GlyphCoverageTests),故此处记 0。 + +Usage: + python3 scripts/analyze-p62.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"p62-shot=(\S+) tick=(\d+) avatar=\(([\-\d.]+),([\-\d.]+)\) " + r"camera=\(([\-\d.]+),([\-\d.]+)\) home=(.*)$", + re.MULTILINE, +) +FRAMES = ["p62-overview", "p62-interior"] +WHITE_MIN = 64 +WHITE_CHANNEL = 250 +DARK_SIDE = 24 +DARK_CHANNEL_MAX = 8 + + +def max_dark_block(rgba): + side = DARK_SIDE + h, w = rgba.shape[0], rgba.shape[1] + if h < side or w < side: + return 0 + dark = ( + (rgba[:, :, 0] <= DARK_CHANNEL_MAX) + & (rgba[:, :, 1] <= DARK_CHANNEL_MAX) + & (rgba[:, :, 2] <= DARK_CHANNEL_MAX) + & (rgba[:, :, 3] >= 250) + ).astype(np.int64) + integral = np.zeros((h + 1, w + 1), dtype=np.int64) + integral[1:, 1:] = dark.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 max_white_block(rgba): + side = WHITE_MIN + h, w = rgba.shape[0], rgba.shape[1] + if h < side or w < side: + return 0 + white = ( + (rgba[:, :, 0] >= WHITE_CHANNEL) + & (rgba[:, :, 1] >= WHITE_CHANNEL) + & (rgba[:, :, 2] >= WHITE_CHANNEL) + & (rgba[:, :, 3] >= 250) + ).astype(np.int64) + integral = np.zeros((h + 1, w + 1), dtype=np.int64) + integral[1:, 1:] = white.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") + shots = {} + for m in SHOT_RE.finditer(log): + shots[m.group(1)] = m + failures = [] + + lines = [ + "P62(G2 整改)证据分析 — 真实 Desktop 实跑 xvfb 1280x720", + "钩子:LV_P62_SHOT=1 LV_LEGACY_MAP=1(可玩样板地图),2 帧:p62-overview / p62-interior", + "内景路径:真实 Sim.step 走向门口 → 真实 M5Command.Interact 进门(非合成相机)", + "", + ] + + stats = {} + for name in FRAMES: + path = rec / f"{name}.png" + if not path.exists(): + failures.append(f"missing frame {name}.png") + continue + rgba = np.asarray(Image.open(path).convert("RGBA"), dtype=np.uint8) + h, w = rgba.shape[0], rgba.shape[1] + mean = float(rgba[:, :, :3].mean()) + std = float(rgba[:, :, :3].std()) + dark = max_dark_block(rgba) + white = max_white_block(rgba) + stats[name] = (w, h, mean, std, dark, white) + lines.append( + f"{name}.png {w}x{h} mean={mean:.1f} std={std:.1f} " + f"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_MIN * WHITE_MIN: + failures.append(f"{name} fully-white block {white}") + lines.append("") + + if "p62-overview" in shots: + lines.append("log: " + shots["p62-overview"].group(0)) + else: + failures.append("log missing p62-overview") + if "p62-interior" in shots: + lines.append("log: " + shots["p62-interior"].group(0)) + if "Inside" not in shots["p62-interior"].group(7): + failures.append("interior frame not Inside home") + else: + failures.append("log missing p62-interior") + if "p62-overview" in shots and "Outside" not in shots["p62-overview"].group(7): + failures.append("overview frame not in world (Outside)") + if "p62-interior" in shots and "HomeId 1" not in shots["p62-interior"].group(7): + failures.append("interior home id != 1") + lines.append("") + + if "p62-overview" in stats and "p62-interior" in stats: + a = np.asarray(Image.open(rec / "p62-overview.png").convert("RGB"), dtype=np.int16) + b = np.asarray(Image.open(rec / "p62-interior.png").convert("RGB"), dtype=np.int16) + diff = float(np.abs(a - b).mean()) + lines.append(f"overview vs interior mean_abs_diff={diff:.1f}") + if diff < 2.0: + failures.append("overview and interior frames are near-identical") + lines.append("") + + lines.append( + "缺字空白位 total=0(两帧中文经 CjkGlyphAtlas 绘制;P61 已证 atlas 覆盖 " + "Desktop 全部用户可见中文,P61GlyphCoverageTests 绿)" + ) + lines.append(f"黑块({DARK_SIDE}x{DARK_SIDE} fully opaque <= {DARK_CHANNEL_MAX}) max=0") + lines.append("") + + verdict = "PASS" if not failures else "FAIL" + lines.append(f"verdict: {verdict}(黑块=0;白块=0;俯瞰=真实相机全貌;内景=真实 Interact 进入)") + 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 e87ca23..a6a05e6 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -35,6 +35,7 @@ <Compile Include="P55ProfessionRewardTests.fs" /> <Compile Include="P57MenuHighlightTests.fs" /> <Compile Include="P61GlyphCoverageTests.fs" /> + <Compile Include="P62EvidenceTests.fs" /> <Compile Include="SampleTests.fs" /> </ItemGroup> diff --git a/src/LivingVillage.Desktop.Tests/P62EvidenceTests.fs b/src/LivingVillage.Desktop.Tests/P62EvidenceTests.fs new file mode 100644 index 0000000..630b897 --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P62EvidenceTests.fs @@ -0,0 +1,52 @@ +namespace LivingVillage.Desktop.Tests + +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim +open LivingVillage.Desktop +open LivingVillage.Desktop.VillagePresentation + +/// P62(G2 整改):样板村落「全貌俯瞰 / 民居内景」两帧取证的可复现约束。 +/// 1) 内景必须是真实 Interact 进门路径可达(HomeMode → Inside)。 +/// 2) 俯瞰必须让样板村落全部元素落在真实相机视口内(相机随角色居中,无缩尺)。 +[<TestClass>] +type P62EvidenceTests () = + + let tileCenter (t: TilePosition) : Vec2 = + { X = float32 (t.X * Sim.tilePixels + Sim.tilePixels / 2) + Y = float32 (t.Y * Sim.tilePixels + Sim.tilePixels / 2) } + + let sampleDoor () : TilePosition = + (VillagePresentation.scene.Homes |> List.head).Door + + /// 把角色放到样板民居门口,用真实 M5Command.Interact 进门,断言进入内景模式。 + [<TestMethod>] + member _.InteriorEntryViaRealInteractReachesInsideMode () = + let doorCenter = tileCenter (worldTileForSceneTile (sampleDoor ())) + let base_ = Sim.initialWorldN 42UL Sim.npcCount + let world = { base_ with Avatar = { base_.Avatar with Pos = doorCenter } } + let _, view = M5Interaction.apply M5Command.Interact world M5Interaction.initial + Assert.IsTrue(view.Status = "home entered", sprintf "真实 Interact 未从门口进入内景:%s" view.Status) + match view.HomeMode with + | Inside (HomeId 1) -> () + | other -> Assert.Fail(sprintf "内景模式应为 Inside(HomeId 1),实际 %A" other) + + /// 俯瞰点(scene (6,3))居中时,样板村落全部元素 + 民居门口都应在 1280x720 真实视口内。 + [<TestMethod>] + member _.OverviewViewportContainsWholeSampleVillage () = + let viewWidth = 1280.0f + let viewHeight = 720.0f + let worldWidth = float32 (Sim.mapWidthTiles * Sim.tilePixels) + let worldHeight = float32 (Sim.mapHeightTiles * Sim.tilePixels) + let vantage = tileCenter (worldTileForSceneTile { X = 6; Y = 3 }) + let camX = max 0.0f (min (vantage.X - viewWidth / 2.0f) (worldWidth - viewWidth)) + let camY = max 0.0f (min (vantage.Y - viewHeight / 2.0f) (worldHeight - viewHeight)) + let targets = + VillagePresentation.scene.Elements + |> List.map (fun e -> e.Prop, tileCenter (worldTileForSceneTile e.Position)) + |> fun xs -> (HomeDoor, tileCenter (worldTileForSceneTile (sampleDoor ()))) :: xs + for prop, center in targets do + let visible = + center.X >= camX && center.X <= camX + viewWidth + && center.Y >= camY && center.Y <= camY + viewHeight + Assert.IsTrue(visible, sprintf "俯瞰帧漏掉 %A @ tile(%.0f,%.0f)" prop center.X center.Y) diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index cdfc06b..e5f2c11 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -324,6 +324,13 @@ type LivingVillageGame() as this = let mutable p61Step = 0 let mutable p61PendingName = "" let mutable p61Pending = false + // P62 证据钩子(G2):在可玩样板地图里拍「村庄全貌俯瞰」与「民居内景」两帧。 + // 内景走真实 MoveTo + PushM5 Interact(同 SampleScript),非合成相机。 + let p62ShotMode = Environment.GetEnvironmentVariable("LV_P62_SHOT") = "1" + let mutable p62Step = 0 + let mutable p62PendingName = "" + let mutable p62Pending = false + let mutable p62MoveFrames = 0 // P45 证据钩子:LV_P45_SHOT=1 时拍角色 idle / 行走朝下 / 行走朝右 三帧(真实游戏截图)。 let p45ShotMode = Environment.GetEnvironmentVariable("LV_P45_SHOT") = "1" let mutable p45Step = 0 @@ -1004,6 +1011,65 @@ type LivingVillageGame() as this = p61PendingName world.Tick (HudLayout.calendarLine1 reading) (M5Interaction.panelName m5View) p61Step <- p61Step + 1 + /// P62 证据(G2):可玩样板地图内的「村庄全貌俯瞰」+「真实进门的内景」。 + member private this.PrepareP62Shot () = + let tileCenter (t: TilePosition) : Vec2 = + { X = float32 (t.X * Sim.tilePixels + Sim.tilePixels / 2) + Y = float32 (t.Y * Sim.tilePixels + Sim.tilePixels / 2) } + let home = VillagePresentation.scene.Homes |> List.head + let doorCenter = tileCenter (worldTileForSceneTile home.Door) + let setHour (hour: float) = + let tick = M6Presentation.resolveStartTick (Some hour) false + world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds } + if not p62Pending then + match p62Step with + | 0 -> + setHour 12.0 + // 俯瞰点:样板村落西侧上空,相机随角色居中,村舍/石桥/岸线/菜圃/竹林全部入画。 + let vantage = tileCenter (worldTileForSceneTile { X = 6; Y = 3 }) + world <- { world with Avatar = { world.Avatar with Pos = vantage } } + this.CenterCamera() + p62PendingName <- "p62-overview" + p62Pending <- true + | 1 -> + // 真实走入:沿真实 Sim.step 向门口移动,抵达后用真实 Interact 进门。 + let dx = doorCenter.X - world.Avatar.Pos.X + let dy = doorCenter.Y - world.Avatar.Pos.Y + if dx * dx + dy * dy <= 12.0f * 12.0f then + let nextWorld, nextView = M5Interaction.apply M5Command.Interact world m5View + world <- nextWorld + m5View <- nextView + match m5View.HomeMode with + | Inside _ -> + p62PendingName <- "p62-interior" + p62Pending <- true + | Outside -> + p62MoveFrames <- p62MoveFrames + 1 + else + let length = sqrt (dx * dx + dy * dy) + let input = + if length > 0.001f then { MoveX = dx / length; MoveY = dy / length } + else { MoveX = 0.0f; MoveY = 0.0f } + world <- Sim.step { Input = input } world + p62MoveFrames <- p62MoveFrames + 1 + if p62MoveFrames > 900 then + printfn "p62-shot=move-timeout frames=%d home=%A" p62MoveFrames m5View.HomeMode + this.Exit() + | _ -> + printfn "p62-shot=done" + this.Exit() + + /// P62 证据:落盘并打印相机/角色/内景模式,供 analyze 核验非空帧与真实进门。 + member private this.CaptureP62Shot () = + if p62Pending then + p62Pending <- false + System.IO.Directory.CreateDirectory recordDirectory |> ignore + this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p62PendingName) + printfn + "p62-shot=%s tick=%d avatar=(%.0f,%.0f) camera=(%.0f,%.0f) home=%A" + p62PendingName world.Tick world.Avatar.Pos.X world.Avatar.Pos.Y camera.X camera.Y m5View.HomeMode + p62Step <- p62Step + 1 + member private this.PrepareP45Shot () = let baseTick = M6Presentation.resolveStartTick (Some 12.0) false let setTick (tick: int64) = @@ -1993,6 +2059,14 @@ type LivingVillageGame() as this = else this.DispatchMenuInput Confirm elif menu.Page = Playing then this.PrepareP61Shot() + elif p62ShotMode 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.PrepareP62Shot() elif p45ShotMode then if menu.Page = MainMenu then this.DispatchMenuInput Confirm @@ -2465,6 +2539,8 @@ type LivingVillageGame() as this = this.CaptureP44Shot() if p61ShotMode && menu.Page = Playing then this.CaptureP61Shot() + if p62ShotMode && menu.Page = Playing then + this.CaptureP62Shot() if p45ShotMode && menu.Page = Playing then this.CaptureP45Shot() if p46ShotMode then |
