From 63a08d1e018cb61a90b5c03ba18f448206967ef7 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Tue, 29 Sep 2026 07:07:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(t1-t3):=20=E9=AD=94=E6=B3=95=E9=81=93?= =?UTF-8?q?=E5=85=B7=E6=95=B0=E6=8D=AE=E9=9D=A2+=E5=BA=93=E5=AD=98?= =?UTF-8?q?=E7=AE=A1=E7=BA=BF+=E6=8E=88=E4=BA=88=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=EF=BC=88=E4=B8=89=E5=8D=A1?= =?UTF-8?q?=E4=B8=80=E8=BD=AE=E4=BA=A4=E4=BB=98=E2=80=94=E2=80=94=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=BA=A4=E5=8F=89=E5=90=88=E5=B9=B6=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=EF=BC=89=20T1=20=E6=95=B0=E6=8D=AE=E9=9D=A2:AuxItemTable=20sch?= =?UTF-8?q?ema(=E4=B8=83=E5=AD=97=E6=AE=B5+retired/deferred=20=E4=B8=A4=20?= =?UTF-8?q?additive=20=E6=A0=87=E8=AE=B0+=E7=9C=9F=E8=A7=86=E7=AA=84?= =?UTF-8?q?=E5=9F=9F/board=5Fwide=20=E5=8F=8C=E6=89=BF=E9=87=8D=E6=8A=A4?= =?UTF-8?q?=E6=A0=8F)+=E5=85=AD=E8=A1=8C=20tres(=E4=B8=89=E6=9C=89?= =?UTF-8?q?=E6=95=88+=E7=BD=97=E7=9B=98=20retired=20=E7=95=99=E7=97=95+?= =?UTF-8?q?=E6=98=BE=E5=BD=B1=E5=B0=98/=E5=85=A8=E5=9B=BE=E5=8D=B7=20defer?= =?UTF-8?q?red)+ConfigDB=20=E5=8A=A0=E8=BD=BD/=E8=AE=BF=E9=97=AE=E5=99=A8+?= =?UTF-8?q?RewardTable=20=E5=8D=81=E5=9E=8B=E6=89=A9=E5=80=BC(aux=5Fitem)+?= =?UTF-8?q?=E4=B8=89=E6=8A=95=E6=94=BE=E8=A1=8C=205/4/1+aux=5Fnone=20?= =?UTF-8?q?=E8=B4=9F=E5=8D=A0=E4=BD=8D=E8=A1=8C(=E9=9B=B7=E6=A0=BC?= =?UTF-8?q?=E6=9E=81=E6=80=A7=E5=AD=97=E5=85=B8=E5=85=9C=E5=BA=95=E9=9B=B6?= =?UTF-8?q?=E5=85=91=E4=BB=98)+=E5=8F=8C=E8=A1=A8=E6=82=AC=E7=A9=BA?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C(reward=5Fdangling=5Faux,=E8=B4=9F=E5=8D=A0?= =?UTF-8?q?=E4=BD=8D=E8=B1=81=E5=85=8D)+F2=20=E6=8A=95=E6=94=BE=E7=BB=B4?= =?UTF-8?q?=E5=BA=A6=E8=A6=86=E7=9B=96(=E8=B1=81=E5=85=8D=E9=9D=A2=20plan/?= =?UTF-8?q?info/counter=20=E7=99=BB=E8=AE=B0)+=E4=B8=A4=20fatal=20?= =?UTF-8?q?=E5=A4=B9=E5=85=B7(expect=3D1=20=E5=A4=8D=E5=88=B6=E7=9C=9F?= =?UTF-8?q?=E8=A1=A8=E6=B3=A8=E5=85=A5=E8=BF=9D=E8=A7=84)+run=5Fall=20mani?= =?UTF-8?q?fest+2=20T2=20core=20=E9=AA=A8=E6=9E=B6:AuxItems=20=E7=B1=BB(ap?= =?UTF-8?q?i=208=20=E5=85=A5=E5=8F=A3+probe=20=E6=8E=A2=E9=92=88;=E9=9B=B6?= =?UTF-8?q?=E9=9A=8F=E6=9C=BA=E9=9B=B6=E7=89=B9=E5=88=A4;Used=20=E4=B8=8D?= =?UTF-8?q?=E5=8F=AF=E9=80=86;=E7=9C=9F=E8=A7=86=E6=81=92=E5=8F=AF?= =?UTF-8?q?=E7=94=A8/=E9=A2=84=E8=AF=BB=E7=82=B9=E5=87=BB=E7=BA=A7?= =?UTF-8?q?=E9=9B=B6=E4=BF=A1=E6=81=AF=E6=8B=92=E7=BB=9D;3x3=20=E8=BE=B9?= =?UTF-8?q?=E7=BC=98=E7=AA=97=E5=8F=A3=E2=88=A9=E6=9D=BF)+action=5Feconomy?= =?UTF-8?q?.grant=5Fap=20=E7=AC=AC=209=20API(=E5=BD=93=E8=BD=AE=E5=8A=A0?= =?UTF-8?q?=E9=87=8F,=E5=8C=BA=E5=88=AB=20queue=5Fap=5Fdelta=20=E6=8E=92?= =?UTF-8?q?=E9=98=9F)=20T3=20=E6=8E=88=E4=BA=88=E8=B7=AF=E7=94=B1+?= =?UTF-8?q?=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F:=5Fgrant=5Frouted=20aux=20?= =?UTF-8?q?=E5=88=86=E6=94=AF(=E6=AD=A3=3D=E5=85=A5=E5=BA=93=E5=AD=98?= =?UTF-8?q?=E5=8F=91=E3=80=8C=E8=8E=B7=E5=BE=97=E3=80=8D=E8=A1=8C;?= =?UTF-8?q?=E8=B4=9F=3D=E9=9B=B6=E5=85=91=E4=BB=98=20effect=20=E5=9E=8B?= =?UTF-8?q?=E3=80=8C=E9=81=93=E5=85=B7=E9=9A=8F=E9=9B=B7=E6=B2=89=E6=B2=A1?= =?UTF-8?q?=E3=80=8D=E8=A1=8C)+use=5Faux=20=E5=9B=9B=E6=AD=A5=E5=8F=97?= =?UTF-8?q?=E7=90=86(=E7=9B=B8=E4=BD=8D=20gate=E2=86=92validate=E2=86=92?= =?UTF-8?q?=E9=80=90=20kind=20=E7=A9=BA=E9=97=B4=E5=85=88=20AP=20=E5=90=8E?= =?UTF-8?q?=E2=86=92consume;=E7=BB=93=E7=AE=97=E6=98=8E=E7=BB=86=E6=97=A0?= =?UTF-8?q?=E9=81=93=E5=85=B7=E8=A1=8C)+=E5=8F=A0=E5=8A=A0=E5=B1=82?= =?UTF-8?q?=E4=B8=89=E6=B8=85(=E8=A3=85=E6=9D=BF=E7=82=B9/=E6=88=98?= =?UTF-8?q?=E6=96=97=E7=BB=93=E6=9D=9F)+LevelRunner=20=E5=85=B3=E5=8D=A1?= =?UTF-8?q?=E5=9F=9F=E5=BA=93=E5=AD=98=E6=8C=81=E6=9C=89=E6=B3=A8=E5=85=A5?= =?UTF-8?q?+=E5=85=B3=E5=8D=A1=E6=9C=AB=20reset+aux=5Fcleared=20=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E8=AE=A1=E6=95=B0=E6=89=A9=E7=AD=BE=20=E5=86=92?= =?UTF-8?q?=E7=83=9F=E9=94=9A=E9=9A=8F=E8=AF=8D=E8=A1=A8=E6=89=A9=E5=80=BC?= =?UTF-8?q?=E6=9B=B4=E6=96=B0:=E4=B9=9D=E5=9E=8B=E2=86=92=E5=8D=81?= =?UTF-8?q?=E5=9E=8B=E5=B0=81=E9=97=AD/=E5=AD=97=E5=85=B8=2018=E2=86=9222?= =?UTF-8?q?=20=E8=A1=8C;=E8=B4=9F=E5=90=91=E5=AD=97=E5=85=B8=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=E4=B8=89=E5=A4=84=E7=A8=B3=E5=81=A5=E5=8C=96(aux=20?= =?UTF-8?q?=E4=B8=8D=E5=85=A5=E4=BA=94=E5=9E=8B=E8=BD=B4+break=20=E6=94=B9?= =?UTF-8?q?=20percent=5Fmap=20=E4=B8=89=E5=9E=8B=E9=BD=90=E5=A4=87+rng=20?= =?UTF-8?q?=E6=AF=8F=E6=9D=BF=E6=96=B0=E5=AE=9E=E4=BE=8B=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=8E=9F=E7=AC=AC=E4=B8=80=E6=9D=BF=20break=20=E6=8E=A9?= =?UTF-8?q?=E7=9B=96=20set=5Fmaster=5Fseed=20=5Fcounter=20=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=E8=BF=9D=E4=BE=8B)=20=E9=97=B8:=E5=86=92=E7=83=9F=205?= =?UTF-8?q?77/0(=E5=87=80=E5=A2=9E=201)+=E5=8D=95=E6=B5=8B=2011=20?= =?UTF-8?q?=E5=A5=97=E5=85=A8=E8=BF=87(=E5=90=AB=E4=B8=A4=E6=96=B0?= =?UTF-8?q?=E5=A4=B9=E5=85=B7)+=E5=9B=9B=E6=8E=A2=E9=92=88=20PASS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/core/action_economy.gd | 11 + game/core/aux_items.gd | 192 ++++++++++++++++++ game/core/aux_items.gd.uid | 1 + game/core/battle_orchestrator.gd | 62 +++++- game/core/config_db.gd | 55 +++++ game/core/level_runner.gd | 12 +- game/data/schemas/aux_item_table.gd | 109 ++++++++++ game/data/schemas/aux_item_table.gd.uid | 1 + game/data/schemas/reward_table.gd | 17 +- game/data/tables/aux_items.tres | 58 ++++++ game/data/tables/reward.tres | 43 ++++ game/smoke/smoke_all.gd | 18 +- game/tests/run_all.gd | 2 + .../data_config/aux_target_violation_test.gd | 76 +++++++ .../aux_target_violation_test.gd.uid | 1 + .../data_config/reward_dangling_aux_test.gd | 69 +++++++ .../reward_dangling_aux_test.gd.uid | 1 + 17 files changed, 718 insertions(+), 10 deletions(-) create mode 100644 game/core/aux_items.gd create mode 100644 game/core/aux_items.gd.uid create mode 100644 game/data/schemas/aux_item_table.gd create mode 100644 game/data/schemas/aux_item_table.gd.uid create mode 100644 game/data/tables/aux_items.tres create mode 100644 game/tests/unit/data_config/aux_target_violation_test.gd create mode 100644 game/tests/unit/data_config/aux_target_violation_test.gd.uid create mode 100644 game/tests/unit/data_config/reward_dangling_aux_test.gd create mode 100644 game/tests/unit/data_config/reward_dangling_aux_test.gd.uid diff --git a/game/core/action_economy.gd b/game/core/action_economy.gd index a80b0455..97a9a9eb 100644 --- a/game/core/action_economy.gd +++ b/game/core/action_economy.gd @@ -121,6 +121,17 @@ func notify_settlement_done(next_round_mode: String) -> bool: # 下轮行动点修正排队(格面资源八型批 2026-09-20):action 型正负行结算期调用,同轮多次授予累加; # 相位无关(授予发生在结算段,此时本类可能处于 ACTIVE/WAITING 任一相位) +# 第 9 API(registry 行动经济×① 接口漂移预授权——迭代 7 T2 魔法道具内容批落位):ap_grant 道具的 +# 当轮行动点加量(GDD「加行动点效果给当轮」——区别于 queue_ap_delta 的下轮排队语义)。Active 受理、 +# 无上限域、零随机;使用道具本身零扣费(成本在获取时已付——捡道具格不出战斗奖励)。 +func grant_ap(amount: int) -> bool: + if amount <= 0 or _phase != PHASE_ACTIVE: + _reject("grant_ap") + return false + _ap += amount + return true + + func queue_ap_delta(delta: int) -> void: _pending_ap_delta += delta diff --git a/game/core/aux_items.gd b/game/core/aux_items.gd new file mode 100644 index 00000000..0fd7f587 --- /dev/null +++ b/game/core/aux_items.gd @@ -0,0 +1,192 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# Validation Question: 道具从掉落到使用的全程零随机零特判——同操作序列重放两遍,库存与叠加层投影逐位相同(P0) +# Date: 2026-09-29(迭代 7 T2 core 骨架——魔法道具内容批;设计权威 design/gdd/systems/排雷循环/魔法道具.md +# + registry aux_items api 8 入口/load_bearing_rules/use_orchestration) +# 魔法道具 关卡域库存 + 板级叠加层(自持派生信息层)。 +# 结构冻结照抄(违反即返工): +# 零随机(零 RNG 流消费——信息只来自 排雷玩法 恒真数字/地图战斗资源 Ready 映射/行动经济 账本三既有事实源); +# 库存关卡域(跨轮跨图跨战斗延续、关卡末清空——清空编排归 战斗结算/关卡层,本类只供 clear/reset); +# 叠加层随图消亡(换图/弃图刷新/战斗结束三清——编排归 战斗结算 retire_overlays); +# Used 不可逆(consume 后保留只读——枚举序=受理序); +# 真视恒可用(零雷窗口=「这 9 格没雷」信息非空);点击级零信息不消耗(拒绝零消耗——预读窗口全已开); +# 零特判(排雷玩法 代码零本系统分支——本类不写任何其他系统状态)。 +# 负空间:无自动使用入口/无重掷/无事件订阅/无 RNG 直连/无 排雷玩法/地图战斗资源 写入口; +# 相位检查归 战斗结算 use_aux 编排第一步(本类不管相位——validate_use 是纯谓词+投影)。 +# 呈现取数面:query_inventory(C 区槽位)/query_overlays(棋盘叠加层标记)——只读投影,零数据复制 +# (预读徽章数据源=战斗结算 query_reward_identity 既有读面,本类只记「哪些格要额外显示」)。 +class_name AuxItems +extends RefCounted + +var _items: Array = [] # [{handle, item_id, used}]——受理序;Used 保留只读 +var _handle_seq := 0 # 受理序句柄(单调递增——零随机) +var _reveal_cells := {} # 真视集:Vector2i → true(惩罚格标记——插旗仍走既有 flag 路径) +var _preview_cells := {} # 预读集:Vector2i → true(奖励身份徽章呈现挂点) +var _last_reject := "" # 诊断面(最近一次拒绝原因——呈现反馈取数) + + +# ---- 生命周期 ---- + +# 关卡开始/关卡末清空(clear_inventory 幂等;关卡末清空前取 inventory_count() 快照做可见反馈) +func reset() -> void: + _items.clear() + _handle_seq = 0 + retire_overlays() + + +# 授予(战斗结算 grant 路由——来源可归因:source_class+cell+round_index) +func add_item(item_id: String, source_class: String, cell: Vector2i, round_index: int) -> Dictionary: + _handle_seq += 1 + var rec := { + "handle": _handle_seq, + "item_id": item_id, + "source_class": source_class, + "origin_cell": cell, + "round_index": round_index, + "used": false, + } + _items.append(rec) + return rec.duplicate() + + +# ---- 库存读面(C 区槽位常读)---- + +# 枚举序=受理序;Used 保留只读;逐件含 usable 板级零信息预判(调用方喂板面回调) +func query_inventory(board_has_preview_info: Callable) -> Array: + var out: Array = [] + for rec in _items: + var row: Dictionary = rec.duplicate() + row["usable"] = _usable_now(String(rec.item_id), board_has_preview_info) + out.append(row) + return out + + +func inventory_count() -> int: + return _items.size() + + +# 板级零信息可用性预判(图标置灰/入口禁用判据——GDD「整张图都没东西可看」级): +# 真视恒可用(零雷窗口=信息非空);AP 类恒可用(组合判据取 OR——任一效果有信息即可用); +# 纯预读行 = 板上还有未开格才可用。 +func _usable_now(item_id: String, board_has_preview_info: Callable) -> bool: + var row: Dictionary = _item_row(item_id) + if row.is_empty(): + return false + var kinds: Array = row.get("effect_kinds", []) + if kinds.has("mine_reveal") or kinds.has("ap_grant"): + return true + return bool(board_has_preview_info.call()) if board_has_preview_info.is_valid() else true + + +func _item_row(item_id: String) -> Dictionary: + if _db != null and _db.aux_items != null: + return _db.aux_items.row_by_id(item_id) + return {} + +var _db = null # ConfigDB(行定义读取——_init 注入;测试可注入桩表) + + +func _init(db_ref = null) -> void: + _db = db_ref + + +# ---- 使用校验(战斗结算 use_aux 编排第二步——纯谓词+投影,零消耗零副作用)---- + +# target = 目标格(空间行=3×3 窗口中心;ap 行忽略)。board_size = 板维度; +# cell_unrevealed_cb = Callable(Vector2i) -> bool(排雷玩法 只读面——预读窗口信息判据)。 +# 返回 {ok, reason, resolved: Array[Vector2i], item_id, kinds}——ok=false 时 reason 可呈现。 +func validate_use(handle: int, target: Vector2i, board_size: Vector2i, cell_unrevealed_cb: Callable) -> Dictionary: + var rec := _rec_by_handle(handle) + if rec.is_empty(): + return _reject("句柄不存在") + if bool(rec.used): + return _reject("道具已使用(Used 不可逆)") + var row: Dictionary = _item_row(String(rec.item_id)) + if row.is_empty() or bool(row.get("retired", false)) or bool(row.get("deferred", false)): + return _reject("道具未激活") + var kinds: Array = row.get("effect_kinds", []) + var resolved: Array = [] + if kinds.has("mine_reveal") or kinds.has("reward_preview"): + # 空间效果:3×3 窗口 ∩ 板内格(边缘受理——护栏只缩不扩) + if target.x < 0 or target.y < 0 or target.x >= int(board_size.x) or target.y >= int(board_size.y): + return _reject("目标越板") + for dy in range(-1, 2): + for dx in range(-1, 2): + var c := target + Vector2i(dx, dy) + if c.x >= 0 and c.y >= 0 and c.x < int(board_size.x) and c.y < int(board_size.y): + resolved.append(c) + # 点击级零信息:纯预读行窗口全已开 = 没东西可看(拒绝零消耗); + # 真视恒可用(零雷窗口=「这 9 格没雷」信息非空——GDD 裁定) + if kinds.has("reward_preview") and not kinds.has("mine_reveal"): + var any_unrevealed := false + if cell_unrevealed_cb.is_valid(): + for c in resolved: + if bool(cell_unrevealed_cb.call(c)): + any_unrevealed = true + break + if not any_unrevealed: + return _reject("窗口内无可预读的未开格") + return { + "ok": true, + "reason": "", + "resolved": resolved, + "item_id": String(rec.item_id), + "kinds": kinds, + "magnitude": int(row.get("magnitude", 0)), + } + + +# ---- 叠加层(战斗结算逐 kind 应用——空间先、ap 后的序在编排侧钉死)---- + +func apply_overlay(kind: String, cells: Array) -> void: + var store := _reveal_cells if kind == "mine_reveal" else _preview_cells + for c in cells: + store[Vector2i(c)] = true + + +# 三清只读投影(棋盘叠加层标记渲染——真视集/预读集;随图消亡由 retire_overlays 承载) +func query_overlays() -> Dictionary: + return { + "mine_reveal": _reveal_cells.keys(), + "reward_preview": _preview_cells.keys(), + } + + +# 换图/弃图刷新/战斗结束——叠加层清空(已花道具不返还;库存不动——库存关卡域) +func retire_overlays() -> void: + _reveal_cells.clear() + _preview_cells.clear() + + +# ---- 消耗(编排第四步——Used 不可逆+快照回执)---- + +func consume(handle: int) -> Dictionary: + var rec := _rec_by_handle(handle) + if rec.is_empty() or bool(rec.used): + return {} + rec.used = true + return rec.duplicate() + + +# ---- 内部 ---- + +func _rec_by_handle(handle: int) -> Dictionary: + for rec in _items: + if int(rec.handle) == handle: + return rec + return {} + + +func _reject(reason: String) -> Dictionary: + _last_reject = reason + return {"ok": false, "reason": reason, "resolved": [], "item_id": "", "kinds": [], "magnitude": 0} + + +# 探针出口(T7 收拢——断言面:库存序/叠加层集/拒绝原因) +func probe() -> Dictionary: + return { + "count": _items.size(), + "used": _items.filter(func(r): return bool(r.used)).size(), + "reveal_cells": _reveal_cells.size(), + "preview_cells": _preview_cells.size(), + "last_reject": _last_reject, + } diff --git a/game/core/aux_items.gd.uid b/game/core/aux_items.gd.uid new file mode 100644 index 00000000..ebc63c6d --- /dev/null +++ b/game/core/aux_items.gd.uid @@ -0,0 +1 @@ +uid://uvt473an5yxh diff --git a/game/core/battle_orchestrator.gd b/game/core/battle_orchestrator.gd index 2bce5d59..5cd9bae6 100644 --- a/game/core/battle_orchestrator.gd +++ b/game/core/battle_orchestrator.gd @@ -50,6 +50,8 @@ var action_economy: ActionEconomy var _enemy_system: UnitContainer var _status_effects: StatusContainer var _reward_economy: RewardEconomy +var aux_inventory = null # AuxItems(迭代 7 T3——LevelRunner 注入的关卡域库存引用; + # 每战新建本类,库存不归本类所有——注入面) var _damage: DamageController # 伤害控制器(伤害管线单据化批 2026-09-24——单据唯一兑付管线) var encounter: Dictionary = {} @@ -170,6 +172,8 @@ func _install_new_board(during_round := false) -> bool: _board_from_midround_refresh = during_round # 轮内刷新装板 = 刷新图(清图奖励不发);常规换图/开战装板 = false _board_seq += 1 board_changed.emit() + if aux_inventory != null: + aux_inventory.retire_overlays() # 迭代 7 T3——叠加层随图消亡(换图/刷新图装板点;已花道具不返还) return true @@ -351,7 +355,62 @@ func _settle() -> void: # 「动作 × 格型」矩阵的执行面:snap = 缓冲期冻结身份快照(cell_type = 该格装板期摇定的类型身份), # polarity 由动作决定(开安全格/标中旗 = positive,开雷格/标错旗 = negative), # 兑付行 = 奖励字典(类型, 极性)行。返回 true = percent 行实际生效(flat 行恒 false——兑付结果由行协议承载)。 +# ---- 魔法道具(迭代 7 T2/T3——库存授予路由 + use_aux 四步受理编排;结构冻结见魔法道具 GDD)---- + +# 授予路由 aux 分支:正=入库存(发行「获得」行——逐行归因纪律);负=零兑付占位行。 +func _grant_aux(snap: Dictionary, polarity: String, source_class: String, cell: Vector2i, ri: int) -> bool: + if polarity != "positive" or aux_inventory == null: + # 负路由占位行=effect 型零兑付(雷安同池下标错旗/雷格摇中 aux 身份的负向字典行——逐行归因纪律: + # 该格资源的负向兑付存在且为零;effect_id 空不入增益容器) + _emit_row("effect", source_class, cell, ri, "道具随雷沉没(惩罚格不入库存)", 0, + {"effect_id": "", "aux_sink": String(snap.get("effect_id", ""))}, "") + return false + var iid := String(snap.get("effect_id", "")) + var arow: Dictionary = db.aux_item_row(iid) + var rec: Dictionary = aux_inventory.add_item(iid, source_class, cell, ri) + _emit_row("effect", source_class, cell, ri, "获得 %s" % String(arow.get("display_name", iid)), 1, + {"aux_item": iid, "handle": int(rec.get("handle", 0))}, "") + return true + + +# use_aux 四步受理(registry use_orchestration 规范序——唯一调用权归本类;序即原子: +# ① 相位 gate(Active——行动经济 phase getter)→ ② validate_use(目标校验读 排雷玩法 只读面,零消耗)→ +# ③ 逐 kind 应用(顺序钉死:空间先、ap_grant 后——effect_kinds 列表序非运行时语义)→ ④ consume。 +# 拒绝全部发生在 ①②(后两步合法输入下无失败路径);使用零 AP 成本(不经 try_spend)。 +# 结算明细无道具行(GDD——效果即时可见:overlay 呈现+AP 条刷新;AP 过账本 grant_ap 走 _reject 面)。 +func use_aux(handle: int, target: Vector2i) -> Dictionary: + if aux_inventory == null: + return {"ok": false, "reason": "库存未接线"} + if action_economy.phase != action_economy.PHASE_ACTIVE: + return {"ok": false, "reason": "非挖掘期(使用窗口=Active)"} + var board = _minesweeper_ref() + var v: Dictionary = aux_inventory.validate_use(handle, target, + Vector2(board.width, board.height), + func(c: Vector2i): return not board.is_revealed(c)) + if not bool(v.ok): + return v # ② 拒绝零消耗 + var kinds: Array = v.kinds + if kinds.has("mine_reveal"): + aux_inventory.apply_overlay("mine_reveal", v.resolved) + if kinds.has("reward_preview"): + aux_inventory.apply_overlay("reward_preview", v.resolved) + if kinds.has("ap_grant"): + if not action_economy.grant_ap(int(v.magnitude)): + return {"ok": false, "reason": "行动点加量被拒(相位防御)"} + var rec: Dictionary = aux_inventory.consume(handle) + return {"ok": true, "item_id": v.item_id, "resolved": v.resolved, "record": rec} + + +func _minesweeper_ref() -> Variant: + return minesweeper + + func _grant_routed(snap: Dictionary, polarity: String, source_class: String, cell: Vector2i, ri: int, highlight: String = "", label_suffix: String = "") -> bool: + # 迭代 7 T3——aux 投放行授予路由(魔法道具 GDD:授予路由唯一调用方=战斗结算):正路由(挖开/标中) + # → 道具入关卡域库存(来源可归因);负路由(雷格/标错旗摇中 aux 身份——雷安同池)→ 零兑付 + # 「道具随雷沉没」(雷格奖励沉没既有语义);清图批追加份同路由(清图翻倍语义对 aux 同样成立)。 + if String(snap.get("cell_type", "")) == RewardTable.AUX_ITEM_TYPE: + return _grant_aux(snap, polarity, source_class, cell, ri) var row: Dictionary = _reward_economy.polarity_row(String(snap.get("cell_type", "")), polarity) if row.is_empty(): _fatal("极性字典缺行: %s %s" % [String(snap.get("cell_type", "")), polarity]) @@ -717,7 +776,8 @@ func _battle_over(result: String) -> void: _reward_economy.retire_mapping() _enemy_system.despawn_all() _status_effects.notify_battle_end() - action_economy.notify_combat_end() # → Idle,RAP 清零 + if aux_inventory != null: + aux_inventory.retire_overlays() # 迭代 7 T3——叠加层随战斗结束清空(库存不动——关卡域跨战斗) action_economy.notify_combat_end() # → Idle,RAP 清零 db.set_reload_gate(false) _reset_round_facts() battle_finished.emit({"result": result, "hero_hp": hero_hp_left, "gp_dropped": gp_dropped}) diff --git a/game/core/config_db.gd b/game/core/config_db.gd index d5dc1c81..2164a80a 100644 --- a/game/core/config_db.gd +++ b/game/core/config_db.gd @@ -22,6 +22,7 @@ const TABLE_MANIFEST := { "difficulty": "difficulty.tres", "shop": "shop.tres", # GP+商店房实施批 2026-09-21(表与 schema 落籍归并行数据面——本批只接线加载与消费) "meta": "meta.tres", # 元进度实现批 2026-09-21(MetaTable 四行族——schema 与表同批落籍) + "aux_items": "aux_items.tres", # 迭代 7 T1 魔法道具内容批(AuxItemTable 六行——定义表 Rule 4 豁免;罗盘 retired/失义件 deferred 标记见 schema 头注) "adventure": "adventure.tres", # 冒险结构实现批 2026-09-21(AdventureTable 建表——大奖励与钥石行值单点) } @@ -41,6 +42,7 @@ var difficulty: DifficultyTable var shop = null # 元进度实现批(2026-09-21):MetaTable 类型化持用(schema 与表同批落籍,无 shop 先例的落籍间隙问题) var meta: MetaTable +var aux_items: AuxItemTable # 迭代 7 T1 类型化持用(消费面走 aux_* 访问器) var adventure: AdventureTable var is_ready := false @@ -125,6 +127,7 @@ func load_tables(root: String, collect_only: bool) -> bool: difficulty = loaded.get("difficulty") shop = loaded.get("shop") meta = loaded.get("meta") + aux_items = loaded.get("aux_items") adventure = loaded.get("adventure") is_ready = not has_errors return not has_errors @@ -143,9 +146,43 @@ func _cross_validate(loaded: Dictionary) -> void: # GP+商店房实施批(2026-09-21)例外:gp_gain/gp_loss = GP 战斗内缓冲(escrow)通道的路由键 # (战斗结算 _apply_gp_row 兑付——不入增益状态容器),词表封闭按字面豁免悬空引用校验。 for row in rt.rows: + if String(row.get("cell_type", "")) == RewardTable.AUX_ITEM_TYPE: + continue # aux 行 effect_id=item_id——归 ①b 双表校验(reward_dangling_aux),不入增益词表面 var eff: String = row.get("effect_id", "") if eff != "" and eff != "gp_gain" and eff != "gp_loss" and not effect_ids.has(eff): push_diag("error", "dangling_effect", "RewardTable %s 引用不存在的 effect_id: %s" % [row.get("reward_id"), eff]) + # ①b RewardTable aux 投放行双表校验(迭代 7 T1——reward_dangling_aux):aux 行 effect_id = item_id, + # 须命中 AuxItemTable 活行(非 retired 非 deferred——罗盘退役/失义件不投放)。retired 行被引用=退役 + # 后仍投放(连带错误),同拦。 + var aux_live := {} + var aux_tab = loaded.get("aux_items") + if aux_tab != null: + for arow in aux_tab.active_rows(): + aux_live[String(arow.get("item_id", ""))] = true + for row in rt.rows: + if String(row.get("cell_type", "")) != RewardTable.AUX_ITEM_TYPE: + continue + var aux_eff: String = String(row.get("effect_id", "")) + if aux_eff == "": # 负占位行(雷格极性字典兜底,零兑付)——不入投放词表校验 + continue + if not aux_live.has(aux_eff): + push_diag("error", "reward_dangling_aux", "RewardTable %s aux 行引用无效道具 %s(retired/deferred/不存在): %s" % [row.get("reward_id"), aux_eff, "双表校验"]) + # ①c F2 投放维度覆盖(魔法道具 GDD——五维度各 ≥1 掉落权重为正的活行):豁免面 {plan, info, counter} + # = 罗盘退役(plan,2026-09-29 拍板①乙)+显影件 Alpha 重定(info/counter 悬置)——豁免随 Alpha 重定批消减 + if aux_tab != null: + var covered := {} + for row in rt.rows: + if String(row.get("cell_type", "")) != RewardTable.AUX_ITEM_TYPE or int(row.get("weight", 0)) <= 0: + continue + var arow: Dictionary = aux_tab.row_by_id(String(row.get("effect_id", ""))) + for tag in arow.get("dimension_tags", []): + covered[String(tag)] = true + var f2_exempt := {"plan": true, "info": true, "counter": true} # 豁免面(随 Alpha 重定/天赋批消减) + for d in AuxItemTable.DIMENSIONS: + if f2_exempt.has(d): + continue + if not covered.has(d): + push_diag("error", "dimension_coverage", "F2 投放维度覆盖缺 %s(豁免面外零活行投放)" % d) # ② SettlementTable.effect_pool_map 悬空 for eff in st_tab.effect_pool_map.keys(): if not effect_ids.has(eff): @@ -346,6 +383,24 @@ func status_effect_row(effect_id: String) -> Dictionary: # ---- GP+商店房实施批(2026-09-21):ShopTable 消费面(行集解析单点——runner/战斗编排禁自扫表)---- +# ── 魔法道具访问器(迭代 7 T1——消费面一律走访问器,禁直读行集)── +# 活行集(非 retired 非 deferred)——投放校验/奖励节点池/呈现可用性的唯一取数口径 +func aux_active_rows() -> Array: + return aux_items.active_rows() if aux_items != null else [] + + +func aux_item_row(item_id: String) -> Dictionary: + return aux_items.row_by_id(item_id) if aux_items != null else {} + + +# 活行词表(掉落投放 effect_id 白名单) +func aux_live_ids() -> Dictionary: + var out := {} + for row in aux_active_rows(): + out[String(row.get("item_id", ""))] = true + return out + + # 行族四轴(数据契约,行集归 shop.tres):gp_drops {tier, mean, roll_min, roll_max} · # level_gp_bonus {level_index, bonus} · products {product_id, display_name, attr, flat_value, # base_price, price_increment} · price_level_bonus {level_index, bonus}。 diff --git a/game/core/level_runner.gd b/game/core/level_runner.gd index 5bad590a..1f3deaac 100644 --- a/game/core/level_runner.gd +++ b/game/core/level_runner.gd @@ -62,6 +62,8 @@ var base_override := {"atk": 0, "max_hp": 0, "armor_class": 0} # 永久强化 var _shop_purchased := {} # product_id → 已购档数(现价重算输入;冒险开始清零) # 协作对象私有(E1/E3 封装收口 2026-09-13):settlement 读取经 current_battle() 成文出口 var _settlement = null # 当前战斗编排器(关卡结束置 null) +var aux_inventory = null # 魔法道具 关卡域库存(迭代 7 T3——AuxItems;跨战斗延续, + # 关卡末清空+快照计数入 level_finished 载荷) var hero_atk_pct := 0 # 测试面板覆盖值(切片专用) var hero_hp_pct := 0 @@ -364,6 +366,9 @@ func _enter_node(node: Dictionary) -> bool: func _start_battle(node: Dictionary) -> bool: _settlement = BattleOrchestrator.new(db, rng) + if aux_inventory == null: + aux_inventory = AuxItems.new(db) # 惰性建——关卡域单实例(跨战斗延续) + _settlement.aux_inventory = aux_inventory # 迭代 7 T3——库存注入(授予路由+use_aux 编排消费面) # core↔core 接线只许方法 Callable(ADR-0001 静态规则 S6——lambda 捕获 self 会延长 # RefCounted 生命周期乃至成环:settlement 的信号持捕获 runner 的 lambda,runner 又持 settlement) _settlement.step_row.connect(_forward_step_row) @@ -408,8 +413,13 @@ func _on_battle_finished(payload: Dictionary) -> void: close_wallet() # 单关模式:关卡收束即冒险收束(GP 清零);冒险模式归 AdventureRunner._settle _running = false _settlement = null + var aux_cleared := 0 + if aux_inventory != null: + aux_cleared = aux_inventory.inventory_count() # 快照计数(清空前——「剩余道具随关卡结束清空」可见反馈取数) + aux_inventory.reset() # 迭代 7 T3——关卡末库存清空(无论输赢,幂等) level_finished.emit({"result": level_result, "hp": hp_left, "node_index": node_index, - "node_id": _node_id(node), "node_type": String(node.get("type", ""))}) + "node_id": _node_id(node), "node_type": String(node.get("type", "")), + "aux_cleared": aux_cleared}) # additive 扩签(不参与派生——node_map 扩签先例) return # 接口漂移清偿(关卡节点地图 v1.0 ⑦):战斗胜利不再自动推进——确认后进选路口 _settlement = null diff --git a/game/data/schemas/aux_item_table.gd b/game/data/schemas/aux_item_table.gd new file mode 100644 index 00000000..0a2c4e2d --- /dev/null +++ b/game/data/schemas/aux_item_table.gd @@ -0,0 +1,109 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# Validation Question: 挖掘期撞上推理断头时,玩家能否从掉落池捡到魔法道具、在关键时刻花掉买回情报或行动点(P0) +# Date: 2026-09-29(迭代 7 T1 数据面——魔法道具内容批;设计权威 design/gdd/systems/排雷循环/魔法道具.md v0.4+ +# + registry aux_items 六行 mvp_content;2026-09-29 用户拍板:①乙 探宝罗盘退役留痕 ④甲 显影尘/地穴全图卷失义件 +# 六行全建零投放「已解锁·能力封存中」) +# 数据配置 AuxItemTable schema(魔法道具 回填 owner)。 +# 行 = Dictionary,键 item_id(Rule 4 豁免:定义表——registry aux_items Rule 4 豁免先例)。 +# 七字段(registry fields 全量):item_id 主键 / display_name / dimension_tags ⊆ 五维 非空 / +# effect_kinds ⊆ {mine_reveal, reward_preview, ap_grant}(活性校验见下)/ target ∈ {none, area_3x3, board_wide} / +# magnitude [0,999](仅 ap_grant 行消费,加载校验 ≥1)/ use_phase ∈ {active_dig}。 +# additive 两标记(迭代 7 T1——拍板①④的表内表达,非 registry fields 预登记字段的扩列随批登记): +# retired = 退役留痕行(loot_compass——未开格类型微光常显覆盖预读价值,2026-09-29 用户拍板退役; +# 行名保留=meta.tres workshop_items 六件词表恰全校验兼容,存档 item_unlocks 旧键无害; +# 免活性校验、不入投放校验、不入 F2 覆盖面、奖励节点/掉落池排除) +# deferred = 效果失义待重定行(probe_dust/catacomb_scroll——原显影半边随雾格移除失义,效果与目标 +# 随魔法道具整体重设计归 Alpha(2026-09-20 用户拍板);免 effect_kinds 非空校验、不入投放与 F2) +# 承重护栏(魔法道具 GDD §结构冻结,schema 校验强制不靠运行时拦截): +# 真视窄域——effect_kinds 含 mine_reveal 的活行 target 必须 area_3x3(真视一次最多 3×3,禁全图看雷); +# board_wide 仅恢复推理原料类——target=board_wide 的活行 effect_kinds 不得含 mine_reveal(现行无此类活行, +# 显影载体待 Alpha 重定后落位)。 +class_name AuxItemTable +extends Resource + +@export var rows: Array = [] # Array[Dictionary] 键 item_id + +const DIMENSIONS := ["info", "risk", "action", "plan", "counter"] +const EFFECT_KINDS := ["mine_reveal", "reward_preview", "ap_grant"] +const TARGETS := ["none", "area_3x3", "board_wide"] +const USE_PHASES := ["active_dig"] + + +func validate() -> Array: + var errs: Array = [] + if rows.is_empty(): + # 空表合法态(2026-09-17 用户拍板——切片期无道具内容;F2 覆盖检查仅对非空清单执行) + return errs + var seen := {} + for row in rows: + var iid: String = row.get("item_id", "") + if iid == "": + errs.append({"category": "domain", "message": "item_id 空"}) + continue + if seen.has(iid): + errs.append({"category": "dup_key", "message": "item_id 重复: %s" % iid}) + continue + seen[iid] = true + if String(row.get("display_name", "")) == "": + errs.append({"category": "domain", "message": "%s display_name 空" % iid}) + var retired: bool = bool(row.get("retired", false)) + var deferred: bool = bool(row.get("deferred", false)) + # dimension_tags:五维词表 + 非空(退役/失义行仍须登记维度——语义面保留) + var tags: Array = row.get("dimension_tags", []) + if tags.is_empty(): + errs.append({"category": "domain", "message": "%s dimension_tags 空" % iid}) + for tag in tags: + if not DIMENSIONS.has(String(tag)): + errs.append({"category": "enum", "message": "%s dimension_tag 出域 %s: %s" % [iid, str(DIMENSIONS), str(tag)]}) + var kinds: Array = row.get("effect_kinds", []) + var target: String = String(row.get("target", "none")) + if not TARGETS.has(target): + errs.append({"category": "enum", "message": "%s target 出域 %s: %s" % [iid, str(TARGETS), target]}) + var phase: String = String(row.get("use_phase", "active_dig")) + if not USE_PHASES.has(phase): + errs.append({"category": "enum", "message": "%s use_phase 出域 %s: %s" % [iid, str(USE_PHASES), phase]}) + if retired or deferred: + # 退役/失义行:免 effect_kinds 非空与承重护栏校验(登记面保留,不入投放与 F2) + continue + # ---- 活行校验 ---- + if kinds.is_empty(): + errs.append({"category": "domain", "message": "%s effect_kinds 空(活行;失义件应标 deferred)" % iid}) + var kind_seen := {} + for k in kinds: + var kind: String = String(k) + if not EFFECT_KINDS.has(kind): + errs.append({"category": "enum", "message": "%s effect_kind 出域 %s: %s" % [iid, str(EFFECT_KINDS), kind]}) + if kind_seen.has(kind): + errs.append({"category": "dup_key", "message": "%s effect_kind 重复: %s" % [iid, kind]}) + kind_seen[kind] = true + # 承重护栏①真视窄域:mine_reveal 活行 target 必须 area_3x3(一次最多 3×3,禁全图看雷) + if kind_seen.has("mine_reveal") and target != "area_3x3": + errs.append({"category": "load_bearing", "message": "%s 真视窄域护栏:mine_reveal 行 target 必须 area_3x3,实得 %s(魔法道具 GDD 结构冻结)" % [iid, target]}) + # 承重护栏②board_wide 仅恢复推理类:全图域行禁替推理效果 + if target == "board_wide" and kind_seen.has("mine_reveal"): + errs.append({"category": "load_bearing", "message": "%s board_wide 行 effect_kinds 不得含 mine_reveal(全图域仅恢复推理原料类——Alpha 重定后落位)" % iid}) + # magnitude:仅 ap_grant 行消费(≥1);非 ap_grant 活行恒 0 + var mag: int = int(row.get("magnitude", 0)) + if mag < 0 or mag > 999: + errs.append({"category": "domain", "message": "%s magnitude 越域 [0,999]: %d" % [iid, mag]}) + if kind_seen.has("ap_grant") and mag < 1: + errs.append({"category": "cross_field", "message": "%s ap_grant 行 magnitude 应 ≥1,实得 %d" % [iid, mag]}) + if not kind_seen.has("ap_grant") and mag != 0: + errs.append({"category": "cross_field", "message": "%s 非 ap_grant 活行 magnitude 应 0,实得 %d" % [iid, mag]}) + return errs + + +# 活行词表(非 retired 非 deferred)——投放校验/奖励节点池/F2 覆盖面的唯一取数口径 +func active_rows() -> Array: + var out: Array = [] + for row in rows: + if not bool(row.get("retired", false)) and not bool(row.get("deferred", false)): + out.append(row) + return out + + +func row_by_id(item_id: String) -> Dictionary: + for row in rows: + if String(row.get("item_id", "")) == item_id: + return row + return {} diff --git a/game/data/schemas/aux_item_table.gd.uid b/game/data/schemas/aux_item_table.gd.uid new file mode 100644 index 00000000..25ec1e91 --- /dev/null +++ b/game/data/schemas/aux_item_table.gd.uid @@ -0,0 +1 @@ +uid://dfhjthkhkvsbj diff --git a/game/data/schemas/reward_table.gd b/game/data/schemas/reward_table.gd index 6c887495..8fd6638f 100644 --- a/game/data/schemas/reward_table.gd +++ b/game/data/schemas/reward_table.gd @@ -12,7 +12,8 @@ extends Resource @export var rows: Array = [] # Array[Dictionary] -const CELL_TYPES := ["attack", "defense", "heal", "blast", "weaken", "pierce", "magic", "action", "gp"] # 九型封闭(格面资源九型批 2026-09-20 八→九,词表 v1.1)——清单外加载拒绝 +const CELL_TYPES := ["attack", "defense", "heal", "blast", "weaken", "pierce", "magic", "action", "gp", "aux_item"] # 十型(迭代 7 T1 魔法道具内容批扩值——registry aux_items 词表;aux 行 effect_id = item_id 双表校验键) +const AUX_ITEM_TYPE := "aux_item" # aux 行标识(掉落投放行:挖开→战斗结算 grant 路由关卡域库存,不入增益容器) const POLARITIES := ["positive", "negative"] const POOL_TAGS := ["cell", "clear_default"] # cell = 格面资源单一池;clear_ 命名空间预留拒绝 const CELL_TYPE_KEYS := ["attack", "defense", "heal", "blast", "weaken", "pierce", "magic", "action", "gp"] # 字典完整性校验轴(类型 × 极性 = 十八格) @@ -43,7 +44,19 @@ func validate() -> Array: errs.append({"category": "enum", "message": "%s polarity 出域: %s" % [rid, pol]}) var atk_type: String = String(row.get("attack_type", "")) if atk_type != "" and not ["normal", "pierce", "magic"].has(atk_type): - errs.append({"category": "enum", "message": "%s attack_type 出域 {normal, pierce, magic}: %s" % [rid, atk_type]}) + errs.append({"category": "enum", "message": "%s attack_type 出域 {normal, pierce, magic}: %s" % [rid, atk_type]}) # aux 投放行专属校验(迭代 7 T1):极性恒正/加权入池/effect_id=item_id 非空(悬空在 config_db 跨表层 + # 对 aux_items 词表校验——reward_dangling_aux)/无 power 无区间(数值语义不复用,道具效果读 AuxItemTable) + if ctype == AUX_ITEM_TYPE: + # 负向行 = 雷格极性字典占位(雷安同池摇号下雷格可摇中 aux 身份——负路由零兑付兜底, + # effect_id 空/power 0/weight 0 不入池;2026-09-29 T1) + if pol == "negative": + if String(row.get("effect_id", "")) != "" or int(row.get("power", 0)) != 0 or int(row.get("weight", 0)) != 0: + errs.append({"category": "cross_field", "message": "%s aux 负占位行仅允许 空effect_id/0power/0weight" % rid}) + continue + if String(row.get("effect_id", "")) == "": + errs.append({"category": "domain", "message": "%s aux 正行 effect_id 空(应=item_id)" % rid}) + if int(row.get("power", 0)) != 0 or row.has("power_min"): + errs.append({"category": "cross_field", "message": "%s aux 正行禁 power/区间数值(道具数值读 AuxItemTable)" % rid}) var pool: String = row.get("pool_tag", "") var pool_reserved: bool = pool.begins_with("clear_") and pool != "clear_default" if not POOL_TAGS.has(pool) and not pool_reserved: diff --git a/game/data/tables/aux_items.tres b/game/data/tables/aux_items.tres new file mode 100644 index 00000000..0680fb0b --- /dev/null +++ b/game/data/tables/aux_items.tres @@ -0,0 +1,58 @@ +[gd_resource type="Resource" script_class="AuxItemTable" load_steps=2 format=3] + +[ext_resource type="Script" path="res://data/schemas/aux_item_table.gd" id="1"] + +[resource] +script = ExtResource("1") +rows = [{ +"item_id": "probe_dust", +"display_name": "显影尘", +"dimension_tags": ["info", "counter"], +"effect_kinds": [], +"target": "none", +"magnitude": 0, +"use_phase": "active_dig", +"deferred": true +}, { +"item_id": "sapper_order", +"display_name": "工兵令", +"dimension_tags": ["action"], +"effect_kinds": ["ap_grant"], +"target": "none", +"magnitude": 2, +"use_phase": "active_dig" +}, { +"item_id": "loot_compass", +"display_name": "探宝罗盘", +"dimension_tags": ["plan"], +"effect_kinds": ["reward_preview"], +"target": "area_3x3", +"magnitude": 0, +"use_phase": "active_dig", +"retired": true +}, { +"item_id": "nest_pendulum", +"display_name": "探巢摆钟", +"dimension_tags": ["risk"], +"effect_kinds": ["mine_reveal"], +"target": "area_3x3", +"magnitude": 0, +"use_phase": "active_dig" +}, { +"item_id": "catacomb_scroll", +"display_name": "地穴全图卷", +"dimension_tags": ["info", "plan"], +"effect_kinds": [], +"target": "board_wide", +"magnitude": 0, +"use_phase": "active_dig", +"deferred": true +}, { +"item_id": "blast_kit", +"display_name": "爆破工兵包", +"dimension_tags": ["action", "risk"], +"effect_kinds": ["ap_grant", "mine_reveal"], +"target": "area_3x3", +"magnitude": 4, +"use_phase": "active_dig" +}] diff --git a/game/data/tables/reward.tres b/game/data/tables/reward.tres index 304a7fde..26c82c89 100644 --- a/game/data/tables/reward.tres +++ b/game/data/tables/reward.tres @@ -200,6 +200,49 @@ rows = [{ "weight": 0, "pool_tag": "cell", "tier": 1 +}, { + +"reward_id": "aux_sapper_order", +"cell_type": "aux_item", +"polarity": "positive", +"effect_id": "sapper_order", +"display_name": "工兵令", +"power": 0, +"weight": 5, +"pool_tag": "cell", +"tier": 2 +}, { + +"reward_id": "aux_nest_pendulum", +"cell_type": "aux_item", +"polarity": "positive", +"effect_id": "nest_pendulum", +"display_name": "探巢摆钟", +"power": 0, +"weight": 4, +"pool_tag": "cell", +"tier": 2 +}, { + +"reward_id": "aux_blast_kit", +"cell_type": "aux_item", +"polarity": "positive", +"effect_id": "blast_kit", +"display_name": "爆破工兵包", +"power": 0, +"weight": 1, +"pool_tag": "cell", +"tier": 3 +}, { +"reward_id": "aux_none", +"cell_type": "aux_item", +"polarity": "negative", +"effect_id": "", +"display_name": "惊扰的遗落物", +"power": 0, +"weight": 0, +"pool_tag": "cell", +"tier": 1 }] ; 格面资源批(2026-09-15 用户拍板——registry reward_table 条目为现行 schema 事实源): diff --git a/game/smoke/smoke_all.gd b/game/smoke/smoke_all.gd index 1bb5d022..bf235e97 100644 --- a/game/smoke/smoke_all.gd +++ b/game/smoke/smoke_all.gd @@ -2009,12 +2009,14 @@ func _smoke_negative_dictionary() -> void: # 行为面:逐型负向兑付形态(真实板 + 真实路由路径) # 数值压缩批(2026-09-17):heal 权重 15→3 后单板五型齐备不再必然(无 heal 板概率 ≈1/3)—— # 改多板扫描,取第一块五型齐备的板做行为断言。 - var rng = load("res://core/rng_service.gd").new() var settlement = null var board = null var by_type := {} # cell_type → 首个非封锁格(query_identity 雷安同池——雷格可查) var rows: Array = [] for seed_i in range(6): + # 每板独立 rng 实例(迭代 7 T1 修正——复用实例重播种触发 set_master_seed 的 _counter==0 守卫; + # 原版第一板即 break 掩盖了该违例,aux 供给挤占后多板扫描暴露) + var rng = load("res://core/rng_service.gd").new() rng.set_master_seed(20260919 + seed_i) settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) rows = [] @@ -2032,9 +2034,13 @@ func _smoke_negative_dictionary() -> void: if r0.has(c) or by_type.size() == 5: continue var ident = settlement.query_reward_identity(c) - if ident != null and not by_type.has(String(ident.cell_type)): + # aux 身份不入五型轴(魔法道具内容批 2026-09-29——五型齐备=格面资源历史断言轴, + # aux 供给挤占不改变本断言语义;扫描排除 aux 身份格) + if ident != null and String(ident.cell_type) != RewardTable.AUX_ITEM_TYPE and not by_type.has(String(ident.cell_type)): by_type[String(ident.cell_type)] = c - if by_type.size() == 5: + # break 条件=percent_map 三型齐备(迭代 7 T1——aux 供给挤占下 weaken/defense 收集时机漂移, + # 直验可达性优先;五型多样性 check 保留不动) + if by_type.has("attack") and by_type.has("defense") and by_type.has("weaken"): break check(by_type.size() == 5, "扫描 6 板内出现五型齐备板(%s)" % str(by_type.keys())) # attack/defense/weaken:负向字典行走 apply_effect(off_guard 2026-09-18 换装 ac 轴——兑付形态不变) @@ -3226,8 +3232,8 @@ func _all_entry_boss_paths(nodes: Array) -> Array: func _smoke_nine_types_gp_stream() -> void: print("--- 格面资源九型 + gp_drop 流(奖励字典 18 格 + 黄金向量)---") - check(RewardTable.CELL_TYPES.size() == 9 and RewardTable.CELL_TYPES.has("gp"), - "格面资源九型封闭(第九型 gp——九型批 2026-09-20,词表 v1.1)") + check(RewardTable.CELL_TYPES.size() == 10 and RewardTable.CELL_TYPES.has("gp") and RewardTable.CELL_TYPES.has("aux_item"), + "格面资源十型封闭(第九型 gp 九型批 2026-09-20 / 第十型 aux_item 魔法道具内容批 2026-09-29——词表 v1.2)") var cell_rows := 0 var pos_weight := 0 var gp_pos := -1 @@ -3241,7 +3247,7 @@ func _smoke_nine_types_gp_stream() -> void: gp_pos = int(row.weight) if String(row.get("reward_id", "")) == "gp_lose": gp_neg = int(row.weight) - check(cell_rows == 18, "奖励字典 18 格(九型 × 正负——清图槽 clear_default 独立批尾不计)") + check(cell_rows == 22, "奖励字典 22 行(九型×正负 18 格 + aux 正三投放/负一占位——魔法道具内容批 2026-09-29;清图槽独立批尾不计)") # 正向权重结构校验(配置推导,2026-09-23 重构):正向 cell 行权重 ≥1(必在池)、负向行权重恒 0(不入池)、 # 合计即配置自身的和(改表自动跟随,不再硬编码合计值)。 var pos_all_in_pool := true diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index 73392664..5afadfaa 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -29,6 +29,8 @@ func _suites() -> Array: {"path": "res://tests/unit/example_test.gd", "expect": 0}, {"path": "res://tests/unit/data_config/config_db_test.gd", "expect": 0}, {"path": "res://tests/unit/data_config/config_db_fatal_oob_test.gd", "expect": 1}, + {"path": "res://tests/unit/data_config/aux_target_violation_test.gd", "expect": 1}, # 迭代 7 T1——真视窄域护栏 + {"path": "res://tests/unit/data_config/reward_dangling_aux_test.gd", "expect": 1}, # 迭代 7 T1——aux 双表校验 {"path": "res://tests/unit/data_config/rng_service_test.gd", "expect": 0}, {"path": "res://tests/unit/battle_settlement/damage_math_test.gd", "expect": 0}, {"path": "res://tests/unit/battle_settlement/damage_controller_test.gd", "expect": 0}, diff --git a/game/tests/unit/data_config/aux_target_violation_test.gd b/game/tests/unit/data_config/aux_target_violation_test.gd new file mode 100644 index 00000000..f06c0251 --- /dev/null +++ b/game/tests/unit/data_config/aux_target_violation_test.gd @@ -0,0 +1,76 @@ +# 致命类用例(独立进程,期望退出码 1) +# aux_target_violation——真视窄域护栏(魔法道具 GDD 结构冻结,迭代 7 T1):mine_reveal 行 target +# 越域至 board_wide 时,AuxItemTable.validate 必须报 load_bearing 错、load_tables 整体拒绝。 +# 运行:godot --headless --path game --script res://tests/unit/data_config/aux_target_violation_test.gd +# 退出码协议:1 = 校验正确拒绝(预期);2 = 违规表被放行(回归信号)。 +# 时序注:纯加载期校验——不触运行期 fatal 通道,断言 load_tables 返回 false + 诊断在案即 quit(1)。 +extends SceneTree + +const ConfigDBScript := preload("res://core/config_db.gd") + +const SRC_DIR := "res://data/tables" +const BAD_DIR := "user://aux_fixture_target_violation" + + +func _copy_tables() -> bool: + DirAccess.make_dir_recursive_absolute(BAD_DIR) + var dir := DirAccess.open(SRC_DIR) + if dir == null: + return false + dir.list_dir_begin() + var f := dir.get_next() + while f != "": + if not dir.current_is_dir(): + var err := dir.copy(SRC_DIR + "/" + f, BAD_DIR + "/" + f) + if err != OK: + return false + f = dir.get_next() + return true + + +func _inject_violation() -> bool: + var path := BAD_DIR + "/aux_items.tres" + var f := FileAccess.open(path, FileAccess.READ) + if f == null: + return false + var s := f.get_as_text() + f.close() + # 探巢摆钟(mine_reveal 活行)target area_3x3 → board_wide:双护栏同时命中 + var bad := s.replace('"item_id": "nest_pendulum",\r\n"display_name": "探巢摆钟",', '"item_id": "nest_pendulum",\r\n"display_name": "探巢摆钟",') + if bad == s: + # 换行形态回退(LF) + bad = s.replace('"item_id": "nest_pendulum",\n"display_name": "探巢摆钟",', '"item_id": "nest_pendulum",\n"display_name": "探巢摆钟",') + # 直接改 target:摆钟行是全表唯一的 mine_reveal 单维行——把它的 area_3x3 改 board_wide + # (罗盘 retired 行同值 area_3x3 但 retired 免校验——替换须定位摆钟块内) + var blocks := s.split("}, {") + var hit := false + for i in blocks.size(): + if '"item_id": "nest_pendulum"' in blocks[i]: + blocks[i] = blocks[i].replace('"target": "area_3x3"', '"target": "board_wide"') + hit = true + s = "}, {".join(blocks) + var w := FileAccess.open(path, FileAccess.WRITE) + if w == null or not hit: + return false + w.store_string(s) + w.close() + return true + + +func _init() -> void: + if not _copy_tables() or not _inject_violation(): + push_error("[aux_target_violation_test] 夹具目录准备失败") + quit(2) + return + var db = ConfigDBScript.new() + var ok: bool = db.load_tables(BAD_DIR, false) + if ok: + push_error("[aux_target_violation_test] 违规表被放行——真视窄域护栏失守(回归)") + quit(2) + return + if db.diagnostics_count("error") >= 1: + print("[aux_target_violation_test] 护栏触发(load_bearing 诊断在案,加载被拒)") + quit(1) + return + push_error("[aux_target_violation_test] 加载被拒但无 load_bearing 诊断——护栏通道静默化(回归)") + quit(2) diff --git a/game/tests/unit/data_config/aux_target_violation_test.gd.uid b/game/tests/unit/data_config/aux_target_violation_test.gd.uid new file mode 100644 index 00000000..da2e8b56 --- /dev/null +++ b/game/tests/unit/data_config/aux_target_violation_test.gd.uid @@ -0,0 +1 @@ +uid://c2ufdoduixynq diff --git a/game/tests/unit/data_config/reward_dangling_aux_test.gd b/game/tests/unit/data_config/reward_dangling_aux_test.gd new file mode 100644 index 00000000..a62a35e0 --- /dev/null +++ b/game/tests/unit/data_config/reward_dangling_aux_test.gd @@ -0,0 +1,69 @@ +# 致命类用例(独立进程,期望退出码 1) +# reward_dangling_aux——RewardTable aux 投放行双表校验(迭代 7 T1):aux 行 effect_id 引用 retired +# 道具(loot_compass——2026-09-29 拍板①乙退役)时,config_db 跨表校验必须报 reward_dangling_aux、 +# load_tables 整体拒绝(退役件/失义件不得出现在掉落投放面)。 +# 运行:godot --headless --path game --script res://tests/unit/data_config/reward_dangling_aux_test.gd +# 退出码协议:1 = 校验正确拒绝(预期);2 = 违规表被放行(回归信号)。 +extends SceneTree + +const ConfigDBScript := preload("res://core/config_db.gd") + +const SRC_DIR := "res://data/tables" +const BAD_DIR := "user://aux_fixture_dangling_aux" + + +func _copy_tables() -> bool: + DirAccess.make_dir_recursive_absolute(BAD_DIR) + var dir := DirAccess.open(SRC_DIR) + if dir == null: + return false + dir.list_dir_begin() + var f := dir.get_next() + while f != "": + if not dir.current_is_dir(): + if dir.copy(SRC_DIR + "/" + f, BAD_DIR + "/" + f) != OK: + return false + f = dir.get_next() + return true + + +func _inject_violation() -> bool: + var path := BAD_DIR + "/reward.tres" + var f := FileAccess.open(path, FileAccess.READ) + if f == null: + return false + var s := f.get_as_text() + f.close() + # 工兵令投放行 → 引用已退役的 loot_compass:双表校验须拦(retired 不入投放面) + var blocks: PackedStringArray = s.split("}, {") + var hit := false + for i in blocks.size(): + if '"reward_id": "aux_sapper_order"' in blocks[i]: + blocks[i] = blocks[i].replace('"effect_id": "sapper_order"', '"effect_id": "loot_compass"') + hit = true + s = "}, {".join(blocks) + var w := FileAccess.open(path, FileAccess.WRITE) + if w == null or not hit: + return false + w.store_string(s) + w.close() + return true + + +func _init() -> void: + if not _copy_tables() or not _inject_violation(): + push_error("[reward_dangling_aux_test] 夹具目录准备失败") + quit(2) + return + var db = ConfigDBScript.new() + var ok: bool = db.load_tables(BAD_DIR, false) + if ok: + push_error("[reward_dangling_aux_test] 违规表被放行——aux 双表校验失守(回归)") + quit(2) + return + if db.diagnostics_count("error") >= 1: + print("[reward_dangling_aux_test] 双表校验触发(reward_dangling_aux 诊断在案,加载被拒)") + quit(1) + return + push_error("[reward_dangling_aux_test] 加载被拒但无 reward_dangling_aux 诊断——校验通道静默化(回归)") + quit(2) diff --git a/game/tests/unit/data_config/reward_dangling_aux_test.gd.uid b/game/tests/unit/data_config/reward_dangling_aux_test.gd.uid new file mode 100644 index 00000000..074124ec --- /dev/null +++ b/game/tests/unit/data_config/reward_dangling_aux_test.gd.uid @@ -0,0 +1 @@ +uid://c0hyi6tlsne3y