diff --git a/prototypes/dungeon-sweeper-vertical-slice/README.md b/prototypes/dungeon-sweeper-vertical-slice/README.md index 91c8bb33..36e517ad 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/README.md +++ b/prototypes/dungeon-sweeper-vertical-slice/README.md @@ -12,7 +12,7 @@ 1. 用 Godot 4.7.2 打开本目录(含 `project.godot` 的文件夹) 2. 按 F5 运行(主场景 `presentation/main.tscn`) -3. **主入口:`▶ 第一关 · 地精洞窟`**——线性节点链:战→战→赏(随机主动道具)→战→战→疗(血量恢复点)→王(Boss 遗迹守卫) +3. **主入口:`▶ 开始冒险(三关连探:地精洞窟 → 幽暗地域 → 灰矮人矿城)`**——一条命走完三关(冒险结构实现批 2026-09-21:生命/GP/加算区跨关同账、非末关通关发大奖励〔上限提升+血回满+钥石〕、任意战斗倒下冒险终、结算屏出冒险摘要五件事);首关节点链:战→战→赏→战→战→疗→王。`▶ 测试直入口 · 第二关 / 第三关` 保留单关试玩模式 4. `测试用 · 角色数值调整板`:攻击/生命百分比加成(模拟天赋树解锁)+ 批量模拟(输出通关率/承伤期望/败北节点分布) headless 冒烟(55 项断言,含关卡线性推进/满配通关/裸装败北/回放确定性,约 30 秒): @@ -169,7 +169,7 @@ Godot_v4.7.2-stable_win64_console.exe --headless --path . --script res://smoke/s ## L2 试玩采集清单(分支节点图批 2026-09-17——关卡 2「幽暗地域」试玩观测项) -入口:首页"▶ 第二关 · 幽暗地域(分支节点图试玩——岔路口选下一跳)"(测试模式直入口,种子框同用;与关卡 1 行为一致:通关/败北回首页)。 +入口:首页"▶ 测试直入口 · 第二关 · 幽暗地域(分支节点图试玩——岔路口选下一跳)"(测试模式直入口,种子框同用;正式序列走「开始冒险」主入口)。 - **选路行为**:战斗胜利/兑付确认后弹出选路覆盖层(整图 + 候选按钮)——玩家是否理解"零自动推进、每跳都要选";单候选节点(恢复点→编号 6)是否被读成"卡住"。 - **岔路使用率**:四处真实岔路的走向分布(入口 0→[1,2];战斗 1→[3,4];奖励 2→[4,5];深层 4→[6,7])——奖励点路线 vs 纯战斗路线的选择比例;整图渲染是否被实际用来规划(对比只点按钮的玩家)。 @@ -209,7 +209,7 @@ Godot_v4.7.2-stable_win64_console.exe --headless --path . --script res://smoke/s 5. **难度台阶可感知性**:D2 伏击兽/D4 Boss 相比 D0 蛆虫的变强是否有具体面孔(新敌种=新图像线索)。 6. **奖励供给体感**:可选类道具(刀/治疗包)获取频率与囤积感受(供给控制器批后每关 ≈4.5–6.5 件)。 -试玩方式:F5 → 「▶ 第一关 · 地精洞窟」基线打一局 → 调整板 +100% 攻击再打一局(对照"数值提升→通关期望改善"的元进度体感)。 +试玩方式:F5 → 「▶ 开始冒险」基线打一局(一条命三关连探——冒险结构实现批 2026-09-21 起,第一关读数即冒险首关) → 调整板 +100% 攻击再打一局(对照"数值提升→通关期望改善"的元进度体感)。 ## 2026-09-12 试玩批次 1 → v3 数值批(F7 流程实践) @@ -522,3 +522,11 @@ Godot_v4.7.2-stable_win64_console.exe --headless --path . --script res://smoke/s - 结构面:SkillTable type 枚举 +debuff;池纪律 +每池妨害 ≤1;施放类 side 守卫(buff→enemy / debuff→hero,`validate_skill_pool_links` 增 status_table 显式入参——装载序修正);意图徽标 +妨害系 ☠(紫)。 - 验证:冒烟 306 项 0 失败(+新探针 `_smoke_debuff_rider`:毒爆行逐拍精确复算 / rider 键封闭 / 蛛网束缚落主角端到端 / 六项加载校验拒绝)+ 四探针 PASS;800 局模拟试玩(两关 × 20 种子 × 双画像)——L2 基线通关 90%→80%、死因全部集中 Boss(设计方向生效),L1 零行为变更回归一致(65%/85%)。 - 顺手收口:smoke_all `variant_pool(1, ["dungeon"])` → `"dungeon"`(并行 board_type 批签名 Array→String 的漏网同步——此前该断言被 SCRIPT ERROR 静默吞掉);analyze_playtest.py 反事实重算 +rider 识别。 + +## 2026-09-21 冒险结构实现批(三关正式序列生效——AdventureRunner + LevelRunner 账本外置 + AdventureTable 建表) + +**主入口升级**:「▶ 开始冒险(三关连探:地精洞窟 → 幽暗地域 → 灰矮人矿城)」——一条命走完三关的完整 Roguelike 冒险;`▶ 测试直入口 · 第二关/第三关` 保留单关试玩模式,L1 单关直入口随批取消(冒险首关即 L1)。 + +**冒险级语义**(设计权威 = 冒险结构.md v0.6):主种子恰一次注入(冒险内不重播种,同种子同操作序列逐位可复现);生命账本跨关同账(战后回写、恢复点按比例封顶、大奖励回满);GP 钱包跨关延续、冒险收束清零;统一冒险加算区(商店购买与大奖励同账);非末关通关发大奖励(上限提升 +10 读 adventure.tres、血回满、钥石 +10),末关不发上限提升、钥石照记;任意战斗倒下 = 冒险失败;结算屏出冒险摘要五件事(打穿没有/通关几关/止步第几关/钥石合计/主种子)。 + +**验证**:冒烟 437→482 项 0 失败三闸全过(冒险表 +4 / API 闸 +8 / 冒险验收 +33:表覆盖义务、满配通关、种子复现、大奖励恰 2 次与覆盖值传播、败北线两种子扫描、重复宣布拒绝、GP 跨关延续、加算区同账、恢复数学、放弃两态);MC 回归零行为变更(run_level 旧路径同种子逐位一致)。 diff --git a/prototypes/dungeon-sweeper-vertical-slice/core/adventure_runner.gd b/prototypes/dungeon-sweeper-vertical-slice/core/adventure_runner.gd new file mode 100644 index 00000000..f24f72d3 --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/core/adventure_runner.gd @@ -0,0 +1,224 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# Validation Question: 三关连探冒险(地精洞窟 → 幽暗地域 → 灰矮人矿城)能否以一条命走完—— +# 主种子恰一次注入、生命/GP/加算区跨关同账、大奖励非末关恰兑付一次、三结局摘要五件事成立(P0) +# Date: 2026-09-21(冒险结构实现批——设计权威 design/gdd/systems/冒险循环/冒险结构.md) +# 冒险结构编排器(Core 层零 UI 依赖——headless 可跑完整冒险):一局的关卡序列、生命账本、GP 冒险 +# 账本、统一冒险加算区、关卡大奖励、三结局结算与主种子接线的唯一所有者。 +# +# 接口契约四件套落点(冒险结构.md「与关卡节点地图的边界」——本类是正式侧承载): +# ① 开始关卡 = start_level_external(载荷 = 关卡序号 + 当前生命 + 生效上限——生命经共享账本交接); +# ② 战斗结果上报 = runner 经共享账本属性直写(LevelRunner._on_battle_finished 回写 hero_hp 到 +# _ledger——零直调战斗域,战后生命终值随账本回流本类); +# ③ 治疗封顶回报 = apply_rest(恢复点节点兑付时 LevelRunner 经 _rest_handler 回调——按比例回血并 +# 封顶的执行归本类); +# ④ 关卡结束 = _on_level_finished(LevelRunner.level_finished 到达——结果 + 生命终值 + 节点位置)。 +# +# 接口漂移清偿登记(06 清单①②③⑥⑦——LevelRunner 侧同批登记):①种子域归本类 +# (start_adventure 调 rng.new_adventure 全冒险恰一次;LevelRunner 冒险模式不重播种); +# ②生命账本归本类(共享 _ledger);③恢复兑付执行归本类(_rest_handler);⑥收束先清库后宣布—— +# 现行无关卡域库存(格面资源批 2026-09-15 后为空集),战斗结束 retire_mapping + despawn_all 先于 +# battle_finished 即事实清库,不另设空接口;⑦失败结算归本类(LevelRunner 只上报结果)。 +# +# 收束次序登记(大奖励兑付时点——冒险结构.md「关卡大奖励」):关卡节点地图先清库、后宣布关卡 +# 结束(次序不可换);大奖励在宣布之后、下一关开始之前兑付——本类 _on_level_finished 兑付、 +# continue_to_next_level 开始下一关,两步间为呈现层大奖励屏(冒烟/自动路径立即确认)。 +# 结算受理只算一次(契约):state != "running" 的关卡结束宣布与放弃请求一律拒绝并留诊断, +# 账本与摘要零变化。 +# 纪律:ADR-0001 S6——连 runner 信号一律 _forward_* 方法 Callable(禁 lambda 捕获 self 成环); +# Core 层禁 UI 引用;数值零字面量——level_clear_hp_bonus / keystone_per_level 只读 db.adventure, +# 基础上限只读 db.hero。 +class_name AdventureRunner +extends RefCounted + +signal level_started(level_index: int, display_name: String) # 每关开始(含冒险首关)——呈现层重取 runner()、刷新图条与层名行 +signal big_reward_paid(reward: Dictionary) # 大奖励兑付(非末关通关恰一次)——{from_level, hp_bonus, new_max_hp, keystone_gained, keystone_total};呈现层大奖励屏/冒烟立即确认 +signal adventure_finished(summary: Dictionary) # 冒险结算(completed/failed/abandoned)——summary 五件事:打穿没有/通关几关/止步第几关/钥石合计/主种子 +# ---- 转发族(签名与 LevelRunner 同款——呈现层对接透明;ADR-0001 S6 经 _forward_* 方法)---- +signal node_entered(node: Dictionary, info: Dictionary) +signal choices_presented(candidates: Array) +signal step_row(row: Dictionary) +signal round_started(round_index: int) +signal board_changed() +signal wallet_changed(event: String, gp: int, extra: Dictionary) + +var db +var rng +var state := "idle" # idle / running / settling(冒险结构.md 三态) +var ledger := {"hero_hp": 0, "gp": 0} # 冒险账本(生命 + GP 钱包——经 start_level_external 注入 runner 共享) +var bonus := {"atk": 0, "max_hp": 0, "armor_class": 0} # 统一冒险加算区(商店购买与大奖励同一本账) +var purchased := {} # product_id → 已购档数(跨关延续、冒险收束清零——递增定价输入) +var level_index := 0 # 当前关卡序号(1 起) +var levels_total := 0 # 序列总关数(读 level 表行数) +var keystone := 0 # 钥石入账累计(摘要第三件事的账面) +var master_seed := 0 # 本局主种子(摘要第五件事) +var summary := {} # 最近一次结算的冒险摘要(idle 后保留——重开才新生成) +var _runner: LevelRunner # 当前关卡行进器(每关新建——关卡域状态天然隔离) +var _pending_next_level := false # 大奖励已兑付、等 continue_to_next_level 确认 +var _override := [0, 0] # TunePanel 百分比通道 [atk_pct, hp_pct](每关新 runner 都要应用) + + +func _init(db_ref, rng_ref) -> void: + db = db_ref + rng = rng_ref + + +# ---- 冒险生命周期 ---- + +# 冒险开始(主种子注入恰一次——全冒险唯一 rng.new_adventure 调用点;重开 = 全新一轮: +# 账本/加算区/已购档/钥石/摘要全重置,生命置满基础上限,从第 1 关起步) +func start_adventure(seed_val: int) -> bool: + if state != "idle": + push_error("[冒险结构] start_adventure 拒绝——state=%s(重开须待上一局结算完毕)" % state) + return false + master_seed = seed_val + rng.new_adventure(seed_val) + ledger = {"hero_hp": 0, "gp": 0} + bonus = {"atk": 0, "max_hp": 0, "armor_class": 0} + purchased = {} + keystone = 0 + summary = {} + levels_total = db.level.rows.size() + level_index = 1 + _pending_next_level = false + ledger["hero_hp"] = int(db.hero.max_hp) # 生命账本置满(基础上限读英雄表——加算区刚归零) + state = "running" + return _start_current_level() + + +# TunePanel 百分比通道(测试面板覆盖——每关新 runner 都要应用;正式侧归元进度) +func set_hero_override(atk_pct: int, hp_pct: int) -> void: + _override = [atk_pct, hp_pct] + if _runner != null: + _runner.set_hero_override(atk_pct, hp_pct) + + +# 当前关卡行进器读面(呈现层/冒烟用——每关新建实例,调用方须在 level_started 后重取) +func runner() -> LevelRunner: + return _runner + + +# 接口③治疗封顶回报执行体(恢复点节点兑付——按比例回血并封顶;与 LevelRunner.rest_heal_amount +# 同式,同一公式两个落点是临时态,注释指认,统一归并归后续批) +func apply_rest(pct: int, hp: int, max_hp: int) -> int: + return mini(int(floor(float(max_hp) * float(pct) / 100.0)), max_hp - hp) + + +# 大奖励确认 → 进下一关(仅兑付后受理——零自动推进的冒险级停点;呈现层大奖励屏 / 冒烟立即确认) +func continue_to_next_level() -> bool: + if not _pending_next_level: + push_error("[冒险结构] continue_to_next_level 拒绝——无待确认的关卡推进") + return false + _pending_next_level = false + level_index += 1 + return _start_current_level() + + +# 主动放弃(仅非战斗期受理——战斗域单事务在飞;与失败同型结算,已获大奖励不追回。 +# MVP 无界面入口,语义先行预留;结算受理只算一次——非 running 态拒绝) +func abandon() -> bool: + if state != "running": + push_error("[冒险结构] abandon 拒绝——state=%s(非进行中不可放弃)" % state) + return false + if _runner != null and _runner.current_battle() != null: + push_error("[冒险结构] abandon 拒绝——战斗进行中(战斗域单事务在飞)") + return false + _settle("abandoned") + return true + + +# ---- 关卡推进(私有——序列编排单一出口)---- + +func _start_current_level() -> bool: + _runner = LevelRunner.new(db, rng) + # core↔core 接线只许方法 Callable(ADR-0001 S6——lambda 捕获 self 会延长 RefCounted 生命周期乃至成环) + _runner.node_entered.connect(_forward_node_entered) + _runner.choices_presented.connect(_forward_choices_presented) + _runner.step_row.connect(_forward_step_row) + _runner.round_started.connect(_forward_round_started) + _runner.board_changed.connect(_forward_board_changed) + _runner.wallet_changed.connect(_forward_wallet_changed) + _runner.level_finished.connect(_on_level_finished) + _runner.set_hero_override(int(_override[0]), int(_override[1])) + # 接口①开始关卡:生命经共享账本交接(账本当前值即开局生命——冒险开始置满 / 大奖励回满已写好) + var ok: bool = _runner.start_level_external(level_index, ledger, bonus, purchased, + Callable(self, "apply_rest")) + if ok: + level_started.emit(level_index, String(_runner.level_row.get("display_name", ""))) + return ok + + +# 接口④关卡结束(结果 + 生命终值 + 节点位置——胜负皆报;结算受理只算一次) +func _on_level_finished(payload: Dictionary) -> void: + if state != "running": + push_error("[冒险结构] 重复关卡结束宣布拒绝——state=%s(账本与摘要零变化)" % state) + return + var result := String(payload.get("result", "")) + if result == "defeat": + ledger["hero_hp"] = 0 # 倒下记 0(生命账本战后回写口径) + _settle("failed") + return + if result != "victory": + push_error("[冒险结构] 关卡结束结果出域: %s" % result) + return + if level_index < levels_total: + # 大奖励(非末关通关恰一次):上限提升写加算区 → 血回满 = 新上限 → 钥石入账(顺序即契约) + var hp_bonus: int = int(db.adventure.level_clear_hp_bonus) + bonus["max_hp"] = int(bonus.get("max_hp", 0)) + hp_bonus + ledger["hero_hp"] = int(db.hero.max_hp) + int(bonus["max_hp"]) + keystone += int(db.adventure.keystone_per_level) + # 置位先于 emit(信号同步派发——处理器内立即确认时 _pending_next_level 必须已就位) + _pending_next_level = true + big_reward_paid.emit({ + "from_level": level_index, + "hp_bonus": hp_bonus, + "new_max_hp": int(db.hero.max_hp) + int(bonus["max_hp"]), + "keystone_gained": int(db.adventure.keystone_per_level), + "keystone_total": keystone, + }) + return + # 末关通关:钥石照记、不发上限提升(没有下一战可受益)——冒险完成结算 + keystone += int(db.adventure.keystone_per_level) + _settle("completed") + + +func _settle(result: String) -> void: + # 冒险摘要五件事:打穿没有 / 通关几关 / 止步第几关 / 钥石入账合计 / 本局主种子 + summary = { + "result": result, + "cleared": result == "completed", + "levels_cleared": levels_total if result == "completed" else level_index - 1, + "stopped_at": 0 if result == "completed" else level_index, + "keystone_total": keystone, + "master_seed": master_seed, + } + if _runner != null: + _runner.close_wallet() # GP 冒险收束清零(三结局都清——经转发发 wallet_changed "clear" 供日志/呈现) + state = "settling" + adventure_finished.emit(summary) + state = "idle" + + +# ---- 转发族(签名与 LevelRunner 同款——呈现层对接透明)---- + +func _forward_node_entered(node: Dictionary, info: Dictionary) -> void: + node_entered.emit(node, info) + + +func _forward_choices_presented(candidates: Array) -> void: + choices_presented.emit(candidates) + + +func _forward_step_row(row: Dictionary) -> void: + step_row.emit(row) + + +func _forward_round_started(ri: int) -> void: + round_started.emit(ri) + + +func _forward_board_changed() -> void: + board_changed.emit() + + +func _forward_wallet_changed(event: String, gp_val: int, extra: Dictionary) -> void: + wallet_changed.emit(event, gp_val, extra) diff --git a/prototypes/dungeon-sweeper-vertical-slice/core/adventure_runner.gd.uid b/prototypes/dungeon-sweeper-vertical-slice/core/adventure_runner.gd.uid new file mode 100644 index 00000000..c9ec8c8f --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/core/adventure_runner.gd.uid @@ -0,0 +1 @@ +uid://lem5anpkcd0p diff --git a/prototypes/dungeon-sweeper-vertical-slice/core/config_db.gd b/prototypes/dungeon-sweeper-vertical-slice/core/config_db.gd index 98f00e4e..4c92f674 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/core/config_db.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/core/config_db.gd @@ -21,6 +21,7 @@ const TABLE_MANIFEST := { "level": "level.tres", "difficulty": "difficulty.tres", "shop": "shop.tres", # GP+商店房实施批 2026-09-21(表与 schema 落籍归并行数据面——本批只接线加载与消费) + "adventure": "adventure.tres", # 冒险结构实现批 2026-09-21(AdventureTable 建表——大奖励与钥石行值单点) } var action_economy: ActionEconomyTable @@ -37,6 +38,7 @@ var difficulty: DifficultyTable # GP+商店房实施批(2026-09-21):ShopTable 暂持无类型注记——schema class_name 落籍归并行数据面, # 落籍后可收紧为 `var shop: ShopTable`(消费面一律走下方 shop_* 访问器,禁直读行集) var shop = null +var adventure: AdventureTable var is_ready := false var _diagnostics: Array = [] # {severity, category, message} @@ -119,6 +121,7 @@ func load_tables(root: String, collect_only: bool) -> bool: level = loaded.get("level") difficulty = loaded.get("difficulty") shop = loaded.get("shop") + adventure = loaded.get("adventure") is_ready = not has_errors return not has_errors diff --git a/prototypes/dungeon-sweeper-vertical-slice/core/level_runner.gd b/prototypes/dungeon-sweeper-vertical-slice/core/level_runner.gd index cfe8b156..59217cb2 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/core/level_runner.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/core/level_runner.gd @@ -17,6 +17,18 @@ # 加算区 bonus{atk,max_hp,armor_class}(商店购买与关卡大奖励共用账本——战斗开始参数 = 基础表值 + # 加算区,TunePanel 百分比在加算之后乘)+ 商店房节点兑付(开覆盖层/购买出账/离开回选路确认—— # 复用 continue_after_node 兑付确认纪律,零自动推进)。 +# 冒险结构实现批(2026-09-21)——接口漂移清偿登记(06 清单「切片行进代码与定稿的接口漂移」①②③⑥⑦): +# ① 种子域归冒险结构:start_level 旧路径(单关测试模式)保留自调 rng.new_adventure;新路径 +# start_level_external(冒险模式)不重播种——主种子注入恰一次归 AdventureRunner.start_adventure。 +# ② 生命账本归冒险结构:hero_hp/gp 改属性落 _ledger 字典——冒险模式经 start_level_external 注入 +# AdventureRunner 共享账本(Dictionary 引用共享),单关模式 = 本实例私有账本;战斗结果上报语义 = +# runner 经共享账本属性直写(接口②——战后生命终值已在本类 _on_battle_finished 回写账本)。 +# ③ 恢复数学抽成单一路径 rest_heal_amount——冒险模式经 _rest_handler 回调(AdventureRunner.apply_rest, +# 接口③治疗封顶回报执行体归冒险结构);单关模式默认内置。公式逐位一致,零行为变更。 +# ⑥ 收束先清库后宣布——现行无关卡域库存(格面资源批 2026-09-15 可选类删除后为空集),战斗结束 +# retire_mapping + despawn_all 先于 battle_finished 发出即事实清库,不另设空接口(假契约)。 +# ⑦ 倒下的失败结算归冒险结构:本类只发 level_finished(每战结果上报语义),失败结算由 +# AdventureRunner._on_level_finished 执行;GP 冒险收束清零同步归冒险结构(本类冒险模式不关钱包)。 class_name LevelRunner extends RefCounted @@ -32,9 +44,20 @@ var db var rng var level_row: Dictionary = {} var node_index := -1 -var hero_hp := 0 -var gp := 0 # GP 冒险钱包(局内货币——冒险开始 = 0、收束清零;不入存档不入摘要) -var bonus := {"atk": 0, "max_hp": 0, "armor_class": 0} # 统一冒险加算区(冒险开始全 0)——商店购买与关卡大奖励 hp 上限提升写同一本账(大奖励在切片序列未生效,契约位:大奖励 hp 上限提升写本加算区,随正式序列批接线) +# ---- 账本载体(冒险结构实现批——漂移②清偿)---- +# 冒险模式 = AdventureRunner 共享字典(Dictionary 引用共享——hero_hp/gp 属性读写直接落冒险账本); +# 单关模式 = 本实例私有账本(start_level 重建,既有单关语义逐位保留)。 +var _ledger: Dictionary = {"hero_hp": 0, "gp": 0} +var _rest_handler: Callable # 治疗封顶回报执行体(默认内置 rest_heal_amount;冒险模式 = AdventureRunner.apply_rest——接口③) +var _external := false # 冒险模式标记(钱包收束时机与播种语义分叉点) +# hero_hp / gp 属性化(既有读写点零改动——值类型经属性落 _ledger) +var hero_hp: int: + get: return int(_ledger["hero_hp"]) + set(v): _ledger["hero_hp"] = v +var gp: int: # GP 冒险钱包(局内货币——冒险开始 = 0、收束清零;不入存档不入摘要) + get: return int(_ledger["gp"]) + set(v): _ledger["gp"] = v +var bonus := {"atk": 0, "max_hp": 0, "armor_class": 0} # 统一冒险加算区(冒险开始全 0)——商店购买与关卡大奖励写同一本账(冒险模式 = AdventureRunner 共享字典;大奖励接线归 AdventureRunner._on_level_finished) var _shop_purchased := {} # product_id → 已购档数(现价重算输入;冒险开始清零) # 协作对象私有(E1/E3 封装收口 2026-09-13):settlement 读取经 current_battle() 成文出口 var _settlement = null # 当前战斗编排器(关卡结束置 null) @@ -87,6 +110,11 @@ func restore_hero_base() -> void: # ---- 关卡生命周期 ---- func start_level(level_index: int, seed_val: int) -> bool: + # 单关测试模式(旧路径——既有语义一字不改):私有账本、内置恢复数学、自调 new_adventure(漂移① + # 单关=冒险的历史简化在测试模式保留;正式种子域归 AdventureRunner.start_adventure) + _external = false + _rest_handler = Callable(self, "rest_heal_amount") + _ledger = {"hero_hp": 0, "gp": 0} var row := _find_level_row(level_index) if row.is_empty(): push_error("[关卡节点地图] level_index %d 在 LevelTable 无行" % level_index) @@ -116,6 +144,42 @@ func start_level(level_index: int, seed_val: int) -> bool: return _enter_node(entry) +# 冒险结构接口①:开始关卡(载荷 = 关卡序号 + 当前生命 + 生效上限——生命经共享账本交接; +# 种子域归冒险结构:本路径不重播种;账本归冒险结构:本路径不重置 gp/bonus/_shop_purchased, +# 也不重置 hero_hp——账本当前值即开局生命,AdventureRunner 已在 ledger 写好) +# 流程与 start_level 同款(_find_level_row → 状态初始化 → _graph_mode 探测 → _enter_node(entry)), +# 差异仅在账本/加算区/已购档/恢复执行体的注入与零重置。 +func start_level_external(level_index: int, ledger: Dictionary, bonus_ref: Dictionary, + purchased_ref: Dictionary, rest_handler: Callable) -> bool: + var row := _find_level_row(level_index) + if row.is_empty(): + push_error("[关卡节点地图] level_index %d 在 LevelTable 无行" % level_index) + return false + _external = true + _ledger = ledger + bonus = bonus_ref + _shop_purchased = purchased_ref + _rest_handler = rest_handler + level_row = row + node_index = -1 + level_result = "" + _awaiting_choice = false + _candidates = [] + visited = [] + _graph_mode = false + for node in nodes(): + if node.has("next"): + _graph_mode = true + break + _running = true + var entry := _entry_node() + if entry.is_empty(): + push_error("[关卡节点地图] 入口节点缺失(无入边唯一节点——图合法性校验应已拦截)") + _running = false + return false + return _enter_node(entry) + + func _find_level_row(level_index: int) -> Dictionary: for row in db.level.rows: if int(row.level_index) == level_index: @@ -281,10 +345,12 @@ func _on_battle_finished(payload: Dictionary) -> void: hero_hp = maxi(1, int(payload.get("hero_hp", 0))) # despawn 前捕获值(载荷自包含) _gain_wallet(int(payload.get("gp_dropped", 0))) # 战斗完成入账(clamp ≥0——破财可把 escrow 打负,钱包入账时钳制) if String(node.get("type", "")) == "boss" or result == "defeat": - # Boss 胜利 = 关卡通关;任意战斗败北 = 关卡失败(Roguelike——冒险即终,重开归 冒险结构/元进度) + # Boss 胜利 = 关卡通关;任意战斗败北 = 关卡失败(漂移⑦清偿——每战结果上报冒险结构, + # 失败结算归 AdventureRunner;本类只发 level_finished) level_result = "victory" if result == "victory" else "defeat" var hp_left := hero_hp if result == "victory" else 0 - _close_wallet() # 冒险收束(通关/倒下)→ GP 清零(局内货币生命周期终点) + if not _external: + close_wallet() # 单关模式:关卡收束即冒险收束(GP 清零);冒险模式归 AdventureRunner._settle _running = false _settlement = null level_finished.emit({"result": level_result, "hp": hp_left, "node_index": node_index, @@ -295,11 +361,17 @@ func _on_battle_finished(payload: Dictionary) -> void: _present_choices() +# 恢复数学单一路径(漂移③清偿——同一公式两个落点:本体内置默认 + AdventureRunner.apply_rest, +# 统一归并归后续批):floor(上限 × 比例 ÷ 100) 后封顶缺口;冒险结构 GDD 公式逐位一致 +func rest_heal_amount(pct: int, hp: int, max_hp: int) -> int: + return mini(int(floor(float(max_hp) * float(pct) / 100.0)), max_hp - hp) + + func _apply_rest() -> int: var pct: int = int(level_row.get("rest_heal_pct", 0)) - var mhp := effective_max_hp() - var heal := int(floor(float(mhp) * float(pct) / 100.0)) - var actual: int = mini(heal, mhp - hero_hp) + # 接口③治疗封顶回报:单关模式 = 内置 rest_heal_amount;冒险模式 = AdventureRunner.apply_rest + #(执行归冒险结构——回报比例、由其按封顶公式回写共享账本后再由本类叠加) + var actual: int = int(_rest_handler.call(pct, hero_hp, effective_max_hp())) hero_hp += actual return actual @@ -355,8 +427,9 @@ func _gain_wallet(amount: int) -> void: wallet_changed.emit("gain", gp, {"amount": amount}) -# 冒险收束清零(通关/倒下——局内货币生命周期终点,不入存档不入摘要) -func _close_wallet() -> void: +# 冒险收束清零(通关/倒下/放弃——局内货币生命周期终点,不入存档不入摘要;public——冒险模式由 +# AdventureRunner._settle 在冒险收束时调用,单关模式 = 关卡收束即冒险收束) +func close_wallet() -> void: gp = 0 wallet_changed.emit("clear", 0, {}) diff --git a/prototypes/dungeon-sweeper-vertical-slice/data/schemas/adventure_table.gd b/prototypes/dungeon-sweeper-vertical-slice/data/schemas/adventure_table.gd new file mode 100644 index 00000000..7d71b3b5 --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/data/schemas/adventure_table.gd @@ -0,0 +1,23 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# Validation Question: 三关连探冒险(地精洞窟 → 幽暗地域 → 灰矮人矿城)能否以一条命走完,通关兑付的大奖励 +# 与钥石合计在冒险摘要里成立(P0) +# Date: 2026-09-21(冒险结构实现批——AdventureTable 建表,registry adventure_table 条目 2026-09-15 登记) +# 数据配置 AdventureTable schema(冒险结构 关卡大奖励行值;单行全局,Rule 4 豁免——HeroTable/SettlementTable 先例)。 +# 两字段 knob(调参表归 冒险结构;数值所有权归 难度曲线与Boss 曲线调参批,全部 provisional): +# level_clear_hp_bonus = 关卡大奖励 HP 上限提升(写统一冒险加算区 max_hp 轴——与商店购买同一本账, +# 经 begin_battle 覆盖值通道对后续每战生效;值锚 = 难度曲线与Boss 区间平移换算率,上调须重扫通关率带)。 +# keystone_per_level = 每通关一关钥石入账值(末关通关照记;消费方 元进度——本表只记账不做消费)。 +class_name AdventureTable +extends Resource + +@export var level_clear_hp_bonus: int = 10 # [0,999] knob——关卡大奖励 HP 上限提升 +@export var keystone_per_level: int = 10 # [0,999] knob——每通关一关钥石入账 + + +func validate() -> Array: + var errs: Array = [] + if level_clear_hp_bonus < 0 or level_clear_hp_bonus > 999: + errs.append({"category": "domain", "message": "level_clear_hp_bonus 越域 [0,999]: %d" % level_clear_hp_bonus}) + if keystone_per_level < 0 or keystone_per_level > 999: + errs.append({"category": "domain", "message": "keystone_per_level 越域 [0,999]: %d" % keystone_per_level}) + return errs diff --git a/prototypes/dungeon-sweeper-vertical-slice/data/schemas/adventure_table.gd.uid b/prototypes/dungeon-sweeper-vertical-slice/data/schemas/adventure_table.gd.uid new file mode 100644 index 00000000..8c9d86ff --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/data/schemas/adventure_table.gd.uid @@ -0,0 +1 @@ +uid://cmg04glgweakh diff --git a/prototypes/dungeon-sweeper-vertical-slice/data/tables/adventure.tres b/prototypes/dungeon-sweeper-vertical-slice/data/tables/adventure.tres new file mode 100644 index 00000000..9b5e5e64 --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/data/tables/adventure.tres @@ -0,0 +1,14 @@ +[gd_resource type="Resource" script_class="AdventureTable" load_steps=2 format=3] + +[ext_resource type="Script" path="res://data/schemas/adventure_table.gd" id="1"] + +[resource] +script = ExtResource("1") +level_clear_hp_bonus = 10 +keystone_per_level = 10 + +; 冒险结构实现批(2026-09-21 建表——registry adventure_table 条目同批注记更新): +; 关卡大奖励三件套的数值半边(上限提升)+ 钥石每关入账值;单行全局 Rule 4 豁免(HeroTable/SettlementTable 先例)。 +; 大奖励 = 非末关通关恰一次:上限提升(写统一冒险加算区 max_hp 轴)+ 血回满(= 新上限)+ 钥石入账; +; 末关通关不发上限提升、钥石照记(设计意图:奖励只朝下一战生效)。钥石消费方 = 元进度(本表只记账)。 +; 两值全部 provisional-pending-曲线调参批(值锚与上调约束归 难度曲线与Boss)。 diff --git a/prototypes/dungeon-sweeper-vertical-slice/presentation/battle_narrator.gd b/prototypes/dungeon-sweeper-vertical-slice/presentation/battle_narrator.gd index 2e05c3e3..c5ee3dc2 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/presentation/battle_narrator.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/presentation/battle_narrator.gd @@ -82,3 +82,23 @@ static func boss_name(runner, db) -> String: var row: Dictionary = db.resolve_enemy_rowset(int(runner.level_row.level_index), int(node.get("node_depth", -1))) return String(row.get("display_name", "")) return "遗迹守卫" + + +# 大奖励屏文案(冒险结构实现批——兑付三件:上限提升/血回满/钥石入账;读数 = big_reward_paid 载荷快照) +static func big_reward_text(reward: Dictionary) -> String: + return "第 %d 层通过!大奖励:生命上限 +%d(新上限 %d),生命回满,钥石 +%d(累计 %d)" % [ + int(reward.get("from_level", 0)), int(reward.get("hp_bonus", 0)), + int(reward.get("new_max_hp", 0)), int(reward.get("keystone_gained", 0)), + int(reward.get("keystone_total", 0))] + + +# 冒险结算屏文案(摘要五件事:打穿没有 / 通关几关 / 止步第几关 / 钥石合计 / 主种子) +static func adventure_summary_text(summary: Dictionary) -> String: + var verdict := "冒险失败 · 未打穿" + if bool(summary.get("cleared", false)): + verdict = "冒险完成 · 已打穿" + elif String(summary.get("result", "")) == "abandoned": + verdict = "冒险放弃 · 未打穿" + return "%s\n通关 %d 关 · 止步第 %d 层 · 钥石合计 %d · 主种子 %d" % [ + verdict, int(summary.get("levels_cleared", 0)), int(summary.get("stopped_at", 0)), + int(summary.get("keystone_total", 0)), int(summary.get("master_seed", 0))] diff --git a/prototypes/dungeon-sweeper-vertical-slice/presentation/main.gd b/prototypes/dungeon-sweeper-vertical-slice/presentation/main.gd index 1e8a0b76..67baab64 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/presentation/main.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/presentation/main.gd @@ -29,7 +29,9 @@ const DebugSessionScript := preload("res://presentation/debug_session.gd") var db var settlement = null # 当前战斗编排器(关卡与调试模式统一读它) -var runner = null # 关卡节点地图 图版推进器(完整关卡模式) +var runner = null # 关卡节点地图 图版推进器(完整关卡模式;冒险模式 = 每关重取 adventure.runner()) +var adventure = null # 冒险结构编排器(冒险结构实现批——三关连探;转发信号照 runner 接线原样对接) +var _adventure_mode := false # 冒险模式标记(inter 层确认分派与终局分派) var logger = null # 试玩日志器(JSONL 持久化 + 卡死诊断转储——呈现层组件) var _level_active := false var _seed_edit: LineEdit @@ -200,6 +202,9 @@ func _input_locked() -> bool: func _start_full_level(level_index: int = 1) -> void: _start_overlay.visible = false + if level_index == 1: + _start_adventure() # 冒险首关即 L1——正式序列入口(冒险结构实现批) + return # E4:加成经 runner.set_hero_override → encounter 参数入战斗(零表写——无双写叠加隐患) runner = LevelRunner.new(db, get_node("/root/RngService")) runner.set_hero_override(tune_panel.atk_pct(), tune_panel.hp_pct()) @@ -211,6 +216,7 @@ func _start_full_level(level_index: int = 1) -> void: runner.level_finished.connect(_on_level_finished) runner.wallet_changed.connect(_on_wallet_changed) _level_active = true + _adventure_mode = false _reset_run_scope() log_view.clear() var seed_val := _seed_value() @@ -223,6 +229,40 @@ func _start_full_level(level_index: int = 1) -> void: tune_panel.atk_pct() > 0 or tune_panel.hp_pct() > 0)) +# 冒险模式(三关连探——AdventureRunner 编排;转发信号签名与 runner 一致,接线照原样对接; +# 信号连接只在组合根——ADR-0001) +func _start_adventure() -> void: + adventure = AdventureRunner.new(db, get_node("/root/RngService")) + _adventure_mode = true + adventure.step_row.connect(_on_step_row) + adventure.round_started.connect(_on_round_started) + adventure.board_changed.connect(_on_board_changed) + adventure.node_entered.connect(_on_runner_node) + adventure.choices_presented.connect(_on_choices_presented) + adventure.wallet_changed.connect(_on_wallet_changed) + adventure.big_reward_paid.connect(_on_big_reward) + adventure.adventure_finished.connect(_on_adventure_finished) + adventure.level_started.connect(_on_adventure_level_started) + _level_active = true + _reset_run_scope() + log_view.clear() + var seed_val := _seed_value() + logger.start_session(seed_val) + adventure.set_hero_override(tune_panel.atk_pct(), tune_panel.hp_pct()) + if not adventure.start_adventure(seed_val): + _fatal_screen("冒险启动失败——见输出日志") + return + + +# 每关开始(冒险模式):重取 runner 实例 + 图条刷新 + 层名行(L1 与单关模式同序——层名行随首战横幅后到达) +func _on_adventure_level_started(level_index: int, display_name: String) -> void: + runner = adventure.runner() + strip_view.refresh(runner) + log_view.append_bb(Narrator.level_open_line(display_name, int(adventure.master_seed), + int(db.hero.atk), int(db.hero.max_hp), + tune_panel.atk_pct() > 0 or tune_panel.hp_pct() > 0)) + + func _reset_run_scope() -> void: # 每 run 一次的提示去重与轮内计数(弃图刷新遥测轮内序号) _hints_shown = {} @@ -231,6 +271,9 @@ func _reset_run_scope() -> void: func _on_runner_node(node: Dictionary, info: Dictionary) -> void: + # 冒险模式:runner 逐关更替——每次节点事件重取(单关模式幂等);须先于 board_changed 回退读面 + if _adventure_mode and adventure != null: + runner = adventure.runner() var t := String(node.get("type", "")) if t == "battle" or t == "boss": settlement = runner.current_battle() # 呈现层统一读 settlement(与调试模式共用接线) @@ -280,8 +323,41 @@ func _on_level_finished(payload: Dictionary) -> void: _refresh_presentations() +# ---- 冒险模式终局面(大奖励屏 / 冒险结算屏——冒险结构实现批)---- + +# 大奖励兑付(非末关通关恰一次):复用 _inter_overlay(label + 确认按钮,零新增交互范式); +# 文案装配归 Narrator +func _on_big_reward(reward: Dictionary) -> void: + _inter_label.text = Narrator.big_reward_text(reward) + _inter_overlay.visible = true + _refresh_presentations() + + +# 冒险结算(completed/failed/abandoned):复用 BattleEndOverlay——摘要五件事进文案 +func _on_adventure_finished(summary: Dictionary) -> void: + _level_active = false + var result: String = String(summary.get("result", "")) + settlement = null + route_overlay.visible = false # 终局兜底(选路口非 Boss 节点才有——防御性收层) + _sync_settlement_visuals() # → 全关 + info_panel.set_status_cleared_feedback() # 状态槽全清可见反馈(增益状态×4④) + stage.gray_all_enemies() # 死亡兜底:站桩灰化残位(禁瞬消语义的战斗收尾面) + if result == "completed": + _end_overlay.open_victory(Narrator.adventure_summary_text(summary), + "返回冒险", board_view.get_global_rect()) + else: + _end_overlay.open_defeat(Narrator.adventure_summary_text(summary), + "返回", board_view.get_global_rect()) + logger.log_level_end(summary) # 试玩时间线:冒险终局(摘要随载荷) + if runner != null: + runner.restore_hero_base() + strip_view.refresh(runner) + _open_modal(_end_overlay) + _refresh_presentations() + + func _on_battle_end_continue() -> void: - # 终局出口:返回开始界面(切片无下一关数据面——NodeOverlay/关卡结算屏归相邻域 spec) + # 终局出口:返回开始界面(冒险摘要留档试玩日志——钥石持久化归存档/元进度批) _close_modal(_end_overlay) _end_overlay.close() _debug.reset() @@ -289,6 +365,8 @@ func _on_battle_end_continue() -> void: runner.restore_hero_base() runner = null settlement = null + adventure = null + _adventure_mode = false tune_panel.reset_sliders() strip_view.refresh(null) _start_overlay.visible = true @@ -328,6 +406,11 @@ func _on_debug_battle_ready(settlement_ref, _level: int, _carry: int) -> void: func _on_inter_next_pressed() -> void: _inter_overlay.visible = false + if _adventure_mode: + # 冒险模式:大奖励屏确认 → 进下一关(AdventureRunner 序列推进——零自动推进的冒险级停点) + if adventure != null and not adventure.continue_to_next_level(): + _fatal_screen("关卡推进失败——见输出日志") + return if not _debug.next_layer(): _fatal_screen("战斗启动失败——见输出日志") diff --git a/prototypes/dungeon-sweeper-vertical-slice/presentation/ui_assembler.gd b/prototypes/dungeon-sweeper-vertical-slice/presentation/ui_assembler.gd index 709a02dd..287581b0 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/presentation/ui_assembler.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/presentation/ui_assembler.gd @@ -120,11 +120,12 @@ static func build_start_overlay(host: Control, db = null) -> Dictionary: seed_edit.custom_minimum_size = Vector2(120, 0) seed_row.add_child(seed_edit) - var lvbtn := UiKit.button("▶ 第一关 · 地精洞窟(战×2 → 奖励点 → 战×2 → 恢复 → Boss)") + # 冒险结构实现批(2026-09-21):lvbtn = 正式「开始冒险」入口(三关序列);L2/L3 保留测试直入口 + var lvbtn := UiKit.button("▶ 开始冒险(三关连探:地精洞窟 → 幽暗地域 → 灰矮人矿城)") box.add_child(lvbtn) - var lv2btn := UiKit.button("▶ 第二关 · 幽暗地域(分支节点图试玩——岔路口选下一跳)") + var lv2btn := UiKit.button("▶ 测试直入口 · 第二关 · 幽暗地域(分支节点图试玩——岔路口选下一跳)") box.add_child(lv2btn) - var lv3btn := UiKit.button("▶ 第三关 · 灰矮人矿城(测试直入口——基线红档属预期,配合调整板加成试玩)") + var lv3btn := UiKit.button("▶ 测试直入口 · 第三关 · 灰矮人矿城(基线红档属预期,配合调整板加成试玩)") box.add_child(lv3btn) var tunebtn := UiKit.button("测试用 · 角色数值调整板(模拟天赋树属性提升)") box.add_child(tunebtn) diff --git a/prototypes/dungeon-sweeper-vertical-slice/smoke/level_autoplay.gd b/prototypes/dungeon-sweeper-vertical-slice/smoke/level_autoplay.gd index 2f452b24..6240c816 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/smoke/level_autoplay.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/smoke/level_autoplay.gd @@ -11,6 +11,10 @@ # 2026-09-17 分支节点图批:选路适配(零自动推进)——兑付确认/战斗胜利后 runner 进选路态, # 驱动循环按 route 策略显式 pick_next_node;route = "min"/"max"(候选节点编号最小/最大—— # 确定性路径轴,零随机流自开;默认 "min" 既有调用方签名兼容)。 +# 2026-09-21 冒险结构实现批:run_adventure 三关连探驱动(AdventureRunner——主种子恰一次、 +# 大奖励立即确认 continue_to_next_level、adventure_finished 置 result/summary;外层每关重取 +# runner 实例经 _drive_level 复用单关驱动)。run_level 驱动段抽成 _drive_level——签名与 +# 返回结构零变化,行为逐位不变(MC 同种子对照闸承载)。 # 运行前提:db 已 load_tables;每个 run_level 全新 RngService(同种子 ⇒ 全程逐步可回放)。 # 实现注:信号处理体全部为静态方法 + bind 传状态——GDScript 多行 lambda 内嵌 match/控制流的解析不可靠。 extends RefCounted @@ -46,6 +50,22 @@ static func run_level(db, seed_val: int, level_index: int, atk_pct: int, hp_pct: if own_session: logger.close_session() return st + _drive_level(runner, st, logger, policy, route) + if st.result == "": + st.result = "timeout" + st.timeout = true + for v in runner.visited_ids(): + st.visited_ids.append(int(v)) + runner.restore_hero_base() + st.log_path = logger.log_path() + if own_session: + logger.close_session() + return st + + +# 单关驱动段(run_level 抽取——零自动推进停点 + 战斗自动玩家;policy 经 st 入 _autoplay_battle) +static func _drive_level(runner, st: Dictionary, logger, policy: String, route: String) -> void: + st["policy"] = policy var guard_battles := 20 var guard_actions := 200 while st.result == "" and guard_battles > 0 and guard_actions > 0: @@ -60,18 +80,110 @@ static func run_level(db, seed_val: int, level_index: int, atk_pct: int, hp_pct: if this_settlement == null: break # level_finished 已发(settlement 置 null)而信号尚未置 result 的防御 _autoplay_battle(runner, this_settlement, st, logger) + + +# 三关连探冒险驱动(冒险结构实现批 2026-09-21)——AdventureRunner 编排:主种子恰一次 +# (start_adventure 唯一 new_adventure 调用点)、大奖励立即确认、冒险终局置 result/summary。 +# 返回结构 = run_level 同族 + summary / wallet_events / encounter_max_hp_seq / reward_events / +# visited_ids(全冒险节点行进序列——跨关累计,非末关 runner 的 visited 随实例更替,此处为唯一全量面)。 +static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_rows: bool = false, + logger = null, policy: String = "normal", route: String = "min") -> Dictionary: + var own_session := logger == null + if own_session: + logger = load("res://presentation/playtest_logger.gd").new() + logger.start_session(seed_val) + logger.ap_per_round = int(db.action_economy.ap_per_round) + logger.log_run({"seed": seed_val, "adventure": true, "atk_pct": atk_pct, "hp_pct": hp_pct, "policy": policy, "route": route}) + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(seed_val) + var adr = load("res://core/adventure_runner.gd").new(db, rng) + var st := { + "result": "", "hp_left": 0, "battles": 0, "rounds": 0, "hero_dmg": 0, + "rest_healed": 0, "hp_before_rest": -1, + "defeat_node_index": -1, "defeat_node_type": "", + "boss_depth": -1, "hp_at_boss": -1, "rows": [], "timeout": false, + "log_path": "", "policy": policy, "refreshes": 0, "mine_hits": 0, + "visited_ids": [], "choices": 0, + "summary": {}, "wallet_events": [], "encounter_max_hp_seq": [], "reward_events": [], + "shop_gp_seq": [], # 商店房到达余额序(逐关 1 次——GP 跨关延续验收取数面) + } + adr.step_row.connect(_on_step_row.bind(adr, st, collect_rows)) + adr.node_entered.connect(_on_adventure_node_entered.bind(adr, st, logger)) + adr.wallet_changed.connect(_on_adventure_wallet.bind(st, logger)) + adr.big_reward_paid.connect(_on_adventure_reward.bind(st, adr)) + adr.adventure_finished.connect(_on_adventure_finished.bind(st, logger)) + adr.set_hero_override(atk_pct, hp_pct) + if not adr.start_adventure(seed_val): + st.result = "start_failed" + if own_session: + logger.close_session() + return st + var guard_drives := 10 # 每次外层迭代 = 驱动一关(关卡更替由 big_reward 确认与 runner 重取承载) + while st.result == "" and guard_drives > 0: + guard_drives -= 1 + _drive_level(adr.runner(), st, logger, policy, route) if st.result == "": st.result = "timeout" st.timeout = true - for v in runner.visited_ids(): - st.visited_ids.append(int(v)) - runner.restore_hero_base() + if adr.runner() != null: + adr.runner().restore_hero_base() st.log_path = logger.log_path() if own_session: logger.close_session() return st +# 冒险模式节点采集(_on_node_entered 的 AdventureRunner 门面版——runner 逐关更替,经 adr.runner() 重取; +# encounter_max_hp_seq 抓 effective_max_hp:node_entered 先于 begin_battle,encounter 尚为空字典, +# 该值 = begin_battle 将收到 hero_max_hp 同一表达式〔规划书抓取点与代码事实冲突,按代码为准已登记〕) +static func _on_adventure_node_entered(node: Dictionary, info: Dictionary, adr, st: Dictionary, logger) -> void: + var r = adr.runner() + var t := String(node.get("type", "")) + st.visited_ids.append(int(r.node_id_of(node))) + if t == "battle" or t == "boss": + st.battles += 1 + st.encounter_max_hp_seq.append(int(r.effective_max_hp())) + if logger != null and r.current_battle() != null: + logger.attach_battle(r.current_battle()) + if t == "boss": + st.boss_depth = int(node.get("node_depth", -1)) + st.hp_at_boss = int(adr.ledger["hero_hp"]) + elif t == "reward": + r.continue_after_node() # 奖励点 = 内容待拍板直通节点(格面资源批 2026-09-15) + elif t == "rest": + st.rest_healed = int(info.get("healed", 0)) + st.hp_before_rest = int(info.get("hp", 0)) - st.rest_healed + r.continue_after_node() + elif t == "shop": + st.shop_gp_seq.append(int(info.get("gp", -1))) + r.continue_after_node() # 商店房零购离开(自动路径直通 Boss——购买行为归冒烟专项/真人试玩) + if logger != null: + logger.log_node(node, info, r.node_index) + + +# 冒险钱包事件采集(wallet 行族——GP 跨关延续与冒险收束 clear 的验收取数面;同日志合规转发) +static func _on_adventure_wallet(event: String, gp_val: int, extra: Dictionary, st: Dictionary, logger) -> void: + st.wallet_events.append({"event": String(event), "gp": int(gp_val), + "amount": int(extra.get("amount", 0)), "product": String(extra.get("product", ""))}) + if logger != null: + logger.log_wallet(event, gp_val, extra) + + +# 大奖励立即确认(非末关通关 → continue_to_next_level 进下一关——自动路径无大奖励屏停点) +static func _on_adventure_reward(reward: Dictionary, st: Dictionary, adr) -> void: + st.reward_events.append(reward.duplicate(true)) + if not adr.continue_to_next_level(): + st.result = "advance_failed" + + +# 冒险终局(completed/failed/abandoned——result 载 summary.result,外层驱动循环随之收敛) +static func _on_adventure_finished(summary: Dictionary, st: Dictionary, logger) -> void: + st.summary = summary.duplicate(true) + st.result = String(summary.get("result", "")) + if logger != null: + logger.log_level_end(summary) + + # 路径轴策略(确定性——候选编号最小/最大;min 侧 = 岔路恒取小编号支)。 # 编号取 runner.next_candidate_ids()(编号空间解析单一出口——单链下标 / 图 node_depth) static func _route_pick(runner, route: String) -> int: diff --git a/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd b/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd index b1162fe7..c1df4902 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd @@ -98,6 +98,248 @@ func _run_core_smoke(db, rng) -> void: _smoke_shop_room() _smoke_shop_graph() _smoke_nine_types_gp_stream() + _smoke_adventure_table() + _smoke_adventure_api() + _smoke_adventure() + + +# ---- 冒险结构实现批冒烟(2026-09-21——AdventureTable 建表 + AdventureRunner + 冒险验收断言)---- + +# ① 冒险表(单行全局 Rule 4 豁免——建表与接线闸) +func _smoke_adventure_table() -> void: + print("--- 冒险表(AdventureTable 建表——大奖励/钥石行值单点)---") + check(_db.adventure != null, "db.adventure 已加载(adventure.tres 入 manifest)") + if _db.adventure == null: + return + check(_db.adventure.validate().is_empty(), "AdventureTable.validate() 零违例(两字段域检在案)") + var hp_bonus: int = int(_db.adventure.level_clear_hp_bonus) + var ks: int = int(_db.adventure.keystone_per_level) + check(hp_bonus >= 0 and hp_bonus <= 999, "level_clear_hp_bonus ∈ [0,999](%d)" % hp_bonus) + check(ks >= 0 and ks <= 999, "keystone_per_level ∈ [0,999](%d)" % ks) + + +# ② 冒险结构 API 闸(状态机受理面——重复开始拒绝 / 放弃两态) +func _smoke_adventure_api() -> void: + print("--- 冒险结构 API 闸(重复开始拒绝 · 放弃两态受理)---") + var adr = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(42)) + adr.set_hero_override(400, 200) # 首战速胜档(放弃的非战斗受理要走到选路态) + check(adr.start_adventure(42), "冒险启动受理(主种子注入恰一次——state=running)") + check(not adr.start_adventure(43), "进行中重复 start_adventure 拒绝(重开须待结算完毕)") + check(adr.runner().current_battle() != null, "首战在飞(接口①开始关卡直达战斗节点)") + check(not adr.abandon(), "战斗中 abandon 拒绝(战斗域单事务在飞)") + # 驱动首战到胜利 → 选路态(非战斗期)→ abandon 受理 + var autoplay = load("res://smoke/level_autoplay.gd") + var battle = adr.runner().current_battle() + var st := {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "normal"} + var guard := 30 + while adr.runner().current_battle() == battle and guard > 0: + guard -= 1 + autoplay._autoplay_battle(adr.runner(), battle, st, null) + check(adr.runner().current_battle() == null and adr.runner().awaiting_choice(), + "首战胜后选路态(非战斗期——current_battle 置空)") + check(adr.abandon(), "非战斗期 abandon 受理(选路态——同失败型结算)") + var sum: Dictionary = adr.summary + check(String(sum.get("result", "")) == "abandoned" and not bool(sum.get("cleared", true)), + "放弃结算:result=abandoned · cleared=false(已获面不追回——钥石累计 %d)" % int(sum.get("keystone_total", -1))) + check(not adr.abandon(), "结算后重复 abandon 拒绝(结算受理只算一次——state=idle)") + + +# ③ 冒险验收断言(映射 冒险结构.md 验收标准——表覆盖/满配通关/种子复现/大奖励/败北线/ +# 重复拒绝/GP 跨关延续/加算区同账/恢复数学/放弃) +func _smoke_adventure() -> void: + print("--- 冒险结构验收(三关序列 · 大奖励 · 摘要五件事 · 账本封闭)---") + var autoplay = load("res://smoke/level_autoplay.gd") + # ① 表与覆盖义务(序列关数 = 行集关数;行集内每个战斗/Boss 节点跨表精确行——覆盖义务断言) + check(int(_db.level.rows.size()) == 3, "序列关数 = 行集关数(三关正式序列生效)") + var bg_keys := {} + for row in _db.board_gen.rows: + bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true + var en_keys := {} + for row in _db.enemy.rows: + en_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true + var miss := 0 + for row in _db.level.rows: + for n in row.nodes: + var nt := String(n.get("type", "")) + if nt != "battle" and nt != "boss": + continue + var key := "%d_%d" % [int(row.level_index), int(n.get("node_depth", -1))] + if not bg_keys.has(key) or not en_keys.has(key): + miss += 1 + check(miss == 0, "行集内每个战斗/Boss 节点在 board_gen + enemy 有精确行(缺失 %d)" % miss) + # ② 满配通关 + ④ 大奖励兑付(恰 2 次 · 读数=表值 · 回满=新上限 · 覆盖值通道传播 L2/L3 首战上限) + var st: Dictionary = autoplay.run_adventure(_db, 42, 200, 100) + var sum: Dictionary = st["summary"] + check(String(st["result"]) == "completed" and bool(sum.get("cleared", false)), + "满配通关(42 / atk+200 / hp+100——cleared=true)") + check(int(sum.get("levels_cleared", -1)) == 3, "通关几关 = 3(levels_cleared)") + check(int(sum.get("keystone_total", -1)) == 3 * int(_db.adventure.keystone_per_level), + "钥石合计 = 3 × keystone_per_level(%d)" % int(sum.get("keystone_total", -1))) + check(int(sum.get("master_seed", -1)) == 42, "摘要携带主种子(42)") + var rewards: Array = st["reward_events"] + check(rewards.size() == 2, "大奖励恰兑付 2 次(L1/L2 后——末关不发上限提升)") + var hp_bonus: int = int(_db.adventure.level_clear_hp_bonus) + var reward_ok: bool = rewards.size() == 2 + if reward_ok: + for i in rewards.size(): + var r: Dictionary = rewards[i] + if int(r.get("hp_bonus", -1)) != hp_bonus or int(r.get("from_level", -1)) != i + 1 \ + or int(r.get("new_max_hp", -1)) != int(_db.hero.max_hp) + hp_bonus * (i + 1) \ + or int(r.get("keystone_gained", -1)) != int(_db.adventure.keystone_per_level): + reward_ok = false + check(reward_ok, "大奖励读数:hp_bonus=表值 · 新上限累加 · 回满=新上限 · 钥石逐关注账") + var seq: Array = st["encounter_max_hp_seq"] + var k := 2.0 # hp+100 ⇒ TunePanel 百分比后乘 ×2(加算先加、百分比后乘——生效次序口径) + var exp_base := maxi(1, int(round(float(int(_db.hero.max_hp)) * k))) + var exp_l2 := maxi(1, int(round(float(int(_db.hero.max_hp) + hp_bonus) * k))) + var exp_l3 := maxi(1, int(round(float(int(_db.hero.max_hp) + 2 * hp_bonus) * k))) + var idx_l2 := seq.find(exp_l2) + var idx_l3 := seq.find(exp_l3) + check(seq.size() > 0 and int(seq[0]) == exp_base, + "L1 首战上限入参 = 基础表值(%d——加算区冒险开始全 0)" % exp_base) + check(idx_l2 > 0 and idx_l3 > idx_l2, + "L2/L3 首战上限入参 = 基础+加算(%d→%d——大奖励经覆盖值通道传播)" % [exp_l2, exp_l3]) + # ③ 种子域可复现(同种子两局:summary + 上限序列 + 行进序列逐位一致) + var st2: Dictionary = autoplay.run_adventure(_db, 42, 200, 100) + check(st2["summary"] == sum and st2["encounter_max_hp_seq"] == seq + and st2["visited_ids"] == st["visited_ids"], + "同种子两局逐位可复现(summary/上限序列/行进序列——主种子单点派生)") + # ⑤ 败北线(零配 L1 败北 + 半配 L2 败北——种子扫描,仿商店房 range(42,92) 先例) + var lose := {} + for s in range(42, 92): + var stl: Dictionary = autoplay.run_adventure(_db, s, 0, 0) + if String(stl["result"]) == "failed" and int(stl["summary"].get("stopped_at", -1)) == 1: + lose = {"s": s, "st": stl} + break + check(not lose.is_empty(), "零配 L1 败北种子可达(seed=%s)" % str(lose.get("s", -1))) + if not lose.is_empty(): + var sl: Dictionary = lose["st"]["summary"] + check(not bool(sl["cleared"]) and int(sl["levels_cleared"]) == 0 \ + and int(sl["keystone_total"]) == 0 and int(sl["stopped_at"]) == 1, + "L1 败北摘要:未打穿 · 通关 0 关 · 钥石 0 · 止步第 1 层") + var half := {} + for s in range(42, 142): + var sth: Dictionary = autoplay.run_adventure(_db, s, 50, 0) + if String(sth["result"]) == "failed" and int(sth["summary"].get("stopped_at", -1)) == 2: + half = {"s": s, "st": sth} + break + check(not half.is_empty(), "半配(atk+50)L2 败北种子可达(seed=%s)" % str(half.get("s", -1))) + if not half.is_empty(): + var sh: Dictionary = half["st"]["summary"] + check(not bool(sh["cleared"]) and int(sh["levels_cleared"]) == 1 \ + and int(sh["keystone_total"]) == int(_db.adventure.keystone_per_level) \ + and int(sh["stopped_at"]) == 2, + "L2 败北摘要:L1 已过(钥石 1×表值)· 止步第 2 层") + # ⑥ 重复宣布拒绝(settle 后再调 _on_level_finished——摘要零变化) + var adr6 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(7)) + adr6.set_hero_override(400, 200) + check(adr6.start_adventure(7), "重复拒绝场景:冒险启动(seed 7)") + var b6 = adr6.runner().current_battle() + var st6 := _adv_st() + var g6 := 30 + while adr6.runner().current_battle() == b6 and g6 > 0: + g6 -= 1 + autoplay._autoplay_battle(adr6.runner(), b6, st6, null) + check(adr6.abandon(), "重复拒绝场景就位:选路态放弃 → 已结算") + var sum6_before: Dictionary = adr6.summary.duplicate(true) + adr6._on_level_finished({"result": "victory", "hp": 10, "node_index": 1, "node_id": 1, "node_type": "battle"}) + check(adr6.summary == sum6_before, "重复关卡结束宣布拒绝(账本与摘要零变化——契约 7)") + # ⑦ GP 跨关延续(L1 收束无 clear · 每战入账恰一次 · L2 商店房余额 > 0 · 冒险收束 clear 恰一次为末事件) + var wallet: Array = st["wallet_events"] + var clears := 0 + var gains := 0 + for ev in wallet: + if String(ev["event"]) == "clear": + clears += 1 + elif String(ev["event"]) == "gain": + gains += 1 + check(gains == int(st["battles"]), "每战完成入账恰一次(gain %d = battles %d)" % [gains, int(st["battles"])]) + check(clears == 1 and wallet.size() > 0 and String(wallet[wallet.size() - 1]["event"]) == "clear", + "钱包 clear 恰一次且为末事件(关卡收束不清——GP 冒险收束清零归冒险结构)") + var shop_seq: Array = st["shop_gp_seq"] + check(shop_seq.size() == 2 and int(shop_seq[0]) > 0, + "L2 商店房到达余额 > 0(%d——L1 积累跨关延续;两关商店房必经各 1)" % (shop_seq[0] if shop_seq.size() > 0 else -1)) + # ⑧ 加算区同账(手工场景:L2 商店购买 → 大奖励直调——同一本账累加 + 回满新上限) + var same := {} + for s in range(42, 92): + var a8 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(s)) + a8.set_hero_override(400, 200) + if not a8.start_adventure(s): + continue + if _adv_drive_to_shop(a8, _adv_st(), autoplay): + same = {"a": a8, "s": s} + break + check(not same.is_empty(), "同账场景种子可达(seed=%s——L2 商店房就位)" % str(same.get("s", -1))) + if not same.is_empty(): + var a8 = same["a"] + var r8 = a8.runner() + var gp0: int = int(r8.gp) + check(gp0 >= 4, "进店余额 ≥ hp_up L2 首价 4(%d)" % gp0) + check(r8.buy_product("hp_up"), "购买 hp_up 受理(出账 + 加算区)") + check(int(a8.bonus.get("max_hp", -1)) == int(_db.adventure.level_clear_hp_bonus) + 3, + "统一冒险加算区含商店值(max_hp = %d[L1 大奖励] + 3[商店]——同一本账前半)" % int(_db.adventure.level_clear_hp_bonus)) + a8._on_level_finished({"result": "victory", "hp": int(r8.hero_hp), "node_index": r8.node_index, + "node_id": r8.current_node_id(), "node_type": "shop"}) # 模拟大奖励(victory 载荷直调——L2 非末关) + check(int(a8.bonus.get("max_hp", -1)) == 2 * int(_db.adventure.level_clear_hp_bonus) + 3, + "大奖励与商店购买同账累加(max_hp = %d + 3 + %d)" % [int(_db.adventure.level_clear_hp_bonus), int(_db.adventure.level_clear_hp_bonus)]) + check(int(a8.ledger["hero_hp"]) == int(_db.hero.max_hp) + int(a8.bonus["max_hp"]), + "血回满 = 新上限(%d/%d)" % [int(a8.ledger["hero_hp"]), int(_db.hero.max_hp) + int(a8.bonus["max_hp"])]) + # ⑨ 恢复点数学(接口③封顶公式三读数 + 同式两落点一致) + var a9 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(99)) + check(int(a9.apply_rest(30, 70, 100)) == 30 and int(a9.apply_rest(30, 95, 100)) == 5 \ + and int(a9.apply_rest(30, 100, 100)) == 0, + "恢复点数学:floor(100×30%)=30 · 封顶缺口 5 · 满血回 0 合法") + var lr9 = load("res://core/level_runner.gd").new(_db, _fresh_rng(99)) + check(int(lr9.rest_heal_amount(30, 70, 100)) == 30 and int(lr9.rest_heal_amount(30, 95, 100)) == 5, + "同一公式两个落点逐位一致(LevelRunner.rest_heal_amount 内置默认)") + # ⑩ 放弃(战斗中拒绝 · 非战斗期受理同失败型结算) + var a10 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(15)) + a10.set_hero_override(400, 200) + check(a10.start_adventure(15), "放弃场景:冒险启动(seed 15——首战在飞)") + check(not a10.abandon(), "战斗中 abandon 拒绝(战斗域单事务在飞)") + var b10 = a10.runner().current_battle() + var st10 := _adv_st() + var g10 := 30 + while a10.runner().current_battle() == b10 and g10 > 0: + g10 -= 1 + autoplay._autoplay_battle(a10.runner(), b10, st10, null) + check(a10.runner().current_battle() == null and a10.abandon(), "非战斗期(选路态)abandon 受理") + var s10: Dictionary = a10.summary + check(String(s10.get("result", "")) == "abandoned" and not bool(s10.get("cleared", true)), + "放弃摘要:abandoned · 未打穿(已获大奖励不追回——同失败型结算)") + + +# 手工场景驱动:L1 全程 + L2 推进到商店房(_drive_level 复用 + 兑付确认/选路/大奖励确认手工编排) +func _adv_drive_to_shop(a, st: Dictionary, autoplay) -> bool: + var guard := 24 + while guard > 0 and String(st["result"]) == "": + guard -= 1 + autoplay._drive_level(a.runner(), st, null, "normal", "min") + if String(st["result"]) != "": + return false + if a._pending_next_level: + a.continue_to_next_level() + continue + var r = a.runner() + if String(r.current_node().get("type", "")) == "shop": + return true + if r.awaiting_choice(): + var ids: Array = r.next_candidate_ids() + ids.sort() + r.pick_next_node(int(ids[0])) + continue + var t := String(r.current_node().get("type", "")) + if t == "reward" or t == "rest": + r.continue_after_node() + return false + + +# 手工场景状态袋(_drive_level/_autoplay_battle 消费的最小字段族——run_adventure st 的同构子集) +func _adv_st() -> Dictionary: + return {"result": "", "hp_left": 0, "battles": 0, "rounds": 0, "hero_dmg": 0, + "rest_healed": 0, "hp_before_rest": -1, "defeat_node_index": -1, "defeat_node_type": "", + "boss_depth": -1, "hp_at_boss": -1, "rows": [], "timeout": false, "log_path": "", + "policy": "normal", "refreshes": 0, "mine_hits": 0, "visited_ids": [], "choices": 0} # ---- 战斗屏棋盘布局·格边长公式回归(2026-09-19 真人试玩 bug⑥:L2 12×12 板下缘被裁约 1/6 格高——