diff --git a/design/gdd/02-系统清单.md b/design/gdd/02-系统清单.md index 20ad7640..9942003a 100644 --- a/design/gdd/02-系统清单.md +++ b/design/gdd/02-系统清单.md @@ -52,7 +52,7 @@ |---|---|---| | `HUD框架.md` | 界面布局与呈现层契约 | 已定稿·自旧体系迁移 | | `数据配置.md` | 数值表与单一事实源管线 | 已定稿·自旧体系迁移 | -| `存档序列化.md` | 存档契约与版本迁移 | 范围草案·待设计 | +| `存档序列化.md` | 存档契约与版本迁移 | 已定稿 | | `音频.md` | 音乐音效方向与实现 | 范围草案·待设计 | | `教学引导.md` | 新手教学与提示 | 范围草案·待设计 | | `设置与本地化.md` | 设置项与多语言 | 范围草案·待设计 | diff --git a/design/gdd/06-待办清单.md b/design/gdd/06-待办清单.md index e5f0e733..d622850d 100644 --- a/design/gdd/06-待办清单.md +++ b/design/gdd/06-待办清单.md @@ -309,15 +309,7 @@ **状态**:待办(档位已定案,条款修订随表现层升级批) -### 存档序列化 - -#### 存档契约条目的登记方式 - -**现状**:内容归生产方、承载归存档序列化的方向已定,但"存档里每个域由谁登记、登记在哪"没有成文——归档曾把存档结构整体记在元进度名下,现行元进度文档又把读写与迁移指给存档序列化;域级契约的持有方式随存档序列化设计批与元进度批联合裁决 - -**归属**:存档序列化与元进度联合裁决 - -**状态**:待办 +### 数据配置 ### 教学引导 diff --git a/design/gdd/systems/冒险循环/元进度.md b/design/gdd/systems/冒险循环/元进度.md index a9578c50..08460f84 100644 --- a/design/gdd/systems/冒险循环/元进度.md +++ b/design/gdd/systems/冒险循环/元进度.md @@ -66,7 +66,7 @@ **投值语言锁定(2026-09-18 数值语言拍板)**:三柜台属性强化一律 **flat 整数加减**("每级 +1 基础攻击"式,而非"+50% 攻击"式)——避开百分比词条被数值成长稀释的先例陷阱、守住数字可读性锚;具体每级数值仍归元进度设计批投值。 -三条柜台纪律:**定价曲线一律几何递增**(每级费用按公比上浮——原则已拍板,起步价与公比数字归设计批配读数),与产出逻辑配套:深层关钥石产出多、后级强化贵,逼玩家往深处走。**道具工坊首版只挂通关次数渠道**是排期裁决(定价体系未建时的零定价依赖落地形态),不是终态。**存档义务随柜台产生**:钥石余额、永久强化等级表、每关通关次数表、Boss 首杀标记位——域级契约登记方式见[存档序列化](../表现与支撑/存档序列化.md)「待办」的联合裁决条目。 +三条柜台纪律:**定价曲线一律几何递增**(每级费用按公比上浮——原则已拍板,起步价与公比数字归设计批配读数),与产出逻辑配套:深层关钥石产出多、后级强化贵,逼玩家往深处走。**道具工坊首版只挂通关次数渠道**是排期裁决(定价体系未建时的零定价依赖落地形态),不是终态。**存档义务随柜台产生**:钥石余额、永久强化等级表、每关通关次数表、Boss 首杀标记位——四件已逐域登记在 registry `save_file` 条目(内容 owner = 元进度,承载 owner = [存档序列化](../表现与支撑/存档序列化.md))。 护甲等级进首版属性柜台是 2026-09-18 拍板(用户选 A),其代价条款见下方换算率节的强条目登记。 diff --git a/design/gdd/systems/表现与支撑/存档序列化.md b/design/gdd/systems/表现与支撑/存档序列化.md index 0e253ef3..15421ca8 100644 --- a/design/gdd/systems/表现与支撑/存档序列化.md +++ b/design/gdd/systems/表现与支撑/存档序列化.md @@ -1,14 +1,14 @@ # 存档序列化 > 一句话:存档契约与版本迁移 -> 状态:范围草案(待设计) +> 状态:已定稿(2026-09-21 存档序列化批落地) > 旧版参考:../../../archive/gdd-2026-09-14/systems-index.md(旧体系未单独成文,散见于归档索引) ## 设计目的 本系统管的是"关掉游戏之后,玩家的东西还在不在"。序列化(本文含义:把游戏运行中的数据写成可存盘的档案,下次启动再原样读回来)与版本迁移(本文含义:游戏更新后,旧版本写下的存档要能被新版本继续读懂,必要时把旧结构改写成新结构)合起来,就是"存档契约"这门生意的全部。 -这件事是[核心循环](../../01-核心循环.md)里"存档在变厚"那条故事线的底层承载:钥石攒进存档、倒下也不丢——写在纸面上就是本系统的职责。本文是范围草案:只收拢现行材料能确证的范围边界与已知约束,存档格式、字段、读写时机与迁移方案一概未定,见文末「待设计问题清单」。 +这件事是[核心循环](../../01-核心循环.md)里"存档在变厚"那条故事线的底层承载:钥石攒进存档、倒下也不丢——写在纸面上就是本系统的职责。本系统在切片里的落地面是 `core/save_manager.gd`(SaveManager,Core 层零 UI、零玩法依赖),本文描述的就是这份契约:存什么、何时读写、版本怎么走、坏了怎么办。 ## 系统范围 @@ -18,20 +18,23 @@ | 负责 | 说明 | |---|---| -| 存档契约 | 存档的域划分与读写约定——域单位已由客户登记(见核心规则),字段与格式未设计 | -| 版本迁移 | 游戏更新后旧存档的兼容与改写策略——只立项,未设计 | -| 读写时机 | 何时写盘、何时读回——只立项,未设计 | +| 存档契约 | 单档 JSON 存档的域划分与读写约定(域清单见核心规则;registry `save_file` 条目逐域登记) | +| 版本迁移 | 版本字段 + 读档时链式迁移 + 高版本拒读禁写 | +| 读写时机 | 启动读盘;正式冒险收束写盘;原子写盘原语 | | 元进度的持久化承载 | 归档登记的职责口径:"元进度持久化框架"——钥石与解锁进度由本系统落盘 | +| 最近冒险摘要的持久化 | 冒险摘要五件事副本入档(cap 10),重开对照跨进程成立 | 不负责: | 不负责 | 归谁 | |---|---| | 存进去的内容口径(钥石怎么得、能换什么) | [元进度](../冒险循环/元进度.md)——本系统只承载,不定义 | -| 冒险摘要记哪几件事 | [冒险结构](../冒险循环/冒险结构.md)——本系统只消费其快照域标识 | +| 冒险摘要记哪几件事 | [冒险结构](../冒险循环/冒险结构.md)——本系统只消费其摘要五件事 | +| 冒险中续玩快照(主种子 + 随机流计数器的局中恢复) | [冒险结构](../冒险循环/冒险结构.md)——P1 后置(06 已登记),本批不涉及 | +| Boss 击杀的钥石金额计算(首杀全量 / 重复杀小额) | [元进度](../冒险循环/元进度.md)——本系统只记域内事实,不算账 | | 数值配方本体(所有玩家一致的表) | [数据配置](数据配置.md)——表是配方,存档是玩家进度,两者分离(见核心规则) | -| 局内数值的持久化 | 无——局内资源全部单局清零,现行没有局内数值需要入档(见核心规则的反面清单) | -| 系统级设置(音量/画面等) | 不入档——设置与存档两分(2026-09-19 用户拍板):设置独立存储、一安装一份,存档只存该存档的生涯数据;设置域归[设置与本地化](设置与本地化.md)所有 | +| 局内数值的持久化 | 无——局内资源全部单局清零,没有局内数值需要入档(见核心规则的反面清单) | +| 系统级设置(音量/画面等) | 不入档——设置与存档两分(2026-09-19 用户拍板):设置独立存储、一安装一份,存档只存生涯数据;设置域归[设置与本地化](设置与本地化.md)所有 | ## 在核心循环中的位置 @@ -41,81 +44,116 @@ 存档序列化不占核心循环里的一段,它贴在"死亡重开"出口的外侧:局内胜负归零时,它保证玩家攒下的东西不跟着归零。核心循环把"存档在变厚"讲成玩家重开的理由之一,本系统就是那条理由的底层承载。 -## 核心规则(已确证的范围边界与已知约束) - -本系统尚未设计,本节只收现行材料能确证的边界与约束;格式、字段、时机等全部问题见文末「待设计问题清单」。 +## 核心规则 ### 基础层身份:无上游依赖的承载方 -归档依赖分层为本文登记的身份是:基础层系统、无上游依赖、里程碑 Alpha,登记职责一句话是"元进度持久化框架"。这个身份有两条现行推论。其一,本系统不依赖任何玩法系统就能工作——它是被动的承载方,将来任何系统要把数据活过单局,都向本系统登记持久化归属,而不是本系统去追认每个系统。其二,登记职责虽从元进度起家,现行文档树上点名本系统的客户已经不止一户(见下一节)。 +SaveManager 不依赖任何玩法系统就能工作:它是被动的承载方,任何系统要把数据活过单局,都向本系统登记持久化归属,而不是本系统去追认每个系统。这条身份可验证:SaveManager 不引入任何玩法脚本、不碰数值表(ConfigDB)与随机服务(RngService),可以独立实例化、独立读写验证(冒烟独立段承载)。 -### 承接已登记的两户客户 +### 存什么:表与档分家,局内资源不入档 -现行文档里明确把数据托付给本系统的有两家,构成本系统目前确定的存档内容输入: +第一条边界:数据表与存档分家。表是配方,所有玩家一致;存档是玩家进度,每人不同。存档只记进度事实(得了什么、解锁了什么),不把数值配方写进存档——数值改版后,旧存档仍按加载时的数据表解释。这条边界从"数值单一事实源在数据表"的纪律直接推出,细节以[数据配置](数据配置.md)为准。 -| 客户 | 存什么 | 权威口径 | +第二条边界是反面清单:局内资源不入存档。行动点、储备行动点、道具库存、生命账本、GP(金币)全部单局清零——各归各的系统管,没有局内数值需要持久化。若未来某局内资源要开放跨局携带(如储备行动点跨战斗携带),须修订其池规则并向本系统登记持久化归属,禁止静默超域。 + +存档是单文件 JSON(`user://save.json`),字典键一律字符串。数据模型: + +```json +{ + "version": 1, + "meta": { + "keystone_balance": 0, + "forge_levels": {"max_hp": 0, "atk": 0, "armor_class": 0}, + "item_unlocks": {}, + "level_clear_counts": {"1": 0, "2": 0, "3": 0}, + "boss_first_kill": {}, + "recent_summaries": [] + } +} +``` + +| 域 | 存什么 | 内容 owner | |---|---|---| -| [元进度](../冒险循环/元进度.md) | 钥石(击杀关底 Boss 取走的永久资源)与解锁进度 | 钥石口径的权威在[游戏概念](../../00-游戏概念.md)与[元进度](../冒险循环/元进度.md),本文引用不展开 | -| [冒险结构](../冒险循环/冒险结构.md) | 快照域(域单位 = 一次冒险:主种子 + 各随机流计数器);冒险摘要(含本局主种子)供重开对照与复盘 | 域单位与摘要口径见[冒险结构](../冒险循环/冒险结构.md)的依赖表,本文照单承载 | +| `keystone_balance` | 钥石余额——只增不减(消费除外) | [元进度](../冒险循环/元进度.md) | +| `forge_levels` | 属性锻造三档等级(生命上限/基础攻击/护甲等级) | [元进度](../冒险循环/元进度.md) | +| `item_unlocks` | 已解锁道具集合(道具工坊消费) | [元进度](../冒险循环/元进度.md) | +| `level_clear_counts` | 每关通关次数(键 = 关卡序号字符串) | [元进度](../冒险循环/元进度.md) | +| `boss_first_kill` | Boss 首杀标记位(键 = 关卡序号字符串) | [元进度](../冒险循环/元进度.md) | +| `recent_summaries` | 最近冒险摘要副本,最新在前,最多 10 条 | [冒险结构](../冒险循环/冒险结构.md) | -表里的快照(本文含义:把某一刻的完整局面记录下来,供之后复盘与回放)由冒险结构定域,本文只管把它存取可靠。两户都是"生产方定内容、本系统管承载"的分工:元进度把钥石永不丢失立为它自己的验收判据,本系统作为承载方,将来的读写设计以不破坏这条判据为硬约束——档案写坏、读不回来,等于把玩家的钥石弄丢。 +消费面纪律:其他系统只经 SaveManager 的类型化访问器(读余额/加钥石/花钥石、读写锻造等级等)与域更新原语(`apply_adventure_summary`)操作存档,不碰内部数据结构。 -### 存什么:进度与配方分家,局内资源不入档 +### 读写时机 -第一条边界:数据表与存档分家。表是配方,所有玩家一致;存档是玩家进度,每人不同。由此推出一条已知约束:存档只记进度事实(得了什么、解锁了什么),不得把数值配方内置进存档——数值改版后,旧存档仍能按加载时的数据表解释。这条边界从"数值单一事实源在数据表"的现行纪律直接推出,细节以[数据配置](数据配置.md)定稿口径为准(该文档并行写作中)。 +``` +启动 ──▶ 读盘(load_from_disk:新档/损坏兜底/迁移链/高版本拒读,见下两节) +正式冒险收束(adventure_finished)──▶ apply_adventure_summary 入账 ──▶ 原子写盘 +营地钥石消费(元进度实现批接线)──▶ 每笔变更后即时写盘 +``` -第二条边界是反面清单:局内资源不入存档。行动点、储备行动点、道具库存、生命账本全部单局清零——各归各的系统管,现行没有任何局内数值需要持久化。这条边界已有登记在案的先例:储备行动点(玩家停手时把剩余行动点存入的池子,现行每场战斗清零)若未来开放跨战斗携带,按行动经济的归档登记口径,须修订其池规则并向本系统登记持久化归属,禁止静默超域;该开关的现行状态见[行动经济](../排雷循环/行动经济.md)的后置清单与[不做清单](../../05-不做清单.md)。 +写盘时机纪律:**凡钥石发生变动的时刻都要落盘**——正式冒险收束写一次,营地内每笔消费后即时写(元进度实现批接线)。这是"钥石不丢"判据的最强保证:任何时刻拔电源,玩家最多丢当前这一局的局内进程,不丢档。 + +写盘是原子操作:先写临时文件(`<路径>.tmp`),写完改名覆盖正式档——写一半断电不留烂档。 + +单局测试入口不写档:关卡直入口(L2/L3)与调试两层路径不接写盘——测试模式不污染生涯数据;只有正式冒险(AdventureRunner)收束才入账。 + +### 版本与迁移 + +存档带版本字段(`version`,当前 = 1,与 registry `schema_version` 的 MVP=1 同源)。三条走向: + +| 情形 | 处置 | +|---|---| +| 版本相同 | 正常加载;旧档缺的新域按默认值补齐(既有值不动)——数据表加字段不弃档 | +| 版本更高(未来版本的档) | **拒读且禁写**:不加载、后续写盘一律拒绝、内存以默认新档运行——保护未来版本的档不被当前版本覆写 | +| 版本更低(旧档) | 读档时逐级迁移:从旧版本起逐个调用注册的迁移函数升到当前版本;任一步失败按损坏兜底处理 | + +迁移函数经 `register_migration(旧版本号, 函数)` 注册:函数把数据从「旧版本」改写成「旧版本 + 1」。当前生产迁移链为空(第一版存档没有前身),骨架先行;首个改结构批随批注册第一个迁移函数。 + +### 损坏兜底 + +档案读不开(文件损坏、JSON 解析失败、结构出域、缺版本字段)时:原文件改名为 `<路径>.corrupt.bak`(已有同名备份则覆盖)留下现场,游戏用默认新档继续运行,并留下警告日志。两条底线:不静默丢档(坏档永远可人工找回)、不崩溃(坏档不挡玩)。 ## 依赖与输出 -本系统没有上游——这是归档依赖分层的登记口径,也是基础层身份的另一半:它先于一切玩法系统存在,供所有人存取。已登记的下游与协作: +本系统没有上游——基础层身份的另一半:它先于一切玩法系统存在,供所有人存取。已登记的下游与协作: | 方向 | 系统 / 文档 | 接口要点 | |---|---|---| -| 下游 | [元进度](../冒险循环/元进度.md) | 钥石与解锁进度的持久化承载方;对方以"钥石不丢"为验收,本系统以不破坏它为约束 | -| 下游 | [冒险结构](../冒险循环/冒险结构.md) | 快照契约域单位 = 冒险(主种子 + 随机流计数器);冒险摘要含主种子,供复盘 | -| 协作 | [数据配置](数据配置.md) | 表与档分家:数值口径永远以加载时的表为准;边界细节以其定稿口径为准 | +| 下游 | [元进度](../冒险循环/元进度.md) | 钥石余额、锻造等级、通关次数、首杀标记的持久化承载方;对方以"钥石不丢"为验收,本系统以不破坏它为约束 | +| 下游 | [冒险结构](../冒险循环/冒险结构.md) | 冒险摘要五件事副本入档(cap 10);快照与复盘的持久化承接 P1 后置 | +| 协作 | [数据配置](数据配置.md) | 表与档分家:数值口径永远以加载时的表为准 | ## P0 最小闭环 -MVP 不要求本系统的闭环:归档把本系统排在 Alpha 里程碑,与元进度同批(见[不做清单](../../05-不做清单.md):先把单局循环做扎实)。MVP 期没有存档,单局循环完整成立。Alpha 期最小闭环的方向跟着元进度的最小集走:钥石与解锁进度能写入存档、下次启动原样读回、数值表更新后旧档仍能读。快照与复盘域是否进首批交付,随元进度批排期裁决(见待办)。 +MVP 不要求本系统的闭环([不做清单](../../05-不做清单.md)把单局循环排前)。本批已落地的最小集:存档域首批内容(元进度四件 + 最近冒险摘要)能写入、读回、跨进程存活;版本与损坏策略就位;正式冒险收束自动入账。元进度消费面(花钥石、买强化、营地界面)归元进度实现批——承载已就绪,内容方接线即可用。 ## P1 / P2 后置 | 优先级 | 内容 | 后置原因 | |---|---|---| -| P1 | 快照与复盘的持久化承接 | 域单位已由冒险结构登记为冒险;是否随 Alpha 首批落地见待办 | -| P2 | 多存档槽位 | 单档还是多档未定,见待设计问题清单 | -| P2 | 损坏处理与兜底策略 | 只立项未设计,见待设计问题清单 | +| P1 | 快照与复盘的持久化承接(主种子 + 随机流计数器续玩恢复) | 域单位已由[冒险结构](../冒险循环/冒险结构.md)登记为冒险;06 已登记后置 | +| P2 | 多存档槽位 | 单档 MVP(2026-09-21 裁决);多档等有真实需求再立项 | ## 调参表 -暂无本系统自有的参数。存档格式、写盘时机、迁移策略都未设计,没有可调的现行值;将来若有(例如写盘频率一类),随设计定稿再登记。 +本系统没有玩法调参数值。两个结构常量登记在 registry `save_file` 条目:存档版本号(=1,与 registry `schema_version` 同源)、最近摘要保留条数(10)。二者是结构契约不是平衡值,改动属于版本迁移事件,不走调参批。 ## 验收标准 -待设计。方向性判据(供未来定稿参考): - -- 钥石不因存档丢失:本系统的写入、读回与迁移,不得破坏[元进度](../冒险循环/元进度.md)验收标准中的钥石不丢判据。 -- 改版不弃档:数据表数值更新后,既有存档仍能被读懂并继续游戏,不要求玩家重开。 -- 零玩法依赖可验证:本系统的读写与迁移,能在不运行任何玩法系统的前提下独立验证。 -- 域界干净:单局清零的资源(行动点、储备行动点、道具库存等)不出现在存档内容清单里。 +- 往返一致:全部域写入非默认值 → 写盘 → 新实例读回,逐域逐值相等(冒烟承载)。 +- 钥石不丢:写入、读回与迁移不得破坏[元进度](../冒险循环/元进度.md)的钥石只增不减判据;余额不足的消费被拒绝且无副作用(冒烟承载)。 +- 高版本拒读禁写:高版本档加载被拒、原文件字节不动、后续写盘被拒且文件仍字节不动(冒烟承载)。 +- 改版不弃档:旧版本档经迁移链读回;低版本档缺新域按默认补齐,既有值保留(冒烟承载)。 +- 域界干净:盘上文本不含局内资源键(行动点/储备行动点/道具库存/生命账本/GP)(冒烟承载)。 +- 零玩法依赖:SaveManager 不引入任何玩法脚本、不碰数值表与随机服务,可独立实例化验证(冒烟独立段承载)。 ## 待办 本系统待办条目已统一收编至 [06-待办清单](../../06-待办清单.md),本节不再逐条登记。 -## 待设计问题清单 - -1. 存档内容清单:除两户已登记的输入外,还有哪些系统的哪些数据需要入档?逐项登记持久化归属。 -2. 存档时机:何时写盘(每次结算、每枚钥石到手、退出时),写盘失败怎么办? -3. 版本迁移策略:迁移发生在哪一步(读档时、启动时),迁移失败怎么处理? -4. 存档槽位与多存档:单档还是多档,槽位与元进度账本如何对应? -5. 损坏处理:档案读不开时的玩家侧表现与数据兜底,是否保留备份。 -6. 冒险摘要的持久化归属:摘要在进程内存活即可,还是必须写入存档(重开对照跨进程是否成立)? - ## 版本历史 | 版本 | 日期 | 改动 | |---|---|---| | v0.1 | 2026-09-15 | 自归档线索整合为范围草案(旧体系未单独成文) | +| v1.0 | 2026-09-21 | **存档序列化批(SaveManager 落地)**:格式裁决 JSON 单档 `user://save.json`;数据模型逐域成文(元进度四件 + 最近冒险摘要 cap 10);读写时机(启动读盘/冒险收束写盘/营地即时写/测试入口不写)+ tmp+rename 原子写;版本策略(高版本拒读禁写/低版本链式迁移/缺域补齐);损坏兜底(.corrupt.bak 备份 + 默认新档);待设计问题清单六条全部裁决成文(1/2/3/5/6 入核心规则,4 = 单档 MVP 多档留 P2)。自范围草案升定稿 | diff --git a/design/registry/entities.yaml b/design/registry/entities.yaml index d9bb2b1a..daae91c7 100644 --- a/design/registry/entities.yaml +++ b/design/registry/entities.yaml @@ -65,10 +65,25 @@ entities: type: constant owner: design/gdd/systems/表现与支撑/数据配置.md attributes: - value: 实现期定(MVP=1);全部占位 schema 均携带版本字段 + value: 实现期定(MVP=1);全部占位 schema 均携带版本字段(已随存档序列化批落地 2026-09-21——存档侧 SAVE_FORMAT_VERSION=1 与本条同源) high_version_policy: 拒读(比较逻辑 owner=存档序列化) referenced_by: [存档序列化] + - name: save_file + type: schema_data + owner: design/gdd/systems/表现与支撑/存档序列化.md + attributes: + path: user://save.json(单档 MVP——多存档槽位 P2 保留) + format: JSON 单档(人可读可调试;字典键一律 String) + version: SAVE_FORMAT_VERSION=1(registry schema_version 同源;高版本拒读 + 禁写盘 + 内存默认新档运行;低版本读档时链式迁移 register_migration,任一步失败按损坏兜底) + domains: meta 域六件——keystone_balance(内容 owner=元进度)/ forge_levels 三档 max_hp·atk·armor_class(元进度)/ item_unlocks(元进度·道具工坊消费)/ level_clear_counts(元进度)/ boss_first_kill(元进度)/ recent_summaries cap 10 冒险摘要五件事副本(内容 owner=冒险结构) + write_timing: 启动读盘;正式冒险收束(adventure_finished → apply_adventure_summary)写盘;营地每笔钥石变更后即时写(元进度实现批接线);单关测试入口与调试两层不写档 + atomic_write: tmp + rename 原子写(写一半断电不留烂档——钥石不丢判据承载) + corruption_policy: 坏档改名 .corrupt.bak(已存在覆盖)+ 默认新档继续 + 警告留痕(不静默丢档不 crash) + domain_boundary: 行动点/RAP/道具库存/生命账本/GP 不入档(局内资源单局清零;冒烟断言承载) + impl: core/save_manager.gd(Core 层零 UI 零玩法依赖;消费面只经类型化访问器与 apply_adventure_summary 原语) + referenced_by: [元进度, 冒险结构] + - name: action_economy type: constant_set owner: design/gdd/systems/排雷循环/行动经济.md diff --git a/production/roadmap.md b/production/roadmap.md index 7919678c..b6f1da28 100644 --- a/production/roadmap.md +++ b/production/roadmap.md @@ -17,7 +17,7 @@ | 2026-09-16 | 行动点压速批 8→6;护甲等级掷点批落地(smoke 233 全绿);AP6+AC 验收判读口径 v1.0;06-待办清单建账改版;系统更名批;敌人技能池提案对答启动 | | 2026-09-17 | 敌人技能池提案定稿(1c63830);技能池实施批落地(smoke 263 全绿,待收编);魔法道具切片期空内容拍板;关卡节点地图定稿批解锁;跨会话状态持久化批(快照节 + 本文件 + 守卫文案升级) | | 2026-09-20 | 第三关内容批(灰矮人矿城行集 + 测试直入口,smoke 375 全绿);GP+商店房实施批(九型格面+随机掉落+Boss前商店房+统一加算区,smoke 437 全绿);八型批呈现层漏网修复(真人试玩卡断——行协议呈现层同步闸 11 断言) | -| 2026-09-21 | **冒险结构实现批:切片从三个单关 demo 升级为一局完整三关冒险**(AdventureRunner+LevelRunner 账本外置+AdventureTable 建表;三关正式序列生效;smoke 482 全绿)——存档批(存冒险状态)与元进度批(钥石消费)挂点就位 | +| 2026-09-21 | **冒险结构实现批:切片从三个单关 demo 升级为一局完整三关冒险**(AdventureRunner+LevelRunner 账本外置+AdventureTable 建表;三关正式序列生效;smoke 482 全绿);存档序列化批(SaveManager 落地:单档 JSON 存档读写/版本迁移/损坏兜底 + 元进度四件与冒险摘要入档)——元进度批(钥石消费)挂点就位 | ## 路线图(主线路线与门) diff --git a/prototypes/dungeon-sweeper-vertical-slice/core/save_manager.gd b/prototypes/dungeon-sweeper-vertical-slice/core/save_manager.gd new file mode 100644 index 00000000..59775b4e --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/core/save_manager.gd @@ -0,0 +1,228 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# Validation Question: 关掉游戏之后玩家的东西还在吗——单档 JSON 存档的写入、读回、版本迁移与损坏 +# 兜底能否在零玩法依赖下独立成立,元进度四件(钥石余额/永久强化等级表/每关通关次数表/Boss 首杀标记位) +# 与最近冒险摘要(cap 10)能否跨进程存活(P0) +# Date: 2026-09-21(存档序列化批——设计权威 design/gdd/systems/表现与支撑/存档序列化.md) +# +# 存档技术契约承载方(Core 层零 UI、零玩法依赖——禁 preload/import 任何玩法脚本、禁碰 ConfigDB/ +# RngService,可独立实例化独立验证〔存档序列化.md 验收标准〕)。 +# +# 域清单与 owner 分工(内容 owner 定口径,本类照单承载、不替任何人定义进度): +# keystone_balance / forge_levels / item_unlocks / level_clear_counts / boss_first_kill +# —— 内容 owner = 元进度.md(营地三柜台:钥石余额/属性锻造/道具工坊/通关次数/首杀标记) +# recent_summaries(冒险摘要五件事副本)—— 内容 owner = 冒险结构.md(摘要五件事口径) +# 承载 owner = 存档序列化.md(格式/时机/迁移/损坏/域登记) +# +# 契约登记(存档序列化.md 核心规则——违反即返工): +# 契约 1 表与档分家:存档只记进度事实,禁止把数值配方写进存档(数值改版后旧档按加载时的表解释); +# 契约 2 域界干净:行动点/RAP/道具库存/生命账本/GP 不出现在存档内容里(反面清单,冒烟有断言); +# 契约 3 钥石不丢:写入/读回/迁移不得破坏元进度「钥石只增不减(消费除外)」判据—— +# tmp+rename 原子写(写一半断电不留烂档)+ 损坏兜底坏档备份; +# 契约 4 高版本拒读:version > SAVE_FORMAT_VERSION → 不加载且禁写盘(保护未来版本的档不被覆写), +# 内存以默认新档运行。 +# +# JSON 键纪律:字典键一律 String——JSON 键非字符串的坑,int 键(关卡序号)读写两侧都要 str() 换算, +# 混用必出静默 miss。 +class_name SaveManager +extends RefCounted + +const SAVE_FORMAT_VERSION := 1 # registry schema_version:MVP=1(owner=数据配置;高版本拒读 owner=存档序列化) +const SUMMARY_CAP := 10 # 最近冒险摘要保留条数(最新在前) + +const FORGE_ATTRS := ["max_hp", "atk", "armor_class"] # 属性锻造三档合法键(元进度.md 属性锻造柜台) + +var _path: String # 存档文件路径(默认 user://save.json;冒烟注入隔离路径) +var _read_only := false # 高版本拒读置位——write_to_disk 一律拒绝(契约 4 禁写) +var _data: Dictionary = {} # 内存数据模型(_init 即内置默认;load 后为档案内容) +var _migrations: Dictionary = {} # from_version → Callable(data)->data(升一版;生产链现为空——骨架先行) + + +func _init(path: String = "user://save.json") -> void: + _path = path + _data = _default_data() + + +# ---- 默认数据模型(新档合法态——全零;白板存档 = 完整可玩〔元进度 单局循环零依赖〕)---- + +func _default_data() -> Dictionary: + return { + "version": SAVE_FORMAT_VERSION, + "meta": { + "keystone_balance": 0, + "forge_levels": {"max_hp": 0, "atk": 0, "armor_class": 0}, + "item_unlocks": {}, + "level_clear_counts": {"1": 0, "2": 0, "3": 0}, + "boss_first_kill": {}, + "recent_summaries": [], + }, + } + + +# ---- 读盘(启动时调用)---- +# 返回 true = 已就绪可用(含新档/损坏兜底/迁移成功);false = 高版本拒读(默认数据运行、禁写盘)。 +# 损坏兜底:原文件改名 .corrupt.bak(已存在则覆盖)+ push_warning + 默认数据继续——不静默丢档。 +func load_from_disk() -> bool: + _read_only = false + if not FileAccess.file_exists(_path): + _data = _default_data() # 新档合法态 + return true + var f := FileAccess.open(_path, FileAccess.READ) + if f == null: + return _corrupt_fallback("读打开失败(%s)" % error_string(FileAccess.get_open_error())) + var text := f.get_as_text() + f.close() + var parsed = JSON.parse_string(text) + if typeof(parsed) != TYPE_DICTIONARY: + return _corrupt_fallback("JSON 解析失败或顶层结构出域(非对象)") + if not parsed.has("version"): + return _corrupt_fallback("缺版本字段") + var v := int(parsed["version"]) + if v > SAVE_FORMAT_VERSION: + _read_only = true + _data = _default_data() + push_error("[存档序列化] 档案版本 %d 高于当前 %d——拒读禁写(保护未来档不被覆写,内存默认新档运行)" % [v, SAVE_FORMAT_VERSION]) + return false + while v < SAVE_FORMAT_VERSION: + if not _migrations.has(v): + return _corrupt_fallback("迁移链缺步骤 %d→%d" % [v, v + 1]) + var stepped = _migrations[v].call(parsed) + if typeof(stepped) != TYPE_DICTIONARY: + return _corrupt_fallback("迁移步骤 %d→%d 执行失败" % [v, v + 1]) + parsed = stepped + v = int(parsed.get("version", v + 1)) + _data = _normalize(parsed) + return true + + +# ---- 写盘(tmp + rename 原子写——写一半断电不留烂档)---- +# 高版本拒读态一律拒绝(契约 4 禁写);写失败返回 false。 +func write_to_disk() -> bool: + if _read_only: + push_error("[存档序列化] 写盘拒绝——档案版本高于当前(拒读禁写态)") + return false + var tmp := _path + ".tmp" + var f := FileAccess.open(tmp, FileAccess.WRITE) + if f == null: + push_error("[存档序列化] 临时文件打开失败:%s(%s)" % [tmp, error_string(FileAccess.get_open_error())]) + return false + f.store_string(JSON.stringify(_data, "\t", true)) + f.close() + if not _rename_overwrite(tmp, _path): + push_error("[存档序列化] 原子改名失败(tmp 保留待查):%s → %s" % [tmp, _path]) + return false + return true + + +# 迁移注册口:from_version → from_version+1 的升级函数(读档时链式调用)。fn 契约: +# Dictionary → Dictionary,须写回 version = from_version + 1。生产迁移当前为空(v1 首版), +# 冒烟经本口验证链式升级。 +func register_migration(from_version: int, fn: Callable) -> void: + _migrations[from_version] = fn + + +# ---- 类型化域访问器(消费面——只经访问器操作,禁碰内部结构)---- + +func keystone_balance() -> int: + return int(_data["meta"]["keystone_balance"]) + + +func add_keystone(n: int) -> void: + _data["meta"]["keystone_balance"] = keystone_balance() + n + + +# 钥石消费唯一口:余额不足拒绝且零副作用(钥石只增不减——消费除外,元进度验收判据) +func spend_keystone(n: int) -> bool: + if keystone_balance() < n: + return false + _data["meta"]["keystone_balance"] = keystone_balance() - n + return true + + +func forge_level(attr: String) -> int: + return int(_data["meta"]["forge_levels"].get(attr, 0)) + + +func set_forge_level(attr: String, n: int) -> bool: + if not FORGE_ATTRS.has(attr): + push_error("[存档序列化] 属性锻造键出域拒绝:%s(合法键 %s)" % [attr, str(FORGE_ATTRS)]) + return false + _data["meta"]["forge_levels"][attr] = n + return true + + +func item_unlocked(item_id: String) -> bool: + return _data["meta"]["item_unlocks"].has(item_id) + + +func unlock_item(item_id: String) -> void: + _data["meta"]["item_unlocks"][item_id] = true + + +func clear_count(level_index: int) -> int: + return int(_data["meta"]["level_clear_counts"].get(str(level_index), 0)) + + +func boss_first_kill_marked(level_index: int) -> bool: + return _data["meta"]["boss_first_kill"].has(str(level_index)) + + +func recent_summaries() -> Array: + return _data["meta"]["recent_summaries"] + + +# 域更新原语(正式冒险收束唯一入账口——组合根经本函数入账,禁直改内部结构): +# 钥石入账 + 逐通关关次 clear_count +1 与 Boss 首杀标记 + 摘要头部插入(cap 10,最新在前)。 +# 本批不含 Boss 金额(首杀全量/重复杀小额归元进度批——届时增第二参 boss_drops,默认 [] 零行为兼容)。 +func apply_adventure_summary(summary: Dictionary) -> void: + add_keystone(int(summary.get("keystone_total", 0))) + var cleared := int(summary.get("levels_cleared", 0)) + for li in range(1, cleared + 1): + var key := str(li) + _data["meta"]["level_clear_counts"][key] = int(_data["meta"]["level_clear_counts"].get(key, 0)) + 1 + _data["meta"]["boss_first_kill"][key] = true + var arr: Array = _data["meta"]["recent_summaries"] + arr.push_front(summary.duplicate(true)) + while arr.size() > SUMMARY_CAP: + arr.pop_back() + + +# ---- 私有:损坏兜底与文件原语 ---- + +# 损坏兜底:坏档改名 .corrupt.bak(已存在则覆盖)→ 默认数据继续。不静默丢档(警告留痕)、不 crash。 +func _corrupt_fallback(reason: String) -> bool: + var bak := _path + ".corrupt.bak" + if not _rename_overwrite(_path, bak): + push_warning("[存档序列化] 坏档备份改名失败(原文件保留原地):%s——%s" % [bak, reason]) + else: + push_warning("[存档序列化] 检测到损坏档案,已备份为 %s——%s(用默认新档继续)" % [bak, reason]) + _data = _default_data() + return true + + +# 迁移后/低版本档字段补齐(改版不弃档:缺新域按默认补齐,既有值保留) +func _normalize(data: Dictionary) -> Dictionary: + var merged := _default_data() + for key in data: + merged[key] = data[key] + var meta: Dictionary = merged.get("meta", {}) + var def_meta: Dictionary = _default_data()["meta"] + for key in def_meta: + if not meta.has(key): + meta[key] = def_meta[key] + var forge: Dictionary = meta.get("forge_levels", {}) + for sk in def_meta["forge_levels"]: + if not forge.has(sk): + forge[sk] = def_meta["forge_levels"][sk] + meta["forge_levels"] = forge + merged["meta"] = meta + return merged + + +# 覆盖式改名(Windows 原生 rename 目标已存在时会失败——先删后改,显式覆盖语义) +func _rename_overwrite(from: String, to: String) -> bool: + var dir := DirAccess.open(from.get_base_dir()) + if dir == null: + return false + if dir.file_exists(to.get_file()) and dir.remove(to.get_file()) != OK: + return false + return dir.rename(from.get_file(), to.get_file()) == OK diff --git a/prototypes/dungeon-sweeper-vertical-slice/core/save_manager.gd.uid b/prototypes/dungeon-sweeper-vertical-slice/core/save_manager.gd.uid new file mode 100644 index 00000000..13ed8b75 --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/core/save_manager.gd.uid @@ -0,0 +1 @@ +uid://ctnurntr6p37h diff --git a/prototypes/dungeon-sweeper-vertical-slice/presentation/main.gd b/prototypes/dungeon-sweeper-vertical-slice/presentation/main.gd index 67baab64..5ddceca9 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/presentation/main.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/presentation/main.gd @@ -26,11 +26,13 @@ const Narrator := preload("res://presentation/battle_narrator.gd") const ModalStack := preload("res://presentation/modal_stack.gd") const TuneSimController := preload("res://presentation/tune_sim_controller.gd") const DebugSessionScript := preload("res://presentation/debug_session.gd") +const SaveManagerScript := preload("res://core/save_manager.gd") # 存档承载(preload 避开全局类缓存时序——与 DebugSessionScript 同款) var db var settlement = null # 当前战斗编排器(关卡与调试模式统一读它) var runner = null # 关卡节点地图 图版推进器(完整关卡模式;冒险模式 = 每关重取 adventure.runner()) var adventure = null # 冒险结构编排器(冒险结构实现批——三关连探;转发信号照 runner 接线原样对接) +var save_manager = null # 存档序列化承载(存档序列化批——启动读盘/冒险收束写盘;只经访问器与 apply 原语,禁读内部结构) var _adventure_mode := false # 冒险模式标记(inter 层确认分派与终局分派) var logger = null # 试玩日志器(JSONL 持久化 + 卡死诊断转储——呈现层组件) var _level_active := false @@ -76,6 +78,10 @@ func _ready() -> void: if not ok: _fatal_screen("数值表加载失败——见输出错误清单(fail-fast)") return + # 存档读盘(存档序列化批):拒读/损坏均已在 SaveManager 内部兜底,失败不阻断启动 + # (fatal 只留给数值表——fail-fast 边界在表加载,不在档);高版本拒读态后续写盘被类内拒绝。 + save_manager = SaveManagerScript.new() + save_manager.load_from_disk() _debug = DebugSessionScript.new(db, get_node("/root/RngService"), Callable(self, "_debug_hero_override"), Callable(self, "_seed_value")) _debug.battle_created.connect(_on_debug_battle_created) @@ -349,6 +355,11 @@ func _on_adventure_finished(summary: Dictionary) -> void: _end_overlay.open_defeat(Narrator.adventure_summary_text(summary), "返回", board_view.get_global_rect()) logger.log_level_end(summary) # 试玩时间线:冒险终局(摘要随载荷) + # 存档入账(存档序列化批):正式冒险收束唯一写档点——域更新原语 + 原子写盘。 + # 单关测试入口与调试两层不经过本处理器,天然不写档(契约:测试模式不污染生涯数据)。 + if save_manager != null: + save_manager.apply_adventure_summary(summary) + save_manager.write_to_disk() if runner != null: runner.restore_hero_base() strip_view.refresh(runner) @@ -357,7 +368,7 @@ func _on_adventure_finished(summary: Dictionary) -> void: func _on_battle_end_continue() -> void: - # 终局出口:返回开始界面(冒险摘要留档试玩日志——钥石持久化归存档/元进度批) + # 终局出口:返回开始界面(冒险摘要留档试玩日志;钥石已在收束时入存档——存档序列化批) _close_modal(_end_overlay) _end_overlay.close() _debug.reset() diff --git a/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd b/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd index c1df4902..1478e30e 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd @@ -101,6 +101,7 @@ func _run_core_smoke(db, rng) -> void: _smoke_adventure_table() _smoke_adventure_api() _smoke_adventure() + _smoke_save() # ---- 冒险结构实现批冒烟(2026-09-21——AdventureTable 建表 + AdventureRunner + 冒险验收断言)---- @@ -3102,3 +3103,148 @@ func _smoke_battle_log_view() -> void: var before: int = view.parsed_text().length() view.append_row(rest[row_type]) check(view.parsed_text().length() > before, "行型 %s 格式化落地(解析文本增长)" % String(row_type)) + + +# ---- 存档序列化冒烟(SaveManager——读写往返/新档合法/余额拒花/锻造出域拒/高版本拒读禁写/ +# 损坏兜底/迁移链/域界干净/摘要 cap/摘要集成)。测试文件统一 user://smoke_save_*.json 隔离, +# 段首清场+段尾清理——绝不碰 user://save.json(红线)。 +func _smoke_save() -> void: + print("--- SaveManager 存档读写/迁移/兜底 ---") + var sm_script = load("res://core/save_manager.gd") + var paths := ["user://smoke_save_roundtrip.json", "user://smoke_save_fresh.json", + "user://smoke_save_spend.json", "user://smoke_save_highver.json", + "user://smoke_save_corrupt.json", "user://smoke_save_migrate.json", + "user://smoke_save_domain.json", "user://smoke_save_cap.json", + "user://smoke_save_integration.json"] + var stale: Array = [] + for p in paths: + stale.append(p) + stale.append(p + ".tmp") + stale.append(p + ".corrupt.bak") + for p in stale: + if FileAccess.file_exists(p): + DirAccess.remove_absolute(p) + + # ① 往返:全访问器写非默认值 → write → 新实例 load → 逐域逐值相等 + var w = sm_script.new(paths[0]) + w.add_keystone(7) + check(w.set_forge_level("atk", 2) and w.set_forge_level("max_hp", 1) and w.set_forge_level("armor_class", 1), + "属性锻造三档写入") + w.unlock_item("probe_compass") + w.apply_adventure_summary({"result": "failed", "cleared": false, "levels_cleared": 2, + "stopped_at": 3, "keystone_total": 3, "master_seed": 99}) + check(w.keystone_balance() == 10 and w.clear_count(1) == 1 and w.clear_count(2) == 1 and w.clear_count(3) == 0, + "apply 原语入账(钥石 7+3、通关次数 1/1/0)") + check(w.boss_first_kill_marked(1) and w.boss_first_kill_marked(2) and not w.boss_first_kill_marked(3), + "首杀标记随通关关卡置位(1/2 有、3 无)") + check(w.write_to_disk(), "写盘成功") + var r = sm_script.new(paths[0]) + check(r.load_from_disk(), "读回成功") + check(r.keystone_balance() == 10, "钥石余额往返相等(10)") + check(r.forge_level("atk") == 2 and r.forge_level("max_hp") == 1 and r.forge_level("armor_class") == 1, + "锻造三档往返相等") + check(r.item_unlocked("probe_compass") and not r.item_unlocked("rope"), "道具解锁往返相等") + check(r.clear_count(1) == 1 and r.clear_count(2) == 1 and r.clear_count(3) == 0, "通关次数表往返相等") + check(r.boss_first_kill_marked(1) and r.boss_first_kill_marked(2) and not r.boss_first_kill_marked(3), + "首杀标记往返相等") + var s0: Dictionary = r.recent_summaries()[0] + check(r.recent_summaries().size() == 1 and int(s0.get("master_seed", -1)) == 99 and int(s0.get("keystone_total", -1)) == 3, + "冒险摘要往返相等(1 条,seed 99)") + + # ② 新档合法:不存在路径 load → true + 全零默认值 + var f2 = sm_script.new(paths[1]) + check(f2.load_from_disk() and f2.keystone_balance() == 0 and f2.forge_level("atk") == 0 + and not f2.item_unlocked("anything") and f2.clear_count(1) == 0 and f2.recent_summaries().is_empty(), + "新档合法态(load true + 全零默认)") + + # ③ spend 拒绝:余额不足 false 且零副作用(钥石只增不减——消费除外) + var f3 = sm_script.new(paths[2]) + f3.add_keystone(5) + check(not f3.spend_keystone(8) and f3.keystone_balance() == 5, "余额 5 花 8 拒绝且余额不动") + check(f3.spend_keystone(3) and f3.keystone_balance() == 2, "余额内消费成功(5→2)") + + # ④ attr 出域拒绝(push_error,值不变) + check(not f3.set_forge_level("luck", 1) and f3.forge_level("luck") == 0 and f3.forge_level("atk") == 0, + "锻造键出域拒绝(值不变)") + + # ⑤ 高版本拒读:load false、原文件字节不动、后续写盘拒绝且文件仍字节不动(契约 4) + var hv_text := "{\"version\": 99, \"meta\": {\"keystone_balance\": 1}}" + var fhv := FileAccess.open(paths[3], FileAccess.WRITE) + fhv.store_string(hv_text) + fhv.close() + var f5 = sm_script.new(paths[3]) + check(not f5.load_from_disk(), "高版本拒读(load false)") + check(FileAccess.open(paths[3], FileAccess.READ).get_as_text() == hv_text, "拒读后原文件字节不动") + check(not f5.write_to_disk() and FileAccess.open(paths[3], FileAccess.READ).get_as_text() == hv_text, + "拒读态写盘拒绝且文件仍字节不动") + + # ⑥ 损坏兜底:非 JSON 字节 → 默认数据 + .corrupt.bak(不静默丢档) + var junk := "not json {{{" + var fj := FileAccess.open(paths[4], FileAccess.WRITE) + fj.store_string(junk) + fj.close() + var f6 = sm_script.new(paths[4]) + check(f6.load_from_disk() and f6.keystone_balance() == 0 and f6.recent_summaries().is_empty(), + "损坏档兜底(默认数据继续)") + check(FileAccess.file_exists(paths[4] + ".corrupt.bak"), "坏档备份 .corrupt.bak 存在") + check(FileAccess.open(paths[4] + ".corrupt.bak", FileAccess.READ).get_as_text() == junk, "备份内容 = 原坏档字节") + + # ⑦ 迁移链:register_migration 测试钩 → 手工 v0 旧结构档 → 链式升级 v1 + var mig := func(d: Dictionary) -> Dictionary: + return {"version": 1, "meta": {"keystone_balance": int(d.get("keystone_old", 0))}} + var f7 = sm_script.new(paths[5]) + f7.register_migration(0, mig) + var fm := FileAccess.open(paths[5], FileAccess.WRITE) + fm.store_string(JSON.stringify({"version": 0, "keystone_old": 12})) + fm.close() + check(f7.load_from_disk() and f7.keystone_balance() == 12, "v0 旧档经迁移链入账(keystone_old 12 → 余额 12)") + check(f7.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[5], FileAccess.READ).get_as_text()).get("version", -1)) == 1, + "迁移后写盘 version == 1") + + # ⑧ 域界干净:盘上文本不含局内资源键(行动点/RAP/GP/道具库存/生命账本不入档——契约 2) + var disk_text := FileAccess.open(paths[0], FileAccess.READ).get_as_text() + var banned := ["\"ap\"", "\"rap\"", "\"gp\"", "\"inventory\"", "\"hero_hp\""] + var leaked := [] + for b in banned: + if disk_text.contains(b): + leaked.append(b) + check(leaked.is_empty(), "域界干净——盘上无局内资源键(发现 %s)" % str(leaked)) + + # ⑩ 摘要 cap:12 份 → 保留 10、最新在前 + var f10 = sm_script.new(paths[7]) + for i in range(12): + f10.apply_adventure_summary({"result": "completed", "cleared": true, "levels_cleared": 0, + "stopped_at": 0, "keystone_total": 0, "master_seed": i}) + var arr: Array = f10.recent_summaries() + check(arr.size() == 10, "摘要 cap 10(12 份 → 10 条)") + check(int(arr[0].get("master_seed", -1)) == 11 and int(arr[9].get("master_seed", -1)) == 2, + "最新在前(头 seed 11 / 尾 seed 2)") + + # ⑨ 摘要集成(冒险结构批产物驱动):run_adventure 真局摘要 → apply 原语 → 域读数 → 写盘读回保持 + var autoplay = load("res://smoke/level_autoplay.gd") + var st9: Dictionary = autoplay.run_adventure(_db, 42, 200, 100) + var f9 = sm_script.new(paths[8]) + f9.apply_adventure_summary(st9.get("summary", {})) + var kpl := int(_db.adventure.keystone_per_level) + check(String(st9.get("result", "")) == "completed", "摘要集成:冒险驱动满配通关(seed 42)") + check(f9.keystone_balance() == 3 * kpl, "集成钥石余额 = 3 × keystone_per_level(%d)" % f9.keystone_balance()) + check(f9.clear_count(1) == 1 and f9.clear_count(2) == 1 and f9.clear_count(3) == 1, "集成通关次数表全 1") + check(f9.boss_first_kill_marked(1) and f9.boss_first_kill_marked(2) and f9.boss_first_kill_marked(3), + "集成三关首杀标记齐") + check(f9.recent_summaries().size() == 1 and int(f9.recent_summaries()[0].get("master_seed", -1)) == 42, + "集成 recent 1 条且 seed 42") + var st9b: Dictionary = autoplay.run_adventure(_db, 42, 200, 100) + f9.apply_adventure_summary(st9b.get("summary", {})) + check(f9.clear_count(1) == 2 and f9.clear_count(2) == 2 and f9.clear_count(3) == 2, "第二局后通关次数全 2") + check(f9.keystone_balance() == 6 * kpl, "第二局后余额翻倍(6 × keystone_per_level)") + check(f9.recent_summaries().size() == 2, "集成 recent 长度 2") + check(f9.write_to_disk(), "集成写盘成功") + var r9 = sm_script.new(paths[8]) + check(r9.load_from_disk() and r9.keystone_balance() == 6 * kpl and r9.clear_count(3) == 2 + and r9.recent_summaries().size() == 2 and r9.boss_first_kill_marked(3), + "集成写盘读回全量保持") + + # 段尾清理(测试文件绝不残留) + for p in stale: + if FileAccess.file_exists(p): + DirAccess.remove_absolute(p)