# 维护说明 结论先行:代码 HEAD = `b830b4b`(2026-09-22 独立复验全绿),主分支保持 0 警告 0 错误 + Kernel **119/119**、Desktop **160/160** 全绿;性能基线 final_digest 固定 953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C。 本文记录模块职责、不变量与逐字可复制的验收命令。 ## 模块职责 - `src/LivingVillage.Kernel/`:纯确定性模拟(需求/关系/谣言/年鉴/交易/职业),无渲染依赖。 - `Sim.fs`:世界结构、`Sim.step` 主推进、对话/交易/谣言决策、地图边界 `configureBounds`(默认 64×48,可到 512×384;桌面默认可玩世界为 256×192 生成器世界, 启动即 `configureBounds 256 192`)。 - `Occupation.fs`:职业系统纯函数——职业档案(`profileOf`)、报价偏置(`quoteBiasBasisPoints`)、 每日任务模板、玩家对话回应偏差(`biasResponse`)、轻剧情线状态机(`Story`); 无职业(None)路径与旧行为逐字节一致。 - `RumorBench.fs`:谣言子系统隔离基准(headless `--rumor-bench`,P27)。 - `WorldSave.fs`:存档序列化。v2 头携带地图宽高;v3 仅在挂职业时产出(occupation / today-task / annals `story` token),无职业路径与 v2 逐字节一致;v1 旧档读入自动回退 64×48。 - `Personality.fs / Interaction.fs(fsm) / M6*.fs`:NPC 行为状态机与里程碑数据。 - `src/LivingVillage.Desktop/`:MonoGame 渲染与交互层。 - `VillageArt.fs`:图集加载与世界/人物/室内绘制;CC0 覆盖层(LV_ASSET_PACK=cc0); 桥头/门窗夜间光晕(`lanternFalloff` 径向衰减)。 - `Interaction.fs`:M5 视图与情境解析(FloatingPrompt / panelLines / 情境提示、剧情线行)。 - `M6Presentation.fs`:昼夜光照纯函数(lightingBlend / transitionWarmth / goldenVeil / moonlightVeil)与渲染档(Blend/LanternGlow)。 - `TitleScreen.fs / Game.fs`:标题布局与主循环;LV Autos(sample / flow / record / map tour)。 - `MapGen.fs`:默认可玩世界的确定性生成器(splitmix 河道 + value noise + 泊松布点; 大图沿河民居组团/田块/主路柔化,默认 256×192,可到 512×384)。 - `ProceduralMap.fs`:遗留 `LV_MAP_SCALE` 大世界路径(512×384 确定性地形)。 - `WorldBootstrap.fs`:开局站位(核心可达域)与职业档案注入。 - `HudLayout.fs / ChineseText.fs / CjkGlyphAtlas.fs`:HUD 几何与描边纯数据、 中文文案清单与 CJK 图集(307 字,384×480)。 - `SampleScript.fs`:自动演示脚本(靠近→提示→交互→关闭→离开全流程)。 - `src/LivingVillage.Headless/`:headless 入口 + PerformanceProbe(digest、基跑)。 - `scripts/make-jiangnan-art.py / make-cc0-art.py`:原创与 CC0 素材确定性生成。 - `third_party/`:Kenney Tiny Farm、Puny Characters(均 CC0 1.0,来源与 sha256 在 PROVENANCE.md)。 ## 模拟状态不变量(验收依据) 1. **数组快照**:`Sim.world.Npcs` 数组与 `InteractionResolver.resolve` / `refreshPrompt` 的 调用之间必须保持恒定——选择/提示路径从不 mutation(PrototypeTests 快照回归)。 2. **确定性 RNG**:世界推进只随 `World.Rng`(splitmix)与 `(seed)` 派生,禁止墙钟与 `System.Random`。昼夜/波纹/入场/动画帧全是 `tick → 值` 纯函数。 3. **存档 v1/v2/v3**:`WorldSave` v2 头携带 `width height`,v1 旧档读入回退默认; v3 仅在挂职业时产出(occupation 段 + `today-task` 尾段 + 年鉴 `story` token),无职业 世界保存与 v2 逐字节一致。`worldDigest` 对版本头与边界 token 不敏感(剥头后重构 V1 常量语义再 SHA256),摘要只绑定模拟状态。 4. **地图 512×384 大世界**:`ProceduralMap.generate` 同 seed 两次生成逐字节一致; 村核矩形(24..38 × 16..34)由生成器不变式保证可通行; 默认可玩世界为 `MapGen.generateWithSize 256 192 42UL`(详见 §大世界(P21))。 ## 动画语义(P13,全部为 `tick → 帧` 纯函数;P45/P72 扩充角色帧) - 角色图集由 `CharacterArt.buildAtlas` **运行时生成**(内嵌原创点阵,非外部素材,P45 起取代 旧的 `Assets/jiangnan-characters.png`;旧 PNG 仅作历史资产保留,运行期不再采样): 5120×48,每个 (变体,方向) 占 **8** 格(**6 走路 + 2 待机呼吸**),格宽 32 高 48。 - `cellIndex = (variantIndex * 4 + directionIndex) * 8 + frameIndex`; 方向序 North/South/West/East = 0/1/2/3,变体序 Indigo/Ochre/Jade/Grey/StrawHat = 0..4。 - frameIndex:Walk 0..5 = 0..5(1/2 与 3 为左右迈步,4/5 为由 1/2 镜像出的过渡步),IdleOne/IdleTwo = 6/7。 - `animationFrame = (tick / 4) % 6`(24 tick 一循环);`idleFrame = (tick / 24) % 2`(48 tick 一循环)。 - `characterFrame isMoving tick`:移动取走路帧,静止取呼吸帧。Avatar 用 `Game.fs` 的 `avatarMoving`(比较前后位置);NPC 用 `npcSpriteSpecAtTarget`(目标向量 ≥0.5px 视为移动)。 单人节奏仍由 `rhythmFor` 的 `WalkWindow/IdleWindow/PhaseOffset` 决定(农夫 4/24、货郎 3/20、书生 6/36)。 - 世界图集 `Assets/jiangnan-world.png`:800×32,25 格。水面 3 帧在 slot 1/13/21, 炊烟 3 帧在 slot 22/23/24。 - `waterFrameTickAt tick x y = (tick / 32 + x + 2y) % 3` → Water / WaterSpriteB / WaterSpriteC。 相位按**世界 tile 坐标**偏移,所以同一帧里相邻河面砖显示不同相位;`waterFrameTick` 仍保留为 全局相位(旧断言)。 - `smokeFrameTick = (tick / 40) % 3` → SmokeSpriteA/B/C。炊烟在 `drawWorld` 的**末尾单独一遍** 绘制,位置 `offsetTile (house door) 0 -3`(屋脊正上方),保证画在屋瓦、竹子等所有 prop 之上。 - 以上帧选择只用 `tick` 与「是否移动」,无墙钟/`System.Random`;同 tick 必得同帧。 ## 预乘 alpha 与夜间光晕/炊烟接线(P14) - **机理**:MonoGame `SpriteBatch` 默认 `BlendState.AlphaBlend` 按**预乘**语义合成 (source blend = `One`,destination = `InverseSourceAlpha`)。因此直传未预乘的 `Color(r,g,b,a)` 会把 rgb 按原值直接加进帧缓冲:多层暖色叠加在核心区饱和成 **不透明纯白方块**(P13 夜晚灯笼的 75×75 白块即由此而来;`Color(255,0,0,60)` 实测渲染为 `(255,63,68)`)。光晕、色调等任何 alpha<255 的前景绘制都必须**按自身 alpha 预乘 rgb**。 - **正确写法**:`VillageArt.premultiply r g b a`(每通道 `round(c * a / 255)`,四舍五入; alpha=255 为恒等)。预乘后单通道 ≤ alpha,alpha<250 时不可能出现纯白。 - **夜间光晕**:`VillageArt.lanternGlowLayers cx cy radius rings r g b peakAlpha` 用同心圆 pixel run 近似径向衰减,每层色值预乘、核心多层叠加≈`peakAlpha`、边缘单层最暗; `Game.fs` 只在 `renderPlan.Props` 的 `RiverLantern` 实体处绘制,无灯笼处不产生光斑。 - **炊烟**:见上「动画语义」——烟雾是独立的后置绘制遍,与灯笼光晕同理。 - **角色站位**:`characterDestination` 把精灵下移 `characterFeetOffset = tilePixels/2`(16px), 使脚底贴合地砖底线;`drawGroundShadow` 用 `ellipseRuns` 画预乘半透明椭圆接触阴影。 - **回归**:Desktop 单测 `PremultiplyScalesEachChannelByItsOwnAlpha`、 `LanternGlowLayersArePremultipliedAndRadial`、`WaterRipplePhaseIsDistributedPerTile`、 `CharacterFeetSitOnTheTileGroundLine`;图像级 `scripts/check-no-white-blocks.py` 对夜晚录帧断言不存在 ≥64×64、每通道 ≥250、alpha ≥250 的方块(P13 旧帧 42/69 命中,修复后 0 命中)。 ## 昼夜光照档(P73) - 既有 `lightingBlend`(0 白天→1 夜晚,dawn 4.0–6.5 / dusk 20.0–22.0)与 `transitionWarmth` (晨曦/黄昏三角暖色峰)是**连续曲线**;P73 在其上再叠一层**四时段档**,填补 17:00–19:15 原本与正午无差的黄昏空档。两者都由 `world.Tick` 纯函数导出,Kernel 不参与。 - `M6Presentation.periodAtTick`:边界与 `timePeriodLabel` 完全一致(<5 夜晚、<8 清晨、 <17 正午、<21 黄昏、否则夜晚)→ `Morning | Noon | Dusk | Night`。 - `M6Presentation.periodLighting`:纯数据参数表,每档给 `AmbientTint`(乘性环境色调)、 `Veil`/`VeilAlpha`(加性覆盖色,经 `VillageArt.premultiply`)、`LanternGlow`(灯光强度)。 **正午档为恒等**(白 tint / `VeilAlpha=0` / 灯光 0),故既有正午白平衡不变。 - 渲染接线(`Game.fs` `DrawWorldView`):`worldTint = multiplyTint profile.WorldTint period.AmbientTint`; `lanternGlow = effectiveLanternGlow tick`(连续曲线与时段档取大,黄昏 0.6 起灯笼点亮、夜晚 1.0); 时段覆盖色在 golden/moon veil 之后最后叠上。HUD/室内界未改。 - **回归**:`P73LightingTierTests`(四档两两不同、正午恒等、黄昏灯亮、夜晚灯满、时段边界与 `timePeriodLabel` 一致)。**证据**:`scripts/analyze-p73.py` 对 `LV_P73_SHOT=1`(同一桥头机位 6/12/18/22 四帧)断言参数一致 + 亮度/暖度可辨 + 两两 pixel diff,落 `docs/evidence/p73-*`。 ## 性能基线命令 ```bash dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll --performance-baseline ``` - 口径:seed=42,npc=4,warmup=120,measure=6000,repetitions=3(见首行 `performance_config`)。 - 门槛:`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 三次一致 + `performance_determinism=PASS`。 - digest 变化处理:先比对 `WorldSave.save world` 全文文本(版本头变化不构成失败即 digest 规范化后等价); 再查 Sim.step 数值路径是否被改动(不允许)。历史归属见 `artifacts/perf-independent/*/README.md`。 ## M3 批验:流式可观测性、门槛与长局成本(M3-1) - **流式输出**:`--batch K D` 每个世界一完成就立即打印 `world=... OK elapsed_s=<秒>` 与 `[done k/N] elapsed=…s world=…` 并 flush,结束时 `batch_summary` 旧字段原样保留并追加 `workers=` 与 `wall_s=`(总墙钟)。完成序可能非升序,监督器按索引集合恰为 0..K-1 校验。 - **正式门槛缩尺 50×100 → 50×20**(`scripts/m3_supervise.py` 的 `PHASES.phase2`): phase2(50×100)实测 11.4 小时仅完成 6/50 世界,单世界 100 天约 6.8 CPU 小时, 全量约 335 CPU 小时,不可行(诊断见 `artifacts/m3-acceptance/final-candidate-20260921T095500Z/logs/phase2_20260921T101500.log.diagnosis.txt`)。 Hermes 实测单 worker 成本:1 天 25.6s / 5 天 123s / 10 天 254s / 20 天 ≈900s / 100 天 ≈6.8 CPU-h。缩尺只改规模,判据语义 check a/b/c 未动。 - **成本探针**:`--cost-probe [D1,D2,...]`(默认 `1,5,10,20,50,100`)逐段真实测量单世界 `ticks_per_s`、墙钟、`allocated_bytes` 与 gen0/1/2,不估算、不写死。 本机(单 worker,npcs=30,seed=42)实测: | days | elapsed_s | ticks_per_s | allocated_bytes | gen0 | gen1 | gen2 | |---|---|---|---|---|---|---| | 1 | 21.615 | 239837.5 | 19,001,478,648 | 2272 | 286 | 11 | | 5 | 111.645 | 232165.0 | 98,066,613,520 | 11728 | 11 | 2 | | 10 | 226.226 | 229151.8 | 202,369,225,952 | 24203 | 22 | 2 | 即约 **20.2 GB / 天·世界** 的分配;10 天内吞吐近乎平稳,超线性主要体现在更老的世界上 (Hermes 20 天 ≈45 s/天、100 天 ≈245 s/天)。 - **成本随年龄上涨的机理(只测不改)**:`World.Rumors: RumorEvent list` 永不裁剪, 每次谣言扩散 `rumor :: world.Rumors` 前插。实测 `rumor_trace count`:1 天 2025、 5 天 9821、10 天 19493(≈1950/天线性累积)。而 `latestRumorFor` / `rumorIsDuplicate` / `rumorPath` 每次都 `world.Rumors |> List.filter/tryFind` 全表扫描,因此每次聊天/对话的 成本随世界年龄线性增长、整轮成本随时长超线性(≈平方)增长;同时每次扫描与前插都产生 列表分配,推高 GC。`Mind.Memory` 有 `memoryCapacity` 上限(实测恒 64),不是主因。 - **修复方向候选(本单不实施,避免动确定性语义)**:①按 `rumorFreshnessTicks`/半衰期 确定性裁剪工作集(谱系/annal 另存);②以 `Map` 或逐接收者索引替代 全表扫描;③把 `latestRumorFor`/`rumorIsDuplicate` 限制到最近窗口。以上都需单开一单、 重跑 performance-baseline 与全部 digest 后由 Hermes 走冻结流程。 ## M6a 批验:谣言工作集容量上界与按天淘汰 - 实施 M3 的三条修复方向: ①`RumorEvent` 新增派生字段 `DayIndex = Tick / ticksPerDay`,不参与存档序列化,读档由 `Tick` 纯函数 重建,故 v1/v2/v3 存档文本逐字节不变; ②新增可变参数 `rumorCapacity`(默认 16384)与 `rumorRetentionDays`(默认 3,>= 新鲜窗口 3 天), 纯函数 `trimRumors` 只保留最新 N 条并丢弃早于 `nowDay - rumorRetentionDays` 的条目;容量内且新鲜时 零拷贝原样返回;两个前插点 `chooseDialogue`/`chat` 统一走 `consRumor`; ③列表最新在前,`latestRumorFor`/`rumorIsDuplicate` 改早停递归(越窗即停、无中间列表), `nextRumorId` 改读表头 O(1)。 - 等价性:保留窗口(>= 3 天)覆盖两个查询的新鲜判定,窗口外条目本就会因 `rumorStrengthAt < rumorMinimumStrength` 被过滤,故裁剪不改变任何查询结果;「表头为最大 Id」不变量在裁剪后保持。 - 实测(本机单 worker,npcs=30,seed=42,`--cost-probe`;同机 M3 未裁剪基线见上节): | days | 未裁剪 elapsed_s | M6a elapsed_s | 未裁剪 allocated_bytes | M6a allocated_bytes | |---|---|---|---|---| | 1 | 23.4(×3) | 23.4(×3) | 19,001,341,888 | 18,898,357,944 | | 10 | 240.6 / 268.6 | 236.2 / 274.0 | 202,373,638,848 | 191,916,294,064 | | 20 | ≈900(M3 记录) | 503.1 | — | 384,468,393,040 | 10 天分配/gen0 稳定下降约 5%;超线性项改善更大:M3 记录 20 天 ≈45 s/天,M6a 后 20 天 ≈25 s/天 (≈1.8×),单日成本回归平稳。 - 工作集实测(`evidence/` 探针,seed=42,npcs=30):第 5 天 `Rumors` 7796(收敛)vs 未裁剪 9821, 两者表头 `Id` 同为 9820,证明创建序列不变、仅淘汰已不可用的旧条目。 - 门槛:`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 三次一致, `performance_determinism=PASS`;Kernel 89 / Desktop 94 全绿。 ## P16 批验:启动画面 / 开始界面 / HUD 精美化 - ①启动画面:新增纯函数模块 `LaunchScreen.fs`(`durationFrames=150`、`fadeInFrames=45`、 `title/subtitle/skipHint`、`revealProgress/veilAlpha/isComplete/skipRequested`);`Game.DrawSplash` 以真实游戏内渲染绘制标题/副标题,Esc 或空格可跳过,`splashFrame` 单调推进。 - ②开始界面:`MenuState.items` 收敛为三项——无存档 `[NewGameItem; LoadItem; ExitItem]`、有存档 `[ContinueItem; LoadItem; ExitItem]`;标签 开始/继续/读档/退出;设置与操作说明不再占位(游戏内 F1/F2/F3 与 P 仍可开)。 - ③HUD:原 `DrawM5Overlay` 拆为 `DrawHudBar`/`DrawTransientStatus`/`DrawPanel`;顶部窄信息条承载 时段/时间/倍速/体力/钱币,无大黑块;`HudLayout.fs` 固化 `barHeight=36`/`accentHeight=3`/ `paddingX=16`/`segmentGap=20`/`textScale=2` 与昼/夜调色板(冬夜冷色)。`FloatingPrompt` 顶避让改为 全宽 `hudTopBarHeight=39`,移除 `hudTopRightWidth/Height`。 - ④文本:全部新 UI 文案走 CJK 图集;图集 228→238 字(补 `币 段 跳 过 空 格 清 晨 黄 昏`),行数仍 15、 PNG 仍 384×360;`Assets/cjk-glyphs.txt`、`CjkGlyphAtlas.characterTable`、`requiredUiLabels` 三者一致,无缺字。 - ⑤回归:新增 `P16UiTests.fs`(启动跳过/菜单导航/HUD 布局确定性);Desktop 94→104,Kernel 89 不变 (未触碰 Kernel/Sim.fs 数值路径)。 - ⑥证据:`/tmp/opencode/lv-p16-art/`(`frame-day-0{1,2,3}.png`、`frame-night-0{1,2,3}.png`、 `frame-splash-01.png`、`hud-bar-day.png`、`hud-bar-night.png`、`sample.mp4` 13.1 s、 `final_verification.txt`);`check-no-white-blocks.py` 对 92 帧 0 命中。 - ⑦(P16-FIX 证据补拍)`frame-menu-0{1,2}.png` 实为游戏内帧,不是开始界面;新增确定性截图模式 `LV_AUTOPLAY_MENU_SHOT=1`:跳过 hero 自动开始,停在开始界面按固定帧表取样。帧表抽为纯数据 `MenuShotScript`(splash 淡入中 30 / 初始高亮 130 / 向下 140 / 高亮读档 150 / 退出 170),附 1 例 回归;`Game.CaptureMenuShot` 复用 `SaveBackBuffer` 落图。产物:`menu-real-initial.png`(初始高亮 「开始」)、`menu-real-load.png`(按向下后高亮「读档」)、`frame-splash-mid-01.png`(淡入中帧)。 像素复验:初始帧仅第 1 行含高亮色 `(245,222,120)`(115 px),load 帧仅第 2 行含(111 px), 两帧差异 575 px,确证选择项迁移。 - 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 一致、`performance_determinism=PASS`。 ## P74 启动开场过渡(水墨式渐显) - 新增纯函数模块 `InkReveal.fs`:`inkStrength`(起手 1,`dissolveFrames=96` 内 ease-out 退到 0)、 `dabs frame width height`(6 滴归一化种子墨滴,随消散半径外扩、alpha 下降)、`inkRgb=(26,36,52)`。 单滴 `alpha < 255` 且用 `VillageArt.ellipseRuns` + `premultiply` 落成柔光墨晕,**绝无实心黑块**。 - 接线:`Game.DrawSplash` 在场景层(天色/云/屋脊/水面/灯笼)之上、标题之下绘制墨滴,与既有 `LaunchScreen.revealProgress/veilAlpha` 淡入/暗幕退场叠加,形成可辨识的水墨开场;HUD 结构未动。 - **回归**:`P74LaunchTransitionTests`(墨幕单调消散且为 0、墨滴确定/随帧变化、alpha<255、 半径 < 半屏、`LaunchScreen` 不变量、开始界面三项 开始/读档/退出 + 有档 继续/读档/退出)。 - **证据**:`LV_P74_SHOT=1`(60Hz 固定步进,splash 帧表 8/30/60/100/140 各一帧)→ `scripts/analyze-p74.py` 断言帧号/墨幕非增/无大黑块/两两 pixel diff>0,落 `docs/evidence/p74-*`。 ## P75 HUD 顶部条信息层次(去整宽深色带) - 现状(P16/P43):整宽竖向渐变深色带(`barHeight=36`,alpha 200/205)+ 每段药丸。P75 把整宽 深色带去掉,信息只由**紧凑半透明药丸**承载:`palette.BarTop/BarBottom` alpha 置 0(字段保留兼容), `DrawHudBar` 不再铺整条底色与整宽 accent 带,改画每段 `segmentBackdrop` + 新增 `pillHairline` 1px 强调发丝线。小图标(7×7 点阵)+ 数值并排、左右各留 6px、右侧大面积留白。 - 纯布局 API 与语义不变:`segments/layout/segmentBackdrop/textOrigin/iconRows`、面板状态机与快捷键 全部保留;中文仍走 CJK 字形图集。日期卡(`calendarCardRect` 在条下 `+accent+8`)与浮动提示避让 不受影响。 - **回归**:`P75HudStripTests`(昼/夜整宽带 alpha=0、药丸比条矮且在条内、发丝线 1px 在药丸内、 HUD 只占左半留白充足、日历卡不压条、亮字 vs 深描边对比、图标+数值并排)。 - **证据**:`LV_P43_SHOT=1`(同机位昼 12:00 / 夜 23:30)→ `scripts/analyze-p75.py`:顶部条近黑占比 ~2.6%(相对整宽深色带基线 100% 下降 >97%)、无实心 24×24 黑块、亮字像素 >1000,落 `docs/evidence/p75-*`。 ## P76 底部条 / 槽位药丸化与面板换行 - 底部快捷栏(`Game.DrawHotbar`):去掉整条大色块与实心 40×40 槽位填充,改与顶部条同语言—— 半透明条药丸 + 1px 描边 + `hotbarBarHairline` 发丝线;每个槽位内 `hotbarKeyChipRect`(键位 符号小片)+ `hotbarIconRectBesideKey`(图形)**并排**。选中格用 accent 描边 + 底部发丝线,不再 大色块。`selectSlotForDigit/cycleHotbar/hotbarInputAllowed/hotbarSlotRect` 等语义与 1-0 映射**不变**。 - 底部**图例**:`HudLayout.actionLegend` 取自真实 `MenuState.controlRows`(E/Q/TAB/C/L/F6/F7), 以「键位符号 + 动作名」并排展示;**纯展示,不新增/改动任何绑定**。 - 对话/交互面板:`Game.DrawDialogOverlay` 背景由近纯黑 (14,22,30,232) 改为半透明深青 (46,60,70,200)/(30,42,52,210) + 1px 描边 + accent 发丝线;`Game.DrawPanel` 同步改半透明描边语言。 - 面板换行:新增 `HudLayout.wrapText/wrapLines`(按 `FloatingPrompt.measureWidth` 实测宽度逐字折行), `DrawPanel` 弃用 `Substring` 整行截断,改折行;行长不溢出、内容不丢字;Esc 关闭行为不变。 - 保存成功/失败短暂反馈沿用既有 `DrawTransientStatus`,未重做。 - **回归**:`P76HotbarPanelTests`(键位小片与图形并排在槽内、图例项全部来自真实 controlRows 且键序 一致、折行不超宽且不丢字、`wrapLines` 铺平无损失)。 - **证据**:`LV_P43_SHOT=1` 昼/夜(同机位)+ `LV_P44_SHOT=1` 对话面板打开态 → `scripts/analyze-p76.py`:底部条/图例/对话/任务四区近黑占比 ≤0.013–0.116、无实心 24×24 近黑块、 亮像素充足,落 `docs/evidence/p76-*`。 ## P17 批验:场景细节 / 人物动效 / 昼夜光照精修 - ①街边中式细节物:`scripts/make-jiangnan-art.py` 世界图集由 25 格扩到 28 格(新增 slot 25 石灯笼 / 26 水缸 / 27 摊贩摊位,全部烘焙进 `Assets/jiangnan-world.png`,尺寸 800x32→896x32),游戏侧只做 `tileSourceRectangle` 贴片、无程序几何。`VillageArt` 新增 `RenderPropKind.StoneLantern/WaterVat/ MarketStall` 与 `worldTileCount=28`;`sampleRenderPlan` 沿石路摆 4 件(石灯笼 4,14 / 16,14,水缸 12,11,摊位 3,11)。图集脚本二次运行 byte-identical(sha256 一致)。 - ②村民走路节奏差异化:`VillageArt` 新增 `NpcTrade`(Farmer/Peddler/Scholar,按 `NpcId mod 3` 纯函数 分配)与 `WalkRhythm`(走路/待机窗口 + 相位)。农夫 4/24、货郎 3/20(更快)、书生 6/36(更慢); `npcSpriteSpec` 改走各自节奏,`animationFrame/idleFrame/characterFrame` 保持农夫节奏即旧语义。 - ③昼夜光照精修:`M6Presentation.lightingBlend` 过渡窗口参数化(清晨 4:00→6:30、黄昏 19:30→22:00) 并把小时改分数(原为整数小时,过渡只有 3 档),新增纯函数 `transitionWarmth`(晨昏中点火暖色峰值 1,正午/午夜 0)驱动 `backgroundAtWarmth/worldTintAtWarmth`,`M6RenderProfile` 增 `SkyWarmth`。 - ④回归:更新/新增确定性测试(行走节奏、光照锚点与暖色、图集 28 格与 PNG 尺寸、证据起时纯函数); Desktop 107 / Kernel 89 全绿(未触碰 Kernel/Sim.fs 数值路径)。 - ⑤证据:`/tmp/opencode/lv-p17-art/`(`p17-day-12h-0{1,2}.png`、`p17-dusk-20h45-0{1,2}.png`、 `p17-night-00h-0{1,2}.png`、`p17-diff-before-dawn.png` vs `p17-diff-after-dawn.png`(同一 6:00 tick, P17 前/后各 1 帧)、`sample.mp4` 13.1 s);`check-no-white-blocks.py` 对所选 6 帧 0 命中。起时用新增 环境钩子 `LV_AUTOPLAY_START_HOUR`(纯函数 `M6Presentation.startTickForHour`,桌面演示专用,不进 Kernel)。 - ⑥(P17-FIX 起时钩子修复)旧 `p17-day-0{1,2}` / `p17-dusk-0{1,2}` 帧的钩子在 sample/autoplay 取证路径下 失效:`StartNewGame` 重新创建世界时按 `daylightAutoplay` 把 Tick 重设为 0/6:00,覆盖了 `createInitialWorld` 设好的值,导致黄昏/昼取证实际全为「夜晚 00:00」,P17 光照精修未被真实帧展示。 修复:抽纯函数 `M6Presentation.resolveStartTick (startHour option) (startAtDaylight)`(显式小时优先, 否则 daylight 6:00,否则 0),`createInitialWorld` 与 `StartNewGame` 统一经 `evidenceStartTick`/`withStartTick` 取同一 startTick(占位/任务日一致),`new-game=ok` 日志追加 `tick=` 便于机器核验;非取证人工路径不受影响。 回归:`StartTickResolutionHonoursEvidenceHourThenDaylightThenMidnight`(Desktop 107→108), 实测 `tick`:昼 12.0→2,592,000、黄昏 20.75→4,482,000、夜 0→0,`sample.mp4` 为黄昏 20:45。 以上新文件名(`p17-day-12h-*` / `p17-dusk-20h45-*` / `p17-night-00h-*`)为准,旧名作废。 (P18 复核确认:这批帧起时虽已正确为 20:45,但画面仍为夜蓝冷调,原因是暖色渲染链缺失, 见 §P18;P17 黄昏/夜间帧的起时有效、但暖色未生效,最终以 P18 证据为准。) - 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 一致、`performance_determinism=PASS`。 ## P18 批验:黄昏暖色调真实渲染整改 - 根因(渲染调用链):①`lightingBlend` 黄昏暗化窗口 19:30–22:00,使 20:45 的 `blend=0.5`,`Mode` 直接落到 Night;②暖色仅经 `worldTintAtWarmth` 的**乘性** tint(R+10 / G−6 / B−26)施加于世界,而乘性通道无法把 低 R 的绿色植被染成橙色,地面依旧偏绿;③`timePeriodLabel` 20:00 起即为「夜晚」,与暖色窗口错位。 - 修复:黄昏**暗化**窗口改为 20:00→22:00(20:45 `blend=0.375`,仍 Day),暖色 bump 独立为 19:15–21:45 (峰值 20:30);`backgroundAtWarmth` 暖移加强(R+120 / G+20 / B−90),`worldTintAtWarmth`(R+70 / G−10 / B−75);新增纯函数 `goldenVeilAlpha`(0..150,峰 150),`Game.DrawWorldView` 在世界与角色绘制后叠加 **预乘暖色整屏 veil**(`premultiply 255 150 70 veilAlpha`,预乘按自身 alpha 缩放、不会饱和成白块), 补上「加性暖光」这一缺失环节;`LanternGlow = max blend (warmth * 0.8)` 让黄昏灯笼同步暖亮; `timePeriodLabel` 黄昏延到 21:00。 - 回归:更新光照锚点/单调/确定性用例并新增 veil 锚点(正午/午夜 0、20:30 峰 150、19:00 前 0), Desktop 108 / Kernel 89(Kernel 未动)。 - 证据:`/tmp/opencode/lv-p18-art/`(`p18-dusk-20h45-0{1,2}.png`、`p18-night-deep-01.png`、 `p18-before-old-dusk-blue.png` vs `p18-after-new-dusk-warm.png`、`sample-dusk.mp4` 13.2 s)。 像素复验:旧黄昏画面均值 (55,81,54)(R−B≈1,冷蓝)→ 新黄昏 (153,115,57)(R−B≈95,暖橙),深夜 (34,60,56) 仍冷;`check-no-white-blocks.py` 0 命中。 - 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 一致、`performance_determinism=PASS`。 ## P19 批验:白天偏黄过曝治理(daylight 色调路径) - line-level 定位:offender 唯一是 `M6Presentation.worldTintAtWarmth`(`M6Presentation.fs:81-87`)的 **day 基线** —— `lerp 248 136 blend`(:84)与 `lerp 224 184 blend`(:85)在 `blend=0`(正午)仍给出 `(255,248,224)`:蓝通道 被压约 12%,整场景乘性偏黄。黄昏暖色链在白天并不生效,已逐一排除:`transitionWarmth`(:51-60)bump 仅在 4.0–6.5 与 19.25–21.75;`goldenVeilAlpha`(:65)正午 = 0;`LanternGlow = max blend (warmth*0.8)`(:101) 正午 = 0;`lightingBlend`(:36-43)正午 = 0。故「白天偏黄」= day world-tint 基线,与黄昏暖窗无关。 - 修复:day 基线改中性白 `(255,255,255)`,乘性恒等(geometry 保留 true white balance,无伪色彩);night 基线 `(118,136,184)` 与 `warmth` 暖移不变,黄昏/清晨暖橙完全保留。 - 回归:新增 `NoonLightingIsWarmthFreeWithNeutralWhiteBalance`(DesktopTests);PrototypeTests 增白天 10:00–15:00 平台断言(warmth=0、通道中性、veil=0、glow=0);Desktop 108→109,Kernel 89 不变。 - 证据:`/tmp/opencode/lv-p19-art/`(`p19-noon-12h-0{1,2}.png`、`p19-dusk-20h45-01.png`、 `p19-night-00h30-01.png`、`p19-before-noon-yellow.png` vs `p19-after-noon-neutral.png`)。画面均值: 正午前 `(71,109,66)` R−B=+5.1(偏黄)→ 正午后 `(70,109,75)` R−B=−5.3(中性);黄昏 `(146,113,61)` R−B=+85(暖橙保留);深夜 `(34,60,56)` 冷调;白块回归 0 命中。 - 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 一致、`performance_determinism=PASS`。 ## P20 第一步:参数化确定性生成器与视口裁剪(大地图扩程前置) - 新增纯函数模块 `src/LivingVillage.Desktop/MapGen.fs`:`generate : Params -> Result`,尺寸参数化 (默认仍是 64x48;可用 `generateWithSize 256 192 seed`)。组成: splitmix64 随机游走河道(`RiverCount`/`RiverWidth`)、2-3 octave value noise 地形(草地/泥炭)、 泊松式最小间距布点(石头装饰);核心活动区先整平为草地,再由 **BFS(4 邻接、非水可走)** 从核心中心洪泛校验 5x6=30 个 spawn 全部可达,不可达则沿直线确定性修整并复验,结果写入 `ReachabilityOk`。 纯函数、无时钟/随机源:同 seed 两次生成逐字节一致(`Array.mapi` 校验 + checksum)。 - 视口裁剪纯函数 `MapGen.visibleTileRange`/`visibleTileCount`(与既有 `VillageArt.drawWorld` 的 `tx0..tx1/ty0..ty1` 窗口一致),`drawWorld` 已改为调用它,行为不变;`drawMapFitted`/`drawMapViewport` 为证据专用绘制(整图等比 + 1:1 裁剪),不进正常游戏循环。 - 存档兼容:沿用既有 v2/v3 的 `mapWidthTiles`/`mapHeightTiles` 边界字段(读旧档仍按 64x48), P20 只补参数化边界测试,未改 Sim 数值语义;`final_digest` 与基线一致。 - 回归:`MapGenTests`(同 seed byte-identical、异 seed 不同、256x192 30 spawn 全可达、默认尺寸 64x48、 裁剪范围/端点钳制)6 条;Kernel `SaveRoundTripsParameterizedMapBounds`(256x192 v2 round-trip)1 条。 Desktop **109 -> 115**,Kernel **89 -> 90**。 - 实测性能(Xvfb,本机):生成 256x192 = **52 ms**、64x48 = **1.9 ms**;256x192 生成 `reachable_ok=true`、`reachable_tiles≈47684`;裁剪后单帧仅绘制 **1050/49152 = 2.1%** (`culled=97.9%`),离屏单帧 **0.62 ms/帧**(implied ≈1610 fps,仅 harness 立即模式)。 - 证据:`/tmp/opencode/lv-p20-art/`(`mapgen-256x192-seed{4242,7,99}.png` 整图三 seed、 `mapgen-culled-256x192-seed{...}.png` 裁剪帧、harness 计数日志)。白块回归 0 命中。 - 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 3 次一致、`performance_determinism=PASS`。 ## P20 第二步:河道连续性不变量 + 桥 / 石板路网 / 沿河民居 - 河道重做:由「`not (inCore)` 逐格随机游走」改为**贯穿全图的东西向连续水带**(`Rivers : River list`, 每条每列恰好一段、宽度恒 `>= max 3 RiverWidth`),置于核心区上下两侧且与核心不相交。 新增不变量测试 `RiversAreContinuousWithoutGapsOrIsolatedTiles`:逐列水段数 == `Rivers.Length` 且每段 `>= RiverWidth`、任意水格 4 邻接水邻居 `>= 2`(禁止断裂缺口与孤立水点)。 - 交通结构(`Result` 新增 `Bridges`/`Paths`/`Buildings`/`BridgeCrossingsOk`): 每条河 2 座桥(2 格宽,桥面仍记水但 `isWalkable` 放行);主石板路沿核心中心行横贯全图, 每座桥有垂直连接路贯通两岸与远岸路;远岸路两侧布置民居矩形(`Building`:白墙黑瓦两行色块,门朝路)。 `MapGen.isWalkable`/`MapGen.floodFill` 公开供测试:`ReachabilityOk` 现在同时要求 30 spawn、所有门、 所有桥面可达;`BridgeCrossingsOk` 要求每座桥两岸均被同一连通域覆盖。 - 回归新增 4 条:河道连续性、桥/路/民居存在且沿河、桥两岸可达(spawn/门/桥全连通)、结构逐字节确定性。 Desktop **115 -> 119**,Kernel **90** 不变。 - 渲染证据(`drawMapFitted` 每瓦片 6px 放大 + `drawMapViewport` 1:1 + 桥中心 detail 帧); `p20b-map-*`(整图,含桥/路/白墙黑瓦民居)、`p20b-detail-*`(1:1 桥/路/民居近景)、 `p20b-culled-*`。白块回归 0 命中;整图 1536x1152。 - 实测(Xvfb,本机):256x192 生成 ≈ **63 ms**、64x48 ≈ **2.0 ms**;`reachable_ok=true`、 `reachable_tiles=47652`(= 49152 - 两河水面 1524 + 桥面 24,三 seed 恒定,印证连续性不变量); 裁剪帧 1050/49152 = 2.1%,1:1 单帧 ≈ 0.7 ms(implied ≈1300-1600 fps)。 - 门槛同上:clean rebuild 0/0;`final_digest` 3 次一致、`performance_determinism=PASS`。 ## P20 第三步:河景与可玩接入收口 - 默认可玩世界改走 **256x192 生成器**:`Game` 启动时 `Sim.configureBounds 256 192` 并 `MapGen.generateWithSize 256 192 42UL`,用 `WorldBootstrap.initialWorldInMap` 以生成器核心可达区站位 (30 NPC 全落可达核心,avatar 落核心中心);`LV_LEGACY_MAP=1` 或 `LV_MAP_SCALE>1`/`LV_MAP_TOUR=1` 回退旧路径(64x48 样例世界 / ProceduralMap 大世界)。`LV_AUTOPLAY_SAMPLE=1`(非巡游)也保持 64x48 以确保既有录像可复现,只有显式 `LV_RIVERSCAPE_TOUR=1` 才启用生成器世界;启动日志打印 `world-mode=`。`WorldBootstrap.initialWorldWithPlacement` 为显式站位入口,旧 64x48 函数字节口径不变(Kernel/Desktop 既有断言不动)。 - 渲染接入:`VillageArt.drawWorld` 新增 `pixel` 与 `mapGen : MapGen.Result option`—— Some 时按生成器瓦片绘制地面(1 水 / 2 石板路 / 其余草地)、叠加 `drawMapStructures`(桥面 + 白墙黑瓦民居 + 门) 与桥头 `LanternSprite`,并跳过 64x48 样例平面/道具;水格沿用既有 `waterFrameTickAt` 多相位波纹。 - 河景灯笼:新增 `VillageArt.bridgeLanternTiles`(每桥每列最上/最下格 = 桥两端);夜间 `LanternGlow` 沿用既有预乘同心圆 `lanternGlowLayers ... 255 214 150`(软暖盘,无过曝白芯),生成器世界用桥头灯笼位置。 - 确定性:`MapGen.serialize` 对尺寸/seed/瓦片/河道/桥/路/民居做序列化,同 seed 逐字节一致; `SameSeedSerializationIsByteIdentical` + `MapWorldPlacementUsesReachableCoreSpawns` 两条新测试。 - 回归:Desktop **119 -> 121**,Kernel **90** 不变;clean rebuild 0 警告 0 错误; `final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 3 次一致、 `performance_determinism=PASS`(未触 Kernel/Sim 数值路径)。 - 证据(`LV_AUTOPLAY_SAMPLE=1 LV_RIVERSCAPE_TOUR=1`,`LV_RECORD_NAME=p20c-*`):真人走位巡游沿桥/路/民居门 逐帧录制,`/tmp/opencode/lv-p20-art/p20c-day-*.png`(`LV_AUTOPLAY_START_HOUR=12.0`)与 `p20c-night-*.png`(20.75)各若干;挑出 `p20c-day-bridge-01.png`、`p20c-day-buildings-01.png`、 `p20c-night-bridge-01.png`、`p20c-night-lantern-01.png` 等代表性帧;716 张全过白块回归 0 命中。 ## 大世界(P21 性能与镜头收口) - 默认世界:`Game` 启动即 `Sim.configureBounds 256 192` + `MapGen.generateWithSize 256 192 42UL` (`MapGen.defaultParams`:`RiverCount=2`、`RiverWidth>=3`、`CoreSide=14`、5x6=30 spawn、 splitmix64 河道 + 2-3 octave value noise + 泊松布点)。核心区先整平,再 BFS 校验 30 spawn 与桥/路/民居门全部连通(`ReachabilityOk`/`BridgeCrossingsOk`);站位由 `WorldBootstrap.initialWorldInMap` 取核心可达域,avatar 落核心中心。 - `world-mode` 回退开关(启动日志会打印实际模式与 bounds): - `LV_LEGACY_MAP=1` -> 旧 64x48 样例世界; - `LV_MAP_SCALE=N`(N>1)-> ProceduralMap 大世界 `64N x 48N`; - `LV_MAP_TOUR=1` -> ProceduralMap 巡游; - `LV_AUTOPLAY_SAMPLE=1`(非巡游)-> 保持 64x48,保证既有录像可复现; - `LV_RIVERSCAPE_TOUR=1` -> 显式启用 256x192 生成器世界巡游。 - 视口裁剪:`MapGen.visibleTileRange` 只绘制可见瓦片(`drawWorld` 已接入),256x192 下 1280x720 视口约绘制 `1050/49152 = 2.1%`,`culled=97.9%`(P20 第二步离屏实测)。 - 实测性能(本机 Xvfb 1280x720x24,Desktop Release,256x192 可玩世界,`LV_UNLOCK_FPS=1` 放开 vsync 以测真实上限): - 帧率:`LV_RIVERSCAPE_SECONDS=60` 实测 58 个有效秒采样(丢 5 秒 warmup), **mean 230.4 FPS / min 199.5 FPS / max 302.8 FPS**;默认 vsync+固定步长为稳定 60 FPS。 - 内存:运行中 `VmRSS` 181.9–183.4 MB(≈182.7 MB 均值,含 MonoGame 运行时与图集), 60s 内平稳无泄漏。 - 生成:256x192 约 52–70 ms;64x48 约 2 ms(P20 实测)。 - headless:`--performance-baseline` 3 次 `ticks/s` 区间 **189,595–445,377**(宿主负载相关), `allocated_bytes≈3.03 MB/6000 ticks`、`gen0=0`,`final_digest=953775FA…`、 `performance_determinism=PASS`(未触 Kernel/Sim 数值路径)。 - 取证:`/tmp/opencode/lv-p21-art/`(P22 重挑后的游戏内代表帧 9 张: `p21-day-bridge-01`/`p21-day-river-01`/`p21-day-buildings-0{1,2}`、 `p21-dusk-bridge-01`/`p21-dusk-street-01`、`p21-night-bridge-01`/`p21-night-street-01`/ `p21-night-lantern-01`;原始帧 `raw-day/`、`raw-night/`、`raw-night2/`),白块回归 0 命中。 ## 大世界默认开启评估(P22) - 结论:**256x192 生成器世界已经是默认可玩世界**(P20 第三步即已切换,`riverscapeActive` 在非 legacy/非 map-scale/非 sample 时成立),`LV_LEGACY_MAP=1` 等回退开关保留。P22 复核 证据支持维持现状,未再改默认值。 - 实测对比(本机 Xvfb 1280x720x24,Desktop Release,`LV_UNLOCK_FPS=1` 放开 vsync, 丢 5s warmup 后按秒采样;两者工作负载不同,仅作量级对比): - 256x192 生成器(巡游,1x 模拟):`mean 230.4 / min 199.5 / max 302.8 FPS`(58 样本)。 - 64x48 样例世界(sample 脚本,2x 模拟):`mean 163.5 / min 113.9 / max 224.3 FPS`(65 样本)。 小世界无裁剪收益、屏内 NPC 更多且 2x 模拟,反而更慢;两种世界都远高于 60 FPS 目标。 - 内存:256x192 运行中 `VmRSS` 181.9–183.4 MB;64x48 为 179.5–182.3 MB —— 差异在噪声内, 内存由 MonoGame 运行时/图集主导,与地图尺寸基本无关。 - 生成耗时:256x192 约 52–70 ms、64x48 约 2 ms(一次性,发生在进入世界前,无可见卡帧)。 - 风险:唯一成本是启动时约 60 ms 一次性生成,无逐帧开销(视口裁剪 97.9%);headless `final_digest` 与 `performance_determinism` 不受影响(未触 Kernel/Sim 数值路径)。回退路径 完整,可随时 `LV_LEGACY_MAP=1` 或 `LV_MAP_SCALE=N`。**评估结论:维持 256x192 默认。** - 遗留观察(仅记录,留给后续单): - 初始 NPC 站位是核心区内的**规则网格**(`MapGen.fs` 第 253 行「30 个 spawn:核心区内的 规则网格」),P20 巡游首帧会看到整齐排布;巡游开始后自然散开,暂不改。 - 个别帧右侧有**孤立砖石小块**(地面 `GroundTile.Stone=2` 单格,`VillageArt.fs:664` 绘 `StonePathSprite`),疑似路网/门阶的单格支路残留,需后续单用定点帧确认后再修。 ## 大世界扩程 512x384(P23) - 来源 commit:merge `7dfd58b`(feat `1724ce6` / `8654557` / `f819d25`)。 - Kernel/Sim:边界本就是可变参数(`Sim.configureBounds w h`,默认仍 64x48);512x384 直接可用。 存档 v2/v3 头两字段即 `bounds.w|bounds.h`,v1 旧档仍按 64x48 读入(`WorldSave.parse`)。 512x384 存档 round-trip、同 world 两次保存逐字节一致、长跑 20000 tick 的 avatar/NPC 边界 与有限性均由 Kernel 用例覆盖(Kernel 90 -> 92)。 - MapGen 尺寸自适应(`paramsForSize`): - 河道数量约每 96 行一条:64x48/256x192 仍 2 条,512x384 得 4 条;第 3 条起在核心区上/下方 逐层外扩,互不重叠、不压核心,仍满足「每列恰好一段、宽度 >= RiverWidth、水格 >=2 水邻居」。 - 噪声特征尺度封顶 4x(`noiseWidthScale`),避免大图退化成过度平滑的放大图;泊松布点间距 `w/24`、尝试数 `(w*h)/16` 本就随尺寸缩放。 - **64x48 / 256x192 生成结果逐字节不变**(新增 checksum 锁定用例:64x48=6042052、 256x192=1284656962)。默认世界仍是 256x192,本轮不切换。 - 生成耗时(本机实测,单次):64x48 ≈ 3 ms;256x192 ≈ 55 ms;512x384 ≈ 134-248 ms (一次性、进入世界前)。 - 512x384 巡游帧率(Xvfb 1280x720x24,Desktop Release,`LV_UNLOCK_FPS=1` 放开 vsync): 单趟巡游 161.7 s,`perf-summary samples=156 mean_fps=153.84 min_fps=51.64 max_fps=228.81`; 运行中 `VmRSS` 185.9-201.9 MB。默认 vsync+固定步长为稳定 60 FPS。 - 取证:`/tmp/opencode/lv-p23-art/`(`p23-map-512x384-seed{4242,7}.png` 整图真实渲染、 `p23-culled-*`/`p23-detail-*` 视口/近景、`tour512/` 巡游帧),白块回归 0 命中。 - 取证钩子:`LV_RIVERSCAPE_WIDTH`/`LV_RIVERSCAPE_HEIGHT`(默认 256x192)把生成器世界扩到 512x384 跑巡游/性能;`LV_MAPGEN_SHOT=1` + `LV_MAPGEN_WIDTH/HEIGHT/SEED` 出整图。 ## 民居密度参数(P24) - 来源 commit:merge `aa3d749`(feat `0931037` / `7e3df86`)。 - `MapGen.Params.ExtraRiversideHouses`:大图额外沿河民居数量,默认 0;`paramsForSize` 仅对 `width>256 || height>192` 设为 **10**。512x384 在原有 8 座桥点民居上再按现有路网泊松散布 10 座(`structureRng` 确定性),**共 18 座**,门全部接入路网且 BFS 可达;64x48/256x192 仍 4 座、checksum 6042052 / 1284656962 不变。 - 民居渲染改用 256x192 同款部件:黛瓦(`RoofRidgeSprite`/`RoofEaveSprite`)+ 木墙/窗 (`WallWoodSprite`/`WallWindowSprite`)+ 门(`DoorSprite`),替换原黑白矩形色块。 - 村民可见性证据钩子 `LV_NPC_VISIBLE_LOG=1`:每秒打印 `visible-npcs=K of 30`;512x384 巡游中 观测到最大 30/30 全部在屏(`/tmp/opencode/lv-p24-tour.txt`)。 - 取证:`/tmp/opencode/lv-p24-art/`(`p24-map-512x384-seed{4242,7}.png` 整图、 `p24-zoom-houses.png`/`p24-zoom-villagers.png` 局部放大含民居与村民、`p24-tour-0{1,2,3}.png` tour 页、`tour512/` 全量帧),白块回归 0 命中。 ## 巡游村民可见性 + 孤立石板治理(P25) - 来源 commit:feat `fc5b375` / `91b7204`(分支 `p25-tour-visibility`,tick `d36e86a` 记录合并)。 - **成因**:NPC 行动目标 `Sim.actionTarget` 固定在遗留四个活动点(厨房 10,10 / 家 52,10 / 集市 32,24 / 工位 52,38),512x384 世界里它们落在左上区域,NPC 出生后从核心走向那里; 原巡游路点走桥/门/核心,故大多数采样帧 `visible-npcs` 只有 0-3。 - **解法(Desktop 取证钩子,不动 Kernel/Sim 数值路径)**:新增 `LV_RIVERSCAPE_VILLAGE_TOUR=1`, 巡游路点改为在这四个村民活动点之间环游(`villageTourWaypoints`)。512x384 跑 150 s: `visible-npcs>=8` 的采样 **112/152 = 74%**,进入村庄段后 **108/122 = 89%**(后 90 s 91%), 峰值 30/30;日志 `/tmp/opencode/lv-p25-village150.txt`。确定性不变。 - **孤立石板修复**:成因是步骤 4 的泊松装饰石块(`GroundTile.Stone=2` 单格渲染成灰石板), 512x384 有 292 个孤立石板。新增 `Params.DecorativeStones`(默认 true),`paramsForSize` 对大于 256x192 的图关闭 → 大图不再产孤立路面 tile(装饰关闭后大图石块全部属于路网)。 64x48/256x192 保持开启,checksum 6042052 / 1284656962 不变。 - 回归:`LargeMapHasNoIsolatedStoneTiles`(大图无孤立石块、石块全属路网)、 `DecorativeStonesDisabledOnLargeMaps`;Desktop 134 -> 136。 - 取证:`/tmp/opencode/lv-p25-art/`(`p25-map-512x384-seed4242.png` 整图无灰石板、 `p25-zoom-village.png` 放大含 >=8 村民、`p25-tour-0{1,2,3,4}.png` tour 页、`tour/` 全量帧), 白块回归 241 张 0 命中。 ## 江南水乡第 4 档:512×384 美术可读性与质感(P26) - 来源 commit:merge `b377e75`。 - **① 民居立面第二变体**:世界图集由 28 格扩到 **32 格**(尺寸 896x32→1024x32),新增 slot 28 屋檐挑出 / 29 木格栅窗 / 30 院门;`scripts/make-jiangnan-art.py` 仍为确定性管线, 二次运行 byte-identical。`VillageArt.houseFacadeVariant (building) = (Left+Top) % 2` 为纯函数, 沿河沿路确定性交替:变体 A 保持 P24 的黛瓦脊 + 木墙/窗 + 门;变体 B 用 `RoofEaveOverhangSprite` 向两侧各挑出一格,墙身用 `WallLatticeSprite`/`CourtyardDoorSprite`。只改绘制分支, 门可达不变量与 `MapGen` 瓦片零改动(64x48=6042052、256x192=1284656962 不变)。 - **② 河岸细节(仅 512)**:`MapGen.Params.RiverDecorations`(默认 false;`paramsForSize` 仅对 `width>256 || height>192` 置 true)。`MapGen.Result.Decorations: RiverDecoration list` 三类—— 护岸石垒 `RevetmentStone` / 芦苇 `Reeds` / 垂柳 `Willow`(占上下两格)。**纯视觉层,不写入任何 瓦片**,故沿用 `DecorativeStones` 参数化先例的同时不产孤立石板;装饰只落在紧邻水面的岸格, 并避开路网/桥面/民居/核心区。回归:`RiverDecorationsOnlyEnabledOnLargeMaps`、 `RiverbankDecorationsSitOnBanksAndNeverBlockPathsOrBridges`(岸格相邻水 + 不压路/桥/核心)、 `LargeMapRiverDecorationsAreDeterministic`。 - **③ 夜晚光照二次整改(仅 Desktop 渲染层)**:新增纯函数 `M6Presentation.moonlightVeilAlpha` (0..72,仅 deep night 有值,暖色晨昏窗口被 `warmth` 压制)与 `moonlightRgb = (120,170,235)`; `Game.DrawWorldView` 在金辉 veil 之后叠加预乘冷色整屏 veil,`premultiply` 保证 alpha<250 不可能 饱和成白块。灯笼光晕复核回归 `LanternGlowIsRadialWithoutSquareCorners`:逐像素累积同心圆 alpha, 四角恒为 0、中心行沿半径单调递减,确认无残留方斑(图像级 145+142 帧白块回归 0 命中)。 - **④ HUD 微调**:`HudLayout.textOutlineOffsets`(上下左右 4 偏移)、`HudLayout.segmentBackdrop` 与 `Palette.SegmentBackdrop/Outline`;`DrawHudBar` 每个常驻片段(时段/时间/倍速/体力/钱币) 先铺半透明深色底药丸,再以 1px 深色描边文字,昼/夜两套调色板均不透明描边。回归 `HudPersistentSegmentsGetBackdropAndOutlineInBothPeriods`。 - **回归**:Desktop **136 → 143**,Kernel 92 不变(未触 Kernel/Sim 数值路径,未动 M3)。 - **门槛**:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 3 次一致、`performance_determinism=PASS`;默认世界仍 256×192。 - **取证** `/tmp/opencode/lv-p26-art/`(沙箱把规格路径 `/tmp/lv-p26-art` 映射到此,不放 git): `p26-map-512x384-seed4242.png`(512 整图真实渲染 3072x2304)、`p26-zoom-480x360-2x.png` (村庄局部 480×360 裁切 → 2× 最近邻放大,含 30/30 可辨村民)、`p26-day-village.png`、 `p26-night-village.png`(夜景:冷月光 + 桥头灯笼 + HUD)、`p26-hud-day.png`/`p26-hud-night.png` (HUD 双时段裁切 2×)、`villager-visibility.txt`(autorun 巡游复跑可见村民统计)、 `white-block-day.txt`/`white-block-night.txt`(0 命中)。 - **范围外遗留(另开一单,本单不做)**:① 512 生成耗时因河岸装饰的 O(n²) 间距检查与民居加密从 ~80ms 升到 ~151ms(一次性、进入世界前,无逐帧开销),可改空间网格索引优化;② 变体 B 挑檐向两侧 各出一格,相邻民居极近时挑檐可能视觉叠压,可在布点时加 1 格间隔;③ 巡游 40s 档的 `visible>=8` 占比仅 13%(P25 150s 档为 74%),属取证时长差异,非可见性回归。 ## P27:Rumor 长局性能修复(工作集扫描去装箱 + 裁剪判定) - 来源 commit:merge `1b0724d`(feat `164f474` / `a059745`)。 - **现状复核(更正 M3-1 前提)**:M3-1 记录的「`World.Rumors` 不裁剪、逐次聊天全表扫描」已由 M6a (`d94ec2d` 工作集容量上界+按天淘汰、`cc850bf` 窗口化早停扫描)修复:`RumorEvent.DayIndex` 派生字段 + `trimRumors` 把工作集按「最新在前 + 3 天保留窗口」收敛。实测单日成本已平稳(本机 `--cost-probe`: 1 天 18.7s/18.9GB、5 天 90.5s/95.7GB、10 天 198.2s/191.9GB,≈19GB/天常数),M3 记录的 20 天 ≈45 s/天的超线性项已消失。故 P27 不采用「砍 depth/strength 传播」这类会改变模拟语义的方案。 - **剩余 Rumor 成本定位**:工作集有界后每次聊天仍有两条 O(N) 扫描 `latestRumorFor` / `rumorIsDuplicate`; 且此前用 `=` 比较 `[]` 的 `NpcId`/`RumorId`,会走泛型结构相等,对每一条被扫描到的谣言 装箱(实测 ≈24 B/条、单次聊天随 N 线性增长——`Sim.chat` 单独测得 N=16000 时 393,736 B/次); 这是 Rumor 逐条分配的主因,而非列表 cons 本身。 - **Kernel 长跑基准**:新增 `src/LivingVillage.Kernel/RumorBench.fs` 与 headless `--rumor-bench [days npcs chats_per_day]`,把「聊天→生成/追溯谣言」路径从 `Sim.step` 的 NPC 快照 分配解耦,按模拟日推进 tick、固定节奏注入聊天,测工作集分配与耗时;同 `Config` 的 `retained_id_checksum` 可证明确定性。 修复前(30 NPC,`--rumor-bench D 30 2000`): | days | chat_calls | rumors_retained | allocated_bytes | elapsed_ms | |---|---|---|---|---| | 3 | 6000 | 6000 | 930,716,456 | 1626.7 | | 30 | 60000 | 8000 | 13,419,045,864 | 12182.5 | | 60 | 120000 | 8000 | 27,297,668,608 | 23769.7 | 60 天工作集稳定在 8000(M6a 的 3 天保留窗口 + 2000/天),不再随年龄增长。 - **修复策略(两条,均不改保留集合与查询结果)**: 1. **扫描去装箱**:`latestRumorFor` / `rumorIsDuplicate` 原来用 `=` 比较 `[]` 的 `NpcId`/`RumorId`(含 `RumorId option`),走泛型结构相等、逐条装箱。改为先解构底层 `int`/`int64` 再比较(`Parent` 用 `int64 option` 比较);语义完全等价,扫描不再逐条分配。 2. **裁剪判定 O(1)**:`World` 新增派生镜像 `RumorCount`/`RumorOldestDay`(不参与存档,读档由 `Sim.rumorWorkingSetStats` 从列表重建)。`appendRumor` 用「`count+1 > capacity` 或 `min(oldest, day) < cutoff`」O(1) 判定;旧 `consRumor` 每次追加都 `rumorsNeedTrim` 全表扫一遍。 判定条件与 `rumorsNeedTrim` 逐字等价,仅真需裁剪时才走 `trimRumors`,保留集合逐条相同。 - **修复后基准(同机、同 `--rumor-bench D 30 2000`)**: | days | 修复前 alloc / ms | 修复后 alloc / ms | alloc 降幅 | |---|---|---|---| | 3 | 930,716,456 / 1626.7 | 482,716,192 / 1511.3 | −48% | | 30 | 13,419,045,864 / 12182.5 | 4,884,067,640 / 8879.6 | −64% | | 60 | 27,297,668,608 / 23769.7 | 9,774,660,360 / 17078.2 | −64% | 60 天分配 −64%、耗时 −28%;`retained_id_checksum=927964000`(3/30/60 天分别为 17997000 / 447996000 / 927964000)与修复前逐位一致,证明保留集合未变。 - **等价性与门槛**:5 天 2000 聊天/天连跑两遍 `WorldSave.save` 逐字节相同; `RumorCount`/`RumorOldestDay` 与对列表重算一致(count=8000、oldest_day=1)。 `final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 三次一致、`performance_determinism=PASS`;Kernel 92 / Desktop 143 全绿。 - **全模拟口径(`--cost-probe`)**:1 天 18.7s/18.8GB、5 天 90.4s/94.6GB,与修复前基本持平—— 全模拟分配由逐 tick 的 `Npc[30]` 快照(`Sim.step` 的 `Array.copy`,≈120 B/NPC/tick)主导, 谣言子系统占比很小;P27 的收益集中在 Rumor 工作集路径,符合「隔离测量」的基准口径。 逐 tick NPC 快照分配属另一个课题(会触及不可变快照不变量),留给后续单。 ## P28:512 大图民居观感整改(P26 遗留) - 来源 commit:merge `b2a6701`(feat `da11e35` / `26afb04` / `1b4c4e0` / `533b39f`)。 - **① 第二变体辨识度(确定性图集 + 纯绘制分支)**:P26 变体 B 在 512 整图 ~2px/tile 下不可辨, 根因是挑檐与变体 A 同为近黑黛瓦、格栅窗与 A 横向窗差异在 2px 内被抹平。P28 给 B 独立 配色/几何:`scripts/make-jiangnan-art.py` slot28 挑檐改为亮青灰瓦色阶(base 84,106,110 / light 134,158,158,A 仍是黛瓦脊 slot8),slot29 木格栅窗改竖长比例(内窗 10x18,A 为 18x16 横向),slot30 院门改绿双扇 + 青灰门楣;`VillageArt` 变体 B 再向上叠一层挑檐(该格为水面时 不叠,避免屋顶压水)且向两侧各出一格,墙身统一为格栅窗。图集二次运行 byte-identical (`jiangnan-world.png` md5 `e5aca186a25b8f8b78d37c0d3ef167b5`)。1 tile≈2px 实测: A 屋脊 `[46,60,54]` vs B 挑檐 `[68,101,82]`(ΔG≈40),墙线 A 暖 `[154,125,97]` vs B 冷 `[118,140,115]`,肉眼可分(对照 4x NEAREST 裁剪)。回归 `VariantBRoofPaletteIsClearlyLighterThanVariantA`:解码 world 图集断言 slot28 平均亮度 > slot8 + 15。 - **② 密度/聚落层次(仅大图 mapgen 参数)**:`MapGen.Params.ExtraRiversideHouses` 10 -> 16, 额外民居宽度按 2/3/4 加权(2=附属小筑 / 3=民居 / 4=大宅)。512x384 seed4242 由 18 -> 24 座 (变体 A 11 / B 13;宽度 2×8、3×10、4×6),不再是单一等距 3x2。默认图仍为 0 且不进入 大图分支、不消耗 `structureRng`,故 **64x48=6042052、256x192=1284656962 逐字节不变** (`DefaultSizeChecksumsArePinned`)。回归 `LargeMapHouseDensityHierarchyOnlyGrowsLargeMaps`。 - **③ 取证钩子(仅证据路径,不改语义)**:`LV_MAPGEN_FIT_WIDTH/HEIGHT` 把整图按指定像素目标 再做一次真实离屏渲染(1024x768 对 512x384 恰为 2px/tile);`LV_MAPGEN_NIGHT=1` 时以 23:30 的 真实 `WorldTint` + golden/moonlight veil 出夜景整图,与 `DrawWorldView` 共用同一套纯函数。 全部为真实渲染帧,非编辑器合成/外表图像。 - **回归/门槛**:Kernel **92** / Desktop **145** 全绿;Release build 0 警告 0 错误; `final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 3 次一致、`performance_determinism=PASS`。 - **取证** `/tmp/opencode/lv-p28/art/`:`p28-map-512x384-seed4242-day.png`(512 整图真实渲染 3072x2304)、`p28-map-512x384-seed4242-2px-day.png`(1024x768 真实帧,1 tile=2px)、 `p28-variant-pair-ab-4x-day.png`(A/B 相邻民居 4x NEAREST 对照,裁 tile x184..214 y186..196)、 `p28-village-core-4x-day.png`(村核 4x)、`p28-village-density-2x-day.png`(密度总览), 以及同名 `*-night.png`(夜景:整图 + 2px + A/B 对照)与 `p28-night-village.png` (512 夜巡真实帧,HUD 在屏、民居/石板路清晰)。白块回归 4 张夜景整图 + 571 张夜巡帧 0 命中。 ## P29:512 聚落组团 + 夜景灯光径向光晕(P28 观感遗留) - 来源 commit:merge `4034f55`(feat `a70a057`)。 - **① 聚落组团(仅大图 mapgen 分支 + 独立 group seed)**:`MapGen.Params` 新增 `ClusterSeed` (`paramsForSize` 仅大图时置 `seed ^^^ 0xC1A57E2`,默认 0 且非大图不进入分支)。512 沿河民居 不再沿路随机散点,改为沿横向石板路(主街 + 河岸路)成排成组:组内 2-4 座、组内 1 格间隙、 组间留 4-6 格空档,宽度仍按 2/3/4 加权;垂柳改为确定性成丛(2-3 株一丛、丛间 3-5 格空档), 与成排民居呼应。组发牌走独立 `clusterRng`,不触碰既有 `structureRng`,桥/路/灯笼坐标不变; 民居/树仍只落核心区外草地并做重叠检测,BFS 可达性保持。默认图仍 0 额外民居、不消费 RNG, 故 **64x48=6042052、256x192=1284656962 逐字节不变**(`DefaultSizeChecksumsArePinned`)。 - **② 夜景灯光径向衰减(渲染层纯函数)**:新增 `VillageArt.lanternFalloff distance radius` (半径 0 处 =1,边缘 =0,中间 `1-(d/r)^2` 严格单调、无平台);`lanternGlowLayers` 改为按该衰减 的逐环增量累积 alpha,`drawNightGlows` 对「桥头灯笼 + 民居门窗暖光」(`nightLightTiles`) 画半径 4 tile、8 环的柔和径向光晕。整图取证(`drawMapFitted`/`drawMapViewport`)与实机 `DrawWorldView` 共用同一套函数;无硬方斑、无白块(`check-no-white-blocks` 11 帧 0 命中)。 - **③ 取证钩子**:复用 `LV_MAPGEN_SHOT` + `LV_MAPGEN_FIT_WIDTH/HEIGHT`(2/4/8 px per tile)与 `LV_MAPGEN_NIGHT=1`(23:30 真实 `WorldTint` + golden/moonlight veil);夜景帧在原整图流程上 叠加与实机一致的灯光光晕。 - **回归**:Kernel 92 / Desktop 151(+6:`ClusterSeedOnlyEnabledForLargeMaps`、 `LargeMapHousesFormClusteredRowsWithControlledGaps`、`LargeMapClusterLayoutChecksumIsEmptyDiffAcrossRuns`、 `LargeMapWillowsGrowInGroves`、`LanternFalloffIsMaxAtCoreAndMonotonicWithoutPlateau`、 `NightLightTilesCombineBridgeLanternsAndHouseWindows`)全绿;Release 0/0; `final_digest=953775FAEB2F…` 3 次一致、`performance_determinism=PASS`。 - **取证** `/tmp/opencode/lv-p29/art/`:`p29-map-512x384-seed4242-{day,night}.png`(512 整图 3072x2304 真实渲染)、`p29-village-cluster-4x-{day,night}.png`(512x256,4px/tile 桥头沿河组团 局部,含成排民居/桥/水面)、`p29-lantern-halo-8x-night.png`(192x192,单灯 8px/tile 特写; 径向剖面 191→189→183→167→137→113→96→84 平滑递减)、`p29-variant-pair-ab-4x-{day,night}.png` (512x256,A/B 对照); 源帧 `p29-day/`、`p29-night/`、`p29-day8/`、`p29-night8/`;工作区 `art/` 不入 git。 - **遗留**:民居总数与 P28 相同(24 座),本轮只做「组团化」未继续提高总密度;河岸垂柳成丛后 仍为纯视觉层,不参与通行;默认 64x48/256x192 观感不变。 ## P30:512 民居增密 + 乡村空区填充 + 主路柔化(P29 观感遗留) - 来源 commit:merge `dabc5d0`(feat `92bcc9b`)。 - **① 民居增密与组团(仅大图 mapgen 分支)**:`ExtraRiversideHouses` 16 -> 30,组团规模由 2-4 改为 2-5 座;含基础岸屋的横向路段优先布组,并为每座仍孤立的基础民居在同行贴 1 格补 一座。`placeGroup` 先局部试排,再用 `connected`(与既有民居或同组民居足迹间隙 <=2)过滤后 才提交,孤立单屋根本不落地。512x384 seed4242:沿河/沿路组团民居 24 -> **45 座**,组团规模 `[5;4x6;3x2;2x3]`,**0 组 <2 座**;宽度仍按 2/3/4 加权(附属小筑/民居/大宅)。 - **② 乡村空区填充(仅大图)**:新增 `GroundTile.PaddyField=4 / VegetablePlot=5`(成熟 tile 素材:水田走静态水面 sprite、菜畦走菜地 sprite;均为非水瓦片故仍可通行)、`Result.Groves` (竹丛/灌木丛,纯视觉层,不入瓦片、不影响通行)、`Result.Farmhouses`(散落农舍,门贴院落路, 计入 BFS 可达性)。按 18x14 确定性网格铺田块/树丛/农舍,田块离水面 >=3 格并避开路网/核心/ 民居;上部与下部空带均有田块。512x384 seed4242:水田 11595 + 菜畦 12877 格、树丛 852 格、 散落农舍 21 座;草地瓦片占比 **96.3% -> 83.9%**(整图真实渲染中草地像素约 80.8%)。 - **③ 主路柔化(仅大图)**:新增 `Result.MainRoadCenter` 逐列中心线,用确定性 value noise (scale 130、幅度 ±3 格)微扰,相邻列步长恒 `<=1`,遇弯补一格保证 4 连通;桥的竖向引路按 `roadYAt bx` 对齐。默认图主街仍笔直。同 seed 两次逐字节一致(`serialize` 含中心线)。 - **回归 +5**:`LargeMapGroupsAreTwoToFiveWithFewIsolated`、 `LargeMapCountrysideFillsEmptyBandsWithMatureTiles`、`FieldsStayOnLandAwayFromWaterPathsAndCore`、 `LargeMapFarmsteadDoorsAreReachableAndDeterministic`、`LargeMapMainRoadIsDeterministicSerpentine`。 Kernel **92** / Desktop **156** 全绿;Release build 0 警告 0 错误; `final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 3 次一致、 `performance_determinism=PASS`;**64x48=6042052、256x192=1284656962 锁定不变** (`DefaultSizeChecksumsArePinned`)。 - **取证** `/tmp/opencode/lv-p30/art/`(真实离屏渲染,非构造):`p30-map-512x384-seed4242-{day,night}.png` (3072x2304 整图)、`p30-field-cluster-4x-{day,night}.png`(512x256,4px/tile,田块+农舍+树丛)、 `p30-roadserp-4x-day.png`(512x256,4px/tile,主路逐点弯曲)。`check-no-white-blocks` 5 帧 0 命中。 - **遗留**:散落农舍与乡村树丛为纯布局/美术层,不与 NPC 数值、任务系统耦合;默认 64x48/256x192 判定不进入新分支、不消费额外 RNG,观感不变。 ## 职业系统(P11-P15 铺底,P31 补完对话与剧情线) - 代码落点:`src/LivingVillage.Kernel/Occupation.fs`(Kernel 纯函数,不进 `Sim.step` 数值路径)+ Desktop 选择页/任务面板/报价接线。设计依据为第二版实现稿 `docs/design/occupation-system-design.md`;第一版草稿 `docs/profession-system-design.md` 已被其取代(「药童」以「农夫」为第四职业合入 main)。 - 职业四档(`Occupation.profileOf`):农夫 Money 60 / 能量 100 / Food×12;渔夫 45 / 95 / Food×8 + Fish×3;货郎 90 / 100 / Food×6 + Spice×4;书生 70 / 100 / Food×10 + Scroll×2。 - P11 每日任务(`f883070`、`27f0ca0`):任务生成纯函数 + `TaskState` 状态机(Offered/Active/Done/Failed); 存档 v3 `today-task` 尾段(annals 之后追加,双向兼容读旧档)。 - P12 报价偏置(`c348da2`、`98b6b21`):4 位定点基点钳 [0.95, 1.05]——货郎买卖双向 ±3.00%、 书生 ±1.00%,农夫/渔夫/无职业恒 1.0;T 键任务面板(Offered/Active/Done 三态 + 职业名), 存读档字段级携带职业。 - P15 玩法收口(feat `0222501` / `e1d8d45` / `60bbc83` / `50875bb`,merge `6ce29c5`,独立复验全绿): 货郎/书生各补 2 条每日任务模板 + 既有事件通道信号桥;CJK 图集 197->228 补职业/任务用字; 开局职业选择页(标题→新游戏→四选一+「暂不选择」);选职业→第一天任务→完成判定→Done 全链路回归。 - 确定性口径:同 seed 同职业两次开局世界逐字节一致(任务哈希走 `(seed, profession, day)` splitmix 模式);无职业世界全部走 identity/原路径,`performance-baseline` digest 不受影响。 ## P31:职业系统 P-d——对话分支倾向 + 四条轻剧情线 - 来源 commit:feat `6c2f180`(step1 偏差)/ `0539bba`(step2 状态机)/ `9821b54`(step3 年鉴+身份+图集)/ `4f51232`(step4 显示与证据);合并经 `b3d1cb3`(tick `d429662` 验收记 「profession system P-a..d complete」)。 - **① 对话分支倾向(design §5)**:`Occupation.biasResponse (kind option) (intent) (personality)` 是 `Sim.responseFor` 之上的纯函数重映射,无职业(None)逐字节等于原输出。规则各自只作用于 签名意图:农夫 AskHelp `Refused->Helpful`;渔夫 SmallTalk `Reserved->Friendly`(聊天气鱼汛, 冷场变热络);货郎 SmallTalk `Reserved->Bargaining`(三句不离买卖);书生 Joke **完全覆盖** 为 `Reserved`(与 personality 相反时取完全覆盖语义而非概率偏向,禁随机,design §5 反差感)。 接线:`Sim.responseFor` 改为公开(同一纯函数,原口径不变);新增 `Sim.chooseDialogueWith respond target intent world`(`chooseDialogue` 保留为 identity 包装, 既有行为逐字节不变);Desktop `M5Interaction.chooseIntent` 按玩家职业传 `biasResponse`, 偏差后的回应照常进 DialogueOutcome/双向记忆/年鉴(`Sim.appendAnnal` 改公开,语义不变)。 - **② 轻剧情线状态机**:`Occupation.Story`,每职业三段式 接任务→走访→收束,`StoryStage` 语义 `0 未开始 / 1 已接任务 / 2 走访中 / 3 已收束`(v3 occupation 段早已序列化该字段, 读取路径原样恢复,存档段格式未动)。每次玩家对话至多推进一段:开线日 `startDayOf seed kind = hash(seed ^ storySeed, day 0) mod 3 + 2 ∈ [2,4]`(沿每日任务的 splitmix `(seed, profession, day)` 哈希模式,story 种子与任务种子两两不撞);`1→2`、`2→3` 分别要求 `day >= startDay+1`、`startDay+2`。终局由 `relationMeanToPlayer`(与 `Sim.relationMatrix` 同口径的衰减 valence,只计认识玩家的村民;无一认识 = 0)过纯阈值 `≥0.3 Warm / [0,0.3) Plain / <0 Distant` 定格三种结局文案,写入年鉴快照后不再随关系衰减改变。 四条线:农夫「雨水与年成」、渔夫「水位与收成」、货郎「新货的路子」、书生「庙前的争论」。 (设计稿 §5 的「药童」在 P-a 落地时以「农夫」为第四职业合入 main,故第四线写农夫题材, 其余三线名照设计稿。) - **③ 年鉴条目**:`Sim.AnnalKind` 新增 `StoryAnnal`(文案落 `Summary`),v3 存档以新 token `story` 序列化;v1/v2(无职业)永不产生该 token,字节口径不变;v3 存→读→存逐字节一致 (有测试)。§3「身份条目」此前未落地,本单补齐:带职业开局由 `StartNewGame` 写入 `以耕田为生 / 以打渔为生 / 以贩货为生 / 以读书为生`(年鉴短句风格,不带主语)。 - **④ 显示层收口(最小展示)**:任务面板(T 键)在职业名/每日任务下新增一行 `剧情线:<线名>,<段名>`;职业选择页选中职业时提示 `剧情线:<线名>`;年鉴面板按既有 `annalLine` 渲染 `StoryAnnal.Summary` 原文。M5View 新增 `Seed` 字段(与开局种子同源), 供剧情线开线日哈希。 - **⑤ 中文图集**:新增文案(四线名、四段名、身份条目、接任务/走访/12 条结局文案)全部进入 `ChineseText.requiredUiLabels`;`cjk-glyphs.txt` 与 `CjkGlyphAtlas.characterTable` 同步 238 -> **307** 字(384x480,行数 15 -> 20),`scripts/generate-cjk-glyph-atlas.sh` 确定性 再生成 PNG;`CjkGlyphAtlasHasAStableCharacterTableAndGeometry` 计数/行数/尺寸断言同步更新。 - **回归**:Kernel **92 -> 119**(偏差 9 + 剧情线 14 + 存档/身份 4)、Desktop **156 -> 160** (面板剧情行 2、M5 对话接线 2)全绿;Release build 0 警告 0 错误; `final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 多次一致; **64x48=6042052、256x192=1284656962 锁定不变**(`DefaultSizeChecksumsArePinned`)。 M3 相关文件零改动;无职业世界(Occupation=None)全部走 identity/原路径,模拟数值语义 未触碰(Sim.fs 仅 visibility 公开 + chooseDialogue 重构为 identity 包装,行为逐字节等价)。 - **取证** `/tmp/opencode/lv-p31/art/`(gitignore 不入库,真实 Xvfb 帧非概念图): `p31-story-{farmer,fisher,peddler,scholar}-stage2.png`(1280x720 各 1 张,任务面板含 剧情线行 + 年鉴身份条目);同批四张已复制到仓库 `docs/evidence/p31-story-*.stage2.png` 供查阅(注:根 .gitignore 的 `evidence/` 规则使其未入库,属文档外仓库状态,留给后续单处置)。复现命令: `timeout 120 xvfb-run -a -s "-screen 0 1280x720x24" env LV_STORY_SHOT=1 LV_STORY_OCCUPATION=fisher LV_STORY_STAGE=2 LV_RECORD_DIR=/tmp/opencode/lv-p31/art dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll`。 - **范围外**:设计稿 §5 的「视频证据」以 4 张真实渲染截图替代(本单验收清单口径); 药童线以农夫线替代(见 ②);NPC 职业化、补选职业、剧情线多线并行仍为设计边界外。 ## 已验证命令 ```bash # 完整重建(0 警告 0 错误) dotnet clean LivingVillage.sln 2>&1 | tail -n1 rm -rf src/*/bin src/*/obj dotnet restore LivingVillage.sln dotnet build LivingVillage.sln -c Release 2>&1 | tail -n3 # 全量测试 dotnet test src/LivingVillage.Desktop.Tests -c Release --no-build dotnet test src/LivingVillage.Kernel.Tests -c Release --no-build # M3 批次(流式;判据行与原格式逐字节兼容,仅行尾追加 elapsed_s) LV_BATCH_WORKERS=4 dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll --batch 4 10 # 长局成本阶梯(只读,真实测量) dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll --cost-probe 1,5,10 # P27 Rumor 长跑基准(隔离谣言子系统;默认 30 NPC×60 天×2000 聊天/天) dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll --rumor-bench dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll --rumor-bench 3 30 2000 # 素材再生成(确定性,输出 byte-identical) python3 scripts/make-jiangnan-art.py && python3 scripts/make-cc0-art.py # Xvfb 录屏取证(沿用既有 pN harness 外参即可;harness 不入库) pkill -f "Xvfb :99"; Xvfb :99 -screen 0 1280x720x24 & sleep 2 ffmpeg -y -f x11grab -video_size 1280x720 -framerate 10 -i :99 -pix_fmt yuv420p out.mp4 LV_AUTOPLAY_SAMPLE=1 LV_AUTOPLAY_DAYLIGHT=1 LV_AUTOPLAY_RECORD=1 \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll # P16-FIX 开始界面截图取证(停在主菜单,拍初始/读档高亮与 splash 淡入中帧) timeout 90 xvfb-run -a -s "-screen 0 1280x720x24" env \ LV_AUTOPLAY_MENU_SHOT=1 LV_RECORD_DIR=/tmp/opencode/lv-p16-art \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll # P17 取证(街边细节/人物节奏/昼夜精修;LV_AUTOPLAY_START_HOUR 定起时:昼 12 / 黄昏 20.75 / 夜 0) for pair in day:12.0 dusk:20.75 night:0.0; do name=${pair%%:*}; hour=${pair##*:}; \ timeout 100 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ LV_AUTOPLAY_START_HOUR=$hour LV_AUTOPLAY_RECORD=1 \ LV_RECORD_DIR=/tmp/opencode/lv-p17-art/$name LV_RECORD_EVERY=2 \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done # P19 白天色温取证(正午 12 / 黄昏 20.75 / 深夜 0.5 三帧对比) for pair in noon:12.0 dusk:20.75 night:0.5; do name=${pair%%:*}; hour=${pair##*:}; \ timeout 100 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ LV_AUTOPLAY_START_HOUR=$hour LV_AUTOPLAY_RECORD=1 \ LV_RECORD_DIR=/tmp/opencode/lv-p19-art/$name LV_RECORD_EVERY=2 \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done # P21 性能核对:256x192 可玩世界实机帧率(放开 vsync)+ 60s 稳定采样 timeout 90 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ LV_RIVERSCAPE_TOUR=1 LV_RIVERSCAPE_SECONDS=60 LV_UNLOCK_FPS=1 LV_PERF_SUMMARY=1 \ LV_AUTOPLAY_START_HOUR=12.0 \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll # 内存(运行中采样 VmRSS)与 headless ticks/s PID=$(pgrep -n -f LivingVillage.Desktop.dll); awk '/VmRSS/{print $2" kB"}' /proc/$PID/status dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll --performance-baseline # P21 昼夜巡游取证(真走位,p21-* 命名;代表帧挑出到 /tmp/opencode/lv-p21-art/) for pair in day:12.0 night:20.75; do name=${pair%%:*}; hour=${pair##*:}; \ timeout 150 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ LV_RIVERSCAPE_TOUR=1 LV_AUTOPLAY_RECORD=1 LV_RECORD_EVERY=20 LV_RECORD_NAME=p21-$name- \ LV_RECORD_DIR=/tmp/opencode/lv-p21-art/raw-$name LV_AUTOPLAY_START_HOUR=$hour \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done # P22 补夜档巡游(23:30),并从中重挑「游戏内」代表帧覆盖启动/标题画面误帧 # HUD 判定:y in [2,35) 且 x in [0,w/2) 的近白像素 > 500 => 游戏内帧(标题/启动为 0) timeout 150 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ LV_RIVERSCAPE_TOUR=1 LV_AUTOPLAY_RECORD=1 LV_RECORD_EVERY=40 LV_RECORD_NAME=p21-n2- \ LV_RECORD_DIR=/tmp/opencode/lv-p21-art/raw-night2 LV_AUTOPLAY_START_HOUR=23.5 \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll # P22 默认评估:64x48 样例世界 60s 帧率采样(LV_PERF_SOAK_SECONDS 让脚本循环采样) timeout 120 xvfb-run -a -s "-screen 0 1280x720x24" env LV_LEGACY_MAP=1 \ LV_AUTOPLAY_SAMPLE=1 LV_UNLOCK_FPS=1 LV_PERF_SUMMARY=1 LV_PERF_SOAK_SECONDS=60 \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll # P23 512x384 扩程取证:整图真实渲染 + 视口/近景(seed 4242 与 7) for seed in 4242 7; do timeout 120 xvfb-run -a -s "-screen 0 1280x720x24" env \ LV_MAPGEN_SHOT=1 LV_MAPGEN_WIDTH=512 LV_MAPGEN_HEIGHT=384 LV_MAPGEN_SEED=$seed \ LV_RECORD_DIR=/tmp/opencode/lv-p23-art \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done # P23 512x384 巡游帧率(单趟,解锁 vsync;日志打印 perf-summary 与 VmRSS 可另采) timeout 280 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ LV_RIVERSCAPE_TOUR=1 LV_RIVERSCAPE_WIDTH=512 LV_RIVERSCAPE_HEIGHT=384 \ LV_UNLOCK_FPS=1 LV_PERF_SUMMARY=1 \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll # P24 512x384 民居密度取证:整图 + 巡游(记录帧;LV_NPC_VISIBLE_LOG 打印可见村民数) timeout 240 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ LV_RIVERSCAPE_TOUR=1 LV_RIVERSCAPE_WIDTH=512 LV_RIVERSCAPE_HEIGHT=384 \ LV_UNLOCK_FPS=1 LV_NPC_VISIBLE_LOG=1 LV_AUTOPLAY_RECORD=1 LV_RECORD_EVERY=120 \ LV_RECORD_NAME=p24-tour- LV_RECORD_DIR=/tmp/opencode/lv-p24-art/tour512 \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll # 局部放大由 tour512 帧裁 480x360 后 2x 最近邻放大(含民居+村民) # P25 村民可见性巡游:路点在 Sim 四个活动点间环游,150s 循环,多数采样帧 >=8 村民 timeout 220 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ LV_RIVERSCAPE_TOUR=1 LV_RIVERSCAPE_VILLAGE_TOUR=1 LV_RIVERSCAPE_WIDTH=512 \ LV_RIVERSCAPE_HEIGHT=384 LV_RIVERSCAPE_SECONDS=150 LV_UNLOCK_FPS=1 \ LV_NPC_VISIBLE_LOG=1 LV_AUTOPLAY_RECORD=1 LV_RECORD_EVERY=120 \ LV_RECORD_NAME=p25-tour- LV_RECORD_DIR=/tmp/opencode/lv-p25-art/tour \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll # P25 整图取证(无孤立灰石板) timeout 120 xvfb-run -a -s "-screen 0 1280x720x24" env LV_MAPGEN_SHOT=1 \ LV_MAPGEN_WIDTH=512 LV_MAPGEN_HEIGHT=384 LV_MAPGEN_SEED=4242 \ LV_RECORD_DIR=/tmp/opencode/lv-p25-art \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll # P26 512 整图真实渲染(含两种民居立面 + 河岸装饰;产物 p20b-map-*.png,改名为 p26-map-*) timeout 150 xvfb-run -a -s "-screen 0 1280x720x24" env LV_MAPGEN_SHOT=1 \ LV_MAPGEN_WIDTH=512 LV_MAPGEN_HEIGHT=384 LV_MAPGEN_SEED=4242 \ LV_RECORD_DIR=/tmp/opencode/lv-p26-art/map \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll # P26 昼夜村庄巡游录帧(村民可见性日志 + 夜景月光/灯笼/HUD 取证) for pair in day:12.0 night:23.5; do name=${pair%%:*}; hour=${pair##*:}; \ timeout 200 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ LV_RIVERSCAPE_TOUR=1 LV_RIVERSCAPE_VILLAGE_TOUR=1 LV_RIVERSCAPE_WIDTH=512 \ LV_RIVERSCAPE_HEIGHT=384 LV_RIVERSCAPE_SECONDS=40 LV_AUTOPLAY_START_HOUR=$hour \ LV_NPC_VISIBLE_LOG=1 LV_AUTOPLAY_RECORD=1 LV_RECORD_EVERY=30 \ LV_RECORD_NAME=p26-$name- LV_RECORD_DIR=/tmp/opencode/lv-p26-art/$name \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done # 局部 480x360 锚定村民+民居质心裁切后 2x 最近邻放大 -> p26-zoom-480x360-2x.png # HUD 双时段:裁顶部 y 0..44 左 640px 再 2x -> p26-hud-day.png / p26-hud-night.png # P26 夜景白块回归(方斑核对,0 命中) python3 scripts/check-no-white-blocks.py /tmp/opencode/lv-p26-art/night # P28 512 整图真实渲染(3072 整图 + 1024x768 恰 2px/tile;night=1 走 23:30 tint+月光 veil) for night in 0 1; do extra=""; [ "$night" = 1 ] && extra="LV_MAPGEN_NIGHT=1"; \ dir=/tmp/opencode/lv-p28/art/map; [ "$night" = 1 ] && dir=/tmp/opencode/lv-p28/art/map-night; \ timeout 180 xvfb-run -a -s "-screen 0 1280x720x24" env LV_MAPGEN_SHOT=1 \ LV_MAPGEN_WIDTH=512 LV_MAPGEN_HEIGHT=384 LV_MAPGEN_SEED=4242 \ LV_MAPGEN_FIT_WIDTH=1024 LV_MAPGEN_FIT_HEIGHT=768 $extra LV_RECORD_DIR=$dir \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done # 两个变体 4x NEAREST 裁剪对照(从 1024x768 真实帧按 2px/tile 裁 tile x184..214 y186..196) python3 - <<'PY' from PIL import Image for tag, p in (("day","/tmp/opencode/lv-p28/art/map"), ("night","/tmp/opencode/lv-p28/art/map-night")): im = Image.open(f"{p}/p28-fit-1024x768-seed4242.png").convert("RGB") c = im.crop((184*2, 186*2, 214*2+2, 196*2+2)) c.resize((c.width*4, c.height*4), Image.NEAREST).save(f"/tmp/opencode/lv-p28/art/p28-variant-pair-ab-4x-{tag}.png") PY # P28 夜景白块回归(4 张整图 + 571 张夜巡帧,0 命中) python3 scripts/check-no-white-blocks.py /tmp/opencode/lv-p28/art/map-night python3 scripts/check-no-white-blocks.py /tmp/opencode/lv-p28/art/night-raw # P28 512 夜巡真实帧(seed 42;默认桥/门巡游路点,夜景 23:30) timeout 220 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ LV_RIVERSCAPE_TOUR=1 LV_RIVERSCAPE_WIDTH=512 LV_RIVERSCAPE_HEIGHT=384 \ LV_RIVERSCAPE_SECONDS=60 LV_AUTOPLAY_START_HOUR=23.5 LV_NPC_VISIBLE_LOG=1 \ LV_AUTOPLAY_RECORD=1 LV_RECORD_EVERY=20 LV_RECORD_NAME=p28-night- \ LV_RECORD_DIR=/tmp/opencode/lv-p28/art/night-raw \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll # 代表帧 p28-night-10100.png -> art/p28-night-village.png # P29 512 组团 + 夜景光晕取证(4/8 px per tile;night 走 23:30 tint+veil+径向光晕) run_p29 () { # $1=fit_w $2=fit_h $3=night(0/1) $4=out_dir local extra=""; [ "$3" = 1 ] && extra="LV_MAPGEN_NIGHT=1" timeout 300 xvfb-run -a -s "-screen 0 1280x720x24" env LV_MAPGEN_SHOT=1 \ LV_MAPGEN_WIDTH=512 LV_MAPGEN_HEIGHT=384 LV_MAPGEN_SEED=4242 \ LV_MAPGEN_FIT_WIDTH=$1 LV_MAPGEN_FIT_HEIGHT=$2 $extra LV_RECORD_DIR=$4 \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll } run_p29 2048 1536 0 /tmp/opencode/lv-p29/art/p29-day run_p29 2048 1536 1 /tmp/opencode/lv-p29/art/p29-night run_p29 4096 3072 0 /tmp/opencode/lv-p29/art/p29-day8 run_p29 4096 3072 1 /tmp/opencode/lv-p29/art/p29-night8 # 裁剪(PIL):整图 p20b-map-*.png -> p29-map-*;组团 4x = 2048 帧裁 tile x278..406 y82..146; # 单灯 8x = 4096 帧以桥灯 (113,180) 为中心裁 192x192; # A/B 对照 = 2048 帧裁 tile x228..260 y170..186 后 4x NEAREST -> 512x256 python3 scripts/check-no-white-blocks.py /tmp/opencode/lv-p29/art/p29-night /tmp/opencode/lv-p29/art/p29-night8 # P30 512 证据出图(真实离屏渲染:整图 3072x2304 + 田块/主路局部 512x256@4px per tile) mkdir -p /tmp/opencode/lv-p30/art timeout 300 xvfb-run -a -s "-screen 0 1280x720x24" env LV_MAPGEN_SHOT=1 LV_MAPGEN_P30_SHOT=1 \ LV_MAPGEN_WIDTH=512 LV_MAPGEN_HEIGHT=384 LV_MAPGEN_SEED=4242 \ LV_RECORD_DIR=/tmp/opencode/lv-p30/art \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll python3 scripts/check-no-white-blocks.py /tmp/opencode/lv-p30/art/*.png # P20 第三步:默认生成器世界的昼夜巡游录制(真走位,含桥面行走帧;p20c-* 命名) for pair in day:12.0 night:20.75; do name=${pair%%:*}; hour=${pair##*:}; \ timeout 150 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ LV_RIVERSCAPE_TOUR=1 LV_AUTOPLAY_RECORD=1 LV_RECORD_EVERY=20 LV_RECORD_NAME=p20c-$name- \ LV_RECORD_DIR=/tmp/opencode/lv-p20-art/p20c-$name LV_AUTOPLAY_START_HOUR=$hour \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done # P20 大地图生成器取证(整图 + 裁剪帧;每 seed 一拍即退,日志打印 gen_ms/drawn/culled) for seed in 4242 7 99; do timeout 120 xvfb-run -a -s "-screen 0 1280x720x24" env \ LV_MAPGEN_SHOT=1 LV_MAPGEN_WIDTH=256 LV_MAPGEN_HEIGHT=192 LV_MAPGEN_SEED=$seed \ LV_RECORD_DIR=/tmp/opencode/lv-p20-art \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done # P18 黄昏暖色取证(20:45 真实帧;深夜把 20.75 换成 0) timeout 100 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ LV_AUTOPLAY_START_HOUR=20.75 LV_AUTOPLAY_RECORD=1 \ LV_RECORD_DIR=/tmp/opencode/lv-p18-art/dusk LV_RECORD_EVERY=2 \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll # 街边细节图集再生成(确定性,二次运行 byte-identical) python3 scripts/make-jiangnan-art.py # P13 动画证据(确定性接触表 + 真实运行帧) python3 scripts/make-animation-evidence.py # -> evidence/walk-idle-frames.png 等 # 真实运行帧(白天 / 夜晚各一次;record 目录与间隔可用环境变量覆盖) timeout 45 xvfb-run -a -s "-screen 0 1280x720x24" env \ LV_AUTOPLAY_SAMPLE=1 LV_AUTOPLAY_DAYLIGHT=1 LV_AUTOPLAY_RECORD=1 \ LV_RECORD_DIR="$PWD/evidence/day-run" LV_RECORD_EVERY=2 \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll timeout 45 xvfb-run -a -s "-screen 0 1280x720x24" env \ LV_AUTOPLAY_SAMPLE=1 LV_AUTOPLAY_RECORD=1 \ LV_RECORD_DIR="$PWD/evidence/night-run" LV_RECORD_EVERY=2 \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll ``` - `LV_RECORD_DIR` 默认 `/tmp/lv-p5-prompt`,`LV_RECORD_EVERY` 默认 25(每 N 次绘制存一帧)。 跑完 `sample result=ok`;连续移动帧(如 day-run 的 0136..0146)即走路逐帧采样。 - 夜晚白块图像级回归(0 命中 = 通过;对 P13 旧帧应报 42/69 命中): ```bash python3 scripts/check-no-white-blocks.py evidence/night-run ``` ## 取证快照位置(近期) - `docs/evidence/`(P31 四职业 stage2 截图 `p31-story-{farmer,fisher,peddler,scholar}-stage2.png`; P32 夜间白块修复运行时截图 `p32-*.png`。该目录被 .gitignore 的 `evidence/` 规则命中, UI/文档类证据用 `git add -f` 显式入库,其余大批量工作区帧仍不入 git) - `/tmp/lv-p2-ui/`、`/tmp/lv-p3-fixes/`(标题/键位/P5 提示与修) - `/tmp/lv-p6-art/`(昼夜光照/四帧行走/标题入场) - `/tmp/lv-p7-map/`(512×384 大世界巡游帧 + 夜灯对照) - `/tmp/lv-p8-cc0/`(CC0 同 seed 对照帧) - `/tmp/opencode/lv-p13/evidence/`(P13:`walk-idle-frames.png`、`water-3phase.png`、 `smoke-3phase.png`、`water-2frame.png`、`smoke-2frame.png`、`day-panorama.png`、 `night-lantern.png`、`day-night-compare.png`、`walk-real-run.png`、`day-run/`、`night-run/`) - `/tmp/opencode/lv-p25-art/`(P25:`p25-map-512x384-seed4242.png` 整图无灰石板、 `p25-culled-*`/`p25-detail-*`、`p25-zoom-village.png` 放大含 >=8 村民、 `p25-tour-0{1,2,3,4}.png` tour 页、`tour/` 全量巡游帧) - `/tmp/opencode/lv-p24-art/`(P24 512x384:`p24-map-512x384-seed{4242,7}.png` 整图、 `p24-zoom-houses.png`/`p24-zoom-villagers.png` 局部放大、`p24-tour-0{1,2,3}.png` tour 页、 `tour512/` 全量巡游帧;规格要求的 `/tmp/lv-p24-art` 被沙箱 external_directory deny,落此路径) - `/tmp/opencode/lv-p23-art/`(P23 512x384:`p23-map-512x384-seed{4242,7}.png` 整图真实渲染、 `p23-culled-512x384-seed{4242,7}.png` 视口裁剪、`p23-detail-512x384-seed{4242,7}.png` 1:1 近景、 `tour512/` 巡游帧) - `/tmp/opencode/lv-p21-art/`(P21:`p21-day-bridge-01.png`、`p21-day-river-01.png`、 `p21-day-buildings-0{1,2}.png`、`p21-night-bridge-01.png`、`p21-night-river-01.png`、 `p21-night-street-01.png`;原始帧 `raw-day/`、`raw-night/`) - `/tmp/opencode/lv-p20-art/`(P20 第三步:默认生成器世界昼夜巡游 `p20c-day-*.png`/`p20c-night-*.png` (含桥面行走)、代表帧 `p20c-day-bridge-01.png`/`p20c-day-buildings-01.png`/ `p20c-night-bridge-01.png`/`p20c-night-lantern-01.png`;P20:第一步 `mapgen-256x192-seed{4242,7,99}.png` 整图、 `mapgen-culled-256x192-seed{4242,7,99}.png` 裁剪帧;第二步 `p20b-map-256x192-seed{4242,7,99}.png` 整图(含桥/路/沿河民居)、`p20b-detail-256x192-seed{4242,7,99}.png` 桥/路/民居 1:1 近景、 `p20b-culled-256x192-seed{...}.png` 裁剪帧) - `/tmp/opencode/lv-p19-art/`(P19:`p19-noon-12h-0{1,2}.png`、`p19-dusk-20h45-01.png`、 `p19-night-00h30-01.png`、`p19-before-noon-yellow.png`/`p19-after-noon-neutral.png`;同一份拷入 `evidence/`) - `/tmp/opencode/lv-p18-art/`(P18:`p18-dusk-20h45-0{1,2}.png`、`p18-night-deep-01.png`、 `p18-before-old-dusk-blue.png`/`p18-after-new-dusk-warm.png`、`sample-dusk.mp4`;同一份拷入 `evidence/`) - `/tmp/opencode/lv-p17-art/`(P17:`p17-day-12h-0{1,2}.png`、`p17-dusk-20h45-0{1,2}.png`、 `p17-night-00h-0{1,2}.png`、`p17-diff-before-dawn.png`/`p17-diff-after-dawn.png`、`sample.mp4`; 同一份拷入 `evidence/`) - `/tmp/opencode/lv-p16-art/`(P16:`frame-day-0{1,2,3}.png`、`frame-night-0{1,2,3}.png`、 `frame-splash-01.png`、`frame-menu-0{1,2}.png`、`hud-bar-day.png`、`hud-bar-night.png`、 `sample.mp4`、`final_verification.txt`;沙箱不允许 `/tmp/lv-p16-art`,故落此路径) - `/tmp/opencode/lv-p13-evidence/`(P14 修复后重录:`day-panorama.png`、`night-lantern.png`、 `day-night-compare.png`、`chimney-closeup.png`、`night-lantern-closeup.png`、 `white-block-check.txt`、`day-run/`、`night-run/`;同步一份到 `evidence/`) - `/tmp/opencode/lv-p28/art/`(P28:`p28-map-512x384-seed4242-day.png`/`-night.png` 512 整图、 `p28-map-512x384-seed4242-2px-day.png`/`-night.png` 1024x768 恰 2px/tile、 `p28-variant-pair-ab-4x-day.png`/`-night.png` A/B 4x 对照、 `p28-village-core-4x-day.png`/`-night.png`、`p28-village-density-2x-day.png`/`-night.png`、 `p28-night-village.png` 夜巡代表帧、`night-raw/` 571 帧;工作区内不入 git) - `/tmp/opencode/lv-p29/art/`(P29:`p29-map-512x384-seed4242-{day,night}.png` 512 整图、 `p29-village-cluster-4x-{day,night}.png` 512x256 桥头沿河组团 4px/tile、 `p29-lantern-halo-8x-night.png` 192x192 单灯 8px/tile 光晕特写、 `p29-variant-pair-ab-4x-{day,night}.png` 512x256 A/B 对照;源帧 `p29-day/`、`p29-night/`、 `p29-day8/`、`p29-night8/`;工作区 `art/` 不入 git) - `/tmp/opencode/lv-p30/art/`(P30:`p30-map-512x384-seed4242-{day,night}.png` 3072x2304 整图、 `p30-field-cluster-4x-{day,night}.png` 512x256 田块+农舍+树丛 4px/tile、 `p30-roadserp-4x-day.png` 512x256 主路逐点弯曲 4px/tile;工作区 `art/` 不入 git) - `artifacts/perf-independent//`(digest 3 联测回读) ## 证据清单(P32 夜间白块修复运行时截图) P32 补拍 P14(预乘 alpha 灯笼光晕 / 水面相位 / 炊烟 late pass)修复后的真实运行时夜间帧, 落库 `docs/evidence/`。该目录被 .gitignore 的 `evidence/` 规则命中,故 P32 这批 UI/文档证据用 `git add -f` 显式入库。 复现命令(Release 构建后;`/tmp` 请用沙箱允许的路径): ```bash DLL=src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll # ① 生成器世界整帧:512x384 tile、seed 4242,2048x1536 = 4px/tile;night 走 23:30 for t in day night; do extra=""; [ "$t" = night ] && extra="LV_MAPGEN_NIGHT=1" timeout 200 xvfb-run -a -s "-screen 0 1280x720x24" env LV_MAPGEN_SHOT=1 \ LV_MAPGEN_WIDTH=512 LV_MAPGEN_HEIGHT=384 LV_MAPGEN_SEED=4242 \ LV_MAPGEN_FIT_WIDTH=2048 LV_MAPGEN_FIT_HEIGHT=1536 $extra \ LV_RECORD_DIR=/tmp/opencode/lv-p32-evidence/$t dotnet "$DLL" done # ② 样例村庄真实运行帧(1280x720;每 10 帧存 1) for h in 23.5 12.0; do tag=night; [ "$h" = 12.0 ] && tag=day timeout 25 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ LV_AUTOPLAY_RECORD=1 LV_AUTOPLAY_START_HOUR=$h LV_RECORD_EVERY=10 \ LV_RECORD_NAME=p32-$tag- LV_RECORD_DIR=/tmp/opencode/lv-p32-evidence/scene-$tag \ dotnet "$DLL" done # ③ 夜间白块回归(本单 7 帧 + 生成器夜帧全集,0 命中) python3 scripts/check-no-white-blocks.py docs/evidence/p32-*.png python3 scripts/check-no-white-blocks.py /tmp/opencode/lv-p32-evidence/night ``` 入库文件(`docs/evidence/`): | 文件 | 内容 | | --- | --- | | `p32-night-map-512x384-seed4242.png` | 生成器世界夜间整帧 2048x1536(23:30 `WorldTint` + 月光 veil + 桥头/民居径向暖光) | | `p32-day-map-512x384-seed4242.png` | 同上白天对照帧 | | `p32-night-map-bridge-2x.png` | 桥 (249,203) + 河面区域 2x NEAREST:暖色径向光晕(R−B 峰值 +47、暖光像素 9884)叠在蓝色水面上 | | `p32-night-village.png` | 样例村庄真实运行夜间整帧 1280x720 | | `p32-day-village.png` | 样例村庄真实运行白天整帧 1280x720 | | `p32-night-village-closeup-2x.png` | 村庄灯笼/水面区域 2x NEAREST(夜光在月光 veil 下偏柔,无白块) | | `p32-day-village-closeup-2x.png` | 村庄民居/水面/炊烟区域 2x NEAREST(炊烟烟柱在屋脊上方,见 P14 late pass) | 说明:`DrawMapFitted` 生成器渲染路径不绘制炊烟(炊烟只在样例村庄 `VillageArt.drawWorld` 的 late pass),故「灯笼光晕 + 水面」以生成器夜帧为准,「炊烟」见样例村庄昼间特写。所有帧 `check-no-white-blocks.py` 0 命中(无 ≥64x64 不透明纯白块)。 ## P33 素材接入样板(真实像素素材竖直切片) 把样例村庄的**一类地面 + 一类角色**从程序绘制换成 CC0 真实像素素材,跑通接入管线;其余仍走 原程序绘制图集 fallback。来源 / 许可 / sha256 完整记录见 `docs/素材来源.md`。 - 构建(确定性,二次运行 byte-identical): ```bash python3 scripts/make-cc0-art.py # -> Assets/cc0-tiles.png 128x32 (4 个 32x32 地面格) # -> Assets/cc0-characters.png 3840x48 (5 变体 x 4 向 x 6 帧, 32x48 格) ``` 同时修复了地块取样间距:源用无间距的 `tilemap_packed.png`(16px pitch),旧代码误按带间距 的 17px pitch 采样导致错格。 - 开关:`LV_ASSET_PACK=cc0` 启用真实素材;未设置或其它值回退程序绘制江南图集。选择入口 `VillageArt.useCc0Pack` / `selectedGroundAtlasRelativePath` / `selectedCharacterAtlasRelativePath`。 - 规格:源 16x16 / 16x24 整数 **2x NEAREST** → 32x32 / 32x48;加载尺寸与整数倍由 `src/LivingVillage.Desktop.Tests/AssetPackTests.fs` 断言(含开关与 fallback)。 - 证据(`docs/evidence/`,被 .gitignore 命中故 `git add -f`;`check-no-white-blocks.py` 4 帧 0 命中): - `p33-atlas-day.png` / `p33-atlas-night.png`:样例村庄真实素材昼 / 夜整帧 1280x720。 - `p33-atlas-closeup-2x.png`:村庄地面 + 角色 2x NEAREST 特写(Kenney 草色 (78,151,76) 与 (132,198,105) 合计约 7.4 万像素,证实真素材上图)。 - `p33-atlas-baseline-day.png`:同场景程序绘制 fallback 对照(草色 (66,112,70)),与真实素材 帧明显不同(昼帧约 92% 像素差异)。 - 复现: ```bash DLL=src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll for h in 12.0 23.5; do tag=day; [ "$h" = 23.5 ] && tag=night timeout 20 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ LV_AUTOPLAY_RECORD=1 LV_AUTOPLAY_START_HOUR=$h LV_RECORD_EVERY=10 \ LV_RECORD_NAME=p33-$tag- LV_ASSET_PACK=cc0 \ LV_RECORD_DIR=/tmp/opencode/lv-p33-evidence/$tag dotnet "$DLL" done ``` - 范围:本单只接入基础地块与一类角色;动画全套 / 全村 / 中式元素补绘留待后续切片。 ## P34 素材扩展切片(水面 / 屋顶 / 墙体 / 栏杆) 在 P33 竖直切片之上,把样例村庄更多表面/构件从程序几何切到真像素素材,延续 `LV_ASSET_PACK=cc0` 开关与程序绘制 fallback。来源/许可/自绘清单见 `docs/素材来源.md`。 - 图集扩展:`Assets/cc0-tiles.png` 由 128x32(4 格)扩到 **384x32(12 格)**。0-3 格仍为 Kenney Tiny Farm 地面(整数 2x);4-11 格为**自绘**构件(Kenney 农场包无水面/屋顶/墙/栏杆): `4 water A / 5 water B / 6 roof ridge / 7 roof eave / 8 wall wood / 9 wall window / 10 railing / 11 door`。 - 构建:`python3 scripts/make-cc0-art.py`(确定性,二次运行 byte-identical)。 - 水面:自绘 **2 相位**,`cc0WaterTileIndexAt tick x y = (tick/32 + x + 2y) mod 2`,单帧内沿河 同时出现两相位;河道与水田/水网(生成器 `code=4`)共用。江南 fallback 仍是 3 相位。 - 屋顶 / 墙体 / 门:样例民居在 cc0 模式下用 6/7/8/9/11 格;桥面用 10 格栏杆;其余道具仍走 江南图集。 - 断言:`src/LivingVillage.Desktop.Tests/AssetPackTests.fs` 更新图集尺寸(12 格)与整数缩放, 并新增「构件索引都在图集内 + 单帧覆盖两水相位」用例。 - 证据(`docs/evidence/`,`git add -f`;`check-no-white-blocks.py` 5 帧 0 命中): - `p34-realassets-day.png` / `p34-realassets-night.png`:样例村庄真实素材昼 / 夜整帧 1280x720。 - `p34-realassets-closeup-2x.png`:民居屋顶/白墙/桥栏杆/水面 2x NEAREST(青灰瓦 (52,58,72)、 白墙 (234,228,212)、木栏杆 (112,74,46)、水面 (54,110,170) 均在场)。 - `p34-water-2x.png`:河面 2x 特写(同帧可见两相位波纹)。 - `p34-baseline-day.png`:同场景程序绘制 fallback 对照。 - 复现: ```bash DLL=src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll for h in 12.0 23.5; do tag=day; [ "$h" = 23.5 ] && tag=night timeout 20 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ LV_AUTOPLAY_RECORD=1 LV_AUTOPLAY_START_HOUR=$h LV_RECORD_EVERY=5 \ LV_RECORD_NAME=p34-$tag- LV_ASSET_PACK=cc0 \ LV_RECORD_DIR=/tmp/opencode/lv-p34-evidence/$tag dotnet "$DLL" done ``` - 范围:仍为切片,不做全村;动画全套与更多中式元素补绘留待后续。 ## P36 星露谷式情境提示系统(交互硬要求) 对齐 `docs/overnight-quality-brief.md`「交互新增硬要求」整段的验收口径:取消常驻键位清单 / 大型帮助面板;只在靠近**可达**可交互目标时显示匹配的短提示;一帧只高亮一个目标;提示对象与 按键对象一致;对话/菜单开启时隔离世界交互,Esc 关闭当前层;保存等操作给短暂中文反馈;提示 布局不遮场景、不越界。 - **常驻 HUD 审计**:常驻只有顶部窄信息条(时段/时间/倍速/体力/钱币,`HudLayout`),无任何 常驻全键位清单或大型帮助面板——世界面板 `M5Interaction.panelLines` 为世界态时不含键位行 (现有用例 `WorldPanelDoesNotRenderA永久KeyList` 断言为空)。完整键位只放在由 `P` 打开的 暂停帮助页(`MenuState.enterPauseHelp`/`exitPauseHelp`,打开时暂停、退出恢复原倍速)。 - **情境提示**:`InteractionResolver.resolve` 选出唯一目标(`InteractionTarget`:动作 + 位置 + 中文短文本 `E 交谈`/`E 进屋`/`E 出屋`);`M5Interaction.refreshPrompt` 仅在 `Panel = WorldPanel` 时把文本/坐标写入视图;离开范围(`Sim.chatRangePx`=96px)立即清空; 隔墙不触发(`hasClearPath` 采样玩家到目标的连线,命中 `WhiteWallDarkTileHome` 墙体瓦片即 判不可达,见 `blockingTiles`)。 - **确定性优先级**:`resolve` 按 `(DistanceSquared, stableId)` 排序取首个;stableId 村民用 `NpcId`、民居用 `HomeId`,距离平局按 ID 稳定裁决;一帧只产出一个目标。 - **提示对象=按键对象**:渲染的提示与 `Interact`(E)都调用同一个 `InteractionResolver.resolve`, 不存在第二套选择逻辑;`Escape` 面板内先走 `M5Interaction.apply ClosePanel`(对话/需求/观察/ 年鉴/任务 → 世界面板),世界面板时才打开主菜单。 - **输入隔离**:`M5Interaction.worldInputAllowed`(仅 `WorldPanel` 为真)与 `commandAllowed` 双重门禁;对话层打开时 `Interact`/`Observe` 等世界命令被拒绝;`Game.Update` 只在 `menu.Page = Playing` 时调用 `UpdatePlaying`,主菜单/设置/操作页打开时世界输入整体不处理。 - **保存反馈**:`F6/F7` 成功或失败写入 `m5View.Status`(`save succeeded`/`save failed`), 经 `statusText` 映射为「保存成功/保存失败」,由 `transientStatusLine` 在 150 tick(≈2.5s)内 以顶部条下小药丸短暂显示后自动消失。 - **布局**:`FloatingPrompt.layout` 把提示水平居中于目标并夹在视口内,永不覆盖顶部状态条与 左下 HUD 带;回归覆盖 800x600 / 1024x576 / 1280x720 / 1920x1080。 - **回归测试**:新增 `src/LivingVillage.Desktop.Tests/P36PromptTests.fs`(10 例:距离边界、 就近切换、离开消失、隔墙不触发、对话层输入隔离、提示=按键对象一致、Esc 关闭当前层、 暂停帮助 Esc 回上层、保存 toast、布局多尺寸不遮 HUD),并复用既有 `PrototypeTests` 的浮动提示/选择确定性用例。Release 全量 Desktop 174/174、Kernel 119/119。 - **证据**(`docs/evidence/`,`git add -f`;`check-no-white-blocks.py` 4 帧 0 命中): - `p36-prompt-approach-1.png`:进入靠近村民阶段(`stage=ApproachNpc`,pos≈(923,753)), 面板=world,`prompt=`(尚未进入范围,无提示)。 - `p36-prompt-approach-2.png`:进入交谈范围(pos≈(933,729)),`prompt=E 交谈`;帧内 x[634,697] y[199,200] 的 2px 强调线即目标上方提示药丸(宽 64 = 文本 52 + 边距),顶部 HUD 完整。 - `p36-prompt-approach-3.png`:按 E 后 `panel=dialogue`、`prompt=`——浮动提示消失 (世界交互被隔离),左下对话层出现(强调线落于 x[18,395] y[516,518])。 - `p36-prompt-approach-4.png`:关闭并走远后(`stage=WalkAway`,pos≈(985,790),距最近村民 > chatRange+24px)`panel=world`、`prompt=`,提示已消失。 - **复现**(确定性钩子 `LV_P36_SHOT=1`,复用 sample 脚本走位/互动并抓 4 帧后退出): ```bash LV_P36_SHOT=1 LV_RECORD_DIR=/tmp/opencode/lv-p36-evidence LV_AUTOPLAY_START_HOUR=9.0 \ xvfb-run -a -s "-screen 0 1280x720x24" \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll # 运行日志逐帧打印 panel/prompt/home 对照点,末行 p36-shot=done ``` ## P37 职业系统首切片(先设计后实现) 设计/验收依据:`docs/design/profession-system.md`(4 职业:农夫/渔夫/货郎/书生 + 暂不选择)。 职业只挂在玩家侧:世界(地图/NPC/谣言/关系)仍只由 seed 决定,职业不改变世界生成确定性。 - **已落地(P11–P15/P31 血统,本单给出设计基线与显式验收)**: - 开局属性差异:农夫/渔夫/货郎/书生开局钱 60/45/90/70、体力 100/95/100/100,写进 `WorldBootstrap.initialWorldWithOccupation` 的 `Avatar.Mind.Needs`;开局物品包在职业档案 `Occupation.profileOf … .InitialInventory`(随 v3 状态持久化)。**注意**:玩家 `Avatar` 结构体 无 `Inventory` 字段(NPC 才有),且设计禁止改 World 结构(会动 digest),故物品差异落在职业 档案/状态而非世界实体上;世界数值路径零感知。 - 每日任务:池按职业分化(农 3 / 渔 3 / 货 2 / 书 2,无职业 0),当日任务 = `hash(seed ⊕ 职业种子, 天) mod 池大小`(splitmix 同源,禁 System.Random),确定性可复现; 完成走既有事件通道,逾期判负,完成写年鉴。 - 对话/剧情接点:村民回应按职业做确定性小幅重映射(农夫求助、渔夫闲聊、货郎闲聊、书生讲 笑话各一规则);每职业一条三段式轻剧情线,靠对话推进、关系均值决定结局;货郎/书生报价小幅 偏置(4 位定点基点,钳 ±5%),其余恒 1.0。 - 开局界面:主菜单「开始」→「选择职业」页(`OccupationSelect`),上下选择、Enter 确认、 Esc 返回,中文文案(标题「选择职业」+ 四项 + 「暂不选择」+ 剧情线提示)。 - 存档兼容:无职业存档保持 v2;v1/v2 读入职业为「无」(等同旧行为),不吞档。 - **回归测试**:新增 `src/LivingVillage.Desktop.Tests/P37ProfessionTests.fs`(8 例,覆盖设计 §4 验收 1–5),并复用既有 `DailyTaskTests`/`OccupationBiasTests`/`OccupationStoryTests`/ `M6bTests`(OccupationSelect 页)。Release 全量 Desktop **182/182**、Kernel **119/119**。 - **证据**(`docs/evidence/`,`git add -f`;`check-no-white-blocks.py` 3 帧 0 命中): - `p37-occupation-select.png`:主菜单职业选择页(标题「选择职业」+ 农夫/渔夫/货郎/书生/暂不选择, 选中项高亮;`selected=1` 即渔夫)。 - `p37-fisher-start.png`:选渔夫(`LV_P37_OCCUPATION=fisher`)开局后 HUD + 任务面板。 - `p37-farmer-start.png`:同 seed 选农夫开局对照。 - 对照量化:两帧同一确定性世界(tick=0、同站位),差异像素共 2479,其中顶部 HUD 1648、 左下任务面板 831、**场景区 0** —— 直接证明职业不改变世界生成,差异只在开局属性与任务。 - **复现**: ```bash DLL=src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll for occ in fisher farmer; do LV_P37_SHOT=1 LV_P37_OCCUPATION=$occ LV_RECORD_DIR=/tmp/opencode/lv-p37-evidence \ xvfb-run -a -s "-screen 0 1280x720x24" dotnet "$DLL" done # 运行日志打印 p37-shot=... page/selected/task 对照点 ``` - **范围**:首切片仅 4 职业;全村 NPC 职业化、技能树/等级、其他职业与玩家实体背包留待后续单。 ## P38 启动/界面升级切片(HUD 图标化) 审计 `docs/overnight-quality-brief.md` 方向 #2(启动动画 / 开始界面 / HUD,禁大黑块、星露谷式布局、 不复制素材)后的现状与差距: - **已达标(沿用,未重复造轮子)**:启动动画 `LaunchScreen`(标题淡入 + 整屏暗幕退场,帧计数纯 函数、Esc/空格跳过)、开始界面 `TitleScreen`(分层天色 + 屋脊/水面剪影 + 灯笼 + 菜单入场, 确定性 2s)、顶部 HUD `HudLayout`(半透明渐变窄条 + 强调线 + 每段半透明药丸 + 1px 描边, `barHeight=36` 并非大黑块)、完整键位仅在暂停帮助页。 - **最大差距(本单实现)**:顶部状态条只有文字,缺少**图标化**辨识度。已为五个常驻段各加一枚 7x7 原创点阵图标,用现有 `pixel` 贴图绘制(**无外部素材**): 时段=太阳、时间=钟面、倍速=双箭头、体力=心形、钱币=圆形钱眼。 - **布局**:`HudLayout` 每段 = 图标 `iconWidth`(7*2=14) + `iconGap`(6) + 文本;`segmentSpan` 为段总宽, `textOrigin` 为文本起点;药丸底色与描边同时覆盖图标与文本。段高与 `barHeight` 不变,仍为窄条。 - **回归测试**:新增 `src/LivingVillage.Desktop.Tests/P38HudIconTests.fs`(4 例:图标唯一且点阵良构、 段宽=图标+间隙+文本且不重叠/在 1280 内、段与布局确定性、昼夜底色半透明且覆盖图标与文本)。 既有 `P16HudTests` 未改(`segmentText`/`segmentWidth` 语义保留)。Release 全量 Desktop **186/186**、 Kernel **119/119**。 - **确定性**:HUD 仅改渲染层,不触 `Sim.step`;`--performance-baseline` 三次 `final_digest` 仍恒为 `953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`(digest 恒定证明 未改模拟;HUD 差异只在截图可见)。 - **证据**(`docs/evidence/`,`git add -f`;`check-no-white-blocks.py` 5 帧 0 命中): - `p38-splash-1.png`:启动动画淡入中帧(`frame-splash-mid`,标题显现中、暗幕未退尽)。 - `p38-splash-2-menu.png`:启动结束、开始界面就位(屋脊/水面/灯笼 + 菜单面板)。 - `p38-hud-day.png` / `p38-hud-night.png`:样例村庄真实运行昼 / 夜帧,顶部窄条五段图标 + 文本; 两帧条内调色板亮色像素分别约 1386 / 1374,证明图标与文本都在场。 - `p38-hud-closeup-2x.png`:顶部条 2x NEAREST 特写(可辨五枚图标与中文字)。 - **复现**: ```bash DLL=src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll LV_AUTOPLAY_MENU_SHOT=1 LV_RECORD_DIR=/tmp/opencode/lv-p38-evidence xvfb-run -a -s "-screen 0 1280x720x24" dotnet "$DLL" for h in 9.0 23.5; do tag=day; [ "$h" = 23.5 ] && tag=night LV_AUTOPLAY_SAMPLE=1 LV_AUTOPLAY_RECORD=1 LV_AUTOPLAY_START_HOUR=$h LV_RECORD_EVERY=20 \ LV_RECORD_NAME=p38-$tag- LV_RECORD_DIR=/tmp/opencode/lv-p38-evidence/$tag xvfb-run -a -s "-screen 0 1280x720x24" dotnet "$DLL" done ``` - **范围**:本单只做 HUD 图标化(审计选出的最大差距);启动动画与开始界面已达标不再改动; 更细的 HUD 动效/主题换肤留待后续单。 ## P39 美术横向扩展切片(cc0 图集补绘中式元素 + 确定性摆放 + 夜间光晕) 在 P33/P34 竖直素材切片之后,为 `LV_ASSET_PACK=cc0` 图集补绘五类中式景观元素,并按确定性规则 接入真实场景(摆放不写瓦片、不改可达性),让 cc0 模式昼夜帧都有可辨的中式村庄细节。 - **图集扩展**:`scripts/make-cc0-art.py` 由 384x32(12 格)扩到 **608x32(19 格)**;新增 7 个自绘 函数(`draw_bridge_center` / `draw_bridge_end` / `draw_boat` / `draw_stone_lantern` / `draw_stall` / `draw_reeds`)。旧索引 0-11 **逐字节不变**(用 `git show HEAD:...` 校验通过);产物 608x32 / 1523 bytes / sha256 `ef86ced2…6adcb`,二次生成 byte-identical。图集 `NEAREST` 放大 2x 无混色。 - **新格索引契约**:12 石桥中段 / 13 石桥桥台 / 14 乌篷船左半 / 15 乌篷船右半 / 16 石灯笼 / 17 路摊 / 18 芦苇(见 `docs/素材来源.md` 第 4 节)。 - **确定性摆放**(`src/LivingVillage.Desktop/VillageArt.fs`,纯函数、仅 cc0 生效): - `cc0BridgeTiles`:每座桥列最上一格铺中段(12),再上一格铺桥台(13)。 - `cc0BoatTile`:开阔水面泊 2 格船,优先靠近最近的桥(曼哈顿距离),平局按 `hash2 … 0x39B0A7UL`。 - `cc0LanternTiles map spacing`:`x % spacing = 0`,y = `MainRoadCenter.[x]`,避开水面。 - `cc0StallTiles map spacing`:`x % spacing = spacing / 2`(与灯笼错开)。 - `cc0ReedTiles map spacing`:陆地格且上下邻格为水,`x % spacing = 0`。 - **奇偶 spacing 边界**(本单自检并修复):灯笼余数类 `{0}`、摊位余数类 `{spacing/2}`,`spacing >= 2` 时两同余类恒不相交(奇偶皆成立);唯一退化点是 `spacing = 1`(`spacing/2 = 0` 与灯笼同余 → 重叠)。 两个摆放函数现**守卫 `spacing < 2` 返回空集**,从源头消除重叠;`spacing = 0`/负值同样返回空集。 - **夜间光晕接入**:新增 `nightLightTilesWith (includeRoadLanterns: bool) map`;`nightLightTiles map = nightLightTilesWith false map`(保持旧断言不变);`drawNightGlows` 末尾新增 `includeRoadLanterns` 参数, VillageArt 内部三处 offscreen 调用传 `false`,`Game.fs` riverscape 分支传 `artTextures.Cc0TileAtlas.IsSome`。 夜帧石灯笼复用 P29/P32 径向光晕路径,真实可见。fallback 模式不摆放新元素。 - **回归测试**:`P39ArtTests.fs` 由 7 例增至 **9 例**(新增 `StallsAndLanternsDoNotOverlapForOddAndEvenSpacing` 覆盖奇 `{3,5,7,11,13}`/偶 `{2,4,6,8,12}` 两组,`DegenerateSpacingPlacesNothing` 覆盖 `{0,1,-3}`); `AssetPackTests` 更新为 19 格 + 新索引断言。Release 全量 Desktop **195/195**、Kernel **119/119**。 - **证据**(`docs/evidence/`,`git add -f`;`check-no-white-blocks.py` 5 帧 0 命中,重复运行 byte-identical): - `p39-day.png` / `p39-night.png`:cc0 模式村庄真实运行昼 / 夜帧(同 seed)。 - `p39-lantern-night.png`:夜间石灯笼光晕可见帧。 - `p39-closeup-2x.png`(640x480)/ `p39-closeup-lantern-2x.png`(640x360):桥/船与灯笼 2x NEAREST 特写。 - **复现**: ```bash DLL=src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll timeout 90 xvfb-run -a -s "-screen 0 1280x720x24" \ env LV_P39_SHOT=1 LV_ASSET_PACK=cc0 LV_RECORD_DIR=docs/evidence dotnet "$DLL" python3 scripts/make-p39-closeups.py docs/evidence docs/evidence python3 scripts/check-no-white-blocks.py docs/evidence/p39-*.png ``` `LV_P39_SHOT=1` 在 `Update` 里 `PrepareP39Shot` 设置 hour/camera 并置 `p39Pending`,`Draw` 末尾 `CaptureP39Shot` 落盘(避免软件渲染每帧多跑导致缓冲相同),须配合 `LV_ASSET_PACK=cc0`。 - **范围**:本单只做横向美术扩展与确定性摆放 + 夜光接入;不写瓦片、不改可达性、不动 `Sim.step` (digest 不变),P36/P37/P38 均未受影响。 ## P40 美术深化切片(石板路衔接 / 乌篷船特征 / 石灯笼与红灯笼区分) 承接 P39 验收的两处观察点(day 帧孤立石板摆位、乌篷船特征较小),做美术深化,范围严格限定: - **石板路衔接**:新增纯函数 `cc0PavingTiles map`(`VillageArt.fs`)=「本身或四邻属于 `map.Paths`(主路/桥引道/门口)或 `map.Bridges`(桥面)」的石板格索引集合。cc0 模式下 `drawTerrainTile` 的 code 2 只用该集合铺**连贯石板带**(新自绘索引 19),稀疏 `DecorativeStones` 孤立散布石板**改画草地**——seed 42/256x192 实测:全图 782 石板格中 502 连贯、280 孤立被抑制。fallback(无 cc0 图集)与无 `mapGen` 的大世界保持原样。 - **乌篷船特征**:重绘索引 14/15,船体放大到满 64x32 足迹、船帮加高,**拱形竹篷**高拱 + 竹骨 + 脊线显著,桥上/岸上一眼可辨(仍是自绘像素格,不引外部素材)。 - **石灯笼 vs 红灯笼形态区分**:石灯笼(16)重绘为明确三段石结构(宽石座 / 石室暖芯 / 外挑石顶盖 + 宝顶);新增红灯笼(20)红纸圆身 + 竹骨 + 金穗 + 木顶盖,门口一盏。两者 **复用同一条 P14 预乘 alpha 径向光晕路径**(`nightLightTilesWith` 纳入 `cc0RedLanternTiles`), 不新增光照系统。 - **图集**:`make-cc0-art.py` 由 608x32(19 格)扩到 **672x32(21 格)**,索引 19 石板 / 20 红灯笼; 重绘 14/15/16;**索引 0-13、17-18 逐字节不变**(`git show HEAD:...` 复核)。二次生成 byte-identical。 - **确定性**:全部摆放为 map 纯函数;同 seed 昼/夜帧位置一致(`P40ArtTests` 断言),重生成逐字节 相同;`Sim.step` 未改,`--performance-baseline` digest 不变。 - **回归测试**:新增 `src/LivingVillage.Desktop.Tests/P40ArtTests.fs`(6 例:连贯带覆盖路网且 抑制孤立散布、摆放确定性、红灯笼落门口且确定、红/石灯笼同光晕路径、同 seed 重生成一致、 P40 索引在界内);`AssetPackTests` 索引列表加入 19/20。Release 全量 Desktop **201/201**、 Kernel **119/119** exit 0。 - **证据**(`docs/evidence/`,`git add -f`;`check-no-white-blocks.py` 5 帧 0 命中,重复运行 byte-identical): - `p40-day.png` / `p40-night.png`:cc0 模式村庄昼 / 夜整帧(相机对准乌篷船河段;昼帧红灯笼 与石板带真实可见)。 - `p40-boat-closeup-2x.png`(400x240):乌篷船 2x NEAREST 特写,拱形竹篷结构清晰。 - **复现**: ```bash DLL=src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll timeout 90 xvfb-run -a -s "-screen 0 1280x720x24" \ env LV_P40_SHOT=1 LV_ASSET_PACK=cc0 LV_RECORD_DIR=docs/evidence dotnet "$DLL" python3 scripts/make-p40-closeups.py docs/evidence docs/evidence python3 scripts/check-no-white-blocks.py docs/evidence/p40-day.png docs/evidence/p40-night.png \ docs/evidence/p40-boat-closeup-2x.png ``` - **范围**:本单只做上述三处美术深化 + 对应确定性摆放/光晕接入;不写瓦片、不改可达性、 不动 `Sim.step`,玩法/重构/新模块未触碰。