From 9214ef8e02ac540ef47be096479a836cfaf72b68 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sat, 24 Oct 2026 09:23:55 +0800 Subject: [PATCH 01/20] =?UTF-8?q?feat(=E8=BF=AD=E4=BB=A335=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E6=89=B9=C2=B7=E5=90=8C=E6=89=B9=E8=90=BD):=20?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=9B=BE=E6=B7=B1=E5=BA=A6=E6=89=A9=E9=9D=A2?= =?UTF-8?q?=E7=9A=84=E6=95=8C/=E6=9D=BF=E8=A1=A8=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E8=90=BD=E5=9C=B0=EF=BC=8BS5/V11=20=E8=A6=86=E7=9B=96=E6=96=AD?= =?UTF-8?q?=E8=A8=80=E2=80=94=E2=80=94=E7=94=A8=E6=88=B7=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E6=9C=AC=E6=89=B9=E9=9A=8F=E5=8D=A1=E2=91=A1=20=E5=90=8C?= =?UTF-8?q?=E6=89=B9=E8=90=BD=EF=BC=88=E5=8D=A1=E2=91=A1=20=C2=A70=20?= =?UTF-8?q?=E5=8E=9F=E6=96=87=E5=85=81=E8=AE=B8=E3=80=8C=E9=A1=BB=E5=85=88?= =?UTF-8?q?=E8=90=BD=E6=88=96=E4=B8=8E=E6=9C=AC=E5=8D=A1=E5=90=8C=E6=89=B9?= =?UTF-8?q?=E8=90=BD=E3=80=8D=EF=BC=89=EF=BC=8C=E6=95=B0=E5=80=BC=E6=8C=89?= =?UTF-8?q?=E7=8E=B0=E6=9C=89=E6=9B=B2=E7=BA=BF=E6=8F=92=E5=80=BC=E5=85=A8?= =?UTF-8?q?=20provisional=20-=20enemy.tres=EF=BC=9A=E6=88=98=E6=96=97?= =?UTF-8?q?=E5=B8=A6=E9=A1=B6=E6=89=A9=E5=88=B0=E5=90=84=E5=85=B3=E6=9C=80?= =?UTF-8?q?=E5=A4=A7=E7=94=9F=E6=88=90=E6=B7=B1=E5=BA=A6=EF=BC=88L1=203?= =?UTF-8?q?=E2=86=926=E3=80=81L2=207=E2=86=929=E3=80=81L3=207=E2=86=9213?= =?UTF-8?q?=EF=BC=8C=E5=85=B1=203=20=E8=A1=8C=EF=BC=89=EF=BC=9BBoss=20?= =?UTF-8?q?=E8=A1=8C=E6=8C=89=E7=BB=88=E7=82=B9=E6=B7=B1=E5=BA=A6=E5=88=86?= =?UTF-8?q?=E5=B8=A6=E4=BD=BF=E5=A4=9A=E7=BB=88=E7=82=B9=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=B8=A6=E9=94=AE=E4=BA=92=E5=BC=82=EF=BC=88L1=20=E5=8A=A0=206?= =?UTF-8?q?=E3=80=81L2=20=E5=8A=A0=208=E3=80=81L3=20=E5=8A=A0=2011/12/13?= =?UTF-8?q?=EF=BC=8C=E5=85=B1=205=20=E8=A1=8C=EF=BC=89=EF=BC=9B=E6=95=8C?= =?UTF-8?q?=E7=A7=8D=E4=B8=80=E5=BE=8B=E5=A4=8D=E7=94=A8=E5=9C=A8=E5=86=8C?= =?UTF-8?q?=20id=EF=BC=88=E9=9B=B6=E6=96=B0=E5=86=85=E5=AE=B9=EF=BC=89?= =?UTF-8?q?=EF=BC=8CAC=20=E5=85=A8=E9=83=A8=E7=95=99=E5=9C=A8=E5=B7=B2?= =?UTF-8?q?=E8=A3=81=E5=B8=A6=E5=AE=BD=EF=BC=88=E6=9D=82=E9=B1=BC=200~5?= =?UTF-8?q?=EF=BC=8FBoss=208~12=EF=BC=89=EF=BC=8C=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E6=97=B6=E4=BB=A3=E5=B8=A6=E9=94=AE=200-3/4/9=20=E4=B8=8E=20ho?= =?UTF-8?q?bgoblin=20=E7=95=99=E4=BB=BB=E4=BD=8D=E4=B8=8D=E5=8A=A8=20-=20b?= =?UTF-8?q?oard=5Fgen.tres=EF=BC=9Anode=5Fdepth=20=E9=94=AE=E5=9F=9F?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=200=E2=80=936=EF=BC=8F0=E2=80=939=EF=BC=8F0?= =?UTF-8?q?=E2=80=9313=EF=BC=88=E6=96=B0=E5=A2=9E=2012=20=E8=A1=8C?= =?UTF-8?q?=EF=BC=8C=E5=85=B6=E4=BD=99=E5=AD=97=E6=AE=B5=E6=8C=89=E8=AF=A5?= =?UTF-8?q?=E5=85=B3=E5=90=8C=E6=A1=A3=E8=A1=8C=E5=A4=8D=E5=88=B6=EF=BC=89?= =?UTF-8?q?=20-=20=E6=96=AD=E8=A8=80=E9=9D=A2=EF=BC=9Alevel=5Fshop=5Fecono?= =?UTF-8?q?my=5Ftest=20=E6=96=B0=E5=A2=9E=20=5Fsmoke=5Fgenerated=5Fcoverag?= =?UTF-8?q?e=20=E4=B8=89=E6=9D=A1=E3=80=94v11=E3=80=95=EF=BC=88=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=9B=BE=E6=AF=8F=20battle/boss=20=E6=B7=B1=E5=BA=A6?= =?UTF-8?q?=E7=BB=8F=20resolve=5Fenemy=5Frowset=20=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E9=9D=9E=E7=A9=BA=EF=BC=9D=E9=A1=B6=20clamp=20=E6=B6=88?= =?UTF-8?q?=E8=A7=A3=E7=A6=81=EF=BC=8F=E6=AF=8F=E6=B7=B1=E5=BA=A6=20board?= =?UTF-8?q?=5Fgen=20=E7=B2=BE=E7=A1=AE=E5=91=BD=E4=B8=AD=E4=B8=94=20node?= =?UTF-8?q?=5Fdepth=20=E7=AD=89=E5=80=BC=EF=BC=8F=E5=A4=9A=E7=BB=88?= =?UTF-8?q?=E7=82=B9=20Boss=20=E8=A7=A3=E6=9E=90=E5=B8=A6=E9=94=AE?= =?UTF-8?q?=E4=B8=A4=E4=B8=A4=E4=BA=92=E5=BC=82=EF=BC=89=EF=BC=8C=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=E9=9D=A2=EF=BC=9D=E4=B8=89=E5=85=B3=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E6=A1=A3=C3=97=E7=A7=8D=E5=AD=90=2042=E2=80=9361=EF=BC=8C?= =?UTF-8?q?=E9=87=8F=E7=A8=8B=E8=AF=BB=E6=95=B0=20L1=200=E2=80=936?= =?UTF-8?q?=E3=80=81L2=200=E2=80=939=E3=80=81L3=200=E2=80=9313=20-=20?= =?UTF-8?q?=E6=97=A7=E6=96=AD=E8=A8=80=E9=9A=8F=E5=8A=A8=E4=B8=89=E6=9D=A1?= =?UTF-8?q?=EF=BC=88=E5=A3=B0=E6=98=8E=E4=BA=8E=E6=AD=A4=EF=BC=8C=E9=9D=9E?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E6=94=B9=E5=88=A4=EF=BC=89=EF=BC=9Atable=5Fs?= =?UTF-8?q?tructure=5Fguard=20=E3=80=94fv11=E3=80=95=E8=A1=8C=E6=95=B0?= =?UTF-8?q?=E4=B8=8E=E9=94=AE=E5=BA=8F=2020=E2=86=9228=20=E6=8D=A2?= =?UTF-8?q?=E4=BB=A3=E4=B8=BA=E8=A6=86=E7=9B=96=E8=A1=A8=E5=BA=8F=E3=80=81?= =?UTF-8?q?=E3=80=94fv13=E3=80=95=E3=80=8C=E4=B8=89=E5=85=B3=20Boss=20?= =?UTF-8?q?=E5=90=84=E6=81=B0=E4=B8=80=E8=A1=8C=EF=BC=9D=E5=9B=BE=20Boss?= =?UTF-8?q?=20=E6=B7=B1=E5=BA=A6=E3=80=8D=E6=8D=A2=E4=B8=BA=20Boss=20?= =?UTF-8?q?=E5=B8=A6=E9=94=AE=E8=A6=86=E7=9B=96=E8=A1=A8=E6=B8=85=E5=8D=95?= =?UTF-8?q?=EF=BC=88=E5=A4=9A=E7=BB=88=E7=82=B9=E5=BF=85=E7=84=B6=E5=A4=9A?= =?UTF-8?q?=E8=A1=8C=EF=BC=8C=E4=BA=92=E5=BC=82=E5=88=A4=E6=8D=AE=E7=A7=BB?= =?UTF-8?q?=E4=BA=A4=E3=80=94v11=E3=80=95=EF=BC=89=EF=BC=9Blevel=5Fshop=5F?= =?UTF-8?q?economy=20=E6=B7=B7=E7=BC=96=E6=AE=B5=E5=9F=BA=E7=BA=BF?= =?UTF-8?q?=E8=A1=8C=E6=95=B0=2020=E2=86=9228=E3=80=81=E6=B7=B1=E5=BA=A6?= =?UTF-8?q?=208=20=E7=9A=84=E6=9D=BF=E4=BE=A7=E3=80=8C=E6=97=A0=E8=A1=8C?= =?UTF-8?q?=E6=AE=8B=E7=95=99=E3=80=8D=E6=94=B9=E5=88=A4=E3=80=8C=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E8=A1=8C=E5=9C=A8=E5=86=8C=E3=80=8D=EF=BC=88=E6=88=98?= =?UTF-8?q?=E6=96=97=E4=BE=A7=E4=BB=8D=E7=A6=81=20(L,battle,8)=20=E7=B2=BE?= =?UTF-8?q?=E7=A1=AE=E9=94=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/data/tables/board_gen.tres | 156 ++++++++++++ game/data/tables/enemy.tres | 224 ++++++++++++++++++ .../unit/core/level_shop_economy_test.gd | 59 ++++- .../data_config/table_structure_guard_test.gd | 51 ++-- 4 files changed, 467 insertions(+), 23 deletions(-) diff --git a/game/data/tables/board_gen.tres b/game/data/tables/board_gen.tres index a783327e..15d9393b 100644 --- a/game/data/tables/board_gen.tres +++ b/game/data/tables/board_gen.tres @@ -70,6 +70,32 @@ rows = [{ "shave_ring_radius": 1, "r0_max_cap": 43 }, { +"level_index": 1, +"node_depth": 5, +"board_width": 12, +"board_height": 12, +"mine_density": 0.25, +"inferability_target": 0.88, +"solver_rules": "single_subset", +"min_safe_count": 60, +"max_gen_attempts": 24, +"board_type": "dungeon", +"shave_ring_radius": 1, +"r0_max_cap": 43 +}, { +"level_index": 1, +"node_depth": 6, +"board_width": 12, +"board_height": 12, +"mine_density": 0.25, +"inferability_target": 0.88, +"solver_rules": "single_subset", +"min_safe_count": 60, +"max_gen_attempts": 24, +"board_type": "dungeon", +"shave_ring_radius": 1, +"r0_max_cap": 43 +}, { "level_index": 2, "node_depth": 0, "board_width": 12, @@ -97,6 +123,32 @@ rows = [{ "r0_max_cap": 43 }, { "level_index": 2, +"node_depth": 2, +"board_width": 12, +"board_height": 12, +"mine_density": 0.25, +"inferability_target": 0.88, +"solver_rules": "single_subset", +"min_safe_count": 60, +"max_gen_attempts": 24, +"board_type": "underdark", +"shave_ring_radius": 1, +"r0_max_cap": 43 +}, { +"level_index": 2, +"node_depth": 3, +"board_width": 12, +"board_height": 12, +"mine_density": 0.25, +"inferability_target": 0.88, +"solver_rules": "single_subset", +"min_safe_count": 60, +"max_gen_attempts": 24, +"board_type": "underdark", +"shave_ring_radius": 1, +"r0_max_cap": 43 +}, { +"level_index": 2, "node_depth": 4, "board_width": 12, "board_height": 12, @@ -149,6 +201,19 @@ rows = [{ "r0_max_cap": 43 }, { "level_index": 2, +"node_depth": 8, +"board_width": 12, +"board_height": 12, +"mine_density": 0.25, +"inferability_target": 0.88, +"solver_rules": "single_subset", +"min_safe_count": 60, +"max_gen_attempts": 24, +"board_type": "underdark", +"shave_ring_radius": 1, +"r0_max_cap": 43 +}, { +"level_index": 2, "node_depth": 9, "board_width": 12, "board_height": 12, @@ -188,6 +253,32 @@ rows = [{ "r0_max_cap": 51 }, { "level_index": 3, +"node_depth": 2, +"board_width": 13, +"board_height": 13, +"mine_density": 0.25, +"inferability_target": 0.88, +"solver_rules": "single_subset", +"min_safe_count": 70, +"max_gen_attempts": 32, +"board_type": "deep_underdark", +"shave_ring_radius": 1, +"r0_max_cap": 51 +}, { +"level_index": 3, +"node_depth": 3, +"board_width": 13, +"board_height": 13, +"mine_density": 0.25, +"inferability_target": 0.88, +"solver_rules": "single_subset", +"min_safe_count": 70, +"max_gen_attempts": 32, +"board_type": "deep_underdark", +"shave_ring_radius": 1, +"r0_max_cap": 51 +}, { +"level_index": 3, "node_depth": 4, "board_width": 13, "board_height": 13, @@ -240,6 +331,19 @@ rows = [{ "r0_max_cap": 51 }, { "level_index": 3, +"node_depth": 8, +"board_width": 13, +"board_height": 13, +"mine_density": 0.25, +"inferability_target": 0.88, +"solver_rules": "single_subset", +"min_safe_count": 70, +"max_gen_attempts": 32, +"board_type": "deep_underdark", +"shave_ring_radius": 1, +"r0_max_cap": 51 +}, { +"level_index": 3, "node_depth": 9, "board_width": 14, "board_height": 14, @@ -251,6 +355,58 @@ rows = [{ "board_type": "deep_underdark", "shave_ring_radius": 1, "r0_max_cap": 59 +}, { +"level_index": 3, +"node_depth": 10, +"board_width": 14, +"board_height": 14, +"mine_density": 0.25, +"inferability_target": 0.88, +"solver_rules": "single_subset", +"min_safe_count": 82, +"max_gen_attempts": 32, +"board_type": "deep_underdark", +"shave_ring_radius": 1, +"r0_max_cap": 59 +}, { +"level_index": 3, +"node_depth": 11, +"board_width": 14, +"board_height": 14, +"mine_density": 0.25, +"inferability_target": 0.88, +"solver_rules": "single_subset", +"min_safe_count": 82, +"max_gen_attempts": 32, +"board_type": "deep_underdark", +"shave_ring_radius": 1, +"r0_max_cap": 59 +}, { +"level_index": 3, +"node_depth": 12, +"board_width": 14, +"board_height": 14, +"mine_density": 0.25, +"inferability_target": 0.88, +"solver_rules": "single_subset", +"min_safe_count": 82, +"max_gen_attempts": 32, +"board_type": "deep_underdark", +"shave_ring_radius": 1, +"r0_max_cap": 59 +}, { +"level_index": 3, +"node_depth": 13, +"board_width": 14, +"board_height": 14, +"mine_density": 0.25, +"inferability_target": 0.88, +"solver_rules": "single_subset", +"min_safe_count": 82, +"max_gen_attempts": 32, +"board_type": "deep_underdark", +"shave_ring_radius": 1, +"r0_max_cap": 59 }] ; 切片调参登记(SLICE-TUNED provisional,调参所有权仍归 C3): diff --git a/game/data/tables/enemy.tres b/game/data/tables/enemy.tres index 615ca76b..e8c5c1de 100644 --- a/game/data/tables/enemy.tres +++ b/game/data/tables/enemy.tres @@ -99,6 +99,31 @@ rows = [{ "armor_class": 2 }, { "level_index": 1, +"row_kind": "battle", +"depth_band": 6, +"enemy_id": "bugbear", +"display_name": "熊地精", +"unit_count": 1, +"unit_count_max": 2, +"hp": 30, +"atk": 1, +"atk_min": 0, +"atk_max": 2, +"pool": [{ +"skill_id": "attack", +"weight": 6 +}, { +"skill_id": "charge", +"weight": 2 +}, { +"skill_id": "heavy", +"weight": 2 +}], +"attack_type": "normal", +"armor_type": "light", +"armor_class": 4 +}, { +"level_index": 1, "row_kind": "boss", "depth_band": 4, "enemy_id": "ogre_chief", @@ -126,6 +151,34 @@ rows = [{ "armor_type": "light", "armor_class": 8 }, { +"level_index": 1, +"row_kind": "boss", +"depth_band": 6, +"enemy_id": "ogre_chief", + "display_name": "食人魔酋长", + "unit_count": 1, + "hp": 88, + "atk": 1, + "atk_min": 0, + "atk_max": 2, +"pool": [{ +"skill_id": "attack", +"weight": 6 +}, { +"skill_id": "heavy", +"weight": 2 +}, { +"skill_id": "harden", +"weight": 2 +}, { +"skill_id": "quake_smash", +"weight": 2, +"unlock_hp_pct": 50 +}], +"attack_type": "normal", +"armor_type": "light", +"armor_class": 8 +}, { "level_index": 2, "row_kind": "battle", "depth_band": 0, @@ -308,6 +361,65 @@ rows = [{ "armor_class": 3 }, { "level_index": 2, +"row_kind": "battle", +"depth_band": 9, + "enemy_id": "giant_spider", + "display_name": "巨蜘蛛巢群", + "unit_count": 2, + "unit_count_max": 3, + "hp": 40, + "atk": 2, +"atk_min": 1, +"atk_max": 3, +"pool": [{ +"skill_id": "attack", +"weight": 6 +}, { +"skill_id": "bite", +"weight": 2 +}, { +"skill_id": "charge", +"weight": 1 +}, { +"skill_id": "venom_spit", +"weight": 2 +}], +"attack_type": "normal", +"armor_type": "light", +"armor_class": 3 +}, { +"level_index": 2, +"row_kind": "boss", +"depth_band": 8, + "enemy_id": "drider", + "display_name": "蛛化卓尔精灵", + "unit_count": 1, + "hp": 112, + "atk": 3, + "atk_min": 2, + "atk_max": 4, +"pool": [{ +"skill_id": "attack", +"weight": 6 +}, { +"skill_id": "heavy", +"weight": 2 +}, { +"skill_id": "venom_burst", +"weight": 2 +}, { +"skill_id": "web_snare", +"weight": 2 +}, { +"skill_id": "venom_curb", +"weight": 2, +"unlock_hp_pct": 50 +}], +"attack_type": "normal", +"armor_type": "ward", +"armor_class": 10 +}, { +"level_index": 2, "row_kind": "boss", "depth_band": 9, "enemy_id": "drider", @@ -495,6 +607,34 @@ rows = [{ "armor_class": 4 }, { "level_index": 3, +"row_kind": "battle", +"depth_band": 13, +"enemy_id": "duergar", + "display_name": "灰矮人小队", + "unit_count": 2, + "unit_count_max": 3, + "hp": 42, + "atk": 3, + "atk_min": 2, + "atk_max": 4, +"pool": [{ +"skill_id": "attack", +"weight": 6 +}, { +"skill_id": "charge", +"weight": 2 +}, { +"skill_id": "heavy", +"weight": 2 +}, { +"skill_id": "shield_bash", +"weight": 2 +}], +"attack_type": "normal", +"armor_type": "heavy", +"armor_class": 4 +}, { +"level_index": 3, "row_kind": "boss", "depth_band": 9, "enemy_id": "duergar_king", @@ -521,6 +661,90 @@ rows = [{ "attack_type": "normal", "armor_type": "heavy", "armor_class": 12 +}, { +"level_index": 3, +"row_kind": "boss", +"depth_band": 11, + "enemy_id": "duergar_king", + "display_name": "灰矮人石君王", + "unit_count": 1, + "hp": 150, + "atk": 3, + "atk_min": 2, + "atk_max": 4, +"pool": [{ +"skill_id": "attack", +"weight": 6 +}, { +"skill_id": "heavy", +"weight": 2 +}, { +"skill_id": "earth_pulse", +"weight": 2 +}, { +"skill_id": "rockslide", +"weight": 2, +"unlock_hp_pct": 50 +}], +"attack_type": "normal", +"armor_type": "heavy", +"armor_class": 12 +}, { +"level_index": 3, +"row_kind": "boss", +"depth_band": 12, + "enemy_id": "duergar_king", + "display_name": "灰矮人石君王", + "unit_count": 1, + "hp": 162, + "atk": 3, + "atk_min": 2, + "atk_max": 4, +"pool": [{ +"skill_id": "attack", +"weight": 6 +}, { +"skill_id": "heavy", +"weight": 2 +}, { +"skill_id": "earth_pulse", +"weight": 2 +}, { +"skill_id": "rockslide", +"weight": 2, +"unlock_hp_pct": 50 +}], +"attack_type": "normal", +"armor_type": "heavy", +"armor_class": 12 +}, { +"level_index": 3, +"row_kind": "boss", +"depth_band": 13, + "enemy_id": "duergar_king", + "display_name": "灰矮人石君王", + "unit_count": 1, + "hp": 176, + "atk": 3, + "atk_min": 2, + "atk_max": 4, +"pool": [{ +"skill_id": "attack", +"weight": 6 +}, { +"skill_id": "heavy", +"weight": 2 +}, { +"skill_id": "earth_pulse", +"weight": 2 +}, { +"skill_id": "rockslide", +"weight": 2, +"unlock_hp_pct": 50 +}], +"attack_type": "normal", +"armor_type": "heavy", +"armor_class": 12 }] ; 切片调参登记 v3(2026-09-12 试玩批次 1 触发——F7 流程;C3 GDD F5 对照): diff --git a/game/tests/unit/core/level_shop_economy_test.gd b/game/tests/unit/core/level_shop_economy_test.gd index af4469a8..4eefdd2a 100644 --- a/game/tests/unit/core/level_shop_economy_test.gd +++ b/game/tests/unit/core/level_shop_economy_test.gd @@ -37,9 +37,60 @@ func _init() -> void: _smoke_shop_room() _smoke_shop_graph() _smoke_shop_pool() + _smoke_generated_coverage() quit(Kit.finish("level_shop_economy_test")) +# ---- 生成图深度扩面覆盖断言(迭代 35 卡② S5/V11——节点源=卡① 生成器产图, +# 敌侧/板侧解析一律走 ConfigDB 真实消费链,禁直读行集自算)---- + +func _smoke_generated_coverage() -> void: + var RngScript := preload("res://core/rng_service.gd") + var GenScript := preload("res://core/node_map_generator.gd") + var gen = GenScript.new(RngScript.new()) + var enemy_ok := true + var board_ok := true + var boss_band_ok := true + var readings := [] + for L in [1, 2, 3]: + var params: Dictionary = _db.level_gen_params_row(L) + if params.is_empty(): + Kit.check("L%d 参数档缺行(覆盖断言无源可扫)〔v11〕" % L, false) + continue + var dmin := 999 + var dmax := 0 + for seed_val in range(42, 62): + var g: Dictionary = gen.generate(params, seed_val) + var by_id := {} + for n in g["nodes"]: + by_id[int(n["id"])] = n + for n in g["nodes"]: + var d: int = int(n["depth"]) + dmin = mini(dmin, d) + dmax = maxi(dmax, d) + var kind := String(n["type"]) + if kind == "battle" or kind == "boss": + if _db.resolve_enemy_rowset(L, d, "boss" if kind == "boss" else "battle").is_empty(): + enemy_ok = false + var board: Dictionary = _db.resolve_board_gen(L, d) + if board.is_empty() or int(board.get("node_depth", -1)) != d: + board_ok = false + var bands := [] + for tid in g["terminals"]: + var bd: int = int(by_id[int(tid)]["depth"]) + var rs: Array[Dictionary] = _db.resolve_enemy_rowset(L, bd, "boss") + bands.append(int(rs[0].get("depth_band", -1)) if not rs.is_empty() else -1) + for i in range(bands.size()): + for j in range(i + 1, bands.size()): + if bands[i] == bands[j]: + boss_band_ok = false + readings.append("L%d 深度域 %d–%d" % [L, dmin, dmax]) + print("[v11] 生成图覆盖量程读数:" + "; ".join(readings)) + Kit.check("生成图每个 battle/boss 节点深度在敌表解析到非空行集(顶 clamp 消解禁)〔v11〕", enemy_ok) + Kit.check("生成图每个节点深度在 board_gen 精确命中(非空且 node_depth 等值)〔v11〕", board_ok) + Kit.check("多终点 Boss 各终点解析带键两两互异(L2/L3×20 种子)〔v11〕", boss_band_ok) + + # ---- 关卡节点地图·线性推进冒烟---- func _smoke_level() -> void: @@ -335,7 +386,7 @@ func _smoke_level_graph() -> void: _db.enemy.rows.remove_at(_db.enemy.rows.size() - 1) _db.enemy.rows.remove_at(_db.enemy.rows.size() - 1) _db.enemy.rows.remove_at(_db.enemy.rows.size() - 1) - Kit.check("混编断言段注入行全量还原(表回 20 行——首批混编投放后基线行数,迭代 18 卡⑤;后续断言面零污染)", int(_db.enemy.rows.size()) == 20) + Kit.check("混编断言段注入行全量还原(表回 28 行——迭代 35 生成图深度扩面覆盖批后的基线行数;首批混编投放基线 20 行+覆盖 8 行)", int(_db.enemy.rows.size()) == 28) # ⑬ 真图类型可见性(信息迷雾 v2.1):L2 入口停点纯核心面读数——候选 ⊆ 可见集 / # Boss 恒可见 / 层距 > N 的非豁免节点入雾。N 取数据表(代码零硬编码)。 @@ -710,7 +761,11 @@ func _smoke_shop_graph() -> void: en_keys["%d_%s_%d" % [int(row.level_index), String(row.get("row_kind", "")), int(row.get("depth_band", -1))]] = true for L in [2, 3]: Kit.check("(%d,boss,9) Boss 行在 board_gen + enemy 精确存在" % L, bg_keys.has("%d_9" % L) and en_keys.has("%d_boss_9" % L)) - Kit.check("(%d,battle,8) 商店房无板/战斗行集残留(陈旧行已清理)" % L, not bg_keys.has("%d_8" % L) and not en_keys.has("%d_battle_8" % L)) + # 迭代 35 生成图深度扩面覆盖批随动(旧判据属模板时代:深度 8 = 商店房专属位,故禁板行与战斗行): + # 生成图下深度 8 是正常可行深度 ⇒ 板侧改判「覆盖行在册」;战斗侧仍禁 (L,battle,8) 精确键 + #(战斗落带经顶带 9/13 解析,不落 8 号精确键——两侧判据都保留,只按现行设计换正负号)。 + Kit.check("(%d,8) 板侧生成图覆盖行在册·(%d,battle,8) 战斗精确键仍不存在" % [L, L], + bg_keys.has("%d_8" % L) and not en_keys.has("%d_battle_8" % L)) Kit.check("L1 行集零改动(单链 Boss (1,4)——教学期最小修订面)", bg_keys.has("1_4") and en_keys.has("1_boss_4") and not bg_keys.has("1_8") and not en_keys.has("1_battle_8")) diff --git a/game/tests/unit/data_config/table_structure_guard_test.gd b/game/tests/unit/data_config/table_structure_guard_test.gd index 7ec9c00d..2552719a 100644 --- a/game/tests/unit/data_config/table_structure_guard_test.gd +++ b/game/tests/unit/data_config/table_structure_guard_test.gd @@ -222,26 +222,31 @@ func _smoke_adventure_table() -> void: v18_ok and locked_n == 12) -# ---- 敌人表深度带键结构(迭代 34:20 行键迁移 · 键序=迁移表序 · hobgoblin 留任 · 三 Boss 行 kind/band)---- +# ---- 敌人表深度带键结构(迭代 34 建·迭代 35 覆盖批换代:28 行键序=覆盖表序·hobgoblin 留任·Boss 行集带键清单)---- func _smoke_enemy_bands() -> void: - print("--- 敌人表深度带键结构(20 行键序 · hobgoblin 留任 · 三 Boss 行)---") - # ① 20 行计数 + 键序 = 迁移表序(文件行序不变——行序即契约) + print("--- 敌人表深度带键结构(28 行键序 · hobgoblin 留任 · Boss 带键覆盖表)---") + # ① 28 行计数 + 键序 = 覆盖表序(文件行序不变——行序即契约) + # 迭代 35 生成图深度扩面覆盖批随动:战斗带顶扩到各关最大生成深度(L1→6/L2→9/L3→13)、 + # Boss 行按终点深度分带(L1 6/L2 8·9/L3 11·12·13),模板时代带键 4/9/9 全部在册不动 var expect_keys: Array[String] = [ "1|battle|0|goblin", "1|battle|1|goblin", "1|battle|2|bugbear", "1|battle|3|hobgoblin", - "1|boss|4|ogre_chief", + "1|battle|6|bugbear", + "1|boss|4|ogre_chief", "1|boss|6|ogre_chief", "2|battle|0|bugbear", "2|battle|1|bugbear", "2|battle|4|giant_spider", "2|battle|5|bugbear", "2|battle|5|giant_spider", "2|battle|6|giant_spider", "2|battle|7|giant_spider", - "2|boss|9|drider", + "2|battle|9|giant_spider", + "2|boss|8|drider", "2|boss|9|drider", "3|battle|0|duergar", "3|battle|1|hook_horror", "3|battle|4|hook_horror", "3|battle|5|duergar", - "3|battle|6|hook_horror", "3|battle|7|duergar", "3|boss|9|duergar_king", + "3|battle|6|hook_horror", "3|battle|7|duergar", "3|battle|13|duergar", + "3|boss|9|duergar_king", "3|boss|11|duergar_king", "3|boss|12|duergar_king", "3|boss|13|duergar_king", ] var got_keys: Array[String] = [] for row in _db.enemy.rows: got_keys.append("%d|%s|%d|%s" % [int(row.get("level_index", -1)), String(row.get("row_kind", "")), int(row.get("depth_band", -1)), String(row.get("enemy_id", ""))]) - Kit.check("敌人表 20 行且键序 = 迁移表序((L,row_kind,depth_band,enemy_id) 逐行比对——文件行序不变)〔fv11〕", - _db.enemy.rows.size() == 20 and got_keys == expect_keys) + Kit.check("敌人表 28 行且键序 = 覆盖表序((L,row_kind,depth_band,enemy_id) 逐行比对——文件行序不变)〔fv11〕", + _db.enemy.rows.size() == 28 and got_keys == expect_keys) # ② hobgoblin 缺口行留任随迁(契约 9:不删行不补立绘——缺口登记随行) var hob := {} for row in _db.enemy.rows: @@ -252,23 +257,27 @@ func _smoke_enemy_bands() -> void: and int(hob.get("depth_band", -1)) == 3) Kit.check("hobgoblin 缺口登记随行(表文件注释在位——缺口不静默)〔fv25〕", FileAccess.get_file_as_string("res://data/tables/enemy.tres").contains("hobgoblin 行为缺口行")) - # ③ 三 Boss 行 kind/band 就位(band = 该关图 Boss 节点深度——单终点图数据驱动) + # ③ Boss 行集带键 = 覆盖表(旧判据「各恰一行=图 Boss 节点深度」属单终点模板时代; + # 生成图多终点要求每终点深度解析到互异带键 ⇒ 每关 Boss 行必然多于一行—— + # 迭代 35 覆盖批随动改写为带键清单,「带键互异」的运行时把关在 level_shop_economy_test〔v11〕) + var boss_bands := {} + for row in _db.enemy.rows: + if String(row.get("row_kind", "")) == "boss": + var lk: int = int(row.get("level_index", -1)) + if not boss_bands.has(lk): + boss_bands[lk] = [] + boss_bands[lk].append(int(row.get("depth_band", -1))) + var expect_bands := {1: [4, 6], 2: [8, 9], 3: [9, 11, 12, 13]} var boss_ok := true var boss_detail := "" for L in [1, 2, 3]: - var bdepth := _boss_depth(L) - var hits := 0 - for row in _db.enemy.rows: - if int(row.get("level_index", -1)) == L and String(row.get("row_kind", "")) == "boss": - hits += 1 - if int(row.get("depth_band", -1)) != bdepth: - boss_ok = false - boss_detail += " L%d:%d≠%d" % [L, int(row.get("depth_band", -1)), bdepth] - if hits != 1: + var got: Array = boss_bands.get(L, []) + got.sort() + if got != expect_bands[L]: boss_ok = false - boss_detail += " L%d:boss 行×%d" % [L, hits] - Kit.check("三关 Boss 行各恰一行且 row_kind=boss、band=图 Boss 节点深度(%d/%d/%d%s)〔fv13〕" % [ - _boss_depth(1), _boss_depth(2), _boss_depth(3), boss_detail], boss_ok) + boss_detail += " L%d:%s≠%s" % [L, str(got), str(expect_bands[L])] + Kit.check("三关 Boss 行集带键 = 覆盖表(模板终点 4/9/9 在册+生成图终点 6/8/9/11/12/13 扩带)%s〔fv13〕" % boss_detail, + boss_ok) # ② 免伤墙归零护栏泛化保留(BUG-0003 事故类永久免疫):hero_defense 池任何 percent 行 From b5fd516b73f0b8a5043dd35012d1e83e0b6683a8 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sat, 24 Oct 2026 09:25:00 +0800 Subject: [PATCH 02/20] =?UTF-8?q?test(=E8=BF=AD=E4=BB=A335=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E6=89=B9):=20=E3=80=94v11=E3=80=95=E6=95=8C=E4=BE=A7?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E5=88=A4=E6=8D=AE=E7=94=B1=E3=80=8C=E8=A1=8C?= =?UTF-8?q?=E9=9B=86=E9=9D=9E=E7=A9=BA=E3=80=8D=E6=94=B9=E4=B8=BA=E3=80=8C?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E8=90=BD=E5=B8=A6=20=E2=89=A5=20=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E6=B7=B1=E5=BA=A6=E3=80=8D=E2=80=94=E2=80=94=E9=9D=9E?= =?UTF-8?q?=E7=A9=BA=E4=B8=8D=E6=98=AF=E8=A6=86=E7=9B=96=E5=88=A4=E6=8D=AE?= =?UTF-8?q?=EF=BC=88=E8=B6=8A=E9=A1=B6=20resolver=20clamp=20=E7=85=A7?= =?UTF-8?q?=E6=A0=B7=E8=BF=94=E8=A1=8C=EF=BC=89=EF=BC=8Cband=5Fof=20?= =?UTF-8?q?=E9=9D=9E=20-1=20=E6=89=8D=E6=98=AF=E5=8D=A1=E2=91=A1=20V11=20?= =?UTF-8?q?=E7=9A=84=E5=8E=9F=E6=84=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/tests/unit/core/level_shop_economy_test.gd | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/game/tests/unit/core/level_shop_economy_test.gd b/game/tests/unit/core/level_shop_economy_test.gd index 4eefdd2a..df6db8ed 100644 --- a/game/tests/unit/core/level_shop_economy_test.gd +++ b/game/tests/unit/core/level_shop_economy_test.gd @@ -70,7 +70,11 @@ func _smoke_generated_coverage() -> void: dmax = maxi(dmax, d) var kind := String(n["type"]) if kind == "battle" or kind == "boss": - if _db.resolve_enemy_rowset(L, d, "boss" if kind == "boss" else "battle").is_empty(): + var rk := "boss" if kind == "boss" else "battle" + var rs0: Array[Dictionary] = _db.resolve_enemy_rowset(L, d, rk) + # 判据=解析落带 ≥ 请求深度(等价 EnemyTable.band_of 非 -1)——行集非空不是覆盖判据, + # 越顶时 resolver 会 clamp 到顶带照样返回非空行(顶 clamp 消解正是设计禁的) + if rs0.is_empty() or int(rs0[0].get("depth_band", -1)) < d: enemy_ok = false var board: Dictionary = _db.resolve_board_gen(L, d) if board.is_empty() or int(board.get("node_depth", -1)) != d: @@ -86,7 +90,7 @@ func _smoke_generated_coverage() -> void: boss_band_ok = false readings.append("L%d 深度域 %d–%d" % [L, dmin, dmax]) print("[v11] 生成图覆盖量程读数:" + "; ".join(readings)) - Kit.check("生成图每个 battle/boss 节点深度在敌表解析到非空行集(顶 clamp 消解禁)〔v11〕", enemy_ok) + Kit.check("生成图每个 battle/boss 节点深度的敌侧解析落带 ≥ 请求深度(band_of 非 -1·顶 clamp 消解禁)〔v11〕", enemy_ok) Kit.check("生成图每个节点深度在 board_gen 精确命中(非空且 node_depth 等值)〔v11〕", board_ok) Kit.check("多终点 Boss 各终点解析带键两两互异(L2/L3×20 种子)〔v11〕", boss_band_ok) From 6a8bde9a8e24aa6561f5800ff0305f914474bc8d Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sat, 24 Oct 2026 09:49:26 +0800 Subject: [PATCH 03/20] =?UTF-8?q?docs(=E8=BF=AD=E4=BB=A335=E5=8D=A1?= =?UTF-8?q?=E2=91=A1):=20=E7=AB=8B=E5=8D=A1=E2=91=A1=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E2=80=94=E2=80=94=E8=A6=86=E7=9B=96=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=AE=B5=E6=94=B6=E5=8F=A3=E8=AF=BB=E6=95=B0=EF=BC=88?= =?UTF-8?q?=E4=B8=89=E5=85=B3=E5=8F=82=E6=95=B0=E6=A1=A3=C3=9720=20?= =?UTF-8?q?=E7=A7=8D=E5=AD=90=E5=85=A8=E7=BB=BF=20152=20checks=E3=80=81?= =?UTF-8?q?=E5=9B=9B=E6=9D=A1=E5=8F=98=E5=BC=82=E5=90=AB=E4=B8=80=E6=9D=A1?= =?UTF-8?q?=20NOT-RED=20=E4=B8=8E=E5=88=A4=E6=8D=AE=E5=8A=A0=E5=9B=BA?= =?UTF-8?q?=E8=BF=87=E7=A8=8B=E3=80=81=E6=97=A7=E6=96=AD=E8=A8=80=E9=9A=8F?= =?UTF-8?q?=E5=8A=A8=E5=9B=9B=E6=9D=A1=E9=80=90=E6=9D=A1=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=EF=BC=89=EF=BC=8B=E5=8D=A1=E2=91=A1=20=E6=9C=AC=E4=BD=93=20T1?= =?UTF-8?q?=E2=80=93T4=20=E8=BF=9B=E5=BA=A6=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- reports/iter35-卡②-实现报告-2026-10-24.md | 59 +++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 reports/iter35-卡②-实现报告-2026-10-24.md diff --git a/reports/iter35-卡②-实现报告-2026-10-24.md b/reports/iter35-卡②-实现报告-2026-10-24.md new file mode 100644 index 00000000..25808aa6 --- /dev/null +++ b/reports/iter35-卡②-实现报告-2026-10-24.md @@ -0,0 +1,59 @@ +# 迭代 35 卡② 实现报告(消费侧换代批·含生成图深度扩面覆盖数据同批落) + +- 日期:2026-10-24 起批(进行中) +- 工作区:`D:/code/g-plan2-iter35b`(分支 `iter35b`,起点=master `99e1b20b`,已含卡① 合回 `6475ebff`) +- 卡片:`reports/需求卡片-节点图消费侧换代批-2026-10-21.md` v1.1 +- 批性质:代码实现批——`design/` 与根规则文件只读;账面随动义务落 `reports/`(见 §五) +- 开工实况:卡② §0 的硬交付序列(生成图深度扩面的敌/板表覆盖)由用户 2026-10-24 裁决**与本卡同批落**(卡片原文允许「须先落或与本卡同批落」),故本报告 §一 先记覆盖数据批,后续各段记卡② 本体。 + +## 一、覆盖数据段(已完成·提交 `9214ef8e`+判据加固一笔) + +**授权口径**:数值面改动本属独立批次;用户点名随本批同落,取值授权为「按现有曲线插值、全 provisional」。 + +| 面 | 改动 | 量程依据(卡① 实测) | +|---|---|---| +| 敌侧战斗带顶 | L1 3→6、L2 7→9、L3 7→13,各加一行;敌种复用在册 id(bugbear / giant_spider / duergar),零新内容 | 生成图实际战斗深度 L1≤4、L2≤4、L3≤9;顶带按立卡要件取该关最大生成深度 | +| Boss 按终点深度分带 | L1 加带 6、L2 加带 8、L3 加带 11/12/13(敌种复用 ogre_chief / drider / duergar_king) | 终点深度=layers−r:L1 6;L2 9/8;L3 13/12/11 | +| 板侧 `node_depth` 键域 | 补齐 L1 0–6/L2 0–9/L3 0–13(新增 12 行,其余字段按该关同档行复制) | 生成图每个深度都要出板(覆盖义务含全部节点类型) | +| 敌种与数值纪律 | AC 全部落在迭代 5 已裁带宽(杂鱼 0–5/Boss 8–12);模板时代带键 0–3/4/9 与 hobgoblin 留任位一律不动 | `table_structure_guard_test` 带域断言零改判 | + +**验收断言**(宿主 `game/tests/unit/core/level_shop_economy_test.gd` 新增 `_smoke_generated_coverage`;扫描面=三关参数档 × 种子 42–61): + +- 〔v11〕敌侧落带——生成图每个 battle/boss 节点深度经 `resolve_enemy_rowset` 解析出行的 `depth_band ≥ 请求深度`(等价 `EnemyTable.band_of` 非 -1)。 + 判据加固记录:初稿写作「行集非空」,被撤销证明证伪——越顶时 resolver clamp 到顶带照样返非空行,正是设计禁的「顶 clamp 消解」,故改为落带等式判据。 +- 〔v11〕板侧精确命中——`resolve_board_gen(L, D)` 非空且返回行 `node_depth == D`。 +- 〔v11〕多终点带键互异——同图各终点深度解析出的 `depth_band` 两两不等。 +- 量程读数:L1 深度域 0–6、L2 0–9、L3 0–13;宿主全绿 152 checks(其中本段新起算 3 条)。 + +**变异验证**(逐条注入→最窄宿主见红→`git checkout` 还原;注入前实现已入库,防还原吞掉未提交码): + +| 注入 | 期望红点 | 实测 | +|---|---|---| +| L2 Boss 带键 8 并入顶带 9(回单带) | 带互异 | RED-OK:`多终点 Boss 各终点解析带键两两互异〔v11〕` 红 | +| L3 战斗顶带 13 降到 8(深 9 战斗节点越顶) | 敌侧落带 | RED-OK:`敌侧解析落带 ≥ 请求深度〔v11〕` 红(连带 `(3,battle,8)` 精确键断言红) | +| 删除 board_gen `(3,13)` 精确行 | 板侧精确命中 | RED-OK:`每个节点深度在 board_gen 精确命中〔v11〕` 红 | +| L3 战斗顶带 13→12(首版注入) | 敌侧落带 | NOT-RED·已标注:13 号战斗顶带未被任何生成节点请求(该行为立卡要件保守上限的余量)——据此把判据从「非空」改为「落带 ≥ 深度」,并补上一条真红注入 | + +**旧断言随动四条(声明于此,非静默改判)**: + +| 断言 | 原口径 | 新口径 | 理由 | +|---|---|---|---| +| `table_structure_guard` 〔fv11〕 | 敌人表 20 行且键序=迁移表序 | 28 行且键序=覆盖表序 | 扩带必然改行数与键序;行序即契约的判据形态不变 | +| `table_structure_guard` 〔fv13〕 | 三关 Boss 各恰一行且 band=图 Boss 节点深度 | 三关 Boss 带键清单=覆盖表(L1 4/6、L2 8/9、L3 9/11/12/13) | 判据对象是单终点模板时代形状;生成图多终点必然多行,「互异」移交〔v11〕运行时把关 | +| `level_shop_economy` 混编段 | 注入段还原后表回 20 行 | 回 28 行 | 同一基线行数随覆盖批换代 | +| `level_shop_economy` 深度 8 段 | `(L,8)` 无板行/战斗行残留(商店房专属位清理确认) | `(L,8)` 板侧覆盖行在册;战斗侧仍禁 `(L,battle,8)` 精确键 | 生成图下深度 8 是正常可行深度;扫描面未缩,只按现行设计换板侧正负号 | + +## 二、卡② 本体进度 + +| T 卡 | 状态 | +|---|---| +| T0 事实坐实与卡① 接口对齐 | 待做——接口四件已可由卡① 定稿直读(`generate(params, seed)` 不收 level_index、`{nodes,entries,terminals}`、节点 `id/type/depth/next`、参数档字段名),本段只需核 §3 行号漂移 | +| T1 加载校验换代(四检+良构) | 未开工——任务见分支待办 #2 | +| T2 消费侧切源与行进/定位/确定性换代 | 未开工——任务见分支待办 #3 | +| T3 模板清理与 S1–S8 断言换代 | 未开工(S5/V11 覆盖断言已由 §一 提前落位)——任务见分支待办 #4 | +| T4 产出物 | 本报告即起始件;代拍板单与反哺点清单随收口补 | + +## 三、遗留读数义务 + +- 覆盖数据为 provisional 插值,未跑 MC/对拍——数值面既动,卡② 收口前须请用户裁定是否发起 11 回放确定性对拍与 `balance_verifier` 重跑(本批目前只跑到「覆盖断言绿」,未测难度观感)。 +- 待办清单 06 条目「覆盖批未立卡」缺口段现况已过期(覆盖数据已随本批落地),状态回写归文档批。 From c0dd442060d8a03aeffc1365b8f225c1627779cc Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sat, 24 Oct 2026 15:52:21 +0800 Subject: [PATCH 04/20] =?UTF-8?q?feat(=E8=BF=AD=E4=BB=A335=E5=8D=A1?= =?UTF-8?q?=E2=91=A1T1):=20=E5=8A=A0=E8=BD=BD=E6=A0=A1=E9=AA=8C=E6=8D=A2?= =?UTF-8?q?=E4=BB=A3=E2=80=94=E2=80=94LevelTable.validate=5Fgraph(nodes)?= =?UTF-8?q?=20=E9=9D=99=E6=80=81=E5=8D=95=E7=82=B9=E6=89=BF=E8=BD=BD?= =?UTF-8?q?=E5=9B=9B=E6=A3=80=EF=BC=8B=E8=89=AF=E6=9E=84=E5=9F=BA=E5=BA=95?= =?UTF-8?q?=EF=BC=88=E2=91=A0=E5=85=A5=E5=8F=A3=E2=89=A51=20=E4=B8=94?= =?UTF-8?q?=E7=BB=88=E7=82=B9=E2=89=A51=20=E2=91=A1=E5=85=A8=E5=91=98?= =?UTF-8?q?=E4=BB=8E=E6=9F=90=E5=85=A5=E5=8F=A3=E5=8F=AF=E8=BE=BE=E4=B8=94?= =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E6=9F=90=E7=BB=88=E7=82=B9=20=E2=91=A2?= =?UTF-8?q?=E7=BB=88=E7=82=B9=E6=81=92=20Boss=20=E4=B8=94=20Boss=20?= =?UTF-8?q?=E6=97=A0=E5=87=BA=E8=BE=B9=20=E2=91=A3=E5=87=BA=E8=BE=B9?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E6=B7=B1=E5=BA=A6=E4=B8=A5=E6=A0=BC=E5=A4=A7?= =?UTF-8?q?=E4=BA=8E=E6=BA=90=EF=BC=9D=E6=97=A0=E7=8E=AF=EF=BC=9B=E8=89=AF?= =?UTF-8?q?=E6=9E=84=EF=BC=9D=E5=8F=A5=E6=9F=84=E5=94=AF=E4=B8=80=C2=B7?= =?UTF-8?q?=E5=87=BA=E8=BE=B9=E5=85=A8=E5=91=BD=E4=B8=AD=C2=B7type=20?= =?UTF-8?q?=E4=BA=94=E7=B1=BB=E6=9E=9A=E4=B8=BE=C2=B7battle=20=E5=B8=A6?= =?UTF-8?q?=E6=B7=B1=E5=BA=A6=EF=BC=89=EF=BC=8C=E8=A1=A8=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E6=9C=9F=E5=9B=BE=E6=A0=A1=E9=AA=8C=E4=B8=8E=E6=97=A7=E5=85=AD?= =?UTF-8?q?=E5=88=A4=E6=8D=AE=EF=BC=88Boss=20=E6=81=B0=E4=B8=80=EF=BC=8F?= =?UTF-8?q?=E6=88=98=E6=96=97=E2=89=A52=EF=BC=8F=E5=8D=95=E9=93=BE?= =?UTF-8?q?=E6=9C=AB=20Boss=EF=BC=8F=E5=89=8D=E4=B8=89=E4=BD=8D=E7=BA=AF?= =?UTF-8?q?=E6=88=98=E6=96=97=EF=BC=8F=E5=87=BA=E5=BA=A6=E2=89=A42?= =?UTF-8?q?=EF=BC=8FBoss=20=E7=BC=96=E5=8F=B7=E6=9C=80=E5=A4=A7=EF=BC=89?= =?UTF-8?q?=E8=BF=9E=E5=90=8C=E6=A8=A1=E6=9D=BF=E6=B1=A0=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E5=88=86=E6=94=AF=E3=80=81BFS=20=E5=B1=82=E5=AE=BD=E2=89=A45?= =?UTF-8?q?=20=E4=B8=80=E5=B9=B6=E9=80=80=E5=BD=B9=EF=BC=8C=E8=A1=8C?= =?UTF-8?q?=E7=BA=A7=E6=A0=A1=E9=AA=8C=E4=BF=9D=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新宿主 tests/unit/core/level_table_validate_test.gd〔v1〕15 条(正例三形:多入口多终点/含岔口单终点/单链退化显式出边;负例八类;范围反证三条:无商店图不报错·双入口合法·缺 next 字段仍拒)+run_all manifest 登记一行(59 套件全绿) - 判据②与④在 DAG 上互斥的实证:无环图每个源点必为入口、每条极长路必止于终点,故「孤儿/断头」构造不出独立负例——② 保留为 ④ 失效时的纵深防御面,测试以两独立链正证不误伤(该关系拟入反哺点,文档判据②的表述宜注明其兜底定位) - 旧断言随动四条(声明,非静默放宽):level_shop_economy ③ 段整段改走 validate_graph 并把「入口唯一违例」翻为合法正例、「孤儿」改为非 Boss 终点;level_map_type_vis 夹具注释订正;round_flow_skill_pool 敌表池清单 20→28 行补齐覆盖批八行池克隆键;board_gen_reward「(2,3) ceil 上取」改判精确命中并以临时摘同行的夹具面保留 ceil 能力判据(覆盖批补齐键域后旧判据对象消亡) - 件核:全仓 grep 六判据消息文本零残留(唯一余留在 level.tres 表尾模板生成史注释,随 T3 模板清出一并消失) --- game/data/schemas/level_table.gd | 261 +++++++----------- game/tests/run_all.gd | 1 + .../round_flow_skill_pool_test.gd | 12 +- game/tests/unit/core/board_gen_reward_test.gd | 19 +- .../unit/core/level_map_type_vis_test.gd | 2 +- .../unit/core/level_shop_economy_test.gd | 46 ++- .../unit/core/level_table_validate_test.gd | 102 +++++++ .../core/level_table_validate_test.gd.uid | 1 + reports/iter35-卡②-实现报告-2026-10-24.md | 2 +- 9 files changed, 249 insertions(+), 197 deletions(-) create mode 100644 game/tests/unit/core/level_table_validate_test.gd create mode 100644 game/tests/unit/core/level_table_validate_test.gd.uid diff --git a/game/data/schemas/level_table.gd b/game/data/schemas/level_table.gd index d37a4061..a0753aa3 100644 --- a/game/data/schemas/level_table.gd +++ b/game/data/schemas/level_table.gd @@ -1,13 +1,14 @@ ## VERTICAL SLICE - NOT FOR PRODUCTION -## 关卡节点表 schema:逐关节点链(单链/节点图两形态)的类型化读取(垂直切片验证:线性第一关承载元进度循环)。 -## 两形态同跑一套图合法性算法,违例加载期致命(validate 报错由 ConfigDB 收敛为 fail-fast)。 +## 关卡节点表 schema:逐关行集(关数/显示名/休整回复/迷雾层数)的类型化读取(垂直切片验证:改表即改关卡)。 +## 图合法性只在 `validate_graph` 一处陈述(消费侧起关校验入口——关卡节点图生成 v1.1 产出规格为准); +## 表加载期不校验图(关卡表不再携带节点数据,图源=参数化生成器)。 class_name LevelTable extends Resource @export var rows: Array[Dictionary] = [] # Array[Dictionary] @export var info_visible_layers: int = 2 # 信息迷雾可见层数 N(int ≥1;类型仅在邻近 N 层内可见——关卡节点地图 v2.1) -const NODE_TYPES := ["battle", "reward", "rest", "boss", "shop"] # 五类(关卡节点地图 v1.1) +const NODE_TYPES := ["battle", "reward", "rest", "boss", "shop"] # 五类(关卡节点地图 v2.1) # 行单点读取(表访问收口——LevelRunner._find_level_row 禁直扫;键 = level_index) @@ -38,182 +39,106 @@ func validate() -> Array: var heal: int = int(row.get("rest_heal_pct", -1)) if heal < 0 or heal > 100: errs.append({"category": "domain", "message": "level %d rest_heal_pct 越域 [0,100]" % L}) - # 模板池(关卡节点图生成 v0.4):row.templates 非空 = 池形态,池内全模板加载期校验 - # (违规致命——不只抽中的那张);空 = 单图形态(row.nodes 直读,向后兼容)。 - var node_lists: Array[Array] = [] # [ [上下文标签, nodes 数组], ... ] - var templates: Array[Variant] = row.get("templates", []) - if templates.is_empty(): - node_lists.append(["", row.get("nodes", [])]) - else: - for j in templates.size(): - var tpl = templates[j] - if typeof(tpl) != TYPE_DICTIONARY or (tpl.get("nodes", []) as Array).is_empty(): - errs.append({"category": "domain", "message": "level %d 模板[%d] 缺 nodes 或为空" % [L, j]}) - continue - node_lists.append(["模板[%d]=%s" % [j, String(tpl.get("display_name", ""))], tpl.get("nodes", [])]) - for nl in node_lists: - for e in _validate_level_nodes(L, nl[0], nl[1]): - errs.append(e) return errs -# 单节点链校验体(validate 抽出——row.nodes 与池内每张模板同跑一套;ctx = 消息上下文标签) -func _validate_level_nodes(L: int, ctx: String, nodes: Array) -> Array: +# 图合法性校验单点(消费侧起关入口——生成图到手、进节点前调用;违规=致命,无软回退)。 +# 判据=四检+良构基底:① 入口(无入边)≥1 且终点(无出边)≥1;② 全部节点从某入口可达且可达某终点 +#(每条路径恰从一个入口出发、恰到好处一个终点,无孤儿无断头);③ 终点恒为 Boss(终点 type=boss 且 boss 无出边); +# ④ 无环(出边目标深度严格大于源)。良构=句柄唯一·出边全命中·type 在五类枚举·battle/boss 带深度。 +# 入口/终点个数与参数档的一致性归产出侧(关卡节点图生成 v1.1),本处不核个数;商店位归冒烟断言把关,本处禁检。 +static func validate_graph(nodes: Array) -> Array: var errs: Array[Dictionary] = [] - var tag: String = "" if ctx.is_empty() else "〔%s〕" % ctx if nodes.is_empty(): - errs.append({"category": "domain", "message": "level %d%s 节点链为空" % [L, tag]}) + errs.append({"category": "structural", "message": "图节点集为空"}) return errs - var boss_count := 0 - var battle_count := 0 - var is_graph := false - for node in nodes: - if node.has("next"): - is_graph = true - var t: String = String(node.get("type", "")) + var by_id := {} + var indeg := {} + for i in nodes.size(): + var node: Dictionary = nodes[i] + for k in ["id", "type", "depth", "next"]: + if not node.has(k): + errs.append({"category": "structural", "message": "节点[%d] 缺字段 %s" % [i, k]}) + var t := String(node.get("type", "")) if not NODE_TYPES.has(t): - errs.append({"category": "enum", "message": "level %d%s 未知节点类型: %s" % [L, tag, t]}) - continue - if t == "battle" or t == "boss": - if int(node.get("node_depth", -1)) < 0: - errs.append({"category": "domain", "message": "level %d%s %s 节点缺 node_depth" % [L, tag, t]}) - battle_count += 1 - if t == "boss": - boss_count += 1 - if boss_count != 1: - errs.append({"category": "structural", "message": "level %d%s Boss 节点数 = %d(须恰一)" % [L, tag, boss_count]}) - if battle_count < 2: - errs.append({"category": "structural", "message": "level %d%s 战斗节点(含 Boss)= %d(≥2 才构成关卡)" % [L, tag, battle_count]}) - if not is_graph: - # 单链形态纪律(第一关零迁移):末节点必须是 Boss(关底收尾——冒险结构 关卡定义) - if String(nodes[nodes.size() - 1].get("type", "")) != "boss": - errs.append({"category": "structural", "message": "level %d%s 末节点非 Boss(单链以 Boss 收尾)" % [L, tag]}) - # 图合法性(单链与图同一套算法——单链 = 出边恰一的退化形态,违规同样加载致命) - for e in _validate_graph(L, nodes, is_graph, tag): - errs.append(e) - # U2 目检规则(两条,加载期同检):①单链前三位纯战斗(奖励/休整禁入前三) - if not is_graph and nodes.size() >= 3: - for i in 3: - if String(nodes[i].get("type", "")) != "battle": - errs.append({"category": "structural", "message": "level %d%s 第 %d 位非战斗(单链前三位纯战斗——U2 目检规则)" % [L, tag, i + 1]}) + errs.append({"category": "enum", "message": "未知节点类型: %s" % t}) + var d: int = int(node.get("depth", -1)) + if d < 0: + errs.append({"category": "domain", "message": "节点 %s 深度越域(≥0): %d" % [str(node.get("id", i)), d]}) + if t == "battle" and not node.has("depth"): + errs.append({"category": "structural", "message": "battle 节点 %s 缺深度" % str(node.get("id", i))}) + var nid: int = int(node.get("id", i)) + if by_id.has(nid): + errs.append({"category": "dup_key", "message": "节点句柄重复: %d" % nid}) + by_id[nid] = node + if not indeg.has(nid): + indeg[nid] = 0 + if not errs.is_empty(): + return errs + var outs := {} + for nid in by_id.keys(): + outs[nid] = [] + for nid in by_id.keys(): + for tid in (by_id[nid]["next"] as Array): + if not by_id.has(int(tid)): + errs.append({"category": "dangling_next", "message": "节点 %d 出边指向不存在句柄: %s" % [nid, tid]}) + continue + if int(by_id[int(tid)]["depth"]) <= int(by_id[nid]["depth"]): + errs.append({"category": "structural", "message": "连线 %d→%d 违反前进方向(目标深度须严格大于源)" % [nid, int(tid)]}) + outs[nid].append(int(tid)) + indeg[int(tid)] = int(indeg[int(tid)]) + 1 + if not errs.is_empty(): + return errs + var entries := [] + var terminals := [] + for nid in by_id.keys(): + if int(indeg[nid]) == 0: + entries.append(nid) + if (outs[nid] as Array).is_empty(): + terminals.append(nid) + if String(by_id[nid]["type"]) != "boss": + errs.append({"category": "structural", "message": "终点 %d 非 Boss(终点恒为 Boss)" % nid}) + elif String(by_id[nid]["type"]) == "boss": + errs.append({"category": "structural", "message": "Boss 节点 %d 带出边(终点不可外出)" % nid}) + if entries.is_empty(): + errs.append({"category": "structural", "message": "入口节点数 = 0(须 ≥1)"}) + if terminals.is_empty(): + errs.append({"category": "structural", "message": "终点节点数 = 0(须 ≥1)"}) + if not errs.is_empty(): + return errs + var from_entry := {} + for eid in entries: + _mark_reachable(int(eid), outs, from_entry) + var to_terminal := {} + for tid in terminals: + _mark_backward(int(tid), outs, to_terminal) + for nid in by_id.keys(): + if not from_entry.has(nid): + errs.append({"category": "structural", "message": "节点 %d 从任何入口都不可达(孤儿)" % nid}) + if not to_terminal.has(nid): + errs.append({"category": "structural", "message": "节点 %d 沿连线到不了任何终点(断头)" % nid}) return errs -# 图合法性核心(返回错误列表):id 解析(图=node_depth / 单链=数组下标)→ 出边表 → -# 唯一性/前进方向/入口唯一/Boss 终点/双向可达六检。参考 关卡节点地图 v1.0 分支节点图规则。 -func _validate_graph(L: int, nodes: Array, is_graph: bool, tag: String = "") -> Array: - var errs: Array[Dictionary] = [] - var ids: Array[int] = [] # 每节点 id(下标对齐) - var nexts: Array[Array] = [] # 每节点出边 id 数组 - var id_to_idx := {} - for i in nodes.size(): - var node: Dictionary[Variant, Variant] = nodes[i] - var nid: int = int(node.get("node_depth", i)) if is_graph else i - if is_graph and not node.has("node_depth"): - errs.append({"category": "structural", "message": "level %d%s 图形态节点[%d] 缺 node_depth(全节点须编号化)" % [L, tag, i]}) - nid = i # 降级继续(后续校验仍有意义) - ids.append(nid) - if id_to_idx.has(nid): - errs.append({"category": "dup_key", "message": "level %d%s 节点编号重复: %d(关卡内唯一定位地址)" % [L, tag, nid]}) - id_to_idx[nid] = i - var nx: Array[int] = [] - if node.has("next"): - for v in node.get("next", []): - nx.append(int(v)) - elif i < nodes.size() - 1: - nx.append(i + 1) # 单链隐式出边 = 数组下一项(id 即下标) - nexts.append(nx) - # ② 出边引用存在 + 前进方向(目标 id 严格递增 ⇒ 无环——环必含非递增边) - for i in nodes.size(): - for tid in nexts[i]: - if not id_to_idx.has(tid): - errs.append({"category": "dangling_next", "message": "level %d%s 节点 %d 出边指向不存在编号: %d" % [L, tag, ids[i], tid]}) - elif tid <= ids[i]: - errs.append({"category": "structural", "message": "level %d%s 连线 %d→%d 违反前进方向(目标编号须严格更大)" % [L, tag, ids[i], tid]}) - # ③b 出度上限(「节点出度最大为 2」):单节点出边 >2 即加载致命。 - # 规则连带:出度 ≤2 + 入口唯一下 BFS 层宽上限 = 4(序列 1,2,4——层宽上限 5 与出度约束 - # 不相容,模板池按层宽 ≤4 构造)。 - for i in nodes.size(): - if nexts[i].size() > 2: - errs.append({"category": "fanout_violation", "message": "level %d%s 节点 %d 出度 %d > 2(出度上限拍板 2026-10-01)" % [L, tag, ids[i], nexts[i].size()]}) - # ④ 入口唯一(无入边节点恰一——唯一入口) - var has_inbound := {} - for i in nodes.size(): - for tid in nexts[i]: - has_inbound[tid] = true - var entries: Array[int] = [] - for i in nodes.size(): - if not has_inbound.has(ids[i]): - entries.append(i) - if entries.size() != 1: - errs.append({"category": "structural", "message": "level %d%s 入口节点数 = %d(须恰一——无入边节点唯一)" % [L, tag, entries.size()]}) - # ⑤ Boss 恒唯一终点:无出边 + 编号最大(图形态;单链由"末节点 Boss"既有校验覆盖终点性) - for i in nodes.size(): - if String(nodes[i].get("type", "")) != "boss": - continue - if not nexts[i].is_empty(): - errs.append({"category": "structural", "message": "level %d Boss 节点 %d 带出边(终点不可外出)" % [L, ids[i]]}) - if is_graph and ids[i] != ids.max(): - errs.append({"category": "structural", "message": "level %d Boss 编号 %d 非最大 %d(终点编号最大)" % [L, ids[i], ids.max()]}) - if entries.size() == 1: - # ⑥ 从入口可达全部(无孤儿)· ⑦ 全部节点可达 Boss(逆向)——邻接表统一下标空间 - var fwd := [] - var back := [] - for i in nodes.size(): - fwd.append([]) - back.append([]) - for i in nodes.size(): - for tid in nexts[i]: - if id_to_idx.has(tid): - fwd[i].append(id_to_idx[tid]) - back[id_to_idx[tid]].append(i) - var reach := _reachable(entries[0], fwd, nodes.size()) - for i in nodes.size(): - if not reach.has(i): - errs.append({"category": "structural", "message": "level %d 节点 %d 从入口不可达(孤儿节点)" % [L, ids[i]]}) - var boss_idx := -1 - for i in nodes.size(): - if String(nodes[i].get("type", "")) == "boss": - boss_idx = i - if boss_idx >= 0: - var to_boss := _reachable(boss_idx, back, nodes.size()) - for i in nodes.size(): - if not to_boss.has(i): - errs.append({"category": "structural", "message": "level %d 节点 %d 沿连线不可达 Boss" % [L, ids[i]]}) - # ⑧ U2 目检规则:BFS 同深度层节点数上限 5(分支宽度天花板) - var layer_of := {} - var bfs: Array[Variant] = [entries[0]] - layer_of[entries[0]] = 0 - while not bfs.is_empty(): - var cur: int = bfs.pop_front() - for tid in nexts[cur]: - var tidx: int = int(id_to_idx.get(tid, -1)) - if tidx >= 0 and not layer_of.has(tidx): - layer_of[tidx] = int(layer_of[cur]) + 1 - bfs.append(tidx) - var width := {} - for i in nodes.size(): - if layer_of.has(i): - width[layer_of[i]] = int(width.get(layer_of[i], 0)) + 1 - for d in width.keys(): - if int(width[d]) > 5: - errs.append({"category": "structural", "message": "level %d BFS 第 %d 层 %d 节点(同层上限 5——U2 目检规则)" % [L, d, int(width[d])]}) - return errs - - -# BFS 可达集(邻接表 adj: 节点下标 → 下标数组) -func _reachable(start: int, adj: Array, n: int) -> Dictionary: - var seen := {} - if start < 0 or start >= n: - return seen +# 正向可达集(含起点) +static func _mark_reachable(start: int, outs: Dictionary, seen: Dictionary) -> void: var queue: Array[Variant] = [start] seen[start] = true while not queue.is_empty(): var cur: int = queue.pop_front() - if cur >= adj.size(): - continue - for nb in adj[cur]: - if not seen.has(nb): - seen[nb] = true - queue.append(nb) - return seen + for tid in outs[cur]: + if not seen.has(int(tid)): + seen[int(tid)] = true + queue.append(int(tid)) + + +# 逆向可达集(含起点)——反向邻接由 outs 现推(图规模小,禁第二份邻接表持有) +static func _mark_backward(target: int, outs: Dictionary, seen: Dictionary) -> void: + var queue: Array[Variant] = [target] + seen[target] = true + while not queue.is_empty(): + var cur: int = queue.pop_front() + for nid in outs.keys(): + if (outs[nid] as Array).has(cur) and not seen.has(int(nid)): + seen[int(nid)] = true + queue.append(int(nid)) diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index 824ecf97..1890c23d 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -54,6 +54,7 @@ func _suites() -> Array: {"path": "res://tests/unit/core/treasure_items_test.gd", "expect": 0}, # 秘宝结构冻结断言面 {"path": "res://tests/unit/core/node_map_generator_test.gd", "expect": 0}, # 节点图生成器产出合格/可复现/岔路取舍/恰一店/归属锁定/战斗保底/出口结构/RNG 纯流面(卡① V1·V3·V5–V7·V9·V10) {"path": "res://tests/unit/core/node_map_growth_test.gd", "expect": 0}, # 生成图生长递增+参数无解 fail-fast 子进程通道(卡① V8/V4) + {"path": "res://tests/unit/core/level_table_validate_test.gd", "expect": 0}, # 消费侧加载校验四检+良构基底正负向(卡② T1/V1〔v1〕) {"path": "res://tests/unit/core/breakpoint_resume_test.gd", "expect": 0}, # 断点往返/续玩确定性/幂等/退出清断点 {"path": "res://tests/unit/core/curio_refresh_paid_test.gd", "expect": 0}, # 三选一刷新免费双源+GP 付费档(对齐商店 MC-1:价曲线/优先序/停点清零/钱包单口) {"path": "res://tests/unit/core/level_map_type_vis_test.gd", "expect": 0}, # 信息迷雾——类型可见性(层距 N+Boss/候选豁免+单圈+N 读表+续玩同值) diff --git a/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd b/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd index 6a6b0428..64526fc0 100644 --- a/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd +++ b/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd @@ -596,6 +596,16 @@ func _smoke_skill_pool_data() -> void: "3_battle_6_hook_horror": {"attack": 6, "bite": 2, "charge": 1}, "3_battle_7_duergar": {"attack": 6, "charge": 2, "heavy": 2, "shield_bash": 2}, "3_boss_9_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2}, + # 迭代 35 生成图深度扩面覆盖批八行=同关同 row_kind 顶档行的池克隆(零新池、零新技能引用): + # 战斗带顶 L1→6/L2→9/L3→13;Boss 按终点深度分带 L1→6、L2→8、L3→11/12/13 + "1_battle_6_bugbear": {"attack": 6, "charge": 2, "heavy": 2}, + "1_boss_6_ogre_chief": {"attack": 6, "heavy": 2, "harden": 2, "quake_smash": 2}, + "2_battle_9_giant_spider": {"attack": 6, "bite": 2, "charge": 1, "venom_spit": 2}, + "2_boss_8_drider": {"attack": 6, "heavy": 2, "venom_burst": 2, "web_snare": 2, "venom_curb": 2}, + "3_battle_13_duergar": {"attack": 6, "charge": 2, "heavy": 2, "shield_bash": 2}, + "3_boss_11_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2}, + "3_boss_12_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2}, + "3_boss_13_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2}, } var pools_ok := true for row in _db.enemy.rows: @@ -608,7 +618,7 @@ func _smoke_skill_pool_data() -> void: got[String(entry.skill_id)] = int(entry.weight) if got != expect_pools[rk]: pools_ok = false - Kit.check("二十行九种池模板 = 建议池行值((L,row_kind,depth_band,enemy_id) 四元键逐行精确比对;L1 地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2;L2 熊地精 6/1/2 · 蜘蛛 6/2/1+毒吐2〔首遇带不投——迭代 24 N2〕 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2〔Boss〕;L3 灰矮人 6/2/2+盾击2 · 钩爪恐魔 6/2/1 · 重装手 5/2/2+盾击2 · 石君王 6/2/2〔earth_pulse〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2+unlock_hp_pct 50——迭代14④ T5〕;混编行集同带多行各一池合法——首批投放 (2,battle,5) 双行〔迭代 18 卡⑤:重击手+巢群〕,其余带键仍恰一行)", pools_ok) + Kit.check("二十八行九种池模板 = 建议池行值((L,row_kind,depth_band,enemy_id) 四元键逐行精确比对;L1 地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2;L2 熊地精 6/1/2 · 蜘蛛 6/2/1+毒吐2〔首遇带不投——迭代 24 N2〕 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2〔Boss〕;L3 灰矮人 6/2/2+盾击2 · 钩爪恐魔 6/2/1 · 重装手 5/2/2+盾击2 · 石君王 6/2/2〔earth_pulse〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2+unlock_hp_pct 50——迭代14④ T5〕;混编行集同带多行各一池合法——首批投放 (2,battle,5) 双行〔迭代 18 卡⑤:重击手+巢群〕;迭代 35 覆盖批八行为顶档池克隆,逐行同检)", pools_ok) var basic_count_ok := true for row in _db.enemy.rows: var n := 0 diff --git a/game/tests/unit/core/board_gen_reward_test.gd b/game/tests/unit/core/board_gen_reward_test.gd index ea75b84f..69a0d3b6 100644 --- a/game/tests/unit/core/board_gen_reward_test.gd +++ b/game/tests/unit/core/board_gen_reward_test.gd @@ -134,9 +134,24 @@ func _smoke_board_generator() -> void: var row = _db.resolve_board_gen(L, 0) Kit.check("BoardGen 行解析 (%d,0)" % L, not row.is_empty()) if L == 2: - # R1 ceil 口径:无精确行向上取整保证偏难——L2 深度 3 为恢复点无行,解析到深度 4 + # 迭代 35 生成图深度扩面覆盖批随动(声明):旧口径「(2,3) 无精确行→ceil 上取到 4」的判据对象 + # 是模板时代键域缺口;覆盖批补齐 0–13 后 (2,3) 有精确行,生产表上不再存在可走 ceil 的空位。 + # 判据不丢——精确命中改判在册,ceil 能力用「临时摘除该行」的夹具面保留,随后原样还原。 + var exact_row: Dictionary = _db.resolve_board_gen(2, 3) + Kit.check("BoardGen 精确命中 (2,3)(覆盖批键域补齐)", int(exact_row.get("node_depth", -1)) == 3) + var gap_idx := -1 + for i in _db.board_gen.rows.size(): + if int(_db.board_gen.rows[i].get("level_index", -1)) == 2 and int(_db.board_gen.rows[i].get("node_depth", -1)) == 3: + gap_idx = i + break + var saved: Dictionary = _db.board_gen.rows[gap_idx] + var rows_before: int = _db.board_gen.rows.size() + _db.board_gen.rows.remove_at(gap_idx) var ceil_row: Dictionary = _db.resolve_board_gen(2, 3) - Kit.check("R1 ceil 解析 (2,3)→(2,4)(向上取整偏难)", int(ceil_row.get("node_depth", -1)) == 4) + Kit.check("R1 ceil 能力不失(夹具摘除 (2,3)→向上取整到 4·偏难口径)", int(ceil_row.get("node_depth", -1)) == 4) + _db.board_gen.rows.insert(gap_idx, saved) + Kit.check("夹具表原样还原(行数与解析回到覆盖批现值)", + _db.board_gen.rows.size() == rows_before and int(_db.resolve_board_gen(2, 3).get("node_depth", -1)) == 3) var b1 = board_generator.generate(L, 0, _rng.open_stream("board_gen"), _db) Kit.check("L%d 生成产物非空" % L, b1 != null) if b1 == null: diff --git a/game/tests/unit/core/level_map_type_vis_test.gd b/game/tests/unit/core/level_map_type_vis_test.gd index b3ca3d97..1c56bbf8 100644 --- a/game/tests/unit/core/level_map_type_vis_test.gd +++ b/game/tests/unit/core/level_map_type_vis_test.gd @@ -57,7 +57,7 @@ func _chain_nodes() -> Array: ] -# validate 正样本(无跨层跳边的合法图:入口唯一、Boss 编号最大收尾、层宽 ≤5) +# 表行夹具的正样本(无跨层跳边的合法图——表加载期只核行级字段,图合法性判据见 validate_graph·卡② T1 换代) func _valid_graph_nodes() -> Array: return [ {"node_depth": 0, "type": "battle", "next": [1, 2]}, diff --git a/game/tests/unit/core/level_shop_economy_test.gd b/game/tests/unit/core/level_shop_economy_test.gd index df6db8ed..98da5e9a 100644 --- a/game/tests/unit/core/level_shop_economy_test.gd +++ b/game/tests/unit/core/level_shop_economy_test.gd @@ -147,8 +147,8 @@ func _battle_st(policy: String) -> Dictionary: func _smoke_level_graph() -> void: print("--- 关卡节点地图·分支节点图(L2 幽暗地域)---") var autoplay = load("res://sim/level_autoplay.gd") - # ① L1 单链与 L2 图经同一套加载校验(单链 = 出边恰一的退化形态——load_tables 全绿已含,显式复述) - Kit.check("LevelTable 图合法性校验:L1 单链 + L2 图同套算法零错误", _db.level.validate().is_empty()) + # ① 关卡表行级校验零错误(图合法性自 T1 起不再由表加载期承担——关卡表携带的模板数据随卡② T3 清出) + Kit.check("LevelTable 行级校验零错误(图合法性移交 validate_graph)", _db.level.validate().is_empty()) # ② L2 行集结构(网状图建议规模) var l2 := {} for row in _db.level.rows: @@ -164,39 +164,37 @@ func _smoke_level_graph() -> void: Kit.check("L2 普通战斗节点 = 6 + Boss 恒一(编号最大恒终点)", int(tcount.battle) == 6 and int(tcount.boss) == 1) Kit.check("L2 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)+ 商店房节点 1(商店房批)", int(tcount.reward) == 1 and int(tcount.rest) == 1 and int(tcount.shop) == 1) Kit.check("L2 真实岔路 = %d 处(≥2)" % branch, branch >= 2) - # ③ 图校验正反断言(违规行集定位到类别——加载期致命的同款算法) - var lt = load("res://data/schemas/level_table.gd").new() - var good := [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [2]}, - {"node_depth": 2, "type": "boss"}] - lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": good}]) - Kit.check("图校验正例:两岔合流小图零错误", lt.validate().is_empty()) + # ③ 图校验正反断言改走 validate_graph 公有面(迭代 35 卡② T1 随动·声明): + # · 夹具字段随生成图读面换代(id/type/depth/next 取代 node_depth 双编号空间); + # · 「入口唯一违例」判据随模板池退役——多入口是现行合法形态(关卡节点图生成 v1.1 结构铁律), + # 该条改判正例;「孤儿节点」条去掉「双入口同体违例」措辞,只保留到不了终点/进不去的违例面; + # · 前进方向判据由「目标编号严格递增」换为「目标深度严格大于源」(契约 1④)。 + var LT = load("res://data/schemas/level_table.gd") + var good := [{"id": 0, "type": "battle", "depth": 0, "next": [1, 2]}, + {"id": 1, "type": "battle", "depth": 1, "next": [2]}, + {"id": 2, "type": "boss", "depth": 2, "next": []}] + Kit.check("图校验正例:两岔合流小图零错误〔v1〕", LT.validate_graph(good).is_empty()) var dup = JSON.parse_string(JSON.stringify(good)) - dup[1].node_depth = 0 - lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dup}]) - Kit.check("图校验拒绝:节点编号重复(关卡内唯一定位地址)", _has_err_category(lt.validate(), "dup_key")) + dup[1].id = 0 + Kit.check("图校验拒绝:句柄重复(图内唯一句柄)〔v1〕", _has_err_category(LT.validate_graph(dup), "dup_key")) var dual = JSON.parse_string(JSON.stringify(good)) - dual.append({"node_depth": 9, "type": "battle", "next": [2]}) - lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dual}]) - Kit.check("图校验拒绝:第二无入边节点(入口唯一违例——入口节点数 = 3)", _has_err_category(lt.validate(), "structural")) + dual.append({"id": 9, "type": "battle", "depth": 0, "next": [2]}) + Kit.check("图校验正例:多入口合法(入口唯一判据随模板池退役)〔v1〕", LT.validate_graph(dual).is_empty()) var boss_out = JSON.parse_string(JSON.stringify(good)) boss_out[2].next = [1] - lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": boss_out}]) - Kit.check("图校验拒绝:Boss 带出边(终点不可外出)", _has_err_category(lt.validate(), "structural")) + Kit.check("图校验拒绝:Boss 带出边(终点不可外出)〔v1〕", _has_err_category(LT.validate_graph(boss_out), "structural")) var cyc = JSON.parse_string(JSON.stringify(good)) cyc[1].next = [0] - lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": cyc}]) - Kit.check("图校验拒绝:回边 1→0(连线只朝前进方向 ⇒ 无环)", _has_err_category(lt.validate(), "structural")) + Kit.check("图校验拒绝:回边 1→0(目标深度须严格大于源)〔v1〕", _has_err_category(LT.validate_graph(cyc), "structural")) var dang = JSON.parse_string(JSON.stringify(good)) dang[0].next = [1, 7] - lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dang}]) - Kit.check("图校验拒绝:出边指向不存在编号(悬空致命)", _has_err_category(lt.validate(), "dangling_next")) + Kit.check("图校验拒绝:出边指向不存在句柄(悬空致命)〔v1〕", _has_err_category(LT.validate_graph(dang), "dangling_next")) var orphan = JSON.parse_string(JSON.stringify(good)) orphan[0].next = [1] orphan[1].next = [2] - orphan.append({"node_depth": 5, "type": "battle", "next": [2]}) - lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": orphan}]) - var orphan_errs: Array = lt.validate() - Kit.check("图校验拒绝:孤儿节点(从入口不可达 / 双入口同体违例)", _has_err_category(orphan_errs, "structural")) + orphan.append({"id": 5, "type": "battle", "depth": 1, "next": [6]}) + orphan.append({"id": 6, "type": "rest", "depth": 2, "next": []}) + Kit.check("图校验拒绝:非 Boss 终点(终点恒为 Boss)〔v1〕", _has_err_category(LT.validate_graph(orphan), "structural")) # ④ L2 选路推进:min / max 双路径全通(确定性路径轴——零随机流自开) # 战斗随机值面随全局序漂移——max 路径种子按行为面扫描(路线形状/商店/选路口为断言本体) var win_min = autoplay.run_level(_db, 42, 2, 200, 100, false, null, "normal", "min", 0) diff --git a/game/tests/unit/core/level_table_validate_test.gd b/game/tests/unit/core/level_table_validate_test.gd new file mode 100644 index 00000000..437cdfee --- /dev/null +++ b/game/tests/unit/core/level_table_validate_test.gd @@ -0,0 +1,102 @@ +# tests/unit/core/level_table_validate_test.gd — 消费侧加载校验四检单测(迭代 35 卡② T1/V1) +# 覆盖:validate_graph 正例(多入口多终点/含岔口单终点/单链退化显式出边)+负例八类 +#(非 Boss 终点/Boss 带出边/环=回边与自环/双独立链全员可达正证/悬空出边/句柄重复/battle 缺深度/空图) +#+「勿扩勿缩」反证:商店位与入口终点个数不在本检范围(关卡节点地图 v2.1「位置约束由冒烟断言把关」)。 +# 运行:godot --headless --path game --script res://tests/unit/core/level_table_validate_test.gd(expect 0) +# 协议(tests/README):extends SceneTree + quit(code);夹具资源直构不碰生产表。 +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const LevelTableScript := preload("res://data/schemas/level_table.gd") + + +func _init() -> void: + _test_positive() + _test_negative() + _test_scope_not_extended() + quit(Kit.finish("level_table_validate_test")) + + +func _n(id: int, type_: String, depth: int, outs: Array) -> Dictionary: + return {"id": id, "type": type_, "depth": depth, "next": outs} + + +func _errs(nodes: Array) -> Array: + return LevelTableScript.validate_graph(nodes) + + +func _has(errs: Array, cat: String, needle: String = "") -> bool: + for e in errs: + if String(e["category"]) != cat: + continue + if needle == "" or String(e["message"]).contains(needle): + return true + return false + + +# ---- 正例(四检全过)---- + +func _test_positive() -> void: + # 多入口多终点:两入口共汇入一岔口,两终点各在互异深度 + var multi := [ + _n(0, "battle", 0, [2]), _n(1, "battle", 0, [2]), + _n(2, "battle", 1, [3, 5]), + _n(3, "rest", 2, [4]), _n(4, "shop", 3, [6]), _n(6, "boss", 4, []), + _n(5, "reward", 2, [7]), _n(7, "shop", 3, [8]), _n(8, "boss", 5, [])] + Kit.check("四检正例:多入口多终点图零错误〔v1〕", _errs(multi).is_empty()) + # 含岔口单终点:岔口两侧链与主去路同深、汇入同一后继 + var forked := [ + _n(0, "battle", 0, [1]), + _n(1, "battle", 1, [2, 3]), + _n(2, "reward", 2, [4]), _n(3, "rest", 2, [4]), + _n(4, "shop", 3, [5]), _n(5, "boss", 4, [])] + Kit.check("四检正例:含岔口单终点图零错误〔v1〕", _errs(forked).is_empty()) + # 单链退化形态合法(校验不设「必有岔口」) + var chain := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [2]), _n(2, "shop", 2, [3]), _n(3, "boss", 3, [])] + Kit.check("四检正例:单链退化图零错误(不设必有岔口)〔v1〕", _errs(chain).is_empty()) + + +# ---- 负例九类(各报对应类别与判据消息)---- + +func _test_negative() -> void: + var non_boss_end := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [])] + Kit.check("负例:终点非 Boss 命中「终点恒为 Boss」〔v1〕", + _has(_errs(non_boss_end), "structural", "终点 1 非 Boss")) + var boss_out := [_n(0, "battle", 0, [1]), _n(1, "boss", 1, [2]), _n(2, "shop", 2, [])] + Kit.check("负例:Boss 带出边被拒〔v1〕", _has(_errs(boss_out), "structural", "Boss 节点 1 带出边")) + var back_edge := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [2]), _n(2, "shop", 0, [3]), _n(3, "boss", 4, [])] + Kit.check("负例:回边(目标深度不严格大于源)命中前进方向判据〔v1〕", + _has(_errs(back_edge), "structural", "违反前进方向")) + var cycle := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [2]), _n(2, "shop", 2, [1]), _n(3, "boss", 3, [])] + Kit.check("负例:环(2→1 回边)被前进方向拦下〔v1〕", _has(_errs(cycle), "structural", "违反前进方向")) + # 判据②(无孤儿无断头)与判据④(前进方向)在无环图上互斥:DAG 的每个源点都是入口、每条极长路都止于终点, + # 故 ② 是④失效时的纵深防御面,构造不出独立负例——此处以「两独立链双入口双终点」正证 ② 不误伤。 + var two_components := [ + _n(0, "battle", 0, [1]), _n(1, "shop", 1, [2]), _n(2, "boss", 2, []), + _n(3, "battle", 0, [4]), _n(4, "rest", 1, [5]), _n(5, "boss", 2, [])] + Kit.check("判据②正证:两独立链(双入口双终点)不误伤·全员可达〔v1〕", _errs(two_components).is_empty()) + var self_loop := [_n(0, "battle", 0, [1]), _n(1, "shop", 1, [2]), _n(2, "boss", 2, []), _n(3, "battle", 1, [3])] + Kit.check("判据②兜底面:自环先被前进方向拦下(④ 与 ② 互斥的实证)〔v1〕", + _has(_errs(self_loop), "structural", "违反前进方向")) + var dangling := [_n(0, "battle", 0, [9]), _n(1, "boss", 1, [])] + Kit.check("负例:悬空出边报 dangling_next〔v1〕", _has(_errs(dangling), "dangling_next")) + var dup := [_n(0, "battle", 0, [1]), _n(1, "boss", 1, []), _n(1, "shop", 2, [])] + Kit.check("负例:句柄重复报 dup_key〔v1〕", _has(_errs(dup), "dup_key")) + var no_depth := [_n(0, "battle", 0, [1]), {"id": 1, "type": "battle", "next": [2]}, _n(2, "boss", 2, [])] + Kit.check("负例:battle 节点缺深度被良构基底拒〔v1〕", _has(_errs(no_depth), "structural", "缺深度")) + Kit.check("负例:空节点集被拒〔v1〕", _has(_errs([]), "structural", "图节点集为空")) + var bad_type := [_n(0, "mine", 0, [1]), _n(1, "boss", 1, [])] + Kit.check("负例:类型出域报 enum〔v1〕", _has(_errs(bad_type), "enum")) + + +# ---- 「勿扩勿缩」反证:本检不含商店位与个数对参数档的核对 ---- + +func _test_scope_not_extended() -> void: + # 合法形状但全程无商店/三个终点同入口图——四检不该因此报错(商店位归冒烟断言、个数归产出侧) + var no_shop := [_n(0, "battle", 0, [1, 2]), _n(1, "battle", 1, [3]), _n(2, "rest", 1, [3]), _n(3, "boss", 2, [])] + Kit.check("范围反证:无商店图不在加载校验内报错(商店位由冒烟断言把关)〔v1〕", _errs(no_shop).is_empty()) + var two_entries := [_n(0, "battle", 0, [2]), _n(1, "battle", 0, [2]), _n(2, "boss", 1, [])] + Kit.check("范围反证:双入口合法(入口唯一判据已随模板池退役,个数归产出侧)〔v1〕", _errs(two_entries).is_empty()) + # 良构字段缺失(无 next 键)该报错——不属被退役判据 + var missing_key := [{"id": 0, "type": "battle", "depth": 0}, _n(1, "boss", 1, [])] + Kit.check("范围反证:节点缺 next 字段仍被良构基底拒〔v1〕", _has(_errs(missing_key), "structural", "缺字段 next")) diff --git a/game/tests/unit/core/level_table_validate_test.gd.uid b/game/tests/unit/core/level_table_validate_test.gd.uid new file mode 100644 index 00000000..24713668 --- /dev/null +++ b/game/tests/unit/core/level_table_validate_test.gd.uid @@ -0,0 +1 @@ +uid://bhymincbe2j4m diff --git a/reports/iter35-卡②-实现报告-2026-10-24.md b/reports/iter35-卡②-实现报告-2026-10-24.md index 25808aa6..d43ebd74 100644 --- a/reports/iter35-卡②-实现报告-2026-10-24.md +++ b/reports/iter35-卡②-实现报告-2026-10-24.md @@ -48,7 +48,7 @@ | T 卡 | 状态 | |---|---| | T0 事实坐实与卡① 接口对齐 | 待做——接口四件已可由卡① 定稿直读(`generate(params, seed)` 不收 level_index、`{nodes,entries,terminals}`、节点 `id/type/depth/next`、参数档字段名),本段只需核 §3 行号漂移 | -| T1 加载校验换代(四检+良构) | 未开工——任务见分支待办 #2 | +| T1 加载校验换代(四检+良构) | 完成(提交随本报告末笔)——`LevelTable.validate_graph(nodes)` 静态单点在位,表加载期图校验与旧六判据退役,新宿主 `level_table_validate_test`〔v1〕15 条+manifest 登记,run_all 全绿 59 套件 | | T2 消费侧切源与行进/定位/确定性换代 | 未开工——任务见分支待办 #3 | | T3 模板清理与 S1–S8 断言换代 | 未开工(S5/V11 覆盖断言已由 §一 提前落位)——任务见分支待办 #4 | | T4 产出物 | 本报告即起始件;代拍板单与反哺点清单随收口补 | From 0fcbcb933b47976a200b76dfcde72a6b81c119c6 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sat, 24 Oct 2026 15:54:52 +0800 Subject: [PATCH 05/20] =?UTF-8?q?docs(=E8=BF=AD=E4=BB=A335=E5=8D=A1?= =?UTF-8?q?=E2=91=A1T1):=20=E5=AE=9E=E7=8E=B0=E6=8A=A5=E5=91=8A=E8=A1=A5?= =?UTF-8?q?=20T1=20=E6=AE=B5=E2=80=94=E2=80=94=E5=9B=9B=E6=A3=80=E4=B8=8E?= =?UTF-8?q?=E8=89=AF=E6=9E=84=E8=90=BD=E7=82=B9=E3=80=81=E3=80=94v1?= =?UTF-8?q?=E3=80=9517=20=E6=9D=A1=E3=80=81=E5=9B=9B=E6=9D=A1=E5=8F=98?= =?UTF-8?q?=E5=BC=82=E6=B3=A8=E5=85=A5=E7=BA=A2=E8=AF=81=EF=BC=88=E5=90=AB?= =?UTF-8?q?=E4=B8=A4=E6=AC=A1=E6=B3=A8=E9=94=99=E8=AF=AD=E6=B3=95=E5=8D=B3?= =?UTF-8?q?=E4=BD=9C=E5=BA=9F=E9=87=8D=E8=B7=91=E7=9A=84=E6=95=99=E8=AE=AD?= =?UTF-8?q?=EF=BC=89=E3=80=81=E5=88=A4=E6=8D=AE=E2=91=A1=E4=B8=8E=E2=91=A3?= =?UTF-8?q?=E5=9C=A8=20DAG=20=E4=B8=8A=E4=BA=92=E6=96=A5=E7=9A=84=E6=96=B0?= =?UTF-8?q?=E5=8F=91=E7=8E=B0=E3=80=81=E9=9A=8F=E5=8A=A8=E5=9B=9B=E6=9D=A1?= =?UTF-8?q?=E9=80=90=E6=9D=A1=E5=A3=B0=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- reports/iter35-卡②-实现报告-2026-10-24.md | 25 ++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/reports/iter35-卡②-实现报告-2026-10-24.md b/reports/iter35-卡②-实现报告-2026-10-24.md index d43ebd74..b514e133 100644 --- a/reports/iter35-卡②-实现报告-2026-10-24.md +++ b/reports/iter35-卡②-实现报告-2026-10-24.md @@ -48,12 +48,35 @@ | T 卡 | 状态 | |---|---| | T0 事实坐实与卡① 接口对齐 | 待做——接口四件已可由卡① 定稿直读(`generate(params, seed)` 不收 level_index、`{nodes,entries,terminals}`、节点 `id/type/depth/next`、参数档字段名),本段只需核 §3 行号漂移 | -| T1 加载校验换代(四检+良构) | 完成(提交随本报告末笔)——`LevelTable.validate_graph(nodes)` 静态单点在位,表加载期图校验与旧六判据退役,新宿主 `level_table_validate_test`〔v1〕15 条+manifest 登记,run_all 全绿 59 套件 | +| T1 加载校验换代(四检+良构) | 完成(提交 `c0dd4420`,明细见 §二·五)——`LevelTable.validate_graph(nodes)` 静态单点在位,表加载期图校验与旧六判据退役,新宿主〔v1〕17 条+manifest 登记,run_all 全绿 59 套件,四条变异注入均见目标尾缀红 | | T2 消费侧切源与行进/定位/确定性换代 | 未开工——任务见分支待办 #3 | | T3 模板清理与 S1–S8 断言换代 | 未开工(S5/V11 覆盖断言已由 §一 提前落位)——任务见分支待办 #4 | | T4 产出物 | 本报告即起始件;代拍板单与反哺点清单随收口补 | +## 二·五 T1 加载校验换代(已完成·提交 `c0dd4420`) + +**改动**:`LevelTable.validate_graph(nodes: Array) -> Array` 静态单点承载四检+良构基底(契约 1 原口径:①入口(无入边)≥1 且终点(无出边)≥1;②全员从某入口可达且可达某终点;③终点恒 Boss 且 Boss 无出边;④出边目标深度严格大于源=无环。良构=句柄唯一·出边全命中·type 五类枚举·battle/boss 带深度)。表加载期不再校验图:旧六判据(Boss 恰一/战斗≥2/单链末 Boss/前三位纯战斗/出度≤2/Boss 编号最大)+模板池校验分支+BFS 层宽≤5 一并删除,行级校验(level_index 唯一/display_name/rest_heal_pct/info_visible_layers)保留。商店位与入口终点个数按「勿扩勿缩」不入本检。 + +**新宿主**:`game/tests/unit/core/level_table_validate_test.gd`〔v1〕17 条断言(正例三形/负例八类/范围反证三条/②④ 关系实证三条)+ `run_all.gd` manifest 一行;闸 02 实跑 59 套件全绿。 + +**撤销(变异)证明**——逐条注入→本宿主见红→`git checkout` 还原,注入前实现已入库: + +| 注入 | 目标尾缀 | 实测 | +|---|---|---| +| 注掉「终点恒为 Boss」判据 | 〔v1〕终点非 Boss 负例 | RED-OK:`负例:终点非 Boss 命中「终点恒为 Boss」〔v1〕` 红 | +| 注掉前进方向判据 | 〔v1〕回边/环两负例 | RED-OK:`回边(目标深度不严格大于源)…` 与 `环(2→1 回边)…` 双红 | +| 注掉句柄唯一登记 | 〔v1〕dup_key 负例 | RED-OK:`负例:句柄重复报 dup_key〔v1〕` 红 | +| 注掉入口收集分支(附带做) | 〔v1〕正例 | RED-OK:多入口/含岔口正例双红(入口集空→可达性全断) | +| 前两轮注错语法(`if false and if …`)致解析错 | — | 已作废重跑,未计入红证;教训=注入必须保持可编译,否则「红」是解析失败给的 | + +**判据②与④在 DAG 上互斥(新发现,拟入反哺点)**:无环图每个源点都是入口、每条极长路都止于终点,故「孤儿/断头」构造不出独立负例;② 的真实定位是 ④ 失效时的纵深防御面。测试据此改为两独立链正证不误伤+自环先被 ④ 拦的实证。文档(关卡节点地图 v2.1 :51 走图三句/关卡节点图生成 v1.1 结构铁律)宜注明这一兜底定位,免得后续批误判 ② 有独立拦截能力。 + +**件核**:全仓 grep 六判据消息文本 → 零残留;唯一余留是 `game/data/tables/level.tres:22` 表尾模板生成史注释,随 T3 模板清出一并消失。 + +**本段旧断言随动(四条,声明)**:`level_shop_economy` ③ 段整段改走 `validate_graph`,其中「入口唯一违例」随设计换代翻为多入口合法正例、「孤儿」条改为非 Boss 终点;`level_map_type_vis` 夹具注释订正;`round_flow_skill_pool` 敌表池清单 20→28 行补覆盖批八行池克隆键;`board_gen_reward`「(2,3) ceil 上取」改判精确命中并以「临时摘该行」的夹具面保留 ceil 能力判据。(前两条属本卡 §9.5① S 随动族;后两条属 §一 覆盖批的键域随动,先前漏跑整套未暴露,本轮 run_all 抓出。) + ## 三、遗留读数义务 + - 覆盖数据为 provisional 插值,未跑 MC/对拍——数值面既动,卡② 收口前须请用户裁定是否发起 11 回放确定性对拍与 `balance_verifier` 重跑(本批目前只跑到「覆盖断言绿」,未测难度观感)。 - 待办清单 06 条目「覆盖批未立卡」缺口段现况已过期(覆盖数据已随本批落地),状态回写归文档批。 From 98c61d50c3c07f2e15b94de48d4fcb9ea3466f63 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sat, 24 Oct 2026 16:08:57 +0800 Subject: [PATCH 06/20] =?UTF-8?q?refactor(=E8=BF=AD=E4=BB=A335=E5=8D=A1?= =?UTF-8?q?=E2=91=A1T2=C2=B7=E4=B8=80=E5=8F=A3):=20=E8=B7=A8=E8=A1=A8?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E2=91=A3=E3=80=8C=E5=85=B3=E5=8D=A1=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E2=86=92=E6=95=8C/=E6=9D=BF=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E4=B8=8E=E5=A4=9A=E7=BB=88=E7=82=B9=20Boss=20=E5=85=B1?= =?UTF-8?q?=E5=B8=A6=E3=80=8D=E9=9A=8F=E9=9D=99=E6=80=81=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=B6=88=E4=BA=A1=E6=95=B4=E4=BD=93=E9=80=80?= =?UTF-8?q?=E5=BD=B9=E2=80=94=E2=80=94=E4=B8=A4=E6=9D=A1=E6=8B=A6=E6=88=AA?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E6=94=B9=E7=94=B1=E8=BF=90=E8=A1=8C=E6=97=B6?= =?UTF-8?q?=E6=96=AD=E8=A8=80=E6=89=BF=E6=8E=A5=EF=BC=9A=E9=A1=B6=20clamp?= =?UTF-8?q?=20=E6=B6=88=E8=A7=A3=E7=A6=81=EF=BC=9D=E3=80=94v11=E3=80=95?= =?UTF-8?q?=E3=80=8C=E8=A7=A3=E6=9E=90=E8=90=BD=E5=B8=A6=20=E2=89=A5=20?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E6=B7=B1=E5=BA=A6=E3=80=8D=E3=80=81=E5=A4=9A?= =?UTF-8?q?=E7=BB=88=E7=82=B9=20Boss=20=E5=B8=A6=E9=94=AE=E4=BA=92?= =?UTF-8?q?=E5=BC=82=EF=BC=9D=E3=80=94v11=E3=80=95=E9=80=90=E7=BB=88?= =?UTF-8?q?=E7=82=B9=E5=B8=A6=E9=94=AE=E4=B8=A4=E4=B8=A4=E6=AF=94=E5=AF=B9?= =?UTF-8?q?=EF=BC=88=E6=8B=A6=E6=88=AA=E9=9D=A2=E4=BB=8E=E8=A1=A8=E5=86=85?= =?UTF-8?q?=E9=9D=99=E6=80=81=E4=BB=A3=E7=90=86=E5=8D=87=E7=BA=A7=E5=88=B0?= =?UTF-8?q?=E6=B6=88=E8=B4=B9=E5=9B=BE=E6=9C=AC=E4=BD=93=EF=BC=8C=E6=97=A0?= =?UTF-8?q?=E5=89=8A=E5=BC=B1=EF=BC=89=EF=BC=9B=E5=85=B6=E5=8D=95=E6=B5=8B?= =?UTF-8?q?=E6=AE=B5=20=5Ftest=5Flevel=5Fnode=5Fband=5Frules=20=E4=B8=8E?= =?UTF-8?q?=E9=9A=8F=E5=AE=83=E8=80=8C=E5=AD=98=E7=9A=84=E5=9B=9B=E4=B8=AA?= =?UTF-8?q?=20preload=20=E4=B8=80=E5=B9=B6=E9=80=80=E5=BD=B9=EF=BC=88fv15/?= =?UTF-8?q?fv16/fv17=20=E4=B8=89=E6=9D=A1=E5=88=A4=E6=8D=AE=E7=9A=84?= =?UTF-8?q?=E7=8E=B0=E5=BD=B9=E7=AD=89=E4=BB=B7=E9=9D=A2=E5=9C=A8=20level?= =?UTF-8?q?=5Fshop=5Feconomy=E3=80=94v11=E3=80=95=E4=B8=8E=20level=5Ftable?= =?UTF-8?q?=5Fvalidate=E3=80=94v1=E3=80=95=EF=BC=89=20-=20=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E4=B8=8D=E9=99=8D=E7=BA=A7=E7=9A=84=E4=BE=9D=E6=8D=AE?= =?UTF-8?q?=EF=BC=9A=E7=94=9F=E6=88=90=E5=9B=BE=E6=B7=B1=E5=BA=A6=E5=9F=9F?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=20L1=200=E2=80=936=EF=BC=8FL2=200=E2=80=939?= =?UTF-8?q?=EF=BC=8FL3=200=E2=80=9313=20=E4=B8=BA=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E6=B1=A0=E6=B7=B1=E5=BA=A6=E5=9F=9F=EF=BC=880=E2=80=934?= =?UTF-8?q?=EF=BC=8F0=E2=80=939=EF=BC=8F0=E2=80=939=EF=BC=89=E7=9A=84?= =?UTF-8?q?=E8=B6=85=E9=9B=86=EF=BC=8C=E4=B8=94=20=E3=80=94v11=E3=80=95?= =?UTF-8?q?=E6=89=AB=E6=8F=8F=E9=9D=A2=E4=B8=89=E6=A1=A3=C3=9720=20?= =?UTF-8?q?=E7=A7=8D=E5=AD=90=E9=80=90=E8=8A=82=E7=82=B9=E6=A0=B8=E6=95=8C?= =?UTF-8?q?=E5=B8=A6=E4=B8=8E=E6=9D=BF=E7=B2=BE=E7=A1=AE=E8=A1=8C=20-=20sa?= =?UTF-8?q?ve=5Fmanager=20=E9=A6=96=E6=AC=A1=E5=86=92=E9=99=A9=E4=BD=8D?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E9=9A=8F=E7=A1=AE=E5=AE=9A=E6=80=A7=E6=89=BF?= =?UTF-8?q?=E8=BD=BD=E6=8D=A2=E4=BB=A3=E6=94=B9=E5=8F=A3=E5=BE=84=EF=BC=88?= =?UTF-8?q?=E9=A6=96=E5=B1=80=E5=9B=BA=E5=AE=9A=E7=A7=8D=E5=AD=90=2042?= =?UTF-8?q?=EF=BC=8B=E5=8F=82=E6=95=B0=E6=A1=A3=E7=94=9F=E6=88=90=E5=9B=BE?= =?UTF-8?q?=EF=BC=8C=E6=97=A0=E5=85=9C=E5=BA=95=E6=A8=A1=E6=9D=BF=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E9=94=AE=E4=B8=8E=E8=A1=8C=E4=B8=BA=E9=9B=B6=E6=94=B9?= =?UTF-8?q?=E5=8A=A8=20-=20run=5Fall=2059=20=E5=A5=97=E4=BB=B6=E5=85=A8?= =?UTF-8?q?=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/core/config_db_cross_validator.gd | 54 +---------------- game/core/save_manager.gd | 7 ++- .../core/config_db_cross_validator_test.gd | 59 ------------------- 3 files changed, 5 insertions(+), 115 deletions(-) diff --git a/game/core/config_db_cross_validator.gd b/game/core/config_db_cross_validator.gd index 7bb4b95f..4e7d8912 100644 --- a/game/core/config_db_cross_validator.gd +++ b/game/core/config_db_cross_validator.gd @@ -51,60 +51,8 @@ func cross_validate(loaded: Dictionary, push_diag: Callable) -> void: for s in required_sources: if not st_tab.source_label_map.has(s): push_diag.call("error", "segorder_violation", "source_label_map 缺必需键: %s" % s) - # ④ LevelTable 节点悬空引用(battle/boss 节点:board_gen 侧须有精确行;enemy 侧按 - # (关卡序号, 行类) 带寻址——落带即设计语义,节点深度高于顶带键=顶 clamp 消解属调参事故,加载期直接拦。 - # 同关同一张图多终点 Boss 节点解析带键须互异(共带=两终点命中同一 Boss 行集,「走哪条路打谁」落空)) - var lv: LevelTable = loaded.get("level") - var bg: BoardGenTable = loaded.get("board_gen") - var en: EnemyTable = loaded.get("enemy") - if lv != null and bg != null and en != null: - var bg_keys := {} - for row in bg.rows: - bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true - var en_bands := {} # "%d_%s" % [L, row_kind] → 升序带键数组 - for row in en.rows: - var ek := "%d_%s" % [int(row.level_index), String(row.get("row_kind", ""))] - if not en_bands.has(ek): - en_bands[ek] = [] - var eb := int(row.get("depth_band", -1)) - if not (en_bands[ek] as Array).has(eb): - (en_bands[ek] as Array).append(eb) - for ek in en_bands.keys(): - (en_bands[ek] as Array).sort() - for lrow in lv.rows: - var li: int = int(lrow.level_index) - # 模板池感知(关卡节点图生成 v0.4——池内全模板跨表校验,不只 row.nodes) - var node_lists: Array[Array] = [] - var tpls: Array[Variant] = lrow.get("templates", []) - if tpls.is_empty(): - node_lists.append(lrow.get("nodes", [])) - else: - for tpl in tpls: - node_lists.append(tpl.get("nodes", [])) - for tpl_i in range(node_lists.size()): - var boss_bands := {} # 本图已占 Boss 带键 - for node in node_lists[tpl_i]: - var t: String = String(node.get("type", "")) - if t != "battle" and t != "boss": - continue - var nd: int = int(node.get("node_depth", -1)) - var key := "%d_%d" % [li, nd] - if not bg_keys.has(key): - push_diag.call("error", "level_node_dangling", "level %d %s 节点 (%s) 在 board_gen 无精确行" % [li, t, key]) - var nk := "%d_%s" % [li, t] - if not en_bands.has(nk): - push_diag.call("error", "level_node_dangling", "level %d %s 节点 (%d) 在 enemy 无该行类行集" % [li, t, nd]) - continue - var hit_band: int = EnemyTable.band_of(en_bands[nk], nd) - if hit_band < 0: - push_diag.call("error", "level_node_dangling", "level %d %s 节点深度 %d 高于 (level %d, %s) 顶带键 %d——顶 clamp 消解" % [ - li, t, nd, li, t, int((en_bands[nk] as Array)[-1])]) - continue - if t == "boss": - if boss_bands.has(hit_band): - push_diag.call("error", "rowset_rule", "level %d 模板 %d 多终点 Boss 节点同带(band %d)——共带使「走哪条路打谁」落空" % [li, tpl_i, hit_band]) - boss_bands[hit_band] = true # ⑤ 技能池引用完整性(敌人技能池批——敌人表池 × 技能表;独立入口 validate_skill_pool_links 供冒烟直验) + var en: EnemyTable = loaded.get("enemy") var skt = loaded.get("skill") if skt != null and en != null: for e in validate_skill_pool_links(skt, en, loaded.get("status_effects")): diff --git a/game/core/save_manager.gd b/game/core/save_manager.gd index 01e0dc26..ef3f9f86 100644 --- a/game/core/save_manager.gd +++ b/game/core/save_manager.gd @@ -299,9 +299,10 @@ func _default_data() -> Dictionary[String, Variant]: } -# ---- 首次冒险布尔位(关卡节点图生成 v0.4)---- -# false = 首次脚本模式(全关零抽签取池内模板 0=现行固定图);置位只在冒险收束挂点(MetaProgression.settle_adventure)。 -# 老档迁移:缺字段 = 该档已有游玩史 → true(不重放教学脚本)。 +# ---- 首次冒险布尔位(关卡节点图生成 v1.1 确定性承载)---- +# false = 本档尚无游玩史 → 首局走固定种子 42 + 参数档生成图(教学与存档复现的固定图由此承载,无兜底模板); +# 置位只在冒险收束挂点(MetaProgression.settle_adventure)。 +# 老档迁移:缺字段 = 该档已有游玩史 → true(不重放首局固定图)。 func is_first_adventure_done() -> bool: return bool(_data["meta"].get("first_adventure_done", true)) diff --git a/game/tests/unit/core/config_db_cross_validator_test.gd b/game/tests/unit/core/config_db_cross_validator_test.gd index f3fac232..e2b7731c 100644 --- a/game/tests/unit/core/config_db_cross_validator_test.gd +++ b/game/tests/unit/core/config_db_cross_validator_test.gd @@ -16,7 +16,6 @@ func _init() -> void: _test_all_links_valid() _test_dangling_skill_id() _test_cast_side_mismatch() - _test_level_node_band_rules() quit(Kit.finish("config_db_cross_validator_test")) @@ -83,61 +82,3 @@ func _test_cast_side_mismatch() -> void: var status := _status([{"effect_id": "st_hero_side", "target_side": "hero"}]) var errs: Array = ValidatorScript.new().validate_skill_pool_links(skills, enemies, status) Kit.check("buff 挂主角侧状态被报(施放类 side 守卫)", _cats(errs).has("cast_side")) - - -# ---- ④ 节点 × 敌人表带寻址(单终点零误报/同带双 Boss 报错/顶 clamp 消解报错)---- -const LevelTableScript := preload("res://data/schemas/level_table.gd") -const BoardGenTableScript := preload("res://data/schemas/board_gen_table.gd") -const RewardTableScript := preload("res://data/schemas/reward_table.gd") -const SettlementTableScript := preload("res://data/schemas/settlement_table.gd") - - -func _loaded(level_nodes: Array, bg_depths: Array, boss_bands: Array, battle_bands: Array) -> Dictionary: - var lv := LevelTableScript.new() - lv.rows.assign([{"level_index": 1, "display_name": "夹具关", "templates": [{"nodes": level_nodes}]}]) - var bg := BoardGenTableScript.new() - var bg_rows: Array = [] - for d in bg_depths: - bg_rows.append({"level_index": 1, "node_depth": int(d), "board_width": 10}) - bg.rows.assign(bg_rows) - var en := EnemyTableScript.new() - var en_rows: Array = [] - for b in battle_bands: - en_rows.append({"level_index": 1, "row_kind": "battle", "depth_band": int(b), "enemy_id": "goblin", - "unit_count": 1, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]}) - for b in boss_bands: - en_rows.append({"level_index": 1, "row_kind": "boss", "depth_band": int(b), "enemy_id": "ogre_chief", - "unit_count": 1, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]}) - en.rows.assign(en_rows) - var st := SettlementTableScript.new() - st.source_label_map.assign({"safe_cell": "a", "seal_hit": "b", "mine_hit": "c", "clear_reward": "d", - "hero_attack": "e", "enemy_action": "f", "seal_miss": "g"}) - return {"status_effects": StatusTableScript.new(), "reward": RewardTableScript.new(), - "settlement": st, "level": lv, "board_gen": bg, "enemy": en} - - -func _cross_cats(loaded: Dictionary, needle: String) -> Array[String]: - var out: Array[String] = [] - ValidatorScript.new().cross_validate(loaded, - func(_sev, cat, msg) -> void: - if String(msg).contains(needle): - out.append(String(cat))) - return out - - -func _test_level_node_band_rules() -> void: - # 正向:单终点三关同构(battle 节点落 band 0、boss 节点精确落顶带)→ ④ 零误报 - var ok_nodes := [{"type": "battle", "node_depth": 0}, {"type": "boss", "node_depth": 9}] - var ok_cats := _cross_cats(_loaded(ok_nodes, [0, 9], [9], [0]), "level 1") - Kit.check("单终点图 ④ 敌侧带寻址零误报〔fv17〕", ok_cats.is_empty()) - # 负向:一路两终点落同一 Boss 带(深度 3/4 都落带 4)→ rowset_rule - var same_band_nodes := [{"type": "battle", "node_depth": 0}, - {"type": "boss", "node_depth": 3}, {"type": "boss", "node_depth": 4}] - var same_band_cats := _cross_cats(_loaded(same_band_nodes, [0, 3, 4], [4, 9], [0]), "同带") - Kit.check("同关双终点共带报 rowset_rule〔fv15〕", - same_band_cats.has("rowset_rule")) - # 负向:Boss 节点深度高于顶带键(顶 clamp 消解)→ level_node_dangling - var over_nodes := [{"type": "battle", "node_depth": 0}, {"type": "boss", "node_depth": 10}] - var over_cats := _cross_cats(_loaded(over_nodes, [0, 10], [9], [0]), "顶 clamp 消解") - Kit.check("Boss 节点越顶带报 level_node_dangling〔fv16〕", - over_cats.has("level_node_dangling")) From 6566200220ac1df815e611d0289744ab1bf8642b Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sat, 24 Oct 2026 16:09:34 +0800 Subject: [PATCH 07/20] =?UTF-8?q?docs(=E8=BF=AD=E4=BB=A335=E5=8D=A1?= =?UTF-8?q?=E2=91=A1T2=C2=B7=E4=B8=80=E5=8F=A3):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E8=A1=A5=20T2=20=E7=AC=AC=E4=B8=80=E5=8F=A3?= =?UTF-8?q?=E8=AF=BB=E6=95=B0=E4=B8=8E=E3=80=8C=E7=BB=AD=E5=81=9A=E6=B8=85?= =?UTF-8?q?=E5=8D=95=E3=80=8D=E2=80=94=E2=80=94=E5=88=87=E6=BA=90=E4=B8=BB?= =?UTF-8?q?=E4=BD=93=E4=B8=83=E6=AD=A5=E9=80=90=E6=9D=A1=E5=B8=A6=20file:l?= =?UTF-8?q?ine=20=E9=94=9A=E7=82=B9=EF=BC=88level=5Frunner/adventure=5Frun?= =?UTF-8?q?ner/main/S1=E2=80=93S8=20=E9=9A=8F=E5=8A=A8/=E4=BB=B6=E6=A0=B8/?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=E9=A1=BA=E5=BA=8F/=E5=BE=85=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E9=A1=B9=EF=BC=89=EF=BC=8C=E5=86=B7=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=8F=AF=E7=85=A7=E5=8D=95=E6=8E=A5=E4=B8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- reports/iter35-卡②-实现报告-2026-10-24.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/reports/iter35-卡②-实现报告-2026-10-24.md b/reports/iter35-卡②-实现报告-2026-10-24.md index b514e133..8d12c4ae 100644 --- a/reports/iter35-卡②-实现报告-2026-10-24.md +++ b/reports/iter35-卡②-实现报告-2026-10-24.md @@ -75,8 +75,23 @@ **本段旧断言随动(四条,声明)**:`level_shop_economy` ③ 段整段改走 `validate_graph`,其中「入口唯一违例」随设计换代翻为多入口合法正例、「孤儿」条改为非 Boss 终点;`level_map_type_vis` 夹具注释订正;`round_flow_skill_pool` 敌表池清单 20→28 行补覆盖批八行池克隆键;`board_gen_reward`「(2,3) ceil 上取」改判精确命中并以「临时摘该行」的夹具面保留 ceil 能力判据。(前两条属本卡 §9.5① S 随动族;后两条属 §一 覆盖批的键域随动,先前漏跑整套未暴露,本轮 run_all 抓出。) +## 二·七 T2 第一口(已完成·提交 `98c61d50`) + +跨表校验 ④ 整体退役(详见提交信息),两条拦截语义由〔v11〕承接、覆盖不降级的依据=生成图深度域是模板深度域的超集;`save_manager` 首次冒险位注释随确定性承载换代改口径(键与行为零改动);run_all 59 套件全绿。 + +## 二·八 续做清单(冷启动可照此接上·T2 剩余+T3) + +1. `game/core/level_runner.gd`:删 `_resolve_template_row`(:300-324 含 `active_template_name/index` 声明)→ 起关改调 `NodeMapGenerator.new(RngService).generate(ConfigDB.level_gen_params_row(L), seed)`;拿到图后、进节点前调 `LevelTable.validate_graph(nodes)`,违规 `push_error`+返回 false(走 `main.gd:396 _fatal_screen` 挂点);`start_level`/`start_level_external` 的 `template_pick` 尾参删除;`_entry_node`→`_entry_nodes`(多入口,入口停点候选=全部入口,单入口逐位不变);`_node_id`(:341-347)改返回 `id` 句柄、`_out_ids`/`_out_nodes`/`visited_ids`/`pick_next_node`/`_restore_to_stop` 同空间随动、单链隐式出边(:360-385 体内)退役;`begin_battle` enc(:513-525)`node_depth` 取节点 `depth` 值(键名不变)。 +2. `game/core/adventure_runner.gd`:删 `_first_script`(:45/:102/:194)与 `template_pick` 计算传递(:850/:852-854/:863);断点快照删 `template_pick` 键(:1013)、`node_id`/`visited_ids`(:1000/:1017)句柄化;扩列**代差标记键**(缺省 0/写 1),`resume_adventure` 遇缺标记键旧断点=返回 false+`push_error`(禁静默按新句柄回放)。 +3. `game/presentation/main.gd`:删 `first_script` 参数构造(:594-596);种子三分判定(:1476-1490 `_seed_value`)零改动。 +4. 断言与驱动面换代(S1–S8,见卡② §8.1):`node_map_templates_test` 模板段①-⑥退役+⑦改写为「首局种子 42」,同宿主新增确定性四断言;`_level_active_nodes` 三副本(`level_shop_economy:970`/`adventure_feats:812`/`round_flow:357`)改经生成器取图;`level.tres` 三行 `templates` 键删除+表尾注释去向段;`sim/level_autoplay.gd`(:37/:63/:141/:200-202/:12/:377-379)与 `adventure_monte_carlo.gd:139` 换代(`level_templates`→`level_maps`);`probe/shot_probe.gd:12-13`、`ui_click_probe.gd:373` 假设性质化;`breakpoint_resume_test`/`pool_case_d_test`/`feat_vouchers_test`/`cross_system_flow_test:79`/`meta_progression_forge_test:316` 随动。 +5. 件核:全仓零 `first_script`/`template_pick`/`active_template`/`node_depth 当身份` 引用;`level.tres` 无 `templates` 键。 +6. 收口顺序:T2+T3 必须同轮(拆开中间态必红)→ `run_all_gates.sh --code` 全绿 → 独立验收审计 → 合回 → 反哺点/代拍板/待办清单状态随批。 +7. 待用户项:覆盖批八行与棋盘 12 行数值为插值 provisional(真人试玩定味);数值面已动,11 回放确定性对拍与 `balance_verifier` 是否发起需重新裁定。 + ## 三、遗留读数义务 + - 覆盖数据为 provisional 插值,未跑 MC/对拍——数值面既动,卡② 收口前须请用户裁定是否发起 11 回放确定性对拍与 `balance_verifier` 重跑(本批目前只跑到「覆盖断言绿」,未测难度观感)。 - 待办清单 06 条目「覆盖批未立卡」缺口段现况已过期(覆盖数据已随本批落地),状态回写归文档批。 From 9ee3bea194f7be57d34be0e20805c196690e4922 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sat, 24 Oct 2026 18:59:57 +0800 Subject: [PATCH 08/20] =?UTF-8?q?refactor(=E8=BF=AD=E4=BB=A335=E5=8D=A1?= =?UTF-8?q?=E2=91=A1T2=C2=B7=E4=BA=8C=E5=8F=A3=EF=BC=8BT3=20S1/S2/S6/S8):?= =?UTF-8?q?=20=E6=B6=88=E8=B4=B9=E4=BE=A7=E5=88=87=E6=BA=90=E8=90=BD?= =?UTF-8?q?=E5=AE=9A=EF=BC=8B=E6=96=AD=E8=A8=80=E9=9D=A2=E6=8D=A2=E4=BB=A3?= =?UTF-8?q?=E4=B8=BB=E4=BD=93=E2=80=94=E2=80=94=E2=91=A0=20level=5Frunner?= =?UTF-8?q?=20=E5=9B=BE=E6=BA=90=E6=8D=A2=E4=BB=A3=EF=BC=88=5Fgenerator=20?= =?UTF-8?q?=E5=8F=96=E4=BB=A3=E6=A8=A1=E6=9D=BF=E8=A7=A3=E6=9E=90=EF=BC=8C?= =?UTF-8?q?=5Fassign=5Fgenerated=5Fgraph=20=E8=AF=BB=20level=5Fgen=5Fparam?= =?UTF-8?q?s=20=E5=8F=82=E6=95=B0=E6=A1=A3=E4=BA=A7=E5=9B=BE=E5=B9=B6?= =?UTF-8?q?=E8=BF=87=20LevelTable.validate=5Fgraph=EF=BC=8C=E8=BF=9D?= =?UTF-8?q?=E8=A7=84=E8=B5=B7=E5=85=B3=E6=8B=92=E7=BB=9D=E9=9B=B6=E5=85=9C?= =?UTF-8?q?=E5=BA=95=EF=BC=89=EF=BC=8B=5Fentry=5Fnodes=20=E5=A4=9A?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E9=A6=96=E8=B7=B3=EF=BC=8B=5Fnode=5Fid=20?= =?UTF-8?q?=E5=8F=A5=E6=9F=84=E5=8D=95=E7=A9=BA=E9=97=B4=EF=BC=8Benc=20nod?= =?UTF-8?q?e=5Fdepth=20=E5=8F=96=E8=8A=82=E7=82=B9=20depth=20=E5=80=BC?= =?UTF-8?q?=EF=BC=9B=E2=91=A1=20adventure=5Frunner/main/save=5Fmanager=20?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E9=93=BE=E8=B7=AF=E9=80=80=E5=BD=B9=EF=BC=88?= =?UTF-8?q?first=5Fscript=20=E5=8F=82=E6=95=B0=E4=B8=8E=20template=5Fpick?= =?UTF-8?q?=20=E8=AE=A1=E7=AE=97=E5=85=A8=E5=88=A0=EF=BC=8C=E6=96=AD?= =?UTF-8?q?=E7=82=B9=20template=5Fpick=20=E9=94=AE=E6=8D=A2=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=9B=BE=E4=BB=A3=E5=B7=AE=E6=A0=87=E8=AE=B0=E9=94=AE?= =?UTF-8?q?=20graph=5Fgen=5Fv2=EF=BC=8C=E7=BC=BA=E9=94=AE=E6=97=A7?= =?UTF-8?q?=E6=96=AD=E7=82=B9=20resume=20=E6=98=BE=E5=BC=8F=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=E7=A6=81=E9=9D=99=E9=BB=98=E5=9B=9E=E6=94=BE=EF=BC=89?= =?UTF-8?q?=EF=BC=9B=E2=91=A2=20level.tres=20=E4=B8=89=E8=A1=8C=20template?= =?UTF-8?q?s=20=E9=94=AE=E6=B8=85=E5=87=BA=EF=BC=8815=20=E5=BC=A0=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E6=95=B0=E6=8D=AE=E5=87=BA=E8=A1=A8=EF=BC=8C=E8=A1=8C?= =?UTF-8?q?=E7=BA=A7=E5=AD=97=E6=AE=B5=E7=95=99=EF=BC=8C=E8=A1=A8=E5=B0=BE?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E6=94=B9=E6=8C=87=E5=8F=82=E6=95=B0=E6=A1=A3?= =?UTF-8?q?=EF=BC=8B=E7=94=9F=E6=88=90=E5=99=A8=EF=BC=89=EF=BC=9B=E2=91=A3?= =?UTF-8?q?=20sim/probe=20=E9=9A=8F=E5=8A=A8=EF=BC=88run=5Flevel/run=5Fadv?= =?UTF-8?q?enture=20=E5=B0=BE=E5=8F=82=E9=80=80=E5=BD=B9=E3=80=81MC=20?= =?UTF-8?q?=E5=87=BA=E6=95=B0=E5=AD=97=E6=AE=B5=20level=5Ftemplates?= =?UTF-8?q?=E2=86=92level=5Fmaps=20=E5=80=BC=EF=BC=9D=E5=85=B3=E5=8D=A1#?= =?UTF-8?q?=E4=B8=BB=E7=A7=8D=E5=AD=90=E3=80=81=E6=8E=A2=E9=92=88=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=E6=80=A7=E8=B4=A8=E5=8C=96=E3=80=81boss=5Fdepth=20?= =?UTF-8?q?=E5=8F=96=E8=8A=82=E7=82=B9=20depth=20=E9=94=AE=EF=BC=89?= =?UTF-8?q?=EF=BC=9B=E2=91=A4=20=E6=96=AD=E8=A8=80=E9=9D=A2=E6=8D=A2?= =?UTF-8?q?=E4=BB=A3=EF=BC=9Anode=5Fmap=5Ftemplates=20=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E6=95=B4=E6=AE=B5=E9=87=8D=E5=86=99=E4=B8=BA=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=9B=BE=E7=A1=AE=E5=AE=9A=E6=80=A7=E5=A5=97=EF=BC=88=E7=BA=AF?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E5=A4=8D=E7=8E=B0/=E6=8D=A2=E7=A7=8D?= =?UTF-8?q?=E5=AD=90=E5=8F=98=E5=9B=BE/=E9=9B=B6=E8=AE=A1=E6=95=B0?= =?UTF-8?q?=E5=99=A8=E5=8F=B7=E4=BD=8D=E4=BE=9D=E8=B5=96/=E5=8D=95?= =?UTF-8?q?=E4=B8=80=E5=9B=BE=E6=BA=90/=E7=A7=8D=E5=AD=90=2042=20=E4=B8=A4?= =?UTF-8?q?=E5=BC=80=E5=B1=80=E5=90=8C=E5=9B=BE/=E6=96=AD=E7=82=B9?= =?UTF-8?q?=E9=87=8D=E7=94=9F=E6=88=90=E5=90=8C=E5=9B=BE/=E7=BC=BA?= =?UTF-8?q?=E4=BB=A3=E5=B7=AE=E9=94=AE=E6=8B=92=E7=BB=9D/=E8=A1=A8?= =?UTF-8?q?=E6=97=A0=20templates/=E6=94=B6=E6=9D=9F=E7=BD=AE=E4=BD=8D?= =?UTF-8?q?=E4=B8=89=E6=9D=A1=E4=BF=9D=E7=95=99=EF=BC=89=EF=BC=8Cbreakpoin?= =?UTF-8?q?t=5Fresume=20=E6=96=B0=E5=A2=9E=20P7=20=E5=85=AD=E6=96=AD?= =?UTF-8?q?=E8=A8=80=EF=BC=88=E5=BF=AB=E7=85=A7=E6=97=A0=E5=B7=B2=E6=8A=BD?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E9=94=AE=E3=80=81=E4=BB=A3=E5=B7=AE=E6=A0=87?= =?UTF-8?q?=E8=AE=B0=E3=80=81=E5=81=9C=E7=82=B9=E5=8F=A5=E6=9F=84=E4=B8=8E?= =?UTF-8?q?=E5=B7=B2=E5=88=B0=E5=BA=8F=E5=BE=80=E8=BF=94=E3=80=81=E7=BB=AD?= =?UTF-8?q?=E7=8E=A9=E5=90=8C=E5=9B=BE=E3=80=81=E6=97=A7=E6=96=AD=E7=82=B9?= =?UTF-8?q?=E6=8B=92=E7=BB=9D=E3=80=81=E5=90=8C=E7=A7=8D=E5=AD=90=E9=87=8D?= =?UTF-8?q?=E5=BC=80=E5=B1=80=E5=90=8C=E5=9B=BE=EF=BC=89=EF=BC=8CS8=20?= =?UTF-8?q?=E5=8D=95=E6=B5=8B=E9=9A=8F=E5=8A=A8=20first=5Fscript=20?= =?UTF-8?q?=E9=94=AE=E6=B8=85=E9=80=80=EF=BC=8B=E6=96=AD=E7=82=B9=E5=A4=B9?= =?UTF-8?q?=E5=85=B7=E6=8D=A2=E4=BB=A3=EF=BC=8Bmeta=20=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E9=9D=A2=20extra=20=E9=94=AE=E6=A0=B7=E6=8D=A2=20treasure=5Fun?= =?UTF-8?q?locked=EF=BC=88=E8=AF=AD=E4=B9=89=E4=BF=9D=E6=8C=81=EF=BC=89?= =?UTF-8?q?=EF=BC=8CS2=20=E7=94=9F=E6=88=90=E5=9B=BE=E5=8F=96=E6=95=B0?= =?UTF-8?q?=E9=9D=A2=E4=B8=89=E5=AE=BF=E4=B8=BB=E6=9B=BF=E6=8D=A2=EF=BC=88?= =?UTF-8?q?level=5Factive=5Fnodes=20=E8=B5=B0=E5=8F=82=E6=95=B0=E6=A1=A3?= =?UTF-8?q?=EF=BC=8B=E7=A7=8D=E5=AD=90=2042=EF=BC=8C=E6=B6=88=E8=B4=B9?= =?UTF-8?q?=E7=82=B9=E6=B7=B1=E5=BA=A6=E9=94=AE=E6=8D=A2=20depth=EF=BC=8Cr?= =?UTF-8?q?ound=5Fflow=20=E6=AD=BB=E4=BB=B6=20=5Fboss=5Fdepth=20=E9=9A=8F?= =?UTF-8?q?=E5=88=A0=EF=BC=89=EF=BC=8Cadventure=5Ffeats=20=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E4=B9=89=E5=8A=A1=E6=AE=B5=E6=94=B9=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E9=93=BE=E5=88=A4=E6=8D=AE=EF=BC=88=E6=95=8C?= =?UTF-8?q?=E4=BE=A7=E8=90=BD=E5=B8=A6=E2=89=A5=E8=AF=B7=E6=B1=82=E6=B7=B1?= =?UTF-8?q?=E5=BA=A6=EF=BC=8B=E6=9D=BF=E4=BE=A7=E5=91=BD=E4=B8=AD=EF=BC=8B?= =?UTF-8?q?=E9=9D=9E=E7=A9=BA=E6=89=AB=E6=8F=8F=E9=9D=A2=EF=BC=89=E3=80=81?= =?UTF-8?q?=E5=A5=96=E5=8A=B1=E8=8A=82=E7=82=B9=E6=AE=B5=E6=8D=A2=E3=80=8C?= =?UTF-8?q?=E5=9B=BE=E4=BE=A7=E5=90=AB=E4=BB=B6=EF=BC=8B=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E9=80=89=E8=B7=AF=E7=82=B9=E9=80=89=E5=91=88=E5=81=9C=E5=85=91?= =?UTF-8?q?=E4=BB=98=E3=80=8D=E5=8F=8C=E6=96=AD=E8=A8=80=EF=BC=88=E6=97=A7?= =?UTF-8?q?=E5=8D=95=E9=93=BE=E5=BF=85=E7=BB=8F=E5=BD=A2=E7=8A=B6=E5=89=8D?= =?UTF-8?q?=E6=8F=90=E9=9A=8F=E6=A8=A1=E6=9D=BF=E6=B6=88=E4=BA=A1=EF=BC=8C?= =?UTF-8?q?=E9=9D=9E=E6=94=BE=E5=AE=BD=EF=BC=9A=E7=BB=8F=E5=81=9C=E5=8D=B3?= =?UTF-8?q?=E5=91=88=E5=80=99=E9=80=89=E7=94=B1=E7=9C=9F=E5=AE=9E=20pick?= =?UTF-8?q?=5Fnext=5Fnode=20=E5=85=A5=E5=8F=A3=E8=AF=81=E6=98=8E=EF=BC=89?= =?UTF-8?q?=E3=80=81=E5=95=86=E5=BA=97=E4=BD=99=E9=A2=9D=E6=AE=B5=E6=8C=89?= =?UTF-8?q?=E6=AF=8F=E5=85=B3=E6=81=B0=E4=B8=80=E5=BA=97=E6=8A=95=E5=BD=B1?= =?UTF-8?q?=E6=94=B9=E4=B8=89=E5=85=B3=E8=AF=BB=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/core/adventure_runner.gd | 19 +- game/core/level_runner.gd | 155 +++++++-------- game/data/tables/level.tres | 21 +-- game/presentation/main.gd | 6 +- game/probe/shot_probe.gd | 2 +- game/probe/ui_click_probe.gd | 2 +- game/sim/adventure_monte_carlo.gd | 2 +- game/sim/level_autoplay.gd | 22 +-- .../integration/cross_system_flow_test.gd | 2 +- .../integration/node_map_templates_test.gd | 178 +++++++++++------- game/tests/run_all.gd | 2 +- .../round_flow_skill_pool_test.gd | 28 +-- game/tests/unit/core/adventure_feats_test.gd | 126 +++++++++---- .../tests/unit/core/breakpoint_resume_test.gd | 46 ++++- .../unit/core/curio_refresh_paid_test.gd | 4 +- game/tests/unit/core/feat_vouchers_test.gd | 2 +- .../unit/core/level_map_type_vis_test.gd | 103 ++++++---- .../unit/core/meta_progression_forge_test.gd | 8 +- game/tests/unit/core/pool_case_d_test.gd | 10 +- .../unit/core/save_manager_domain_test.gd | 6 +- .../data_config/table_structure_guard_test.gd | 19 -- .../presentation/level_map_info_fog_test.gd | 59 +++--- 22 files changed, 460 insertions(+), 362 deletions(-) diff --git a/game/core/adventure_runner.gd b/game/core/adventure_runner.gd index 379f423a..4823b21e 100644 --- a/game/core/adventure_runner.gd +++ b/game/core/adventure_runner.gd @@ -42,7 +42,7 @@ var _pending_next_level := false # 大奖励已兑付、等 continue_to var _last_level_payload := {} # 最近一次关卡结束载荷(失败结算的败退节点明细来源——数值验证取数面) var _override := [0, 0] # 调试浮窗百分比通道 [atk_pct, hp_pct](每关新 runner 都要应用) var _base_override := {"atk": 0, "max_hp": 0, "armor_class": 0} -var _first_script := false # 首次脚本模式(关卡节点图生成 v0.4——true = 全关零抽签取池内模板 0;测量夹具恒 false) # 永久强化 flat 注入(冒险开始参数面 params.hero_override 存入,共享给 LevelRunner;全零 = 白板) +# 永久强化 flat 注入(冒险开始参数面 params.hero_override 存入,共享给 LevelRunner;全零 = 白板) var _last_treasure_cleared := 0 # additive(最近一次关卡末清空计数,summary 取数面) var _treasure_unlocked := {} # aux 解锁门控集(params.treasure_unlocked 接收面) var _attunement: Array[Variant] = [] # 同调装配集(params.attunement 接收面,start_level 外下传 LevelRunner) @@ -99,7 +99,6 @@ func start_adventure(seed_val: int, params: Dictionary = {}) -> bool: _base_override = _normalize_override(params.get("hero_override", {})) hero_attack_type = String(params.get("attack_type", "")) # 两根类型轴快照(契约 8 锁死:开局后不随存档选中变化) hero_armor_type = String(params.get("armor_type", "")) - _first_script = bool(params.get("first_script", false)) # 缺省 = 非首次(抽签路径;真实侧由呈现层读存档布尔位传入) _shop_mode = "pool" if String(params.get("shop_mode", "legacy")) == "pool" else "legacy" # 缺省 legacy(off 对照零扰动) _treasure_unlocked = params.get("treasure_unlocked", {}) if params.get("treasure_unlocked", {}) is Dictionary else {} _attunement = params.get("attunement", []) if params.get("attunement", []) is Array else [] # additive @@ -144,6 +143,11 @@ func resume_adventure(bp: Dictionary, params: Dictionary = {}) -> bool: if bp.is_empty(): push_error("[冒险结构] resume_adventure 拒绝——空断点快照") return false + if int(bp.get("graph_gen_v2", 0)) != 1: + # 旧代差断点(模板池时代的 node_id 与生成图句柄空间可碰撞错位)——显式拒绝, + # 禁按新语义静默回放;旧模板图已随模板池退役,无迁移动作可做(凭空捏图=兜底图红线)。 + push_error("[冒险结构] resume_adventure 拒绝——断点缺生成图代差标记(graph_gen_v2),属换代前旧断点") + return false master_seed = int(bp.get("master_seed", 0)) rng.restore_adventure(master_seed, int(bp.get("rng_counter", 0))) ledger = {"hero_hp": int(bp.get("hero_hp", 0)), "gp": int(bp.get("gp", 0))} @@ -191,7 +195,6 @@ func resume_adventure(bp: Dictionary, params: Dictionary = {}) -> bool: # 钳制用 flat 已含的保守口径即可,加算区恢复后其余战斗照常)。 ledger["hero_hp"] = mini(int(ledger.get("hero_hp", 0)), maxi(1, int(round(float(int(db.hero.max_hp) + int(_base_override.get("max_hp", 0))) * (100.0 + float(_override[1])) / 100.0)))) - _first_script = false # 续玩模板已随快照强制(已抽模板零重抽)——脚本模式不重放 _shop_mode = "pool" if String(params.get("shop_mode", "legacy")) == "pool" else "legacy" # 续玩参数面同 start_adventure(缺省 legacy) _treasure_unlocked = params.get("treasure_unlocked", {}) if params.get("treasure_unlocked", {}) is Dictionary else {} _attunement = params.get("attunement", []) if params.get("attunement", []) is Array else [] # additive @@ -844,14 +847,12 @@ func _start_current_level() -> bool: _runner.wallet_changed.connect(_forward_wallet_changed) _runner.level_finished.connect(_on_level_finished) _runner.set_hero_override(int(_override[0]), int(_override[1])) - # 断点续玩分支:模板随快照强制(已抽模板零重抽零 RNG)、resume_state 带 - # 停点节点与已到序——重建到停点后经 present_resumed_choice 呈现(信号顺序契约同入口)。 + # 断点续玩分支:图由主种子+参数档重生成同图(产图与计数器号位无关,停点零重抽语义随之消解), + # resume_state 带停点句柄与已到序——重建到停点后经 present_resumed_choice 呈现(信号顺序契约同入口)。 var resume_state: Dictionary[Variant, Variant] = {} - var template_pick: int = 0 if _first_script else -1 # 首次脚本 = 每关固定模板 0 零抽签;否则逐关抽签 if not _resume_bp.is_empty(): resume_state = {"node_id": int(_resume_bp.get("node_id", -1)), "visited_ids": _resume_bp.get("visited_ids", []) as Array} - template_pick = int(_resume_bp.get("template_pick", -1)) # 接口①开始关卡:生命经共享账本交接(账本当前值即开局生命——冒险开始置满 / 大奖励回满已写好); # _base_override 共享引用随载荷传入(永久强化 flat——LevelRunner.effective_* 叠加序第二项)。 # defer_entry=true 起每关(含首关)——先 emit level_started(呈现层在 @@ -860,7 +861,7 @@ func _start_current_level() -> bool: _runner.set_run_ledger(run_ledger) # 共享四账单类下传(run_ledger=四账类,ledger=关卡账) _runner.sync_attune_inventory() # 能力件骑库存面注入(随账下传同拍) var ok: bool = _runner.start_level_external(level_index, ledger, purchased, - apply_rest, _base_override, true, template_pick, resume_state) + apply_rest, _base_override, true, resume_state) if ok: _runner.set_attunement(_attunement) # 同调装配下传(enc 携带进战斗结算) _runner.set_feats(_feats) # 特性持有集下传(begin_battle enc.feats 求值面) @@ -1010,7 +1011,7 @@ func _snapshot_breakpoint() -> void: "shop_shelf": _runner.shop_bp_shelf() if _runner != null else [], # 货架入断点(离开店/非店停点 = 空架合法态) "shop_visit_buys": _runner.shop_bp_visit_buys() if _runner != null else 0, # 本次进店已购次数(A 链定价输入) "attunement_charges": run_ledger.attune_charges.duplicate(true), # 同调主动件充能余量随断点(save v4 域) - "template_pick": _runner.active_template_index if _runner != null else -1, + "graph_gen_v2": 1, # 生成图换代代差标记(缺键 = 换代前旧断点 → resume 显式拒绝) "master_seed": master_seed, "rng_counter": rng.counter(), "keystone": keystone, diff --git a/game/core/level_runner.gd b/game/core/level_runner.gd index 85b4c4ea..cb9927bc 100644 --- a/game/core/level_runner.gd +++ b/game/core/level_runner.gd @@ -64,7 +64,7 @@ var _phase: int = Phase.IDLE var level_result := "" # "" / "victory" / "defeat"(外部契约面) var _candidates: Array[Dictionary] = [] # 当前选路候选(节点引用数组) var visited: Array[Dictionary] = [] # 已兑付/到达节点序列(呈现层图条数据源;含当前节点) -var _graph_mode := false # 图形态缓存(行集任一节点带 next;单链 = false) +var _generator # 参数化长图生成器(惰性建——NodeMapGenerator 持注入的 RngService) func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void: @@ -127,7 +127,7 @@ func restore_hero_base() -> void: # ---- 关卡生命周期 ---- -func start_level(level_index: int, seed_val: int, base_override_in: Dictionary = {}, template_pick: int = -1) -> bool: +func start_level(level_index: int, seed_val: int, base_override_in: Dictionary = {}) -> bool: # 单关测试模式:私有账本、内置恢复数学、自调 new_adventure(漂移① # 单关=冒险的历史简化在测试模式保留;正式种子域归 AdventureRunner.start_adventure) # base_override_in = 永久强化 flat 注入尾参(缺省 {} 全零白板, @@ -146,34 +146,31 @@ func start_level(level_index: int, seed_val: int, base_override_in: Dictionary = _phase = Phase.IDLE _candidates = [] visited = [] - _graph_mode = false gp = 0 # 冒险钱包清零开局(局内货币生命周期起点) run_ledger.bonus = {"atk": 0, "max_hp": 0, "armor_class": 0} # 加算区冒险开始全 0(单关入口零元进度——注入面见上) base_override = {"atk": 0, "max_hp": 0, "armor_class": 0} # 永久强化 flat:缺省全零白板(单关入口零元进度——注入面见上) _shop_purchased = {} # 已购档数清零(跨冒险不延续——递增定价按冒险内累计) for k in base_override.keys(): base_override[k] = maxi(0, int(base_override_in.get(k, 0))) # 注入规范化(契约同冒险开始参数面) - for node in nodes(): - if node.has("next"): - _graph_mode = true - break hero_hp = effective_max_hp() rng.new_adventure(seed_val) # 关卡起点 = 冒险起点(切片口径;跨关接续归 冒险结构) - level_row.assign(_resolve_template_row(row, template_pick)) # 池解析在播种后(node_map 抽签须已播种——EC7) - _phase = Phase.RUNNING - var entry := _entry_node() - if entry.is_empty(): - push_error("[关卡节点地图] 入口节点缺失(无入边唯一节点——图合法性校验应已拦截)") + if not _assign_generated_graph(row, seed_val): _phase = Phase.ENDED return false - return _enter_node(entry) + _phase = Phase.RUNNING + var entries := _entry_nodes() + if entries.is_empty(): + push_error("[关卡节点地图] 入口节点缺失(无入边节点集为空——validate_graph 应已拦)") + _phase = Phase.ENDED + return false + return _enter_node(entries[0]) # 冒险结构接口①:开始关卡(载荷 = 关卡序号 + 当前生命 + 生效上限——生命经共享账本交接; # 种子域归冒险结构:本路径不重播种;账本归冒险结构:本路径不重置 gp/bonus/_shop_purchased, # 也不重置 hero_hp——账本当前值即开局生命,AdventureRunner 已在 ledger 写好)。 # base_override = 永久强化 flat 共享引用(缺省 {} 全零白板,与 B1 空参数路径同值)。 -# 流程与 start_level 同款(_find_level_row → 状态初始化 → _graph_mode 探测 → _enter_node(entry)), +# 流程与 start_level 同款(_find_level_row → 生成图装配 → 状态初始化 → _enter_node(入口)), # 差异仅在账本/加算区/已购档/恢复执行体的注入与零重置。 # defer_entry 尾参(缺省 false = 直接进节点)——true 时不自动 # _enter_node(entry),改置选路态(awaiting_choice + 单候选 = 入口节点),由 AdventureRunner 在 @@ -183,7 +180,7 @@ func start_level(level_index: int, seed_val: int, base_override_in: Dictionary = # _enter_node(entry)——测量面零漂移红线。 func start_level_external(level_index: int, ledger: Dictionary, purchased_ref: Dictionary, rest_handler: Callable, base_override_ref: Dictionary = {}, - defer_entry: bool = false, template_pick: int = -1, resume_state: Dictionary = {}) -> bool: + defer_entry: bool = false, resume_state: Dictionary = {}) -> bool: var row := _find_level_row(level_index) if row.is_empty(): push_error("[关卡节点地图] level_index %d 在 LevelTable 无行" % level_index) @@ -193,34 +190,30 @@ func start_level_external(level_index: int, ledger: Dictionary, base_override = base_override_ref _shop_purchased = purchased_ref _rest_handler = rest_handler - level_row.assign(_resolve_template_row(row, template_pick)) + if not _assign_generated_graph(row, rng.master_seed): + return false node_index = -1 level_result = "" _phase = Phase.IDLE _candidates = [] visited = [] - _graph_mode = false - for node in nodes(): - if node.has("next"): - _graph_mode = true - break _phase = Phase.RUNNING # 断点续玩路径(defer_entry 之外的第三种停点入口):直接重建到停点所在节点, - # 零 RNG 消耗(模板已随 template_pick 强制、节点状态纯重建不 _enter_node——当场战斗从头打)。 + # 零 RNG 消耗(图由主种子+参数档重生成同图、节点状态纯重建不 _enter_node——当场战斗从头打)。 # resume_state = {"node_id": int(-1 = 入口停点), "visited_ids": Array[int]};空字典 = 既有路径逐位不变。 if not resume_state.is_empty(): return _restore_to_stop(int(resume_state.get("node_id", -1)), resume_state.get("visited_ids", []) as Array) - var entry := _entry_node() - if entry.is_empty(): - push_error("[关卡节点地图] 入口节点缺失(无入边唯一节点——图合法性校验应已拦截)") + var entries := _entry_nodes() + if entries.is_empty(): + push_error("[关卡节点地图] 入口节点缺失(无入边节点集为空——validate_graph 应已拦)") _phase = Phase.ENDED return false if defer_entry: - _phase = Phase.AWAITING_CHOICE # 入口停点(node_index 保持 -1——进节点前无 current_node) - _candidates = [entry] + _phase = Phase.AWAITING_CHOICE # 入口停点(node_index 保持 -1)——候选=全部入口(多入口首跳即选路) + _candidates = entries return true - return _enter_node(entry) + return _enter_node(entries[0]) # 入口停点呈现(defer_entry=true 的第二步):发出入口候选供呈现层弹 @@ -240,14 +233,14 @@ func present_entry_choice() -> bool: # 候选 = 停点节点出边(与 choices_presented 停点语义逐位一致)。 func _restore_to_stop(node_id: int, visited_ids: Array) -> bool: if node_id < 0: - # 入口停点:与 defer_entry 同款形态(node_index 保持 -1,单候选 = 入口节点) - var entry := _entry_node() - if entry.is_empty(): + # 入口停点:与 defer_entry 同款形态(node_index 保持 -1,候选 = 全部入口) + var entries := _entry_nodes() + if entries.is_empty(): push_error("[关卡节点地图] 断点恢复失败——入口节点缺失") _phase = Phase.ENDED return false _phase = Phase.AWAITING_CHOICE - _candidates = [entry] + _candidates = entries return true var stop: Dictionary[Variant, Variant] = {} for node in nodes(): @@ -296,32 +289,31 @@ func _find_level_row(level_index: int) -> Dictionary: return db.level_row(level_index) # 表访问收口(行集直扫归 LevelTable/ConfigDB 访问器) -# ---- 模板池解析(关卡节点图生成 v0.4——M-3)---- -# row.templates 非空 = 池形态:template_pick >= 0 = 强制取该模板(首次脚本模式/冒烟固定图,零 RNG 消费); -# -1 = 抽签(node_map 用途标签恰一 scope,均匀取模)。空池 = 旧单图形态原样返回(向后兼容零迁移)。 -# 解析产物 = row 副本换 nodes(下游 nodes()/校验/呈现全透明);active_template_name 供 MC 分层出数; -# active_template_index 供断点快照备案(已抽模板续玩零重抽零 RNG 消耗)。 -var active_template_name := "" -var active_template_index := -1 # -1 = 非池形态(旧单图)/未解析 - -func _resolve_template_row(row: Dictionary, template_pick: int) -> Dictionary: - var templates: Array[Variant] = row.get("templates", []) - active_template_name = "" - active_template_index = -1 - if templates.is_empty(): - return row - var idx := 0 - if template_pick >= 0: - idx = clampi(template_pick, 0, templates.size() - 1) - else: - var stream: RandomNumberGenerator = rng.open_stream("node_map") - idx = stream.randi_range(0, templates.size() - 1) - var tpl: Dictionary[Variant, Variant] = templates[idx] - active_template_name = String(tpl.get("display_name", "")) - active_template_index = idx - var out := row.duplicate(true) - out["nodes"] = tpl.get("nodes", []) - return out +# ---- 图源装配(关卡节点图生成 v1.1——图 = f(参数档, 种子) 纯函数;无模板池、无兜底图)---- +# 参数档按关卡序号解析(消费侧职责——生成器不认关卡号),种子取本局口径:单关模式用传入种子、 +# 冒险模式用主种子(产图与全局计数器号位无关,断点在停点重生成同图)。产图先过消费侧独立校验 +# LevelTable.validate_graph(与产出侧自证同源规格、分工不重叠);违规=起关拒绝,不降级不回退。 +func _assign_generated_graph(row: Dictionary, seed_val: int) -> bool: + var level_index: int = int(row.get("level_index", 0)) + var params: Dictionary = db.level_gen_params_row(level_index) + if params.is_empty(): + push_error("[关卡节点地图] level %d 无参数档行(level_gen_params 缺行——配置错误)" % level_index) + return false + if _generator == null: + _generator = NodeMapGenerator.new(rng) + var graph: Dictionary = _generator.generate(params, seed_val) + if graph.is_empty() or _generator.is_fatal(): + push_error("[关卡节点地图] level %d 生成图失败(参数无解或产出不合格——无兜底图)" % level_index) + return false + var bad := LevelTable.validate_graph(graph["nodes"] as Array) + if not bad.is_empty(): + for e in bad: + push_error("[关卡节点地图] level %d 生成图违规: %s" % [level_index, String(e["message"])]) + return false + var merged := row.duplicate(true) + merged["nodes"] = graph["nodes"] + level_row.assign(merged) + return true func nodes() -> Array: @@ -334,37 +326,30 @@ func current_node() -> Dictionary: return nodes()[node_index] -# ---- 图版行进面(v1.0:出边表选路 · 零自动推进 · 单链 = 隐式出边退化形态)---- -# 编号空间(与 LevelTable 校验器同一解析):图形态 = node_depth(全节点编号化);单链 = 数组下标。 -# 混用两空间会在单链上碰撞(如 reward 下标 2 vs 战斗 node_depth 2)——节点寻址一律经 _node_id。 +# ---- 图版行进面(生成图恒带显式出边;单链退化形态 = 出度 ≤1 的图,无数组序隐式边)---- +# 节点身份=生成图句柄 id(图内唯一)。定位信息另算(enc 携带关卡序号 + 节点深度), +# 禁再以深度当身份(句柄/位置两轴语义单一)。 func _node_id(node: Dictionary) -> int: - if _graph_mode: - return int(node.get("node_depth", -1)) - return nodes().find(node) + return int(node.get("id", -1)) -# 入口节点 = 无入边唯一节点(图合法性校验保证恰一) -func _entry_node() -> Dictionary: +# 入口节点集 = 全部无入边节点(个数=参数档 entry_count;多入口时首跳即选路) +func _entry_nodes() -> Array[Dictionary]: var inbound := {} for node in nodes(): for tid in _out_ids(node): - inbound[tid] = true + inbound[int(tid)] = true + var out: Array[Dictionary] = [] for node in nodes(): if not inbound.has(_node_id(node)): - return node - return {} + out.append(node) + return out -# 出边编号表:显式 next 优先;单链(无 next 键)= 数组顺序下一项(退化形态) +# 出边句柄表(生成图恒显式 next) func _out_ids(node: Dictionary) -> Array: - if node.has("next"): - return node.get("next", []) - var nodes: Array[Variant] = nodes() - var i := nodes.find(node) - if i >= 0 and i < nodes.size() - 1: - return [i + 1] - return [] + return node.get("next", []) # 出边候选(节点引用数组——选路口/呈现层共用读面) @@ -442,11 +427,13 @@ func type_visible_ids() -> Array[int]: # 不可达节点不落表(加载期孤儿校验应已拦截)。 func _bfs_layers() -> Dictionary: var layers := {} - var entry := _entry_node() - if entry.is_empty(): + var entries := _entry_nodes() + if entries.is_empty(): return layers - var queue: Array[Dictionary] = [entry] - layers[_node_id(entry)] = 0 + var queue: Array[Dictionary] = [] + for entry in entries: + layers[_node_id(entry)] = 0 + queue.append(entry) var i := 0 while i < queue.size(): var cur: Dictionary[Variant, Variant] = queue[i] @@ -510,7 +497,7 @@ func _start_battle(node: Dictionary) -> bool: # node_entered 先于 begin_battle:呈现层须先拿到 settlement 引用,才能响应 begin 期间发出的 # board_changed(首板安装)并重建棋盘——后发会导致首战棋盘不渲染 node_entered.emit(node, {}) - if not _settlement.begin_battle({"level_index": int(level_row.level_index), "node_depth": int(node.get("node_depth", 0)), + if not _settlement.begin_battle({"level_index": int(level_row.level_index), "node_depth": int(node.get("depth", 0)), "hero_hp": hero_hp, "hero_atk": effective_atk(), "hero_max_hp": effective_max_hp(), "hero_armor_class": effective_ac(), "hero_ap_bonus": effective_ap_bonus(), "attack_type": _hero_attack_type, "armor_type": _hero_armor_type, # 两根类型轴(spawn 英雄快照;空串=锻造配置默认值回退) @@ -523,7 +510,7 @@ func _start_battle(node: Dictionary) -> bool: "voucher_rap": _voucher_rap, "voucher_time_sand": _voucher_time_sand, "node_type": String(node.get("type", ""))}): - push_error("[关卡节点地图] 战斗启动失败(depth %s)" % str(node.get("node_depth", -1))) + push_error("[关卡节点地图] 战斗启动失败(depth %s)" % str(node.get("depth", -1))) return false _voucher_rap = 0 # 节点级一次性:注入即消费(战斗未启动则保留至下次注入前不清) _voucher_time_sand = 0 @@ -535,7 +522,7 @@ func _forward_step_row(row: Dictionary) -> void: # battle_index=第几场战斗(关卡域 1 基序——visited 已含当前节点〔_enter_node :437 先追加后启动〕; -# 按已到节点 type=battle 计数,与图形态 node_depth/单链数组下标两编号空间解耦〔混编碰撞免疫〕; +# 按已到节点 type=battle 计数,与句柄 id / 节点深度两轴解耦〔身份与位置互不兼任〕; # 开局姿态组「每关首轮窗」条件面) func _battle_index() -> int: var n := 0 diff --git a/game/data/tables/level.tres b/game/data/tables/level.tres index ee4a96aa..739e83cb 100644 --- a/game/data/tables/level.tres +++ b/game/data/tables/level.tres @@ -6,20 +6,11 @@ script = ExtResource("1") info_visible_layers = 2 rows = [ -{"level_index": 1, "display_name": "第一层 · 地精洞窟", "rest_heal_pct": 30, "templates": [{"display_name": "地精洞窟·主线洞道(现行)", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"node_depth": 3, "type": "battle"}, {"type": "reward"}, {"type": "rest"}, {"node_depth": 4, "type": "boss"}]}, {"display_name": "地精洞窟·先赏后整", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"type": "reward"}, {"type": "rest"}, {"node_depth": 3, "type": "battle"}, {"node_depth": 4, "type": "boss"}]}, {"display_name": "地精洞窟·先整后赏", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"type": "rest"}, {"type": "reward"}, {"node_depth": 3, "type": "battle"}, {"node_depth": 4, "type": "boss"}]}, {"display_name": "地精洞窟·赏战整", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"type": "reward"}, {"node_depth": 3, "type": "battle"}, {"type": "rest"}, {"node_depth": 4, "type": "boss"}]}, {"display_name": "地精洞窟·整战赏", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"type": "rest"}, {"node_depth": 3, "type": "battle"}, {"type": "reward"}, {"node_depth": 4, "type": "boss"}]}]}, -{"level_index": 2, "display_name": "第二层 · 幽暗地域", "rest_heal_pct": 30, "templates": [{"display_name": "宽廊双干(现行)", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [5, 6]}, {"node_depth": 3, "type": "rest", "next": [7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "三干中廊", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 5]}, {"node_depth": 2, "type": "reward", "next": [4, 5]}, {"node_depth": 3, "type": "rest", "next": [6]}, {"node_depth": 4, "type": "battle", "next": [6, 7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "宽廊斜岔", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 5]}, {"node_depth": 2, "type": "reward", "next": [4, 6]}, {"node_depth": 3, "type": "rest", "next": [7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "三干宽门", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [4, 5]}, {"node_depth": 3, "type": "rest", "next": [6, 7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "窄廊短支", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [5]}, {"node_depth": 3, "type": "rest", "next": [5, 6]}, {"node_depth": 4, "type": "battle", "next": [6]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}]}, -{"level_index": 3, "display_name": "第三层 · 灰矮人矿城", "rest_heal_pct": 30, "templates": [{"display_name": "宽廊双干(现行)", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [5, 6]}, {"node_depth": 3, "type": "rest", "next": [7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "三干中廊", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 5]}, {"node_depth": 2, "type": "reward", "next": [4, 5]}, {"node_depth": 3, "type": "rest", "next": [6]}, {"node_depth": 4, "type": "battle", "next": [6, 7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "宽廊斜岔", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 5]}, {"node_depth": 2, "type": "reward", "next": [4, 6]}, {"node_depth": 3, "type": "rest", "next": [7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "三干宽门", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [4, 5]}, {"node_depth": 3, "type": "rest", "next": [6, 7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "窄廊短支", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [5]}, {"node_depth": 3, "type": "rest", "next": [5, 6]}, {"node_depth": 4, "type": "battle", "next": [6]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}]} +{"level_index": 1, "display_name": "第一层 · 地精洞窟", "rest_heal_pct": 30}, +{"level_index": 2, "display_name": "第二层 · 幽暗地域", "rest_heal_pct": 30}, +{"level_index": 3, "display_name": "第三层 · 灰矮人矿城", "rest_heal_pct": 30} ] -; 关卡节点图生成 v0.4 台阶一内容批(M-3 迭代 5 T5,2026-09-28——15 张骨架 = 3 关 × 5,拍板点④): -; L2/L3 网状图约束(承袭):战斗节点编号恒 ∈ 遭遇表行集 {0,1,4,5,6,7}、Boss 9 最大编号、 -; 商店房 8→Boss 必经位承袭;遭遇表按编号共用——零新增敌人数值。L1 五模板 = 单链排列变体 -; (战斗深度序单调保难度坡,变化面 = 奖励/恢复位次)。 -; 池内全模板加载期校验(level_table.gd validate + config_db 跨表校验模板感知); -; 行为读 LevelRunner._resolve_template_row(node_map 标签恰一 scope 抽签 / template_pick 强制)。 -; 生成器 = tools/t5_build_templates.py(生成前自校验,一次性内容批工具随批入库留档)。 -; U2 目检返工(M-3 迭代 5 返工批,2026-09-29——U2 目检 15/15 不过两规则缺口,全池重生成): -; ① L1 奖励/休整禁入前三位(用户目检裁决)——五链全部前三位纯战斗,位 4-6 = {战斗3,奖励,休整} 排列; -; ② L2/L3 BFS 同深度层上限 5 且应用足(每张最大层宽 4-5,旧池层宽最大 3 被否);模板 0 一并重排 -; (待批点甲按推荐=随新规则重生成——首次冒险/钉固断言期望随新图更新;模板 0 名保持「(现行)」后缀=新现行图锚)。 -; 加载校验两规则同落 level_table.gd validate(单链前三纯战斗/图形态层宽 ≤5)。 +; 节点图源已换代为参数化生成器(关卡节点图生成 v1.1)——本表逐关行只承载关卡序号/显示名/恢复比例, +; 不再携带节点数据;图 = f(参数档, 种子),参数档见 level_gen_params.tres 三行(消费侧按 level_index 解析后交 generate)。 +; 旧 15 张模板池(3 关 × 5)随迭代 35 卡② 清出,历史查 git 提交史与 tools/t5_build_templates.py、t17_fanout2_rebuild.py 留档。 diff --git a/game/presentation/main.gd b/game/presentation/main.gd index 2ea655c9..8a5a4cab 100644 --- a/game/presentation/main.gd +++ b/game/presentation/main.gd @@ -589,11 +589,9 @@ func _start_adventure() -> void: logger.start_session(seed_val) run_scope.adventure.set_hero_override(debug_overlay.atk_pct(), debug_overlay.hp_pct()) # 永久强化注入:冒险开始参数面——meta 现算 override 显式传参(全零 = 白板) - # 首次冒险脚本(关卡节点图生成 v0.4):存档布尔位 false = 全关固定模板 0(教学重放);置位后逐关抽签 - # 同调三键随开局下传(build_adventure_params 统一构造,与营地投影同源) - var first_script: bool = not save_manager.is_first_adventure_done() + # 开局参数面(同调三键同源构造——确定性承载=固定种子+参数档,首局教学图由种子域三分判定给 42) if not run_scope.adventure.start_adventure(seed_val, build_adventure_params(save_manager, meta, - {"first_script": first_script, "treasure_unlocked": _treasure_ui.unlocked_set(), "shop_mode": "pool"})): + {"treasure_unlocked": _treasure_ui.unlocked_set(), "shop_mode": "pool"})): _fatal_screen("冒险启动失败——见输出日志") return diff --git a/game/probe/shot_probe.gd b/game/probe/shot_probe.gd index c74a3e7f..2b108b21 100644 --- a/game/probe/shot_probe.gd +++ b/game/probe/shot_probe.gd @@ -10,7 +10,7 @@ # 每屏双帧 + frame_post_draw 同步后抓 get_viewport().get_texture(),PNG 落输出目录; # 最小自检 = 七件存在且非零字节(像素方差断言归 tools/shot_checker.py——B 档骨架分工)。 # 商店房不纳屏(真实路径口径):真实路径必经 L1 打穿→L2 商店节点 -# 选路(商店层在 L2 Boss 前必经位)——探针现行驱动只进首战节点,低代价确定性 +# 选路(商店按摆放约束恒在终点前一位,每局必经恰一店)——探针现行驱动只进首战节点,低代价确定性 # 到达不成立;合成投喂(ui_struct_probe 同款)破坏本探针真实路径口径 → 不纳屏。 extends Node diff --git a/game/probe/ui_click_probe.gd b/game/probe/ui_click_probe.gd index d64d845d..a9645af2 100644 --- a/game/probe/ui_click_probe.gd +++ b/game/probe/ui_click_probe.gd @@ -370,7 +370,7 @@ func _ready() -> void: _seals_placed.clear() continue if main.shop_overlay.visible: - # 商店房覆盖层(L2 商店为 Boss 前必经位):零购离开(真实 leave 信号路径)放行选路。 + # 商店房覆盖层(摆放约束:每终点前一位恰一店,真实路径恒必经一店):零购离开(真实 leave 信号路径)放行选路。 # 分派链序:商店层=选路页上的模态覆盖(停点呈现为 # level_screen+shop 双 visible)——必须先于 level_screen 分派,否则下层候选点击 # 空转烧尽预算(实证:lv2 商店停点双 visible 循环 12000 帧 done=false)。 diff --git a/game/sim/adventure_monte_carlo.gd b/game/sim/adventure_monte_carlo.gd index 95cbbae3..5c02fb9d 100644 --- a/game/sim/adventure_monte_carlo.gd +++ b/game/sim/adventure_monte_carlo.gd @@ -136,7 +136,7 @@ func _initialize() -> void: "encounter_max_hp_seq": st["encounter_max_hp_seq"], "reward_events": st["reward_events"], "shop_gp_seq": st["shop_gp_seq"], - "level_templates": st.get("level_templates", []), + "level_maps": st.get("level_maps", []), "wallet_count": (st["wallet_events"] as Array).size(), } if shop_mode == "on": diff --git a/game/sim/level_autoplay.gd b/game/sim/level_autoplay.gd index 7c6a13dc..a719f0d1 100644 --- a/game/sim/level_autoplay.gd +++ b/game/sim/level_autoplay.gd @@ -9,7 +9,7 @@ # 策略档 policy:"normal"(默认)/ "perfect"(零盲挖/全封印/卡死刷新——上界测量仪,求解器与 # 动作语义见 auto_player.gd 完美策略档节)。 # 选路驱动(零自动推进):兑付确认/战斗胜利后 runner 进选路态,驱动循环按 route 策略显式 -# pick_next_node;route = "min"/"max"(候选节点编号最小/最大——确定性路径轴,零随机流自开;默认 "min")。 +# pick_next_node;route = "min"/"max"(候选节点句柄最小/最大——确定性路径轴,零随机流自开;默认 "min")。 # run_adventure 三关连探驱动(AdventureRunner——主种子恰一次、通关兑付停点确认、adventure_finished # 置 result/summary;外层每关重取 runner 实例经 _drive_level 复用单关驱动)。 # 大奖励停点 = 三选一驱动(候选取首件/替换放弃新件)。 @@ -34,7 +34,7 @@ const NORMAL_SKIP_DIG_P := 0.05 const NORMAL_IDLE_GAMBLE_P := 0.10 -static func run_level(db, seed_val: int, level_index: int, atk_pct: int, hp_pct: int, collect_rows: bool = false, logger = null, policy: String = "normal", route: String = "min", template_pick: int = -1) -> Dictionary: +static func run_level(db, seed_val: int, level_index: 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() @@ -60,7 +60,7 @@ static func run_level(db, seed_val: int, level_index: int, atk_pct: int, hp_pct: runner.node_entered.connect(_on_node_entered.bind(runner, st, logger)) runner.level_finished.connect(_on_level_finished.bind(st, logger)) runner.set_hero_override(atk_pct, hp_pct) - if not runner.start_level(level_index, seed_val, {}, template_pick): + if not runner.start_level(level_index, seed_val, {}): st.result = "start_failed" runner.restore_hero_base() if own_session: @@ -138,7 +138,7 @@ static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_ "prng": _policy_rng(seed_val), "mine_slips": 0, "skip_digs": 0, "shop_buys": 0, "adv": true, # v3 档位行为开关(选路评分/失误注入/商店购买只挂冒险测量面——单关夹具语义不变) - "level_templates": [], # 逐关图模板名(关卡节点图生成 v0.4 分层出数) + "level_maps": [], # 逐关节点图标识(生成图 = 关卡序号+本局主种子——MC 分层出数) } adr.step_row.connect(_on_step_row.bind(adr, st, collect_rows)) adr.node_entered.connect(_on_adventure_node_entered.bind(adr, st, logger)) @@ -150,7 +150,7 @@ static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_ adr.adventure_finished.connect(_on_adventure_finished.bind(st, logger)) adr.set_hero_override(atk_pct, hp_pct) # 同调分层注入(缺省 {} 与既有调用方逐位一致零行为变更) - var adv_params := {"hero_override": hero_override, "first_script": false} + var adv_params := {"hero_override": hero_override} if not attune.is_empty(): adv_params["attunement"] = attune.get("equipped", []) adv_params["attune_refresh_y"] = int(attune.get("refresh_y", 0)) @@ -197,9 +197,9 @@ static func _on_adventure_node_entered(node: Dictionary, info: Dictionary, adr, var r = adr.runner() var t := String(node.get("type", "")) st.visited_ids.append(int(r.node_id_of(node))) - if not st.has("level_templates"): - st["level_templates"] = [] - st["level_templates"].append(r.active_template_name) # MC 分层出数(关卡×图模板——v0.4 测量对账条款) + if not st.has("level_maps"): + st["level_maps"] = [] + st["level_maps"].append("%d#%d" % [int(r.level_row.get("level_index", 0)), int(adr.master_seed)]) # MC 分层出数(关卡×生成图标识) if t == "battle" or t == "boss": st.battles += 1 @@ -208,7 +208,7 @@ static func _on_adventure_node_entered(node: Dictionary, info: Dictionary, adr, 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.boss_depth = int(node.get("depth", -1)) st.hp_at_boss = int(adr.ledger["hero_hp"]) elif t == "reward": # 奖励节点三选一(共享池确定性策略;TP0 起 316-f 判读:1 级最稀有、 @@ -376,7 +376,7 @@ static func _policy_rng(seed_val: int) -> RandomNumberGenerator: # 路径轴选路(目标价值评分制):候选按节点类型评分取最高, -# 同分 tie-break 沿 route 方向(min=小编号/max=大编号——verifier 双窗轴保持)。 +# 同分 tie-break 沿 route 方向(min=小句柄/max=大句柄——verifier 双窗轴保持)。 # 评分语义(确定性,零随机流):rest=伤重高值(缺口比例×100)/shop=买得起高值(GP 局内死钱, # 真人实证两店全购)/reward=占位低值(MC 夹具解锁空集=零兑付直通)/boss=缓冲低值(有店有_rest # 先补给再打)/battle=基线。编号取 runner.next_candidate_ids()(编号空间解析单一出口)。 @@ -478,7 +478,7 @@ static func _on_node_entered(node: Dictionary, info: Dictionary, runner, st: Dic if logger != null and runner.current_battle() != null: logger.attach_battle(runner.current_battle()) if t == "boss": - st.boss_depth = int(node.get("node_depth", -1)) + st.boss_depth = int(node.get("depth", -1)) st.hp_at_boss = runner.hero_hp elif t == "reward": runner.continue_after_node() # 奖励点 = 内容未定直通节点 diff --git a/game/tests/integration/cross_system_flow_test.gd b/game/tests/integration/cross_system_flow_test.gd index 8543e0ab..8d3a68a5 100644 --- a/game/tests/integration/cross_system_flow_test.gd +++ b/game/tests/integration/cross_system_flow_test.gd @@ -76,7 +76,7 @@ func _feats_breakpoint_roundtrip() -> void: var live_rows: FeatTable = _db.feats _db.feats = rt var a_r = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(72)) - a_r.start_adventure(72, {"first_script": true}) # 源 :723(受理断言留单元宿主) + a_r.start_adventure(72) # 源 :723(受理断言留单元宿主) a_r._curio_pending = {"candidates": [{"kind": "relic", "tier": "rare", "feat_id": "x3"}, {"kind": "gp", "tier": "common"}], "payload": {}} a_r.pick_curio("relic", "rare", "x3") # 源 :781(兑付断言留单元宿主) # 断点往返(持有集+余库 feat_id 随行) diff --git a/game/tests/integration/node_map_templates_test.gd b/game/tests/integration/node_map_templates_test.gd index 839fc9c0..eb662953 100644 --- a/game/tests/integration/node_map_templates_test.gd +++ b/game/tests/integration/node_map_templates_test.gd @@ -1,18 +1,21 @@ -# tests/integration/node_map_templates_test.gd — L2 集成用例:关卡节点图生成·模板池 -# 断言面 = 模板池套(批 B 断言迁移自 smoke_all.gd `_smoke_node_map_templates`,G12 施工组; +# tests/integration/node_map_templates_test.gd — L2 集成用例:关卡节点图生成·确定性承载 +# 断言面 = 生成图确定性套(批 B 断言迁移自 smoke_all.gd,G12 施工组; # 主会话裁决六整套集成不拆——跨四系统且走落盘)。 -# 跨系统面 = RngService(node_map 用途标签/抽签确定性/零消耗计账)× -# LevelRunner(start_level_external 受理/模板 0 回滚档)× -# AdventureRunner(first_script 全关零抽签/逐关恰一抽签)× +# 跨系统面 = RngService(纯派生流零计数器占用)× +# LevelRunner(start_level/start_level_external 受理·生成图装配·单一图源)× +# AdventureRunner(开局种子承载·断点写读后同图)× # SaveManager+MetaProgression(首次冒险布尔位收束置位落盘——真实档写盘)。 # 与单元侧同入口同协议:extends SceneTree+Kit.check 主通道;--script 模式无 autoload, -# ConfigDB 显式实例化构造注入;表加载失败不中止(沿被迁断言面的源语义——诊断展开,红由断言落)。 +# ConfigDB 显式实例化构造注入;表加载失败不中止(沿被迁断言面的源语义——诊断展开,红由具体断言落)。 # 运行:godot --headless --path game --script res://tests/integration/node_map_templates_test.gd extends SceneTree const Kit := preload("res://tests/unit/test_kit.gd") +const SaveManagerScript := preload("res://core/save_manager.gd") +const AdventureRunnerScript := preload("res://core/adventure_runner.gd") var _db +var _bp_path := "user://smoke_node_map_save.json" func _init() -> void: @@ -24,84 +27,115 @@ func _init() -> void: for d in _db.diagnostics(): if d.severity == "error" or d.severity == "fatal": push_error(" [%s] %s: %s" % [d.severity, d.category, d.message]) - _smoke_node_map_templates() + _smoke_generated_map_determinism() quit(Kit.finish("node_map_templates_test")) -# ---- 关卡节点图生成·模板池冒烟(v0.4 §P0.6 断言面)---- -func _smoke_node_map_templates() -> void: - # ① 扩签:node_map 入用途标签封闭词表(派生零改动——标签只进事件日志) - var svc = load("res://core/rng_service.gd").new() - Kit.check("node_map 标签入 rng_service 封闭词表(扩签通道=改代码+冒烟守卫)", - (svc.PURPOSE_TAGS as Array).has("node_map")) - # ② 池参数:三关各 5 模板、模板 0 存在(回滚档成员) - for li in range(1, 4): - var row := {} - for r in _db.level.rows: - if int(r.level_index) == li: - row = r - var tpls: Array = row.get("templates", []) - Kit.check("L%d 模板池 = 5 张(拍板点④——首版池规模)" % li, tpls.size() == 5) - Kit.check("L%d 模板 0 = 现行固定图(回滚档成员)" % li, - not tpls.is_empty() and String(tpls[0].get("display_name", "")).ends_with("(现行)")) - # ③ 强制取 0 = 模板 0 节点表逐位相等 + 零 scope 消费 - var rng0 = load("res://core/rng_service.gd").new() - rng0.new_adventure(910001) - var runner0 = load("res://core/level_runner.gd").new(_db, rng0) - var c_before: int = rng0.counter() - Kit.check("强制 template_pick=0 起关受理", runner0.start_level_external(2, {"hero_hp": 20, "gp": 0}, {}, # start_level_external 签名收口(bonus 参数由 RunLedger 单类下传) - Callable(self, "_smoke_rest_stub"), {}, true, 0)) - Kit.check("强制取模板零 RNG 消耗(零抽签——首次脚本语义)", rng0.counter() == c_before) - var tpl0: Dictionary = {} - for r in _db.level.rows: - if int(r.level_index) == 2: - tpl0 = r.get("templates", [])[0] - Kit.check("强制 0 的生效节点表 = 模板 0 逐位相等(回滚档成立)", - runner0.level_row.get("nodes", []) == tpl0.get("nodes", [])) - # ④ 抽签确定性:同种子两实例同模板(同种子同操作序列 → 逐位同图) - var names := [] - for i in range(2): - var rg = load("res://core/rng_service.gd").new() - rg.new_adventure(910002) - var rn = load("res://core/level_runner.gd").new(_db, rg) - rn.start_level_external(2, {"hero_hp": 20, "gp": 0}, {}, - Callable(self, "_smoke_rest_stub"), {}, true, -1) - names.append(rn.active_template_name) - Kit.check("抽签确定性:同种子逐位同图(实测 %s)" % String(names[0]), - names[0] == names[1] and String(names[0]) != "") - # ⑤ N=1 池与现行固定图等价:单模板行过图校验(抽签取模 1 恒 0 = 回滚档) - var lt = load("res://data/schemas/level_table.gd").new() - lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, - "templates": [tpl0]}]) - Kit.check("N=1 池表通过图校验(回滚档形态合法)", lt.validate().is_empty()) - # ⑥ 首次脚本分支:first_script=true 全关模板 0;false = 抽签(scope 恰一差异) - var rngF = load("res://core/rng_service.gd").new() - rngF.new_adventure(910003) - var adrF = load("res://core/adventure_runner.gd").new(_db, rngF) - adrF.start_adventure(910003, {"first_script": true}) - Kit.check("首次脚本模式:L1 取模板 0(全关零抽签——存档布尔位 false 语义)", - String(adrF.runner().active_template_name).ends_with("(现行)")) - var cF: int = rngF.counter() - var rngT = load("res://core/rng_service.gd").new() - rngT.new_adventure(910003) - var adrT = load("res://core/adventure_runner.gd").new(_db, rngT) - adrT.start_adventure(910003, {"first_script": false}) - Kit.check("抽签模式恰多消费 1 scope(node_map 逐关恰一——L1 起关抽签)", rngT.counter() == cF + 1) - Kit.check("抽签模式 active_template_name 有值(MC 分层出数取数面在位)", - String(adrT.runner().active_template_name) != "") +# ---- 关卡节点图生成·确定性承载断言面 ---- +func _smoke_generated_map_determinism() -> void: + # ① 纯函数复现:图 = f(参数档, 种子)——同档同种子两次产图逐位同图,换种子必变图 + var gen = NodeMapGenerator.new(_fresh_rng(910001)) + var params2: Dictionary = _db.level_gen_params_row(2) + var g_a: Dictionary = gen.generate(params2, 910002) + var g_b: Dictionary = gen.generate(params2, 910002) + var g_c: Dictionary = gen.generate(params2, 910003) + var nodes_a: Array = g_a.get("nodes", []) + Kit.check("同参数档同种子两次产图逐位同图(D2 执行边界)", + nodes_a == (g_b.get("nodes", []) as Array)) + Kit.check("同参数档换种子即变图(图确为种子函数,非恒定输出)", + nodes_a != (g_c.get("nodes", []) as Array)) + Kit.check("产图节点携图内唯一句柄 id 与自入口 depth(消费侧寻址面)", + _not_empty_graph(nodes_a) and _handles_unique(nodes_a)) + # ② 消费侧单一图源:起关装配的图与生成器直取逐位一致(runner 内不存第二份图数据) + var runner1 = load("res://core/level_runner.gd").new(_db, _fresh_rng(910004)) + Kit.check("单关起关受理(种子 910004——生成图直供,无兜底图)", + runner1.start_level(2, 910004)) + Kit.check("起关生效图 = 生成器同档同种子直取逐位一致(消费侧无第二图源)", + runner1.level_row.get("nodes", []) == (gen.generate(params2, 910004) as Dictionary).get("nodes", [])) + # ③ 产图零统一流消费(纯派生流不占计数器号位——断点在任意号位重生成同图的机制承载) + var rng_c = _fresh_rng(910005) + var counter_before: int = rng_c.counter() + var runner2 = load("res://core/level_runner.gd").new(_db, rng_c) + Kit.check("冒险模式起关受理(入口停点)", runner2.start_level_external(3, + {"hero_hp": 20, "gp": 0}, {}, Callable(self, "_smoke_rest_stub"), {}, true)) + Kit.check("起关产图零计数器消费(同图不依赖取数号位)", rng_c.counter() == counter_before) + Kit.check("入口停点候选 = 生成图全部无入边节点(单入口时恰 1)", + (runner2.next_candidates() as Array).size() >= 1) + # ④ 开局种子承载:新档(首局)种子 42 两次开局逐位同图,且与生成器直取一致 + DirAccess.remove_absolute(_bp_path) # 测试隔离——上轮落盘残留先删,断点面从零档起 + var sv_w = SaveManagerScript.new(_bp_path) + var adr_x = AdventureRunnerScript.new(_db, _fresh_rng(42)) + adr_x.set_save_manager(sv_w) # 先挂档再开局——停点快照由真实写手落盘 + Kit.check("首局口径种子 42 开局受理(defer_entry 路径)", adr_x.start_adventure(42, + {"hero_override": {}, "treasure_unlocked": {}})) + var adr_y = AdventureRunnerScript.new(_db, _fresh_rng(42)) + Kit.check("同种子第二次开局受理", adr_y.start_adventure(42, + {"hero_override": {}, "treasure_unlocked": {}})) + Kit.check("种子 42 两次开局图逐位同图(教学局恒同图——首局固定种子的确定性承载)", + adr_x.runner().nodes() == adr_y.runner().nodes()) + Kit.check("开局图 = L1 参数档在种子 42 下的生成图(开局无隐式取数分叉)", + adr_x.runner().nodes() == (gen.generate(_db.level_gen_params_row(1), 42) as Dictionary).get("nodes", [])) + # ⑤ 断点写→恢复→再产图同图(续玩按主种子+参数档重生成,断点不存图快照) + Kit.check("停点断点落盘在档", sv_w.has_breakpoint()) + var bp: Dictionary = sv_w.breakpoint_snapshot() + var adr_z = AdventureRunnerScript.new(_db, load("res://core/rng_service.gd").new()) + Kit.check("断点续玩受理(代差标记键在档)", adr_z.resume_adventure(bp, + {"hero_override": {}, "treasure_unlocked": {}})) + Kit.check("续玩重生成图与中断前逐位同图", adr_z.runner().nodes() == adr_x.runner().nodes()) + var adr_old = AdventureRunnerScript.new(_db, load("res://core/rng_service.gd").new()) + var bp_old: Dictionary = bp.duplicate(true) + bp_old.erase("graph_gen_v2") # 换代前旧断点形态(模板池时代无代差标记键) + Kit.check("缺生成图代差标记键的旧断点被显式拒绝(禁按新语义静默回放)", + adr_old.resume_adventure(bp_old, {}) == false) + # ⑥ 关卡表不再携带图数据(图源换代后 level 行只剩行级字段) + var tpl_free := true + for li in range(1, _db.level_count() + 1): + if (_db.level_row(li) as Dictionary).has("templates"): + tpl_free = false + Kit.check("level 表三行均无 templates 键(模板数据已清出)", + tpl_free and _db.level_count() == 3) # ⑦ 收束置位:新档 false → settle 置 true 恰一次(幂等) - DirAccess.remove_absolute("user://smoke_node_map_save.json") # 测试隔离——上轮 settle 写盘的残留档先删 - var sv = load("res://core/save_manager.gd").new("user://smoke_node_map_save.json") + var sv = SaveManagerScript.new(_bp_path + ".flag.json") sv.load_from_disk() - Kit.check("新档首次冒险布尔位 = false(默认域)", sv.is_first_adventure_done() == false) + Kit.check("新档首次冒险布尔位 = false(默认域——首局恒走固定种子 42)", + sv.is_first_adventure_done() == false) var mp = load("res://core/meta_progression.gd").new(_db, sv) mp.settle_adventure({"levels_cleared": 1, "keystone_total": 10, "cleared": false, "result": "failed"}) Kit.check("冒险收束点置位(三结局同写——写入只在收束挂点)", sv.is_first_adventure_done() == true) mp.settle_adventure({"levels_cleared": 0, "keystone_total": 0, "cleared": false, "result": "failed"}) Kit.check("置位幂等(重复收束不翻转)", sv.is_first_adventure_done() == true) + DirAccess.remove_absolute(_bp_path) + DirAccess.remove_absolute(_bp_path + ".flag.json") -# ---- 冒烟恢复执行体(LevelRunner 注入用——与 AdventureRunner.apply_rest 同式占位; +# 图数据良构前置(防空集假绿——节点数 > 1、句柄唯一、depth 与 next 键在位) +func _not_empty_graph(nodes: Array) -> bool: + if nodes.size() < 2: + return false + for node in nodes: + var n := node as Dictionary + if not n.has("id") or not n.has("depth") or not n.has("next"): + return false + return true + + +func _handles_unique(nodes: Array) -> bool: + var seen := {} + for node in nodes: + var id: int = int((node as Dictionary).get("id", -1)) + if id < 0 or seen.has(id): + return false + seen[id] = true + return true + + +func _fresh_rng(seed_val: int): + var r = load("res://core/rng_service.gd").new() + r.new_adventure(seed_val) + return r + + +# ---- 恢复执行体(LevelRunner 注入用——与 AdventureRunner.apply_rest 同式占位; # smoke_all.gd helper 的宿主自持私有副本——源文件收尾整体删除,宿主自给自足)---- func _smoke_rest_stub(pct: int, hp: int, max_hp: int) -> int: return mini(int(floor(float(max_hp) * float(pct) / 100.0)), max_hp - hp) diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index 1890c23d..724c7ba2 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -87,7 +87,7 @@ func _suites() -> Array: {"path": "res://tests/unit/presentation/modal_flow_test.gd", "expect": 0}, # 模态编排纯逻辑(V6:直接 new/互斥域谓词/三例外锁/演播后排窗) # ---- L2 集成层(跨系统用例,与 unit/ 的隔离断言面分工)---- {"path": "res://tests/integration/save_rng_roundtrip_test.gd", "expect": 0}, # 存档⇄RNG 快照往返(SaveManager 迁移链 × 断点消费 × RNG 续跑 × 停点态确定性) - {"path": "res://tests/integration/node_map_templates_test.gd", "expect": 0}, # 关卡节点图生成·模板池(模板池跨系统×首冒险置位落盘——批 B 自 smoke_all 迁入,裁决六整套集成不拆) + {"path": "res://tests/integration/node_map_templates_test.gd", "expect": 0}, # 关卡节点图生成·确定性承载(生成图复现×首冒险置位落盘——批 B 自 smoke_all 迁入,裁决六整套集成不拆) {"path": "res://tests/integration/cross_system_flow_test.gd", "expect": 0}, # 跨系统真实流×落盘(RNG 事件日志落盘/专长断点往返/存档摘要集成/试玩记录仪真实结算流——批 B 转办块宿主,裁决一/二/三/七) # ---- core 四件与呈现层两件的直接单测 ---- {"path": "res://tests/unit/core/board_generator_test.gd", "expect": 0}, # 棋盘生成(尺寸合表/数字=八邻雷数/同种子逐位一致/可推理自洽) diff --git a/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd b/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd index 64526fc0..5bd587a5 100644 --- a/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd +++ b/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd @@ -290,7 +290,7 @@ func _smoke_encounter_roll() -> void: # 战斗结算 roll_lo 下限 + 敌人系统 spawn count<1 fail-fast 双闸;单深度单种子是盲区,全域扫描才钉死不变式) var depths: Array = [] for node in _level_battle_nodes(1): - depths.append(int(node.get("node_depth", -1))) + depths.append(int(node.get("depth", -1))) var scan_bad := 0 var scan_total := 0 for i in range(12): @@ -317,22 +317,11 @@ func _smoke_encounter_roll() -> void: # 节点类型 → 敌人表 row_kind(非 Boss 战斗位恒 battle——深度带批寻址第三维) func _row_kind_of(level_index: int, depth: int) -> String: for n in _level_active_nodes(level_index): - if int(n.get("node_depth", -1)) == depth and String(n.get("type", "")) == "boss": + if int(n.get("depth", -1)) == depth and String(n.get("type", "")) == "boss": return "boss" return "battle" -# 该关 Boss 节点深度(单终点图的终点编号——数据驱动,禁写死坐标) - - -# 该关 Boss 节点深度(单终点图的终点编号——数据驱动,禁写死坐标) -func _boss_depth(level_index: int) -> int: - for n in _level_active_nodes(level_index): - if String(n.get("type", "")) == "boss": - return int(n.get("node_depth", -1)) - return -1 - - # 首遇带数据驱动定位(契约 8):该 (L, battle, enemy_id) 的最低 depth_band,禁写死坐标数字 @@ -353,15 +342,12 @@ func _first_encounter_band(level_index: int, enemy_id: String) -> int: # 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义) -# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义) +# 生成图取数面(图 = f(关卡行参数档, 种子 42)——与 runner 起关同图;节点键 id/type/depth/next) func _level_active_nodes(level_index: int) -> Array: - for row in _db.level.rows: - if int(row.level_index) == level_index: - var tpls: Array = row.get("templates", []) - if not tpls.is_empty(): - return tpls[0].get("nodes", []) - return row.get("nodes", []) - return [] + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(42) + var gen = NodeMapGenerator.new(rng) + return (gen.generate(_db.level_gen_params_row(level_index), 42) as Dictionary).get("nodes", []) func _level_battle_nodes(level_index: int) -> Array: diff --git a/game/tests/unit/core/adventure_feats_test.gd b/game/tests/unit/core/adventure_feats_test.gd index a19ac9d1..bc809a18 100644 --- a/game/tests/unit/core/adventure_feats_test.gd +++ b/game/tests/unit/core/adventure_feats_test.gd @@ -58,7 +58,7 @@ func _smoke_adventure_api() -> void: adr.level_started.connect(_api_mark_started.bind(sig_seq)) adr.choices_presented.connect(_api_mark_choices.bind(sig_seq, obs)) adr.node_entered.connect(_api_mark_node.bind(sig_seq)) - Kit.check("冒险启动受理(主种子注入恰一次——state=running;first_script=固定模板 0——精确路径断言需要现行图,零抽签)", adr.start_adventure(42, {"first_script": true})) + Kit.check("冒险启动受理(主种子注入恰一次——state=running;种子 42=首局固定种子生成图——精确路径断言需要确定图(卡② 代差))", adr.start_adventure(42)) Kit.check("进行中重复 start_adventure 拒绝(重开须待结算完毕)", not adr.start_adventure(43)) Kit.check("停点前移次序契约:level_started 先于入口候选呈现(反序选路口会被呈现层 runner 空防御丢弃)", sig_seq.size() == 2 and String(sig_seq[0]) == "level_started" and String(sig_seq[1]) == "choices_presented") Kit.check("入口停点:选路态 + 入口单候选 + 首战未开(呈现零 RNG 消耗——pick 前零战斗域)", @@ -108,26 +108,27 @@ func _api_mark_node(_node: Dictionary, _info: Dictionary, seq: Array) -> void: func _smoke_adventure() -> void: print("--- 冒险结构验收(三关序列 · 大奖励 · 摘要五件事 · 账本封闭)---") var autoplay = load("res://sim/level_autoplay.gd") - # ① 表与覆盖义务(序列关数 = 行集关数;行集内每个战斗/Boss 节点跨表精确行集——覆盖义务断言; - # enemy 侧键域 = (level_index, row_kind, depth_band)——战斗节点查 battle 键域、Boss 节点查 boss 键域, - # 同带多行合法,带键聚合天然兼容〔fv24〕) + # ① 表与覆盖义务(序列关数 = 行集关数;节点源 = 参数档在固定种子下的生成图, + # 每个战斗/Boss 节点经 ConfigDB 真实解析链查敌行集与棋盘行——顶 clamp 消解禁〔fv24〕) Kit.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_%s_%d" % [int(row.level_index), String(row.get("row_kind", "")), int(row.get("depth_band", -1))]] = true - var miss := 0 - for row in _db.level.rows: - for n in _level_active_nodes(int(row.level_index)): + var cov_miss := 0 + var cov_scan := 0 + for li in range(1, int(_db.level.rows.size()) + 1): + for n in _level_active_nodes(li): var nt := String(n.get("type", "")) if nt != "battle" and nt != "boss": continue - var key := "%d_%s_%d" % [int(row.level_index), nt, int(n.get("node_depth", -1))] - if not bg_keys.has("%d_%d" % [int(row.level_index), int(n.get("node_depth", -1))]) or not en_keys.has(key): - miss += 1 - Kit.check("行集内每个战斗/Boss 节点在 board_gen + enemy 有精确行集(缺失 %d——带键 (L,row_kind,depth_band))" % miss, miss == 0) + var d: int = int(n.get("depth", -1)) + cov_scan += 1 + var rk := "boss" if nt == "boss" else "battle" + var rs: Array[Dictionary] = _db.resolve_enemy_rowset(li, d, rk) + if rs.is_empty() or int(rs[0].get("depth_band", -1)) < d: + cov_miss += 1 + if not (_db.resolve_board_gen(li, d) as Dictionary).is_empty(): + continue + cov_miss += 1 + Kit.check("生成图每个战斗/Boss 节点经解析链命中敌行集与棋盘行(扫描 %d 节点,缺失 %d)〔fv24〕" % [cov_scan, cov_miss], + cov_scan > 0 and cov_miss == 0) # ② 满配通关 + ④ 三层奖励兑付(三选一恰 2 次 · 回血封顶 · 钥石逐杀 · 候选零重复) var st: Dictionary = autoplay.run_adventure(_db, 42, 900, 400) var sum: Dictionary = st["summary"] @@ -151,7 +152,65 @@ func _smoke_adventure() -> void: elif (ev as Dictionary).has("curio_resolved"): resolves.append((ev as Dictionary)["curio_resolved"]) Kit.check("通关三选一恰呈现 2 次、恰兑付 2 次(L1/L2 后——末关不发;resolves 含节点回执另计)", picks.size() == 2 and resolves.size() == 2 + node_picks) - Kit.check("奖励节点三选一 ≥1 次(L1 链必经 1 个;L2/L3 图可选路经若干——实际经停 %d)" % node_picks, node_picks >= 1) + # 奖励节点在生成图里是可选路位(旧单链模板的「L1 必经 1 个」形状前提随模板消亡)—— + # 接线证明拆两半:图侧每关含 ≥1 奖励节点,行进侧种子扫描内确有经停(扫描零经停=三选一接线不可达) + var reward_levels := 0 + for li in range(1, int(_db.level.rows.size()) + 1): + var rc := 0 + for n in _level_active_nodes(li): + if String(n.get("type", "")) == "reward": + rc += 1 + if rc >= 1: + reward_levels += 1 + Kit.check("三关生成图各含 ≥1 奖励节点(节点三选一候选位在图)", reward_levels == 3) + # 行进侧接线证明走真实选路入口:满配打赢入口战→岔口点选奖励分支→三选一候选可取且停点在途 + # (确定性评分策略恒选战斗/恢复分支,奖励位不在扫描路由上——旧「链必经」形状断言的替代形态) + var adr_rw = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(42)) + adr_rw.set_hero_override(900, 400) + Kit.check("奖励分支场景冒险启动(seed 42 满配)", adr_rw.start_adventure(42)) + var r_rw = adr_rw.runner() + var rw_st := _adv_st() + var rw_cands: Array = [] + var rw_guard := 30 + while r_rw != null and rw_guard > 0: + rw_guard -= 1 + if r_rw.awaiting_choice(): + var rw_id := -1 + for c in r_rw.next_candidates(): + if String((c as Dictionary).get("type", "")) == "reward": + rw_id = int((c as Dictionary).get("id", -1)) + break + if rw_id >= 0: + r_rw.pick_next_node(rw_id) + rw_cands = (adr_rw.node_curio_candidates() as Dictionary).get("candidates", []) as Array + break + var rw_ids: Array = r_rw.next_candidate_ids() + if rw_ids.is_empty(): + break + r_rw.pick_next_node(int(rw_ids[0])) + continue + var b_rw = r_rw.current_battle() + if b_rw == null: + break + autoplay._autoplay_battle(r_rw, b_rw, rw_st, null) + var rw_pending_ok := false + if not rw_cands.is_empty(): + adr_rw.present_node_curio(rw_cands) + rw_pending_ok = not (adr_rw._node_curio_pending as Dictionary).is_empty() + var rw_pc: Dictionary = {} + for c in rw_cands: + var cd := c as Dictionary + if String(cd.get("kind", "")) != "feat_ticket": + rw_pc = cd + break + if rw_pc.is_empty() and not rw_cands.is_empty(): + rw_pc = rw_cands[0] as Dictionary + var rw_pick_ok := false + if rw_pending_ok and not rw_pc.is_empty(): + rw_pick_ok = adr_rw.apply_node_curio(String(rw_pc.get("kind", "")), + String(rw_pc.get("tier", "")), String(rw_pc.get("feat_id", ""))) + Kit.check("奖励分支点选→三选一停点在途且兑付受理(候选 %d 件)" % rw_cands.size(), + rw_pending_ok and rw_pick_ok) var node_resolves := 0 for r0 in resolves: if String((r0 as Dictionary).get("context", "")) == "reward_node": @@ -260,7 +319,7 @@ func _smoke_adventure() -> void: # ⑥ 重复宣布拒绝(settle 后再调 _on_level_finished——摘要零变化) var adr6 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(7)) adr6.set_hero_override(900, 400) - Kit.check("重复拒绝场景:冒险启动(seed 7——固定模板 0 同上)", adr6.start_adventure(7, {"first_script": true})) + Kit.check("重复拒绝场景:冒险启动(seed 7——生成图确定同上)", adr6.start_adventure(7)) adr6.runner().pick_next_node(int(adr6.runner().next_candidate_ids()[0])) # 入口停点 pick 进首战 var b6 = adr6.runner().current_battle() var st6 := _adv_st() @@ -284,13 +343,13 @@ func _smoke_adventure() -> void: Kit.check("每战完成入账恰一次(gain %d = battles %d——GP 袋件入账单列)" % [gains, int(st["battles"])], gains == int(st["battles"])) Kit.check("钱包 clear 恰一次且为末事件(关卡收束不清——GP 冒险收束清零归冒险结构)", clears == 1 and wallet.size() > 0 and String(wallet[wallet.size() - 1]["event"]) == "clear") var shop_seq: Array = st["shop_gp_seq"] - Kit.check("L2 商店房到达余额 > 0(%d——L1 积累跨关延续;两关商店房必经各 1)" % (shop_seq[0] if shop_seq.size() > 0 else -1), shop_seq.size() == 2 and int(shop_seq[0]) > 0) + Kit.check("三关商店房各到达一次(每关入口→终点路径恰穿一店的驱动投影)且首达余额 > 0(%d——GP 跨关延续)" % (shop_seq[0] if shop_seq.size() > 0 else -1), shop_seq.size() == 3 and int(shop_seq[0]) > 0) # ⑧ 加算区同账(手工场景: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(900, 400) - if not a8.start_adventure(s, {"first_script": true}): # 固定模板 0——种子扫描语义按现行图 + if not a8.start_adventure(s): # 种子扫描语义按生成图(同种子同图) continue if _adv_drive_to_shop(a8, _adv_st(), autoplay): same = {"a": a8, "s": s} @@ -335,7 +394,7 @@ func _smoke_adventure() -> void: # ⑩ 放弃(战斗中拒绝 · 非战斗期受理同失败型结算) var a10 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(15)) a10.set_hero_override(900, 400) - Kit.check("放弃场景:冒险启动(seed 15——固定模板 0 同上)", a10.start_adventure(15, {"first_script": true})) + Kit.check("放弃场景:冒险启动(seed 15——生成图确定同上)", a10.start_adventure(15)) Kit.check("放弃场景:入口停点 pick 进首战(停点前移批 2026-09-23 驱动口径)", a10.runner().pick_next_node(int(a10.runner().next_candidate_ids()[0]))) Kit.check("战斗中 abandon 拒绝(战斗域单事务在飞)", not a10.abandon()) var b10 = a10.runner().current_battle() @@ -348,7 +407,7 @@ func _smoke_adventure() -> void: # ⑪ 池耗即枯竭 + 池收缩 + 满血回 0(TP0——white-box:耗尽 attack/ap 令牌后候选零出现; # 无种类次数上限,「达上限剔除」语义由牌库耗尽承载) var a11 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(11)) - Kit.check("剔牌库/替换场景:冒险启动(seed 11——道具解锁集只含 wide_scout_band)", a11.start_adventure(11, {"first_script": true, "treasure_unlocked": {"wide_scout_band": true}})) + Kit.check("剔牌库/替换场景:冒险启动(seed 11——道具解锁集只含 wide_scout_band)", a11.start_adventure(11, {"treasure_unlocked": {"wide_scout_band": true}})) for di in range(a11._curio_deck.size() - 1, -1, -1): var dk := String((a11._curio_deck[di] as Dictionary).get("kind", "")) if dk == "attack" or dk == "ap": @@ -370,7 +429,7 @@ func _smoke_adventure() -> void: Kit.check("池收缩——40 次重抽零 aux 候选(停点渠道 aux 卡退役,迭代 11 第①批 T7)", not treasure_seen) # 满血回 0 合法(通关回血封顶口径) var a12 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(12)) - a12.start_adventure(12, {"first_script": true}) + a12.start_adventure(12) var cap := {"payload": {}} a12.curio_choice_presented.connect(func(p): cap["payload"] = p) var max_full := int(_db.hero.max_hp) + int(a12._base_override.get("max_hp", 0)) @@ -379,7 +438,7 @@ func _smoke_adventure() -> void: Kit.check("满血通关回血 0 合法(封顶口径——heal_gained=0 非负溢出)", int(cap["payload"].get("heal_gained", -1)) == 0) # ⑬ 奖励节点共享池(同库同池同摘牌;TP0:attack 令牌耗尽后节点域同样零出现) var a13 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(13)) - Kit.check("节点共享池场景:冒险启动(seed 13)", a13.start_adventure(13, {"first_script": true, "treasure_unlocked": {"wide_scout_band": true}})) + Kit.check("节点共享池场景:冒险启动(seed 13)", a13.start_adventure(13, {"treasure_unlocked": {"wide_scout_band": true}})) for di13 in range(a13._curio_deck.size() - 1, -1, -1): if String((a13._curio_deck[di13] as Dictionary).get("kind", "")) == "attack": a13._curio_deck.remove_at(di13) @@ -411,7 +470,7 @@ func _smoke_feats() -> void: Kit.check("feat_table 已加载(30 件=基础 27+迁入 2+卡② 完美保险 1——迭代 31 卡② 换代)", _db.feats != null and (_db.feats.rows as Array).size() == 30) Kit.check("feat_table.validate 零违例(四级 rarity/cap 域/trigger 词表/行键白名单全过)", _db.feats.validate().is_empty()) var a_r0 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(71)) - Kit.check("专长带场景:冒险启动(seed 71)", a_r0.start_adventure(71, {"first_script": true})) + Kit.check("专长带场景:冒险启动(seed 71)", a_r0.start_adventure(71)) var feat_tokens := 0 var tier_counts := {} for t in a_r0._curio_deck: @@ -465,7 +524,7 @@ func _smoke_feats() -> void: var live_rows: FeatTable = _db.feats _db.feats = rt var a_r = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(72)) - Kit.check("专长带场景:冒险启动(seed 72——注入 5 行临时表)", a_r.start_adventure(72, {"first_script": true})) + Kit.check("专长带场景:冒险启动(seed 72——注入 5 行临时表)", a_r.start_adventure(72)) var deck_relic := 0 for t in a_r._curio_deck: if String((t as Dictionary).get("kind", "")) == "relic": @@ -485,7 +544,7 @@ func _smoke_feats() -> void: Kit.check("节点渠道零专长(60 次重抽零 relic——专长在非 Boss 渠道只以专长凭证形态出现)", not node_relic_seen) # T5 专长凭证行为断言(A5 拿到即开 / A6 保底计数——契约④四口径;独立 runner 防污染 a_r 牌库) var a_t = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(91)) - a_t.start_adventure(91, {"first_script": true}) + a_t.start_adventure(91) var stock_before := {} for kk in ["attack", "gp", "heal"]: stock_before[kk] = a_t.pool_stock(kk) @@ -809,12 +868,7 @@ func _fresh_rng(seed_val: int): # ---- ① GP 冒险钱包·到账 ---- -# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义) +# 生成图取数面(图 = f(关卡行参数档, 种子 42)——与 runner 起关同图;节点键 id/type/depth/next) func _level_active_nodes(level_index: int) -> Array: - for row in _db.level.rows: - if int(row.level_index) == level_index: - var tpls: Array = row.get("templates", []) - if not tpls.is_empty(): - return tpls[0].get("nodes", []) - return row.get("nodes", []) - return [] + var gen = NodeMapGenerator.new(_fresh_rng(42)) + return (gen.generate(_db.level_gen_params_row(level_index), 42) as Dictionary).get("nodes", []) diff --git a/game/tests/unit/core/breakpoint_resume_test.gd b/game/tests/unit/core/breakpoint_resume_test.gd index 92323340..bae37e7f 100644 --- a/game/tests/unit/core/breakpoint_resume_test.gd +++ b/game/tests/unit/core/breakpoint_resume_test.gd @@ -147,8 +147,52 @@ func _init() -> void: Kit.check_quiet(adr_i.runner().pick_next_node(int(adr_i.runner().next_candidate_ids()[0])), "P6 续玩选路进首战") Kit.check_quiet(adr_i.runner().current_battle().attune_has("morning_order"), "P6 回局首战装配集仍含 morning_order(停点往返载体态一致)") + # ---- Part 7:生成图换代断言面(快照键换代、停点句柄往返、续玩按种子+参数档重生成同图)---- + var rng_j = load("res://core/rng_service.gd").new() + var sm_j = SaveManagerScript.new(_bp_path) + var adr_j = AdventureRunnerScript.new(db, rng_j) + adr_j.set_save_manager(sm_j) + Kit.check_quiet(adr_j.start_adventure(910042, params), "P7 冒险启动(seed 910042——生成图开局)") + var runner_j = adr_j.runner() + var graph_j: Array = runner_j.nodes() + var bp_j: Dictionary = sm_j.breakpoint_snapshot() + Kit.check_quiet(not bp_j.has("template_pick"), + "P7 断点快照无已抽模板键(模板链路退役——件核面在档)") + Kit.check_quiet(int(bp_j.get("graph_gen_v2", 0)) == 1, + "P7 断点含生成图代差标记键(换代承载)") + var entry_id: int = int(runner_j.next_candidate_ids()[0]) + var mid_id: int = _first_out_id(graph_j, entry_id) + var bp_mid: Dictionary = bp_j.duplicate(true) + bp_mid["node_id"] = mid_id + bp_mid["visited_ids"] = [entry_id] + var adr_m = AdventureRunnerScript.new(db, load("res://core/rng_service.gd").new()) + Kit.check_quiet(adr_m.resume_adventure(bp_mid, params), "P7 中途停点断点续玩受理(句柄落档形态)") + var runner_m = adr_m.runner() + Kit.check_quiet(int(runner_m.current_node_id()) == mid_id, + "P7 停点句柄回读一致(图内 id 单空间寻址)") + Kit.check_quiet(runner_m.visited_ids() == [entry_id], "P7 已到节点序按句柄往返一致") + Kit.check_quiet(runner_m.nodes() == graph_j, "P7 续玩重生成图与中断前逐位同图(断点不存图快照)") + var adr_l = AdventureRunnerScript.new(db, load("res://core/rng_service.gd").new()) + var bp_legacy: Dictionary = bp_mid.duplicate(true) + bp_legacy.erase("graph_gen_v2") + Kit.check_quiet(adr_l.resume_adventure(bp_legacy, params) == false, + "P7 缺生成图代差标记键的旧断点显式拒绝(禁按新句柄空间静默回放)") + var adr_n = AdventureRunnerScript.new(db, load("res://core/rng_service.gd").new()) + Kit.check_quiet(adr_n.start_adventure(910042, params) and adr_n.runner().nodes() == graph_j, + "P7 同种子重开局同图(开局入口复现承载)") + _clear_disk() - quit(Kit.finish("breakpoint_resume_test", "PASS — 断点往返/续玩确定性/幂等/退出清断点/装配存档回归 全绿")) + quit(Kit.finish("breakpoint_resume_test", "PASS — 断点往返/续玩确定性/幂等/退出清断点/装配存档回归/生成图换代 全绿")) + + +# 句柄寻址夹具辅助:取生成图中指定句柄节点的首个出边目标(构造中途停点落档形态) +func _first_out_id(nodes: Array, node_id: int) -> int: + for node in nodes: + var n := node as Dictionary + if int(n.get("id", -1)) == node_id: + var outs: Array = n.get("next", []) + return int(outs[0]) if not outs.is_empty() else -1 + return -1 func _board_fingerprint(b) -> String: diff --git a/game/tests/unit/core/curio_refresh_paid_test.gd b/game/tests/unit/core/curio_refresh_paid_test.gd index de765073..26392adc 100644 --- a/game/tests/unit/core/curio_refresh_paid_test.gd +++ b/game/tests/unit/core/curio_refresh_paid_test.gd @@ -53,12 +53,12 @@ func _init() -> void: Kit.check_quiet(lr.spend_gp(20) == true and int(lr.gp) == 10, "足额支出扣账(30→10)") # ---- ⑥ Y 断点随存(缺陷锚:续玩自参数面整额重注入=用完 Y 退菜单再续玩满血复活)---- - var bp_y := {"level_index": 1, "hero_hp": 10, "attune_refresh_y": 2} + var bp_y := {"level_index": 1, "hero_hp": 10, "attune_refresh_y": 2, "graph_gen_v2": 1} var adr_r = load("res://core/adventure_runner.gd").new(db, load("res://core/rng_service.gd").new()) Kit.check_quiet(adr_r.resume_adventure(bp_y, {"hero_override": {}, "attune_refresh_y": 3}), "续玩受理(最小断点+参数面)") Kit.check_quiet(adr_r._attune_refresh_y == 2, "断点有键=Y 余量随断点还原(花 1 剩 2——不再整额重注入=满血复活堵死)") var adr_o = load("res://core/adventure_runner.gd").new(db, load("res://core/rng_service.gd").new()) - Kit.check_quiet(adr_o.resume_adventure({"level_index": 1, "hero_hp": 10}, {"hero_override": {}, "attune_refresh_y": 3}), "旧断点续玩受理(快照缺键)") + Kit.check_quiet(adr_o.resume_adventure({"level_index": 1, "hero_hp": 10, "graph_gen_v2": 1}, {"hero_override": {}, "attune_refresh_y": 3}), "旧断点续玩受理(快照缺键)") Kit.check_quiet(adr_o._attune_refresh_y == 3, "旧断点缺键=回退参数面整额注入(旧档行为零变化)") quit(Kit.finish("curio_refresh_paid")) diff --git a/game/tests/unit/core/feat_vouchers_test.gd b/game/tests/unit/core/feat_vouchers_test.gd index 0179721c..f529bf49 100644 --- a/game/tests/unit/core/feat_vouchers_test.gd +++ b/game/tests/unit/core/feat_vouchers_test.gd @@ -22,7 +22,7 @@ func _init() -> void: quit(1) return var a = load("res://core/adventure_runner.gd").new(db, load("res://core/rng_service.gd").new()) - if not a.start_adventure(301, {"first_script": true}): + if not a.start_adventure(301): Kit.check_quiet(false, "V9 前置:冒险启动") quit(Kit.finish("feat_vouchers")) return diff --git a/game/tests/unit/core/level_map_type_vis_test.gd b/game/tests/unit/core/level_map_type_vis_test.gd index 1c56bbf8..50762634 100644 --- a/game/tests/unit/core/level_map_type_vis_test.gd +++ b/game/tests/unit/core/level_map_type_vis_test.gd @@ -29,61 +29,71 @@ func _init() -> void: # ---- 夹具 ---- -# 图形态夹具(id = node_depth):单干 0→1→2→3→4→5→6→7 加一条 6→3 跨层跳边。 -# BFS 层:0=0/1=1/2=1/3=2/4=3/5=4/6=5/7=6——锚 6 时候选 3 层距 3 = N+1。 +# 节点构造器(生成图口径:id=图内唯一句柄、depth=自入口最长路径步数、next=出边句柄数组) +static func _gn(id: int, depth: int, type_: String, next_ids: Array) -> Dictionary: + return {"id": id, "type": type_, "depth": depth, "next": next_ids} + + +# 图形态夹具(id = 图内句柄,depth = 自入口沿边严格前进的深度):干 0→1→{2,8},2→3, +# 8→4→5→6,长跳边 6→3(与 2→3 同落节点 3,目标 depth 严格大于源——生成图连线铁律), +# 3 经尾链 9→10→11 汇入终点 7(7 的 depth = 自入口最长路径步数 10)。 +# BFS 层:0=0/1=1/2=1/3=2/4=3/5=4/6=5/7=6(8=2/9=3/10=4/11=5)——锚 6 时候选 3 层距 3 = N+1。 func _graph_nodes() -> Array: return [ - {"node_depth": 0, "type": "battle", "next": [1, 2]}, - {"node_depth": 1, "type": "battle", "next": [2]}, - {"node_depth": 2, "type": "reward", "next": [3]}, - {"node_depth": 3, "type": "rest", "next": [4]}, - {"node_depth": 4, "type": "battle", "next": [5]}, - {"node_depth": 5, "type": "battle", "next": [6]}, - {"node_depth": 6, "type": "battle", "next": [7, 3]}, - {"node_depth": 7, "type": "boss", "next": []}, + _gn(0, 0, "battle", [1, 2]), + _gn(1, 1, "battle", [2, 8]), + _gn(2, 2, "reward", [3]), + _gn(3, 6, "rest", [9]), + _gn(4, 3, "battle", [5]), + _gn(5, 4, "battle", [6]), + _gn(6, 5, "battle", [7, 3]), + _gn(7, 10, "boss", []), + _gn(8, 2, "battle", [4]), + _gn(9, 7, "battle", [10]), + _gn(10, 8, "battle", [11]), + _gn(11, 9, "battle", [7]), ] -# 单链夹具(无 next 键,仿 L1 现役模板形态):编号空间 = 数组下标,node_depth 只出现在战斗/Boss 上 +# 单链退化形态 = 出度 ≤1 的图(显式 next 出边、编号 = id 句柄——无数组下标隐式边) func _chain_nodes() -> Array: return [ - {"node_depth": 0, "type": "battle"}, - {"node_depth": 1, "type": "battle"}, - {"node_depth": 2, "type": "battle"}, - {"node_depth": 3, "type": "battle"}, - {"type": "reward"}, - {"type": "rest"}, - {"node_depth": 4, "type": "boss"}, + _gn(0, 0, "battle", [1]), + _gn(1, 1, "battle", [2]), + _gn(2, 2, "battle", [3]), + _gn(3, 3, "battle", [4]), + _gn(4, 4, "reward", [5]), + _gn(5, 5, "rest", [6]), + _gn(6, 6, "boss", []), ] # 表行夹具的正样本(无跨层跳边的合法图——表加载期只核行级字段,图合法性判据见 validate_graph·卡② T1 换代) func _valid_graph_nodes() -> Array: return [ - {"node_depth": 0, "type": "battle", "next": [1, 2]}, - {"node_depth": 1, "type": "battle", "next": [3]}, - {"node_depth": 2, "type": "reward", "next": [3]}, - {"node_depth": 3, "type": "battle", "next": [4]}, - {"node_depth": 4, "type": "boss", "next": []}, + _gn(0, 0, "battle", [1, 2]), + _gn(1, 1, "battle", [3]), + _gn(2, 1, "reward", [3]), + _gn(3, 2, "battle", [4]), + _gn(4, 3, "boss", []), ] -# 白盒 runner:夹具行集注入 db.level + level_row,停点态直设(anchor_id < 0 = 入口停点) -func _runner(db, nodes: Array, anchor_id: int, candidates: Array, - graph_mode: bool = true, n: int = 2): +# 白盒 runner:夹具行集注入 db.level + level_row,停点态直设(anchor_id < 0 = 入口停点; +# 锚点与候选的编号空间 = 节点 id 句柄——runner._node_id 单点) +func _runner(db, nodes: Array, anchor_id: int, candidates: Array, n: int = 2): var t := LevelTableScript.new() t.info_visible_layers = n t.rows = [{"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes}] db.level = t var r = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new()) r.level_row.assign({"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes}) - r._graph_mode = graph_mode r.visited.clear() r._candidates.assign(candidates) r.node_index = -1 if anchor_id >= 0: for i in nodes.size(): - if int((nodes[i] as Dictionary).get("node_depth", -1)) == anchor_id: + if int((nodes[i] as Dictionary).get("id", -1)) == anchor_id: r.node_index = i break return r @@ -157,15 +167,15 @@ func _test_single_circle(db) -> void: Kit.check("单圈只按锚层取窗:已过近端节点 4(层距 2)仍可见〔fv7〕", vis.has(4)) -# ---- ⑤ 单链退化形态(编号空间 = 数组下标)〔fv1〕---- +# ---- ⑤ 单链退化形态(出度 ≤1 图,编号 = id 句柄)〔fv1〕---- func _test_single_chain_entry(db) -> void: var nodes := _chain_nodes() - var r = _runner(db, nodes, -1, [nodes[0]], false) + var r = _runner(db, nodes, -1, [nodes[0]]) var vis: Array = r.type_visible_ids() - Kit.check("单链退化形态:入口锚层 0,下标 2 可见〔fv1〕", vis.has(2)) - Kit.check("单链退化形态:层距 N+1(下标 3)不可见〔fv1〕", not vis.has(3)) - Kit.check("单链编号空间 = 数组下标:Boss 以末位下标 6 入集(非 node_depth 4)〔fv1〕", + Kit.check("单链退化形态:入口锚层 0,句柄 2 可见〔fv1〕", vis.has(2)) + Kit.check("单链退化形态:层距 N+1(句柄 3)不可见〔fv1〕", not vis.has(3)) + Kit.check("单链编号空间 = 图句柄 id:Boss 以句柄 6 入集(链上层距 4 的战斗节点 4 入雾)〔fv1〕", vis.has(6) and not vis.has(4)) @@ -189,20 +199,37 @@ func _test_n_from_table(db) -> void: # ---- ⑦ 续玩重建同值〔fv7〕---- func _test_resume_rebuild(db) -> void: - var nodes := _graph_nodes() var t := LevelTableScript.new() t.info_visible_layers = 2 - t.rows = [{"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes}] + t.rows = [{"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30}] db.level = t var ledger := {"hero_hp": 10, "gp": 0} var resumed = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new()) - var ok: bool = resumed.start_level_external(1, ledger, {}, Callable(), {}, false, 0, + var ok: bool = resumed.start_level_external(1, ledger, {}, Callable(), {}, false, {"node_id": 2, "visited_ids": [0, 1, 2]}) Kit.check_quiet(ok, "续玩用例:resume_state 重建停点成功") if not ok: return Kit.check_quiet(resumed.awaiting_choice(), "续玩用例:重建后处于选路停点") - var direct = _runner(db, nodes, 2, [nodes[3]]) - direct.visited.assign([nodes[0], nodes[1], nodes[2]]) + # 图源 = 参数化生成图(图 = f(参数档, 种子))——直入对照侧与 resume 侧同吃一张生成图, + # 停点态按句柄对齐:锚 = id 2、visited = id 0/1/2、候选 = id 2 出边指向的节点 + var gnodes: Array = resumed.nodes() + var stop: Dictionary = {} + var by_id := {} + for node in gnodes: + var nid: int = int((node as Dictionary).get("id", -1)) + by_id[nid] = node + if nid == 2: + stop = node + var cand_nodes: Array = [] + for tid in (stop.get("next", []) as Array): + if by_id.has(int(tid)): + cand_nodes.append(by_id[int(tid)]) + var visited_nodes: Array = [] + for vid in [0, 1, 2]: + if by_id.has(vid): + visited_nodes.append(by_id[vid]) + var direct = _runner(db, gnodes, 2, cand_nodes) + direct.visited.assign(visited_nodes) Kit.check("续玩重建同值:resume 路径与同停点直入 type_visible_ids() 逐位一致〔fv7〕", resumed.type_visible_ids() == direct.type_visible_ids()) diff --git a/game/tests/unit/core/meta_progression_forge_test.gd b/game/tests/unit/core/meta_progression_forge_test.gd index 7bbe65c2..c4d63db8 100644 --- a/game/tests/unit/core/meta_progression_forge_test.gd +++ b/game/tests/unit/core/meta_progression_forge_test.gd @@ -313,15 +313,15 @@ func _meta_settle_and_workshop() -> void: Kit.check("开局传参夹具前置:Fork 侧 a 激活 rule 键非空 + Y=Lv2 行表值 2(防断言空转)", rules_e.has("posture_first_strike") and mp_e.attune_refresh_charges() == 2) var p_new: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv_e, mp_e, - {"first_script": true, "treasure_unlocked": {}, "shop_mode": "pool"}) + {"treasure_unlocked": {"wide_scout_band": true}, "shop_mode": "pool"}) Kit.check("主菜单开新冒险参数面:同调装配 = 存档装配集(营地装了什么开局就带什么)", (p_new.get("attunement", []) as Array) == (sv_e.attunement_equipped() as Array)) Kit.check("主菜单开新冒险参数面:Y 刷新池 = 当前等级行表值(营地升几档开局就给几次)", int(p_new.get("attune_refresh_y", -1)) == mp_e.attune_refresh_charges()) Kit.check("主菜单开新冒险参数面:树 rule 键集 = Fork 激活侧 effects(点哪侧带哪侧)", (p_new.get("tree_rules", []) as Array) == rules_e) - Kit.check("主菜单开新冒险参数面:入口特有键(first_script/shop_mode)随 extra 原样并入", - bool(p_new.get("first_script", false)) and String(p_new.get("shop_mode", "")) == "pool") + Kit.check("主菜单开新冒险参数面:入口特有键(treasure_unlocked/shop_mode)随 extra 原样并入", + bool((p_new.get("treasure_unlocked", {}) as Dictionary).get("wide_scout_band", false)) and String(p_new.get("shop_mode", "")) == "pool") var p_resume: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv_e, mp_e, {"treasure_unlocked": {}}) Kit.check("主菜单继续上局参数面:三键与开新冒险同源同值(续玩不吃白板开局)", @@ -333,7 +333,7 @@ func _meta_settle_and_workshop() -> void: var adr_res = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(19)) var bp_min: Dictionary = {"level_index": 1, "node_id": -1, "hero_hp": 40, "gp": 5, "bonus": {"atk": 0, "max_hp": 0, "armor_class": 0}, "shop_purchased": {}, - "template_pick": -1, "master_seed": 777, "rng_counter": 3, "keystone": 0, + "graph_gen_v2": 1, "master_seed": 777, "rng_counter": 3, "keystone": 0, "visited_ids": []} Kit.check("继续上局受理面:resume_adventure 受理三键参数面(尾裁微批b 受理闭环——受理不再吃白板)", adr_res.resume_adventure(bp_min, p_resume)) diff --git a/game/tests/unit/core/pool_case_d_test.gd b/game/tests/unit/core/pool_case_d_test.gd index c22b3805..5f3b4639 100644 --- a/game/tests/unit/core/pool_case_d_test.gd +++ b/game/tests/unit/core/pool_case_d_test.gd @@ -34,7 +34,7 @@ func _init() -> void: # T10——空配置验收闸(三选一池空兜底/商店空货架) var e = load("res://core/adventure_runner.gd").new(db, load("res://core/rng_service.gd").new()) - e.start_adventure(501, {"first_script": true}) + e.start_adventure(501) e._curio_pending = {"candidates": []} Kit.check_quiet(e.skip_empty_curio() and e._curio_pending.is_empty(), "空配置闸:三选一池空=停点整体跳过(skip_empty_curio 消化)〔专长批 T10〕") @@ -65,7 +65,7 @@ func _tiers_of(cands: Array) -> Array: func check_v7(a, db) -> void: - if not a.start_adventure(201, {"first_script": true}): + if not a.start_adventure(201): Kit.check_quiet(false, "V7 前置:冒险启动") return # 构成①:全池含优秀档以上——曝光槽应出 1 格高档(rank≥excellent) @@ -110,7 +110,7 @@ func check_v7(a, db) -> void: func check_v6(b, db) -> void: - if not b.start_adventure(202, {"first_script": true}): + if not b.start_adventure(202): Kit.check_quiet(false, "V6 前置:冒险启动") return # 拿了消耗:直置停点候选后 pick_curio——牌库摘牌单点 @@ -139,7 +139,7 @@ func check_v6(b, db) -> void: func check_v12(m, db) -> void: - if not m.start_adventure(401, {"first_script": true}): + if not m.start_adventure(401): Kit.check_quiet(false, "V12 前置:冒险启动") return var tier_n := {"common": 0, "excellent": 0, "rare": 0, "legendary": 0} @@ -157,7 +157,7 @@ func check_v12(m, db) -> void: func check_v8(c, db) -> void: - if not c.start_adventure(203, {"first_script": true}): + if not c.start_adventure(203): Kit.check_quiet(false, "V8 前置:冒险启动") return # 概率位与 cap 两属性独立:改 cap 不动牌库(概率位)、改牌库不动 cap 字段 diff --git a/game/tests/unit/core/save_manager_domain_test.gd b/game/tests/unit/core/save_manager_domain_test.gd index b24e5847..784b79e3 100644 --- a/game/tests/unit/core/save_manager_domain_test.gd +++ b/game/tests/unit/core/save_manager_domain_test.gd @@ -184,7 +184,7 @@ func _smoke_save() -> void: "level_index": 2, "node_id": 5, "hero_hp": 13, "bonus": {"atk": 2, "max_hp": 5, "armor_class": 1}, "gp": 17, "shop_purchased": {"hp_small": 2, "atk_small": 1}, - "template_pick": 3, + "graph_gen_v2": 1, "master_seed": 424242, "rng_counter": 38, "keystone": 6, "visited_ids": [0, 2, 5], "aux_items": [{"item_id": "aux_scout", "source_class": "reward_node", @@ -205,7 +205,7 @@ func _smoke_save() -> void: var bp_shop: Dictionary = bp.get("shop_purchased", {}) Kit.check("断点七字段往返:商店已购档", int(bp_shop.get("hp_small", -1)) == 2 and int(bp_shop.get("atk_small", -1)) == 1) - Kit.check("断点七字段往返:已抽模板", int(bp.get("template_pick", -1)) == 3) + Kit.check("断点七字段往返:生成图代差标记键", int(bp.get("graph_gen_v2", 0)) == 1) Kit.check("断点附随件往返:主种子/计数器/钥石累计", int(bp.get("master_seed", -1)) == 424242 and int(bp.get("rng_counter", -1)) == 38 and int(bp.get("keystone", -1)) == 6) @@ -237,7 +237,7 @@ func _smoke_save() -> void: fv2f.store_string(JSON.stringify({"version": 2, "meta": {"keystone_balance": 7}, "breakpoint": {"level_index": 1, "node_id": -1, "hero_hp": 40, "gp": 5, "bonus": {"atk": 0, "max_hp": 0, "armor_class": 0}, "shop_purchased": {}, - "template_pick": -1, "master_seed": 777, "rng_counter": 3, "keystone": 0, + "graph_gen_v2": 1, "master_seed": 777, "rng_counter": 3, "keystone": 0, "visited_ids": []}})) fv2f.close() Kit.check("v2 旧档迁移 v3:断点域与 meta 域原样保留(缺新附随件=合法空态)", diff --git a/game/tests/unit/data_config/table_structure_guard_test.gd b/game/tests/unit/data_config/table_structure_guard_test.gd index 2552719a..285d5bad 100644 --- a/game/tests/unit/data_config/table_structure_guard_test.gd +++ b/game/tests/unit/data_config/table_structure_guard_test.gd @@ -72,14 +72,6 @@ static func _errs_has_cross_field(errs: Array) -> bool: return false -# 该关 Boss 节点深度(单终点图的终点编号——数据驱动,禁写死坐标) -func _boss_depth(level_index: int) -> int: - for n in _level_active_nodes(level_index): - if String(n.get("type", "")) == "boss": - return int(n.get("node_depth", -1)) - return -1 - - # 首遇带数据驱动定位(契约 8):该 (L, battle, enemy_id) 的最低 depth_band,禁写死坐标数字 func _first_encounter_band(level_index: int, enemy_id: String) -> int: var best := -1 @@ -94,17 +86,6 @@ func _first_encounter_band(level_index: int, enemy_id: String) -> int: return best -# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义) -func _level_active_nodes(level_index: int) -> Array: - for row in _db.level.rows: - if int(row.level_index) == level_index: - var tpls: Array = row.get("templates", []) - if not tpls.is_empty(): - return tpls[0].get("nodes", []) - return row.get("nodes", []) - return [] - - # ① 冒险表(单行全局 Rule 4 豁免——建表与接线闸;TP0 令牌池:配额结构/件值对/池总量域检) func _smoke_adventure_table() -> void: print("--- 冒险表(AdventureTable——令牌池行值单点:回血/配额/件值对/同调位)---") diff --git a/game/tests/unit/presentation/level_map_info_fog_test.gd b/game/tests/unit/presentation/level_map_info_fog_test.gd index 732900f9..56caa3b0 100644 --- a/game/tests/unit/presentation/level_map_info_fog_test.gd +++ b/game/tests/unit/presentation/level_map_info_fog_test.gd @@ -29,7 +29,7 @@ func _run() -> void: quit(Kit.finish("level_map_info_fog_test")) -# 白盒 runner:夹具行集注入 db.level + level_row,入口停点态(node_index=-1,候选=入边首节点) +# 白盒 runner:夹具行集注入 db.level + level_row,入口停点态(node_index=-1,候选=首层首节点句柄 1) func _runner(db, nodes: Array, candidates: Array, n: int = 2): var t := LevelTableScript.new() t.info_visible_layers = n @@ -37,7 +37,6 @@ func _runner(db, nodes: Array, candidates: Array, n: int = 2): db.level = t var r = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new()) r.level_row.assign({"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes}) - r._graph_mode = true r.node_index = -1 r._candidates.assign(candidates) return r @@ -46,7 +45,7 @@ func _runner(db, nodes: Array, candidates: Array, n: int = 2): # ---- ① 范围外只见结构:雾下节点类型图标与文字均不绘制〔fv2〕---- func _test_fog_hides_type() -> void: - var node := {"node_depth": 3, "type": "battle"} + var node := {"id": 3, "type": "battle", "depth": 3, "next": []} var shown: Dictionary = ScreenScript.node_display(node, 2, [2], false, false) Kit.check("可见集内节点:图标/类型文字均绘制〔fv2〕", bool(shown["draw_icon"]) and bool(shown["draw_label"])) @@ -60,24 +59,24 @@ func _test_fog_hides_type() -> void: func _test_boss_exempt_and_multi_boss() -> void: var type_vis: Array = [1, 7, 9] # 双 Boss 图:7/9 均在可见集 - var boss_a: Dictionary = ScreenScript.node_display({"node_depth": 7, "type": "boss"}, 7, type_vis, false, false) - var boss_b: Dictionary = ScreenScript.node_display({"node_depth": 9, "type": "boss"}, 9, type_vis, false, false) + var boss_a: Dictionary = ScreenScript.node_display({"id": 7, "type": "boss", "depth": 7, "next": []}, 7, type_vis, false, false) + var boss_b: Dictionary = ScreenScript.node_display({"id": 9, "type": "boss", "depth": 9, "next": []}, 9, type_vis, false, false) Kit.check("多终点:两个 Boss 节点均带 Boss 描边〔fv3〕", bool(boss_a["boss_border"]) and bool(boss_b["boss_border"])) Kit.check("Boss 不入雾:可见集与图标/文字三通道均在〔fv3〕", bool(boss_a["draw_icon"]) and bool(boss_a["draw_label"]) and bool(boss_b["draw_icon"])) - var far_boss: Dictionary = ScreenScript.node_display({"node_depth": 9, "type": "boss"}, 9, [1], false, false) + var far_boss: Dictionary = ScreenScript.node_display({"id": 9, "type": "boss", "depth": 9, "next": []}, 9, [1], false, false) Kit.check("Boss 豁免独立于可见集:不在 type_vis 的 Boss 仍出示类型〔fv3〕", bool(far_boss["draw_icon"]) and bool(far_boss["draw_label"])) - var fogged_battle: Dictionary = ScreenScript.node_display({"node_depth": 5, "type": "battle"}, 5, type_vis, false, false) + var fogged_battle: Dictionary = ScreenScript.node_display({"id": 5, "type": "battle", "depth": 5, "next": []}, 5, type_vis, false, false) Kit.check("Boss 豁免不扩散:雾下非 Boss 仍隐类型〔fv3〕", not bool(fogged_battle["draw_icon"])) # ---- ③ 节点内详情图上从不显示〔fv5〕---- func _test_details_never_shown() -> void: - var plain := {"node_depth": 3, "type": "battle"} - var loaded := {"node_depth": 3, "type": "battle", "enemy_id": "goblin_secret", "reward_id": "reward_secret"} + var plain := {"id": 3, "type": "battle", "depth": 3, "next": [4]} + var loaded := {"id": 3, "type": "battle", "depth": 3, "next": [4], "enemy_id": "goblin_secret", "reward_id": "reward_secret"} var a: Dictionary = ScreenScript.node_display(plain, 3, [3], false, true) var b: Dictionary = ScreenScript.node_display(loaded, 3, [3], false, true) var same := true @@ -92,7 +91,7 @@ func _test_details_never_shown() -> void: # ---- ④ label_text 三态文案〔fv2〕---- func _test_label_three_states() -> void: - var node := {"node_depth": 3, "type": "battle"} + var node := {"id": 3, "type": "battle", "depth": 3, "next": [4]} Kit.check("当前位文案=「当前位置」〔fv2〕", String(ScreenScript.node_display(node, 3, [3], true, false)["label_text"]) == "当前位置") Kit.check("候选文案=「战斗 · 可选」〔fv2〕", @@ -103,9 +102,10 @@ func _test_label_three_states() -> void: # ---- 大图(11 层×6 槽含双 Boss)画布拟合与布局包络〔fv8〕---- -# 大图夹具:编号按 12 层×6 槽构造,层 0 入口、末层双 Boss;含层 1 首节点→第 3 层节点的跨层跳边。 -# 跳边目标按 BFS 最短路径提前一层,其后继链随之提前——布局落 11 层(最大槽 7: -# 跳边目标并入第 2 层槽组)。断言按布局实测取最大层/最大槽,画布公式与之同源。 +# 大图夹具:按 12 层×6 槽构造(id = 图内句柄 = 构造序号;depth 随构造序号严格前进——生成图连线铁律), +# 层 0 入口、末层双 Boss(id 61/62,句柄互异、next 恒空);含层 1 首节点→第 3 层节点的跨层长跳边。 +# 跳边目标按 BFS 最短路径提前一层,其后继链随之逐层提前(目标并入第 2 层槽组)——布局落 11 层。 +# 断言按布局实测取最大层/最大槽,画布公式与之同源。 func _big_graph_nodes() -> Array: var layers: Array = [] var total := 0 @@ -118,30 +118,25 @@ func _big_graph_nodes() -> Array: total += 1 layers.append(ids) var nodes: Array = [] - var jump_from := -1 - var jump_to := -1 for l in tiers: for i in layers[l].size(): var nid: int = layers[l][i] - var n := {"node_depth": nid} - if l == 0: - n["type"] = "battle" - n["next"] = layers[1].duplicate() # 入口扇出到全层(层宽 6) - elif l == tiers - 1: - n["type"] = "boss" - n["next"] = [] + var t := "battle" + var next_ids: Array = [] + if l == tiers - 1: + t = "boss" + elif l == 0: + next_ids = layers[1].duplicate() # 入口扇出到全层(层宽 6) else: if i % 5 == 0: - n["type"] = "reward" if l % 2 == 0 else "rest" - else: - n["type"] = "battle" + t = "reward" if l % 2 == 0 else "rest" var w2: int = layers[l + 1].size() - n["next"] = [layers[l + 1][i % w2], layers[l + 1][(i + 1) % w2]] - nodes.append(n) - jump_from = layers[1][0] - jump_to = layers[3][2] + next_ids = [layers[l + 1][i % w2], layers[l + 1][(i + 1) % w2]] + nodes.append({"id": nid, "type": t, "depth": nid, "next": next_ids}) + var jump_from: int = layers[1][0] + var jump_to: int = layers[3][2] for n in nodes: - if int(n["node_depth"]) == jump_from: + if int(n["id"]) == jump_from: (n["next"] as Array).append(jump_to) break return nodes @@ -188,9 +183,9 @@ func _test_big_graph_fit(db) -> void: var boss_ids: Array = [] for node in nodes: if String(node["type"]) == "boss": - boss_ids.append(int(node["node_depth"])) + boss_ids.append(int(node["id"])) Kit.check("大图呈现:两个终点 Boss 均有描边标记〔fv8〕", disp.has(boss_ids[0]) and disp.has(boss_ids[1]) and bool(disp[boss_ids[0]]["boss_border"]) and bool(disp[boss_ids[1]]["boss_border"])) - Kit.check("大图呈现:入口停点远处战斗节点(编号 32,层 6)类型入雾〔fv8〕", not bool(disp[32]["draw_icon"])) + Kit.check("大图呈现:入口停点远处战斗节点(句柄 32,层 6)类型入雾〔fv8〕", not bool(disp[32]["draw_icon"])) screen.queue_free() From 3849211621765d330bd2dc39fa84f279e964e46f Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sat, 24 Oct 2026 19:09:04 +0800 Subject: [PATCH 09/20] =?UTF-8?q?test(=E8=BF=AD=E4=BB=A335=E5=8D=A1?= =?UTF-8?q?=E2=91=A1T3=20=E8=A1=A5=E5=8F=A3):=20V2=20=E5=A4=9A=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E4=B8=8E=20V6=20=E5=AE=9A=E4=BD=8D=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E4=B8=A4=E6=9D=A1=E7=9C=9F=E5=AE=9E=E5=85=A5=E5=8F=A3=E6=96=AD?= =?UTF-8?q?=E8=A8=80=E8=A1=A5=E4=BD=8D=EF=BC=8B=E6=AD=BB=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=E6=B8=85=E6=89=AB=E2=80=94=E2=80=94=E2=91=A0=20node=5Fmap=5Fte?= =?UTF-8?q?mplates=20=E5=85=A5=E5=8F=A3=E5=81=9C=E7=82=B9=E5=80=99?= =?UTF-8?q?=E9=80=89=E6=95=B0=E6=94=B9=E5=88=A4=E3=80=8C=EF=BC=9D=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E6=A1=A3=20entry=5Fcount=E3=80=8D=EF=BC=88L3=20?= =?UTF-8?q?=E6=A1=A3=E5=AE=9E=E6=B5=8B=202=EF=BC=89=E5=B9=B6=E5=8A=A0?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E5=85=A5=E5=8F=A3=E5=8F=A5=E6=9F=84=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=E8=BF=9B=E5=85=A5=E6=96=AD=E8=A8=80=EF=BC=88=E5=8F=98?= =?UTF-8?q?=E5=BC=82=E3=80=8C=E5=80=99=E9=80=89=E5=86=99=E6=AD=BB=E5=8D=95?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E3=80=8D=E7=9A=84=E6=8B=A6=E6=88=AA=E9=9D=A2?= =?UTF-8?q?=EF=BC=8C=E5=8E=9F=20>=3D1=20=E5=86=99=E6=B3=95=E6=8B=A6?= =?UTF-8?q?=E4=B8=8D=E4=BD=8F=EF=BC=89=EF=BC=9B=E2=91=A1=20adventure=5Ffea?= =?UTF-8?q?ts=20=E6=96=B0=E5=A2=9E=20=5Fdrive=5Fto=5Fpref=20=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E9=80=89=E8=B7=AF=E9=A9=B1=E5=8A=A8=EF=BC=88=E6=BB=A1?= =?UTF-8?q?=E9=85=8D=E8=B5=A2=E9=80=94=E4=B8=AD=E6=88=98=E2=86=92=E5=B2=94?= =?UTF-8?q?=E5=8F=A3=E6=8C=89=E5=81=8F=E5=A5=BD=E7=82=B9=E9=80=89=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E6=8D=AE=E6=AD=A4=E8=90=BD=E4=B8=89=E6=96=AD=E8=A8=80?= =?UTF-8?q?=EF=BC=9A=E5=A5=96=E5=8A=B1=E6=94=AF=E8=B7=AF=E7=BB=8F=E5=81=9C?= =?UTF-8?q?=E5=91=88=E4=B8=89=E9=80=89=E4=B8=80=E5=80=99=E9=80=89=E5=9C=A8?= =?UTF-8?q?=E9=80=94=E3=80=81=E7=82=B9=E9=80=89=E5=85=91=E4=BB=98=E5=8F=97?= =?UTF-8?q?=E7=90=86=E3=80=81=E8=BF=9B=E6=88=98=E8=BD=BD=E8=8D=B7=20node?= =?UTF-8?q?=5Fdepth=EF=BC=9D=E8=8A=82=E7=82=B9=E6=B7=B1=E5=BA=A6=E4=B8=94?= =?UTF-8?q?=E5=91=BD=E4=B8=AD=E8=8A=82=E7=82=B9=E5=8F=A5=E6=9F=84=E2=89=A0?= =?UTF-8?q?=E6=B7=B1=E5=BA=A6=EF=BC=88=E5=8F=98=E5=BC=82=E3=80=8Cenc=20?= =?UTF-8?q?=E5=8F=96=E5=8F=A5=E6=9F=84=E3=80=8D=E6=8B=A6=E6=88=AA=E9=9D=A2?= =?UTF-8?q?=E3=80=94v6=E3=80=95=EF=BC=89=EF=BC=9B=E2=91=A2=20round=5Fflow?= =?UTF-8?q?=20=E6=A8=A1=E6=9D=BF=E6=97=B6=E4=BB=A3=E5=AD=A4=E5=84=BF?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integration/node_map_templates_test.gd | 10 +- .../round_flow_skill_pool_test.gd | 3 - game/tests/unit/core/adventure_feats_test.gd | 107 +++++++++++------- 3 files changed, 73 insertions(+), 47 deletions(-) diff --git a/game/tests/integration/node_map_templates_test.gd b/game/tests/integration/node_map_templates_test.gd index eb662953..45d8a2fc 100644 --- a/game/tests/integration/node_map_templates_test.gd +++ b/game/tests/integration/node_map_templates_test.gd @@ -59,8 +59,14 @@ func _smoke_generated_map_determinism() -> void: Kit.check("冒险模式起关受理(入口停点)", runner2.start_level_external(3, {"hero_hp": 20, "gp": 0}, {}, Callable(self, "_smoke_rest_stub"), {}, true)) Kit.check("起关产图零计数器消费(同图不依赖取数号位)", rng_c.counter() == counter_before) - Kit.check("入口停点候选 = 生成图全部无入边节点(单入口时恰 1)", - (runner2.next_candidates() as Array).size() >= 1) + var params3: Dictionary = _db.level_gen_params_row(3) + var entry_count: int = int(params3.get("entry_count", -1)) + Kit.check("入口停点候选数=参数档入口个数(L3 档 %d——多入口首跳即选路,禁写死单入口)" % entry_count, + entry_count >= 1 and (runner2.next_candidates() as Array).size() == entry_count) + if entry_count >= 2: + var eids: Array = runner2.next_candidate_ids() + Kit.check("第二入口经句柄显式进入(句柄 %d)" % int(eids[1]), + runner2.pick_next_node(int(eids[1])) and int(runner2.current_node_id()) == int(eids[1])) # ④ 开局种子承载:新档(首局)种子 42 两次开局逐位同图,且与生成器直取一致 DirAccess.remove_absolute(_bp_path) # 测试隔离——上轮落盘残留先删,断点面从零档起 var sv_w = SaveManagerScript.new(_bp_path) diff --git a/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd b/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd index 5bd587a5..066a3ad0 100644 --- a/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd +++ b/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd @@ -339,9 +339,6 @@ func _first_encounter_band(level_index: int, enemy_id: String) -> int: return best -# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义) - - # 生成图取数面(图 = f(关卡行参数档, 种子 42)——与 runner 起关同图;节点键 id/type/depth/next) func _level_active_nodes(level_index: int) -> Array: var rng = load("res://core/rng_service.gd").new() diff --git a/game/tests/unit/core/adventure_feats_test.gd b/game/tests/unit/core/adventure_feats_test.gd index bc809a18..d6ec9e6e 100644 --- a/game/tests/unit/core/adventure_feats_test.gd +++ b/game/tests/unit/core/adventure_feats_test.gd @@ -163,54 +163,33 @@ func _smoke_adventure() -> void: if rc >= 1: reward_levels += 1 Kit.check("三关生成图各含 ≥1 奖励节点(节点三选一候选位在图)", reward_levels == 3) - # 行进侧接线证明走真实选路入口:满配打赢入口战→岔口点选奖励分支→三选一候选可取且停点在途 - # (确定性评分策略恒选战斗/恢复分支,奖励位不在扫描路由上——旧「链必经」形状断言的替代形态) - var adr_rw = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(42)) - adr_rw.set_hero_override(900, 400) - Kit.check("奖励分支场景冒险启动(seed 42 满配)", adr_rw.start_adventure(42)) - var r_rw = adr_rw.runner() - var rw_st := _adv_st() - var rw_cands: Array = [] - var rw_guard := 30 - while r_rw != null and rw_guard > 0: - rw_guard -= 1 - if r_rw.awaiting_choice(): - var rw_id := -1 - for c in r_rw.next_candidates(): - if String((c as Dictionary).get("type", "")) == "reward": - rw_id = int((c as Dictionary).get("id", -1)) - break - if rw_id >= 0: - r_rw.pick_next_node(rw_id) - rw_cands = (adr_rw.node_curio_candidates() as Dictionary).get("candidates", []) as Array - break - var rw_ids: Array = r_rw.next_candidate_ids() - if rw_ids.is_empty(): - break - r_rw.pick_next_node(int(rw_ids[0])) - continue - var b_rw = r_rw.current_battle() - if b_rw == null: - break - autoplay._autoplay_battle(r_rw, b_rw, rw_st, null) - var rw_pending_ok := false - if not rw_cands.is_empty(): - adr_rw.present_node_curio(rw_cands) - rw_pending_ok = not (adr_rw._node_curio_pending as Dictionary).is_empty() + # 行进侧两断言走真实选路入口(满配赢下途中战→岔口按偏好显式点选):奖励支路经停即呈三选一、 + # 进战载荷带节点深度——确定性评分策略恒选推进支路,奖励位不在扫描路由上,故显式点选验证接线 + var rw: Dictionary = _drive_to_pref(autoplay, "reward") + Kit.check("奖励分支点选→三选一候选可取且停点在途(候选 %s 件)" % str((rw.get("cands", []) as Array).size()), + bool(rw.get("pending", false)) and (rw.get("cands", []) as Array).size() >= 1) var rw_pc: Dictionary = {} - for c in rw_cands: + for c in (rw.get("cands", []) as Array): var cd := c as Dictionary if String(cd.get("kind", "")) != "feat_ticket": rw_pc = cd break - if rw_pc.is_empty() and not rw_cands.is_empty(): - rw_pc = rw_cands[0] as Dictionary + if rw_pc.is_empty() and not (rw.get("cands", []) as Array).is_empty(): + rw_pc = (rw.get("cands", []) as Array)[0] as Dictionary var rw_pick_ok := false - if rw_pending_ok and not rw_pc.is_empty(): - rw_pick_ok = adr_rw.apply_node_curio(String(rw_pc.get("kind", "")), - String(rw_pc.get("tier", "")), String(rw_pc.get("feat_id", ""))) - Kit.check("奖励分支点选→三选一停点在途且兑付受理(候选 %d 件)" % rw_cands.size(), - rw_pending_ok and rw_pick_ok) + if not rw_pc.is_empty() and rw.get("adr") != null: + rw_pick_ok = rw["adr"].apply_node_curio(String(rw_pc.get("kind", "")), String(rw_pc.get("tier", "")), String(rw_pc.get("feat_id", ""))) + Kit.check("奖励三选一点选兑付受理(件=%s)" % String(rw_pc.get("kind", "")), rw_pick_ok) + var enc_run: Dictionary = _drive_to_pref(autoplay, "battle") + var enc_probe: Dictionary = enc_run.get("enc", {}) as Dictionary + var node_probe: Dictionary = enc_run.get("node", {}) as Dictionary + # 定位信息两样(契约 2):战斗侧收到的 node_depth=节点深度,且命中节点句柄≠深度—— + # 变异「enc 取句柄」必红(V6) + Kit.check("进战载荷两样=关卡序号+节点深度(实测 depth=%s 句柄=%s)〔v6〕" % [str(enc_probe.get("node_depth", -1)), str(node_probe.get("id", -1))], + not enc_probe.is_empty() + and int(enc_probe.get("node_depth", -1)) == int(node_probe.get("depth", -1)) + and int(enc_probe.get("level_index", -1)) == 1 + and int(node_probe.get("id", -1)) != int(node_probe.get("depth", -1))) var node_resolves := 0 for r0 in resolves: if String((r0 as Dictionary).get("context", "")) == "reward_node": @@ -828,6 +807,50 @@ func _adv_drive_to_shop(a, st: Dictionary, autoplay) -> bool: # 手工场景状态袋(_drive_level/_autoplay_battle 消费的最小字段族——run_adventure st 的同构子集) +# 真实选路驱动到偏好支路:赢下途中战斗,遇候选含目标类型即显式点选(reward=首个奖励节点、 +# battle=首个句柄≠深度的战斗节点);返回 {node, enc, cands, pending, adr} +func _drive_to_pref(autoplay, prefer: String) -> Dictionary: + var adr = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(42)) + adr.set_hero_override(900, 400) + var out := {"node": {}, "enc": {}, "cands": [] as Array, "pending": false, "adr": adr} + if not adr.start_adventure(42): + return out + var r = adr.runner() + var st := _adv_st() + var guard := 30 + while r != null and guard > 0: + guard -= 1 + if r.awaiting_choice(): + var hit: Dictionary = {} + for c in r.next_candidates(): + var cd := c as Dictionary + if prefer == "reward" and String(cd.get("type", "")) == "reward": + hit = cd + break + if prefer == "battle" and String(cd.get("type", "")) == "battle" and int(cd.get("id", -1)) != int(cd.get("depth", -1)): + hit = cd + break + if not hit.is_empty(): + r.pick_next_node(int(hit.get("id", -1))) + out["node"] = hit + if prefer == "battle": + out["enc"] = r.current_battle().encounter + else: + var cands: Array = (adr.node_curio_candidates() as Dictionary).get("candidates", []) as Array + adr.present_node_curio(cands) + out["cands"] = cands + out["pending"] = not (adr._node_curio_pending as Dictionary).is_empty() + return out + var ids: Array = r.next_candidate_ids() + if ids.is_empty(): + return out + r.pick_next_node(int(ids[0])) + continue + var b = r.current_battle() + if b == null: + return out + autoplay._autoplay_battle(r, b, st, null) + return out 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": "", From 78924fddbd801d0203b15cb77bae49297ae9c209 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sat, 24 Oct 2026 19:11:22 +0800 Subject: [PATCH 10/20] =?UTF-8?q?style(=E8=BF=AD=E4=BB=A335=E5=8D=A1?= =?UTF-8?q?=E2=91=A1T3):=20=E9=9D=99=E6=80=81=E6=89=AB=E6=8F=8F=20S9=20?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E9=97=B8=E5=BD=92=E9=9B=B6=E2=80=94=E2=80=94?= =?UTF-8?q?R1=20=E6=97=A0=E7=B1=BB=E5=9E=8B=E5=AE=B9=E5=99=A8=E4=B8=89?= =?UTF-8?q?=E5=A4=84=E5=8F=82=E6=95=B0=E5=8C=96=EF=BC=88level=5Frunner=20?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E6=A1=A3/=E7=94=9F=E6=88=90=E5=9B=BE?= =?UTF-8?q?=E6=8C=81=E6=9C=89=E5=8F=98=E9=87=8F=E3=80=81level=5Ftable=20va?= =?UTF-8?q?lidate=5Fgraph=20=E9=80=90=E8=8A=82=E7=82=B9=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E6=94=B9=20Dictionary[Variant,=20Variant]=EF=BC=89=EF=BC=9B?= =?UTF-8?q?=E4=BA=A7=E5=93=81=E7=A0=81=E7=B1=BB=E5=9E=8B=E9=97=B8=E4=B8=8E?= =?UTF-8?q?=20node=5Fmap=5Ftemplates=20=E5=AE=BF=E4=B8=BB=E5=90=8C?= =?UTF-8?q?=E8=B7=91=E5=A4=8D=E9=AA=8C=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/core/level_runner.gd | 4 ++-- game/data/schemas/level_table.gd | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/game/core/level_runner.gd b/game/core/level_runner.gd index cb9927bc..300534b3 100644 --- a/game/core/level_runner.gd +++ b/game/core/level_runner.gd @@ -295,13 +295,13 @@ func _find_level_row(level_index: int) -> Dictionary: # LevelTable.validate_graph(与产出侧自证同源规格、分工不重叠);违规=起关拒绝,不降级不回退。 func _assign_generated_graph(row: Dictionary, seed_val: int) -> bool: var level_index: int = int(row.get("level_index", 0)) - var params: Dictionary = db.level_gen_params_row(level_index) + var params: Dictionary[Variant, Variant] = db.level_gen_params_row(level_index) if params.is_empty(): push_error("[关卡节点地图] level %d 无参数档行(level_gen_params 缺行——配置错误)" % level_index) return false if _generator == null: _generator = NodeMapGenerator.new(rng) - var graph: Dictionary = _generator.generate(params, seed_val) + var graph: Dictionary[Variant, Variant] = _generator.generate(params, seed_val) if graph.is_empty() or _generator.is_fatal(): push_error("[关卡节点地图] level %d 生成图失败(参数无解或产出不合格——无兜底图)" % level_index) return false diff --git a/game/data/schemas/level_table.gd b/game/data/schemas/level_table.gd index a0753aa3..d9aea353 100644 --- a/game/data/schemas/level_table.gd +++ b/game/data/schemas/level_table.gd @@ -55,7 +55,7 @@ static func validate_graph(nodes: Array) -> Array: var by_id := {} var indeg := {} for i in nodes.size(): - var node: Dictionary = nodes[i] + var node: Dictionary[Variant, Variant] = nodes[i] for k in ["id", "type", "depth", "next"]: if not node.has(k): errs.append({"category": "structural", "message": "节点[%d] 缺字段 %s" % [i, k]}) From 548cd2da74b7dd573e056c222d46e4c587f67faf Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sat, 24 Oct 2026 19:13:39 +0800 Subject: [PATCH 11/20] =?UTF-8?q?fix(=E8=BF=AD=E4=BB=A335=E5=8D=A1?= =?UTF-8?q?=E2=91=A1T3=20=E4=BB=B6=E6=A0=B8):=20=E4=B8=A4=E6=9E=9A?= =?UTF-8?q?=E7=94=BB=E9=9D=A2=E6=8E=A2=E9=92=88=E8=B5=B7=E5=85=B3=E5=B0=BE?= =?UTF-8?q?=E5=8F=82=E6=AE=8B=E7=95=99=E6=B8=85=E7=90=86=EF=BC=88board=5Fs?= =?UTF-8?q?hot/type=5Fglow=5Fshot=20=E4=BB=8D=E4=BC=A0=E5=B7=B2=E9=80=80?= =?UTF-8?q?=E5=BD=B9=E7=9A=84=20template=5Fpick=3D0=EF=BC=8C=20arity=20?= =?UTF-8?q?=E4=B8=8D=E7=AC=A6=E4=BC=9A=E8=A2=AB=E6=B8=B2=E6=9F=93=E9=97=B8?= =?UTF-8?q?=E6=8B=A6=E4=B8=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/probe/board_shot_probe.gd | 2 +- game/probe/type_glow_shot_probe.gd | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/game/probe/board_shot_probe.gd b/game/probe/board_shot_probe.gd index b5c7bb67..9b9340d6 100644 --- a/game/probe/board_shot_probe.gd +++ b/game/probe/board_shot_probe.gd @@ -54,7 +54,7 @@ func _ready() -> void: var rng = load("res://core/rng_service.gd").new() rng.set_master_seed(420000 + level_index) var runner = load("res://core/level_runner.gd").new(db, rng) - if not runner.start_level(level_index, 420000 + level_index, {}, 0): + if not runner.start_level(level_index, 420000 + level_index): push_error("[board_shot] 开关失败 level=%d" % level_index) get_tree().quit(1) return diff --git a/game/probe/type_glow_shot_probe.gd b/game/probe/type_glow_shot_probe.gd index f7b9c385..73fd27fe 100644 --- a/game/probe/type_glow_shot_probe.gd +++ b/game/probe/type_glow_shot_probe.gd @@ -46,7 +46,7 @@ func _ready() -> void: var rng = load("res://core/rng_service.gd").new() rng.set_master_seed(421001) var runner = load("res://core/level_runner.gd").new(db, rng) - if not runner.start_level(1, 421001, {}, 0): + if not runner.start_level(1, 421001): push_error("[type_glow_shot] 开局失败") get_tree().quit(1) return From 6b213a21ea839e13a5ea80b0d8f8ce6369a4c4ec Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sat, 24 Oct 2026 19:16:23 +0800 Subject: [PATCH 12/20] =?UTF-8?q?test(=E8=BF=AD=E4=BB=A335=E5=8D=A1?= =?UTF-8?q?=E2=91=A1T3=20S3/S4/S5=20=E6=94=B6=E5=8F=A3):=20=E5=95=86?= =?UTF-8?q?=E5=BA=97=E7=BB=8F=E6=B5=8E=E5=AE=BF=E4=B8=BB=E6=96=AD=E8=A8=80?= =?UTF-8?q?=E9=9D=A2=E6=8D=A2=E4=BB=A3=EF=BC=88147=E2=86=92176=20=E6=9D=A1?= =?UTF-8?q?=EF=BC=89=E2=80=94=E2=80=94=E5=9B=BE=E6=BA=90=E5=85=A8=E9=9D=A2?= =?UTF-8?q?=E6=94=B9=E5=8F=82=E6=95=B0=E6=A1=A3=E7=94=9F=E6=88=90=E5=9B=BE?= =?UTF-8?q?=EF=BC=88=5Fgenerated=5Fgraph=20=E5=8D=95=E7=82=B9=E5=8F=96?= =?UTF-8?q?=E6=95=B0=EF=BC=8B=5Flevel=5Factive=5Fnodes=20=E9=9A=8F?= =?UTF-8?q?=E5=8A=A8=EF=BC=8C=E6=A8=A1=E6=9D=BF=E5=85=9C=E5=BA=95=E6=AE=B5?= =?UTF-8?q?=E6=95=B4=E4=BD=93=E5=88=A0=E9=99=A4=EF=BC=89=EF=BC=9B=E2=91=A0?= =?UTF-8?q?=20S4=20=E5=BD=A2=E7=8A=B6=E5=86=99=E6=AD=BB=E6=96=AD=E8=A8=80?= =?UTF-8?q?=E9=80=80=E5=BD=B9=EF=BC=9A=E8=8A=82=E7=82=B9=E6=95=B0/?= =?UTF-8?q?=E4=BA=94=E5=9E=8B=E8=AE=A1=E6=95=B0/=E5=B2=94=E5=8F=A3?= =?UTF-8?q?=E6=95=B0/=E7=BB=88=E7=82=B9=E6=95=B0/=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E6=95=B0=E6=94=B9=E5=9B=BE=E8=AF=BB=E6=95=B0=E6=80=A7=E8=B4=A8?= =?UTF-8?q?=E5=B9=B6=E5=8A=A0=E9=9D=9E=E7=A9=BA=E6=89=AB=E6=8F=8F=E5=89=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=88=E5=90=AB=20L1=20=E7=BB=88=E7=82=B9=E6=81=B0?= =?UTF-8?q?=E4=B8=80=E3=80=81L2/L3=20=E5=A4=9A=E7=BB=88=E7=82=B9=E5=A4=A7?= =?UTF-8?q?=E5=B2=94=E5=8F=A3=E6=8A=95=E5=BD=B1=E3=80=81L1=20=E7=BB=88?= =?UTF-8?q?=E7=82=B9=E6=B7=B1=E5=BA=A6=EF=BC=9D=E5=8F=82=E6=95=B0=E6=A1=A3?= =?UTF-8?q?=E5=B1=82=E9=AB=98=EF=BC=89=EF=BC=9B=E2=91=A1=20S3=20=E5=95=86?= =?UTF-8?q?=E5=BA=97=E7=BC=96=E6=8E=92=E4=B8=89=E6=9D=A1=E6=80=A7=E8=B4=A8?= =?UTF-8?q?=E6=96=AD=E8=A8=80=EF=BC=88=E6=AF=8F=E7=BB=88=E7=82=B9=E5=89=8D?= =?UTF-8?q?=E4=B8=80=E4=BD=8D=E6=81=B0=E4=B8=80=E5=BA=97=C2=B7=E5=BA=97?= =?UTF-8?q?=E6=95=B0=EF=BC=9D=E7=BB=88=E7=82=B9=E6=95=B0=C2=B7=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E2=86=92=E7=BB=88=E7=82=B9=E5=85=A8=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=20DFS=20=E6=81=B0=E7=A9=BF=E4=B8=80=E5=BA=97=EF=BC=89=C3=97=20?= =?UTF-8?q?=E4=B8=89=E5=85=B3=E5=8F=82=E6=95=B0=E6=A1=A3=20=C3=97=20?= =?UTF-8?q?=E7=A7=8D=E5=AD=90=2042=EF=BC=8B42=E2=80=9361=20=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=EF=BC=8C=E7=A1=AC=E7=BC=96=E7=A0=81=20node=5Fdepth=20?= =?UTF-8?q?8/9=C2=B7=E7=A9=BF=E8=8A=82=E7=82=B9=208=C2=B7L1=20=E8=A1=8C?= =?UTF-8?q?=E9=9B=86=E9=9B=B6=E6=94=B9=E5=8A=A8=E9=80=80=E5=BD=B9=EF=BC=8C?= =?UTF-8?q?=E8=A1=8C=E8=BF=9B=E9=9D=A2=E4=BF=9D=E7=95=99=E3=80=8C=E5=88=B0?= =?UTF-8?q?=E5=BA=97=E5=BD=93=E5=89=8D=E8=8A=82=E7=82=B9=E5=9E=8B=E5=88=AB?= =?UTF-8?q?=EF=BC=9Dshop=20=E4=B8=94=E5=87=BA=E8=BE=B9=E6=81=B0=E4=B8=80?= =?UTF-8?q?=E6=9D=A1=E6=8C=87=E7=BB=88=E7=82=B9=E3=80=8D=E4=B8=8E=E3=80=8C?= =?UTF-8?q?min/max=20=E4=B8=A4=E8=BD=B4=E8=B7=AF=E5=BE=84=E7=A9=BF?= =?UTF-8?q?=E5=BA=97=E6=81=B0=E4=B8=80=C2=B7=E9=80=90=E8=B7=B3=E4=B8=BA?= =?UTF-8?q?=E5=9B=BE=E5=87=BA=E8=BE=B9=E3=80=8D=EF=BC=9B=E2=91=A2=20S5=20?= =?UTF-8?q?=E8=B7=A8=E8=A1=A8=E8=A6=86=E7=9B=96=E6=94=B9=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E9=93=BE=EF=BC=88=E6=95=8C=E4=BE=A7=E8=90=BD?= =?UTF-8?q?=E5=B8=A6=E2=89=A5=E8=AF=B7=E6=B1=82=E6=B7=B1=E5=BA=A6=E3=80=81?= =?UTF-8?q?=E6=9D=BF=E4=BE=A7=E7=B2=BE=E7=A1=AE=E5=91=BD=E4=B8=AD=E3=80=81?= =?UTF-8?q?=E7=BB=88=E7=82=B9=20Boss=20=E8=A1=8C=E9=9B=86=E6=8C=89?= =?UTF-8?q?=E5=9B=BE=E7=BB=88=E7=82=B9=E6=B7=B1=E5=BA=A6=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=EF=BC=89=E5=B9=B6=E9=80=90=E5=85=B3=E7=99=BB=E8=AE=B0=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=E9=9D=A2=E9=A2=97=E6=95=B0=EF=BC=9B=E2=91=A3=20?= =?UTF-8?q?=E8=B5=B7=E5=85=B3=E7=AD=BE=E5=90=8D=E5=B0=BE=E5=8F=82=20templa?= =?UTF-8?q?te=5Fpick=20=E9=80=80=E5=BD=B9=E3=80=81=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E8=87=AA=E6=B4=BD=E6=96=AD=E8=A8=80=E6=96=B0=E5=A2=9E=EF=BC=88?= =?UTF-8?q?=E5=8F=A5=E6=9F=84=E8=BD=B4=E8=A1=8C=E8=BF=9B=C2=B7=E6=96=B9?= =?UTF-8?q?=E5=90=91=E8=BD=B4=E5=88=86=E5=B2=94=E7=BB=88=E7=82=B9=E4=BA=92?= =?UTF-8?q?=E5=BC=82=EF=BC=89=EF=BC=9B=E2=91=A4=20=E6=AE=B5=E5=86=85?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E4=B8=8E=E6=96=87=E4=BB=B6=E5=A4=B4=E9=9A=8F?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E6=8D=A2=E4=BB=A3=E6=94=B9=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unit/core/level_shop_economy_test.gd | 888 ++++++++++++------ 1 file changed, 621 insertions(+), 267 deletions(-) diff --git a/game/tests/unit/core/level_shop_economy_test.gd b/game/tests/unit/core/level_shop_economy_test.gd index 98da5e9a..2eaa06ed 100644 --- a/game/tests/unit/core/level_shop_economy_test.gd +++ b/game/tests/unit/core/level_shop_economy_test.gd @@ -1,12 +1,14 @@ # tests/unit/core/level_shop_economy_test.gd — 关卡图与 GP 商店单元测试宿主 # 断言面 = 七套(批 B 断言迁移自 smoke_all.gd,G8 施工组): -# 关卡节点地图·线性推进(满配通关/恢复点公式/败北线/回放确定性/属性覆盖恢复) +# 关卡节点图·生成图推进(满配通关/恢复点公式/败北线/回放确定性/属性覆盖恢复) # 分支节点图(图校验正反/L2·L3 行进与行集定点/混编运行面/真图类型可见性) # GP 冒险钱包·到账(掷定窗口/关级加值) # escrow 隔离(格面兑付入缓冲/破财直验/钱包钳制不透支) # 商店房三收口(现价/加算区/余额不足拒/离开回选路/收束清零) -# 商店房图不变式(恰 1 商店房/全路径必经/跨表行集迁移) +# 商店编排性质(每终点前一位恰一店/店数=终点数/全路径恰穿一店) # 商店共享池(抽 3 买 1/乘法定价/跨渠道共库/断点扩键/终局池读数) +# 节点图源 = 参数档 × 种子的生成图——图侧断言一律经 _generated_graph 单点取数 +# (与 LevelRunner._assign_generated_graph 同参数档表、同产图路径),形状断言只写性质。 # 协议照单元侧惯例:extends SceneTree+Kit.check 主通道;--script 模式无 autoload, # ConfigDB 显式实例化构造注入(_fresh_rng 同种子隔离夹具——RngService 构造注入 runner); # 表加载失败不中止(沿被迁断言面的源语义——承 G2–G6 同款)。 @@ -15,8 +17,218 @@ extends SceneTree const Kit := preload("res://tests/unit/test_kit.gd") const ConfigDBScript := preload("res://core/config_db.gd") +const NodeMapGenScript := preload("res://core/node_map_generator.gd") + +# 现行首局种子(图侧断言的固定取图种子——与组合根首次冒险同一种子承载) +const ACTIVE_GRAPH_SEED := 42 +# 覆盖与编排断言的扫描种子面(含固定取图种子 42) +const SWEEP_SEED_FROM := 42 +const SWEEP_SEED_TO := 62 var _db +var _graph_gen = null # 生成图取数单实例(图 = f(参数档, 种子) 纯函数,复现与计数器位次无关) + + +# ---- 生成图取数面(本文件唯一实现——图侧断言一律经此,禁另起第二份取数)---- + +# 参数档 × 种子产图(与 LevelRunner._assign_generated_graph 同一产图路径与同一参数档表) +func _generated_graph(level_index: int, seed_val: int) -> Dictionary: + var params: Dictionary = _db.level_gen_params_row(level_index) + if params.is_empty(): + return {} + if _graph_gen == null: + _graph_gen = NodeMapGenScript.new(_fresh_rng(ACTIVE_GRAPH_SEED)) + var graph: Dictionary = _graph_gen.generate(params, seed_val) + if graph.is_empty() or _graph_gen.is_fatal(): + return {} + return graph + + +# 该关现行起关图(固定种子+该关参数档)——节点数组,句柄/深度/出边读面见节点字典 +func _level_active_nodes(level_index: int) -> Array: + return _generated_graph(level_index, ACTIVE_GRAPH_SEED).get("nodes", []) + + +# 句柄 → 节点(图内唯一句柄解析) +func _nodes_by_id(nodes: Array) -> Dictionary: + var out := {} + for n in nodes: + out[int(n.get("id", -1))] = n + return out + + +# 入口句柄集(无入边节点——个数 = 参数档 entry_count) +func _entry_ids(nodes: Array) -> Array: + var inbound := {} + for n in nodes: + for tid in (n.get("next", []) as Array): + inbound[int(tid)] = true + var out: Array = [] + for n in nodes: + if not inbound.has(int(n.get("id", -1))): + out.append(int(n.get("id", -1))) + return out + + +# 终点句柄集(无出边节点——恒 Boss,个数 = 参数档 exit_count) +func _terminal_ids(nodes: Array) -> Array: + var out: Array = [] + for n in nodes: + if (n.get("next", []) as Array).is_empty(): + out.append(int(n.get("id", -1))) + return out + + +# 指定型别句柄集(岔口=出度 ≥2 另走 _fork_ids) +func _ids_of_type(nodes: Array, type_name: String) -> Array: + var out: Array = [] + for n in nodes: + if String(n.get("type", "")) == type_name: + out.append(int(n.get("id", -1))) + return out + + +# 岔口句柄集(出度 ≥2 的节点——「真实岔路」现行定义) +func _fork_ids(nodes: Array) -> Array: + var out: Array = [] + for n in nodes: + if (n.get("next", []) as Array).size() >= 2: + out.append(int(n.get("id", -1))) + return out + + +# 逐节点型别计数(五型合计 = 节点数——图形状读数的唯一计数口径) +func _type_tally(nodes: Array) -> Dictionary: + var out := {"battle": 0, "boss": 0, "reward": 0, "rest": 0, "shop": 0} + for n in nodes: + var t := String(n.get("type", "")) + if out.has(t): + out[t] = int(out[t]) + 1 + return out + + +# 序列去重(保持首现序——句柄集与型别集通用) +func _uniq(values: Array) -> Array: + var out: Array = [] + for v in values: + if not out.has(v): + out.append(v) + return out + + +# 句柄序升序(候选集与图出边比对用——集合相等判据) +func _sorted_ids(ids: Array) -> Array: + var out: Array = [] + for v in ids: + out.append(int(v)) + out.sort() + return out + + +# 路径上商店节点计数(句柄序列经图解析——「恰穿一店」判据的行进面与枚举面共用口径) +func _path_shop_count(nodes: Array, path: Array) -> int: + var by_id := _nodes_by_id(nodes) + var count := 0 + for vid in path: + if String(by_id.get(int(vid), {}).get("type", "")) == "shop": + count += 1 + return count + + +# 终点深度升序表(多终点图的终点深度域——Boss 行集与带互异判据的取数面) +func _terminal_depths(nodes: Array) -> Array: + var by_id := _nodes_by_id(nodes) + var out: Array = [] + for tid in _terminal_ids(nodes): + out.append(int(by_id.get(int(tid), {}).get("depth", -1))) + out.sort() + return out + + +# 入口 BFS 层表(句柄 → 层深:入口层 0、出边逐层 +1、首达即定层——与 runner 可见性锚层同源口径) +func _bfs_layers(nodes: Array) -> Dictionary: + var by_id := _nodes_by_id(nodes) + var layers := {} + var queue: Array = [] + for eid in _entry_ids(nodes): + layers[eid] = 0 + queue.append(eid) + var i := 0 + while i < queue.size(): + var cur_id := int(queue[i]) + i += 1 + var layer := int(layers[cur_id]) + for oid in (by_id.get(cur_id, {}).get("next", []) as Array): + if not layers.has(int(oid)): + layers[int(oid)] = layer + 1 + queue.append(int(oid)) + return layers + + +# 跨表覆盖扫描(敌侧落带 ≥ 请求深度+板侧精确命中):返回 {nodes 扫描读数, enemy_bad, board_bad} +func _coverage_scan(level_index: int, nodes: Array) -> Dictionary: + var scanned := 0 + var enemy_bad := 0 + var board_bad := 0 + for n in nodes: + var d: int = int(n.get("depth", -1)) + if not _board_row_exact(level_index, d): + board_bad += 1 + var t := String(n.get("type", "")) + if t == "battle" or t == "boss": + scanned += 1 + if not _enemy_band_covers(level_index, d, "boss" if t == "boss" else "battle"): + enemy_bad += 1 + return {"nodes": scanned, "enemy_bad": enemy_bad, "board_bad": board_bad} + + +# 入口→终点全路径枚举(DFS 迭代栈——路径为句柄序;生成图无环且全员可达终点,路径有限) +func _all_entry_terminal_paths(nodes: Array) -> Array: + var by_id := _nodes_by_id(nodes) + var paths: Array = [] + var stack: Array = [] + for eid in _entry_ids(nodes): + stack.append([eid]) + while not stack.is_empty(): + var path: Array = stack.pop_back() + var cur: Dictionary = by_id[int(path[path.size() - 1])] + var outs: Array = cur.get("next", []) + if outs.is_empty(): + paths.append(path) + continue + for t in outs: + var next_path: Array = path.duplicate() + next_path.append(int(t)) + stack.append(next_path) + return paths + + +# 路径逐跳是否为图出边(含首节点为入口、末节点为终点) +func _path_in_graph(nodes: Array, path: Array) -> bool: + if path.is_empty(): + return false + var by_id := _nodes_by_id(nodes) + if not _entry_ids(nodes).has(int(path[0])): + return false + for i in range(path.size() - 1): + if not by_id.has(int(path[i])): + return false + if not (by_id[int(path[i])].get("next", []) as Array).has(int(path[i + 1])): + return false + var last: Dictionary = by_id.get(int(path[path.size() - 1]), {}) + return not last.is_empty() and (last.get("next", []) as Array).is_empty() + + +# 敌侧覆盖判据:请求深度经 band_of 落带(非 -1)——解析落带 ≥ 请求深度即等价判据(禁顶 clamp 消解) +func _enemy_band_covers(level_index: int, depth: int, kind: String) -> bool: + var rows: Array[Dictionary] = _db.resolve_enemy_rowset(level_index, depth, kind) + return not rows.is_empty() and int(rows[0].get("depth_band", -1)) >= depth + + +# 板侧覆盖判据:该深度在 board_gen 精确命中(node_depth 等值) +func _board_row_exact(level_index: int, depth: int) -> bool: + var board: Dictionary = _db.resolve_board_gen(level_index, depth) + return not board.is_empty() and int(board.get("node_depth", -1)) == depth func _init() -> void: @@ -45,84 +257,112 @@ func _init() -> void: # 敌侧/板侧解析一律走 ConfigDB 真实消费链,禁直读行集自算)---- func _smoke_generated_coverage() -> void: - var RngScript := preload("res://core/rng_service.gd") - var GenScript := preload("res://core/node_map_generator.gd") - var gen = GenScript.new(RngScript.new()) var enemy_ok := true var board_ok := true var boss_band_ok := true - var readings := [] + var scanned := 0 + var readings: Array = [] for L in [1, 2, 3]: - var params: Dictionary = _db.level_gen_params_row(L) - if params.is_empty(): + if _db.level_gen_params_row(L).is_empty(): Kit.check("L%d 参数档缺行(覆盖断言无源可扫)〔v11〕" % L, false) continue var dmin := 999 var dmax := 0 - for seed_val in range(42, 62): - var g: Dictionary = gen.generate(params, seed_val) - var by_id := {} - for n in g["nodes"]: - by_id[int(n["id"])] = n - for n in g["nodes"]: - var d: int = int(n["depth"]) + for seed_val in range(SWEEP_SEED_FROM, SWEEP_SEED_TO): + var nodes: Array = _generated_graph(L, seed_val).get("nodes", []) + if nodes.is_empty(): + Kit.check("L%d 种子 %d 生成图为空(覆盖扫描面空转)〔v11〕" % [L, seed_val], false) + continue + var by_id := _nodes_by_id(nodes) + for n in nodes: + var d: int = int(n.get("depth", -1)) + var kind := String(n.get("type", "")) dmin = mini(dmin, d) dmax = maxi(dmax, d) - var kind := String(n["type"]) - if kind == "battle" or kind == "boss": - var rk := "boss" if kind == "boss" else "battle" - var rs0: Array[Dictionary] = _db.resolve_enemy_rowset(L, d, rk) - # 判据=解析落带 ≥ 请求深度(等价 EnemyTable.band_of 非 -1)——行集非空不是覆盖判据, - # 越顶时 resolver 会 clamp 到顶带照样返回非空行(顶 clamp 消解正是设计禁的) - if rs0.is_empty() or int(rs0[0].get("depth_band", -1)) < d: - enemy_ok = false - var board: Dictionary = _db.resolve_board_gen(L, d) - if board.is_empty() or int(board.get("node_depth", -1)) != d: + scanned += 1 + # 判据=解析落带 ≥ 请求深度(等价 EnemyTable.band_of 非 -1)——行集非空不是覆盖判据, + # 越顶时 resolver 会 clamp 到顶带照样返回非空行(顶 clamp 消解正是设计禁的) + if kind == "battle" and not _enemy_band_covers(L, d, "battle"): + enemy_ok = false + elif kind == "boss" and not _enemy_band_covers(L, d, "boss"): + enemy_ok = false + if not _board_row_exact(L, d): board_ok = false - var bands := [] - for tid in g["terminals"]: - var bd: int = int(by_id[int(tid)]["depth"]) - var rs: Array[Dictionary] = _db.resolve_enemy_rowset(L, bd, "boss") + var bands: Array = [] + for tid in _terminal_ids(nodes): + var rs: Array[Dictionary] = _db.resolve_enemy_rowset(L, int(by_id[int(tid)].get("depth", -1)), "boss") bands.append(int(rs[0].get("depth_band", -1)) if not rs.is_empty() else -1) for i in range(bands.size()): for j in range(i + 1, bands.size()): if bands[i] == bands[j]: boss_band_ok = false readings.append("L%d 深度域 %d–%d" % [L, dmin, dmax]) - print("[v11] 生成图覆盖量程读数:" + "; ".join(readings)) + print("[v11] 生成图覆盖量程读数:%s(扫描节点读数 %d)" % ["; ".join(readings), scanned]) + Kit.check("生成图覆盖扫描面非空(三关参数档×种子 %d–%d 共 %d 个节点读数)〔v11〕" % [SWEEP_SEED_FROM, SWEEP_SEED_TO - 1, scanned], scanned > 0) Kit.check("生成图每个 battle/boss 节点深度的敌侧解析落带 ≥ 请求深度(band_of 非 -1·顶 clamp 消解禁)〔v11〕", enemy_ok) Kit.check("生成图每个节点深度在 board_gen 精确命中(非空且 node_depth 等值)〔v11〕", board_ok) Kit.check("多终点 Boss 各终点解析带键两两互异(L2/L3×20 种子)〔v11〕", boss_band_ok) -# ---- 关卡节点地图·线性推进冒烟---- +# ---- 关卡节点图·生成图推进冒烟(图 = 该关参数档 × 种子)---- func _smoke_level() -> void: - print("--- 关卡节点地图·线性推进(战×2→道具→战×2→恢复→Boss)---") + print("--- 关卡节点图·生成图推进(参数档×种子产图·满配通关/恢复公式/败北线/回放确定性)---") var autoplay = load("res://sim/level_autoplay.gd") var hero_base := {"atk": int(_db.hero.atk), "hp": int(_db.hero.max_hp), "ac": int(_db.hero.armor_class)} # 配置基线快照——⑤ 回基线对比用(改表自动跟随,不再硬编码) + var nodes1: Array = _level_active_nodes(1) + var params1: Dictionary = _db.level_gen_params_row(1) + Kit.check("L1 现行起关图取数非空(种子 %d × 参数档 → 节点 %d 颗)" % [ACTIVE_GRAPH_SEED, nodes1.size()], not nodes1.is_empty()) # ① 满配通关路径(atk+200%/hp+100%——测试面板同源的属性覆盖) - var win = autoplay.run_level(_db, 42, 1, 200, 100) + var win = autoplay.run_level(_db, ACTIVE_GRAPH_SEED, 1, 200, 100) Kit.check("关卡·满配(atk+200/hp+100)通关(实测:%s @节点 %d)" % [win.result, win.defeat_node_index], win.result == "victory") - Kit.check("战斗节点数 = 5(战×4 + Boss)", win.battles == 5) - Kit.check("Boss 节点以 Dmax=4 解析(棋盘生成 第三轴登记约定)", win.boss_depth == 4) + # 行进面读数与图读数自洽:路径逐跳为图出边,路径上战斗/Boss 计数 = battles 读数 + var by_id1 := _nodes_by_id(nodes1) + var path_battles := 0 + for vid in win.visited_ids: + var vkind := String(by_id1.get(int(vid), {}).get("type", "")) + if vkind == "battle" or vkind == "boss": + path_battles += 1 + Kit.check("L1 通关路径逐跳为图出边(入口起步·末节点无出边——句柄轴行进自洽)", _path_in_graph(nodes1, win.visited_ids)) + Kit.check("L1 通关路径战斗/Boss 节点数 = 行进 battles 读数(路径 %d 节点·战斗 %d 颗·battles %d)" % [win.visited_ids.size(), path_battles, int(win.battles)], + path_battles == int(win.battles) and path_battles >= 2) + # 终点恒落最长路径步数:Boss 深度 = 参数档层高,且通关路径末节点即该终点 + var boss_depth1 := -1 + for n in nodes1: + if String(n.get("type", "")) == "boss": + boss_depth1 = maxi(boss_depth1, int(n.get("depth", -1))) + var last_id := -1 + if win.visited_ids.size() > 0: + last_id = int(win.visited_ids[win.visited_ids.size() - 1]) + Kit.check("L1 终点 Boss 深度 = 参数档层高 %d 且通关路径末节点落该深度(实测终点深度 %d·末句柄 %d)" % [int(params1.get("layers", -1)), boss_depth1, last_id], + boss_depth1 == int(params1.get("layers", -1)) and int(by_id1.get(last_id, {}).get("depth", -1)) == boss_depth1) + Kit.check("L1 终点恰一个(大岔口自 L2 起的图侧投影)", _terminal_ids(nodes1).size() == 1 and _ids_of_type(nodes1, "boss").size() == 1) + Kit.check("L1 生成图含岔口(出度 ≥2 节点 %d 处)" % _fork_ids(nodes1).size(), _fork_ids(nodes1).size() >= 1) Kit.check("进 Boss HP 携带在域内(%d)" % win.hp_at_boss, win.hp_at_boss >= 1 and win.hp_at_boss <= int(_db.hero.max_hp) * 3) - # ② 恢复点:回复 = min(floor(生效max_hp×pct/100), 缺口)——hp+100% 档生效 max = 基线×2 - # seed 42(探针目的 = 验回复公式,种子仅载体) - var rest = autoplay.run_level(_db, 42, 1, 100, 100) - if rest.result != "start_failed" and rest.hp_before_rest >= 0: - var eff_max := maxi(1, int(round(float(_db.hero.max_hp) * 2.0))) # hp_pct=100(run_level 已恢复基线) - var expect: int = mini(int(floor(float(eff_max) * 0.3)), eff_max - rest.hp_before_rest) - Kit.check("恢复点回复 = floor(生效max_hp×30%%) 与缺口的较小值(%d/%d,生效 max %d)" % [rest.rest_healed, expect, eff_max], rest.rest_healed == expect) + # ② 恢复点公式:回复 = min(floor(生效max_hp×rest_heal_pct/100), 缺口)——两档缺口各验一条分支 + #(真入口驱动:起关→首战→选恢复候选;缺口由夹具造,恢复比例读关卡行) + var pct1: int = int(_db.level_row(1).get("rest_heal_pct", 0)) + var rest_wide := _drive_to_rest(1, ACTIVE_GRAPH_SEED, 100000) + if rest_wide.is_empty(): + Kit.check("恢复点公式无源(现行图入口后一位无可直达的恢复候选)", false) else: - Kit.check("恢复点未到达(seed 42 hp+100 在恢复点前败北?%s)" % rest.result, false) + var expect_pct := int(floor(float(int(rest_wide["max"])) * float(pct1) / 100.0)) + Kit.check("恢复点公式·缺口充分档:回复 = floor(生效max_hp×%d%%) = %d(生效 max %d,实测 %d)" % [pct1, expect_pct, int(rest_wide["max"]), int(rest_wide["healed"])], + int(rest_wide["healed"]) == expect_pct and expect_pct > 0) + var rest_narrow := _drive_to_rest(1, ACTIVE_GRAPH_SEED, 2) + if rest_narrow.is_empty(): + Kit.check("恢复点公式缺口封顶档无源(同图二次驱动未达恢复候选)", false) + else: + Kit.check("恢复点公式·缺口不足档:回复封顶到缺口 2(实测 %d,比例项 %d)" % [int(rest_narrow["healed"]), expect_pct], + int(rest_narrow["healed"]) == mini(expect_pct, 2) and expect_pct > 2) # ③ 裸装败北路径(atk1/hp10——Roguelike 基线应难以通关) - var lose = autoplay.run_level(_db, 42, 1, -80, -80) + var lose = autoplay.run_level(_db, ACTIVE_GRAPH_SEED, 1, -80, -80) Kit.check("关卡·裸装(atk≈1/hp≈10)败北路径可达(实测:%s)" % lose.result, lose.result == "defeat") - Kit.check("败北节点记录在域内(节点 %d)" % lose.defeat_node_index, lose.defeat_node_index >= 0 and lose.defeat_node_index < 7) + Kit.check("败北节点记录在域内(节点 %d,图节点数 %d)" % [lose.defeat_node_index, nodes1.size()], + lose.defeat_node_index >= 0 and lose.defeat_node_index < nodes1.size()) # ④ 回放确定性:同种子两遍步骤流逐行相同 - var rep1 = autoplay.run_level(_db, 777, 1, 100, 100, true, null, "normal", "min", 0) - var rep2 = autoplay.run_level(_db, 777, 1, 100, 100, true, null, "normal", "min", 0) + var rep1 = autoplay.run_level(_db, 777, 1, 100, 100, true, null, "normal", "min") + var rep2 = autoplay.run_level(_db, 777, 1, 100, 100, true, null, "normal", "min") Kit.check("关卡回放断言:同种子两遍结算步骤流逐行相同", JSON.stringify(rep1.rows) == JSON.stringify(rep2.rows)) Kit.check("关卡步骤流非空(%d 行)" % rep1.rows.size(), rep1.rows.size() > 0) # ⑤ 属性覆盖恢复:模拟后 hero 表回基线(调整板不泄漏到后续会话;基线 = 函数入口的配置快照,改表自动跟随) @@ -137,33 +377,68 @@ func _battle_st(policy: String) -> Dictionary: return {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": policy} +# 真入口驱动到恢复节点并造缺口(起关→打赢入口战斗→在候选里取恢复型句柄→显式选路进点)。 +# 缺口 = 生效上限 − gap(gap 大于上限时按下限 1 血起算);返回 {healed, hp_before, gap, max}。 +# 现行图入口后一位无恢复候选时返回空字典(由调用方判空报红,禁静默跳过)。 +func _drive_to_rest(level_index: int, seed_val: int, gap: int) -> Dictionary: + var autoplay = load("res://sim/level_autoplay.gd") + var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(seed_val)) + runner.set_hero_override(200, 100) + if not runner.start_level(level_index, seed_val): + return {} + var first = runner.current_battle() + if first == null: + return {} + var st = _battle_st("normal") + var guard := 30 + while runner.current_battle() == first and guard > 0: + guard -= 1 + autoplay._autoplay_battle(runner, first, st, null) + if not runner.awaiting_choice(): + return {} + var by_id := _nodes_by_id(runner.nodes()) + var rest_id := -1 + for cid in runner.next_candidate_ids(): + if String(by_id.get(int(cid), {}).get("type", "")) == "rest": + rest_id = int(cid) + break + if rest_id < 0: + return {} + var eff_max: int = runner.effective_max_hp() + runner.hero_hp = maxi(1, eff_max - gap) + var hp_before: int = runner.hero_hp + if not runner.pick_next_node(rest_id): + return {} + return {"healed": runner.hero_hp - hp_before, "hp_before": hp_before, "gap": eff_max - hp_before, "max": eff_max} + + # ==================== 敌人技能池(开轮摇号出招系统) ==================== # 六项验收断言(proposal-skill-pool.md〔提案已吸收退役 git a6833b6a——现行事实源=敌人系统.md 技能池节〕 §8 批 1):同种子回放逐位复现 / 单敌轨迹独立复放 / # EV 抽样对锚 / 死亡打断作废 / 空池不可能 / 加载校验拒绝。 -# ---- 分支节点图冒烟:图合法性校验 + L2 幽暗地域图版行进(关卡节点地图 v1.0 图版)---- +# ---- 分支节点图冒烟:图合法性校验 + L2 幽暗地域生成图行进(图 = 参数档 × 种子)---- func _smoke_level_graph() -> void: - print("--- 关卡节点地图·分支节点图(L2 幽暗地域)---") + print("--- 关卡节点图·分支节点图(L2 幽暗地域·生成图)---") var autoplay = load("res://sim/level_autoplay.gd") - # ① 关卡表行级校验零错误(图合法性自 T1 起不再由表加载期承担——关卡表携带的模板数据随卡② T3 清出) + # ① 关卡表行级校验零错误(关卡表只承载序号/显示名/恢复比例——图合法性由 validate_graph 在起关时承担) Kit.check("LevelTable 行级校验零错误(图合法性移交 validate_graph)", _db.level.validate().is_empty()) - # ② L2 行集结构(网状图建议规模) - var l2 := {} - for row in _db.level.rows: - if int(row.level_index) == 2: - l2 = row - Kit.check("L2 节点数 = 10(入口 1 + 中间三层 2/3/2 + 商店房 + Boss——商店房批 Boss 重编号 8→9)", not l2.is_empty() and _level_active_nodes(2).size() == 10) - var tcount := {"battle": 0, "reward": 0, "rest": 0, "boss": 0, "shop": 0} - var branch := 0 - for node in _level_active_nodes(2): - tcount[String(node.get("type", ""))] = int(tcount[String(node.get("type", ""))]) + 1 - if node.get("next", []).size() >= 2: - branch += 1 - Kit.check("L2 普通战斗节点 = 6 + Boss 恒一(编号最大恒终点)", int(tcount.battle) == 6 and int(tcount.boss) == 1) - Kit.check("L2 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)+ 商店房节点 1(商店房批)", int(tcount.reward) == 1 and int(tcount.rest) == 1 and int(tcount.shop) == 1) - Kit.check("L2 真实岔路 = %d 处(≥2)" % branch, branch >= 2) + # ② L2 生成图形状读数(性质断言——规模/型别/岔口全部由参数档与逐节点扫描互校) + var nodes2: Array = _level_active_nodes(2) + var params2: Dictionary = _db.level_gen_params_row(2) + Kit.check("L2 现行起关图取数非空(种子 %d × 参数档 → 节点 %d 颗)" % [ACTIVE_GRAPH_SEED, nodes2.size()], not nodes2.is_empty()) + var tcount := _type_tally(nodes2) + var branch := _fork_ids(nodes2).size() + Kit.check("L2 节点数 %d ≥ 层高 %d+1 且 = 句柄去重读数 %d(全员句柄唯一)" % [nodes2.size(), int(params2.get("layers", -1)), _nodes_by_id(nodes2).size()], + nodes2.size() >= int(params2.get("layers", -1)) + 1 and _nodes_by_id(nodes2).size() == nodes2.size()) + Kit.check("L2 五型计数合计 = 节点数(battle %d + boss %d + reward %d + rest %d + shop %d = %d)" % [tcount["battle"], tcount["boss"], tcount["reward"], tcount["rest"], tcount["shop"], nodes2.size()], + int(tcount["battle"]) + int(tcount["boss"]) + int(tcount["reward"]) + int(tcount["rest"]) + int(tcount["shop"]) == nodes2.size()) + Kit.check("L2 战斗节点数 %d ≥ 参数档战斗保底 %d 且 Boss 节点数 %d = 参数档出口数 %d" % [tcount["battle"], int(params2.get("battle_floor", -1)), tcount["boss"], int(params2.get("exit_count", -1))], + int(tcount["battle"]) >= int(params2.get("battle_floor", -1)) and int(tcount["boss"]) == int(params2.get("exit_count", -1))) + Kit.check("L2 恢复/奖励/商店节点各 ≥1(实测 rest %d · reward %d · shop %d——计数由逐节点扫描同一读数)" % [tcount["rest"], tcount["reward"], tcount["shop"]], + int(tcount["rest"]) >= 1 and int(tcount["reward"]) >= 1 and int(tcount["shop"]) >= 1) + Kit.check("L2 岔口(出度 ≥2 的节点)= %d 处(≥1)" % branch, branch >= 1) # ③ 图校验正反断言改走 validate_graph 公有面(迭代 35 卡② T1 随动·声明): # · 夹具字段随生成图读面换代(id/type/depth/next 取代 node_depth 双编号空间); # · 「入口唯一违例」判据随模板池退役——多入口是现行合法形态(关卡节点图生成 v1.1 结构铁律), @@ -195,27 +470,37 @@ func _smoke_level_graph() -> void: orphan.append({"id": 5, "type": "battle", "depth": 1, "next": [6]}) orphan.append({"id": 6, "type": "rest", "depth": 2, "next": []}) Kit.check("图校验拒绝:非 Boss 终点(终点恒为 Boss)〔v1〕", _has_err_category(LT.validate_graph(orphan), "structural")) - # ④ L2 选路推进:min / max 双路径全通(确定性路径轴——零随机流自开) - # 战斗随机值面随全局序漂移——max 路径种子按行为面扫描(路线形状/商店/选路口为断言本体) - var win_min = autoplay.run_level(_db, 42, 2, 200, 100, false, null, "normal", "min", 0) + # ④ L2 选路推进:min / max 双路径全通(确定性路径轴——同种子同图,方向轴只换候选取值) + var win_min: Dictionary = autoplay.run_level(_db, ACTIVE_GRAPH_SEED, 2, 200, 100, false, null, "normal", "min") Kit.check("L2·满配 min 路径通关(实测:%s)" % win_min.result, win_min.result == "victory") - Kit.check("L2 Boss 以编号 9 解析(终点编号最大——商店房批重编号 8→9)", win_min.boss_depth == 9) - Kit.check("min 路径 = 0→1→3(休整)→7→8(商店零购离开)→9(实测 %s——T17 出度≤2 拍板模板 0 重建)" % str(win_min.visited_ids), win_min.visited_ids == [0, 1, 3, 7, 8, 9]) + var n2_last := -1 + if win_min.visited_ids.size() > 0: + n2_last = int(win_min.visited_ids[win_min.visited_ids.size() - 1]) + var n2_last_type := String(_nodes_by_id(nodes2).get(n2_last, {}).get("type", "")) + Kit.check("L2 min 路径逐跳为图出边·末节点为 Boss(实测路径 %s·末句柄型别 %s)" % [str(win_min.visited_ids), n2_last_type], + _path_in_graph(nodes2, win_min.visited_ids) and n2_last_type == "boss") + Kit.check("L2 min 路径终点深度 = 参数档层高 %d(行进读数 %d——终点落最长路径步数)" % [int(params2.get("layers", -1)), int(win_min.boss_depth)], + int(win_min.boss_depth) == int(params2.get("layers", -1))) + Kit.check("L2 min 路径穿商店恰 1 家(路径句柄 %s 型别计数 = 1——必经恰一店的行进面)" % str(win_min.visited_ids), + _path_shop_count(nodes2, win_min.visited_ids) == 1) Kit.check("选路口发生 %d 次(每节点兑付后——零自动推进)" % win_min.choices, win_min.choices >= 3) - # max 路径 = 存储种子(T17 改造——废除逐种子扫描):本项目核心承诺=确定性复放(同代码同表 - # 同种子逐位一致),「能赢且路径符合的种子」是恒定事实,采一次存死即可。旧形态 range(42,300) - # 逐种子全关卡通关扫描找命中,期望路径过期时 258 次模拟撞 180s 闸(台账 257 + - # 出度≤2 重建两次实证)。存储种子常态一次跑完;图表/数值有意变更致本断言红=快速失败(一次 - # 即红非扫描超时),重采=临时脚本跑同款 run_level 循环取首命中种子回填(离线一次性)。 - const MAX_PATH_SEED := 43 # 种子重采程序:同款循环首命中(断言注释既定程序,临时脚本用后即删) - var win_max: Dictionary = autoplay.run_level(_db, MAX_PATH_SEED, 2, 200, 100, false, null, "normal", "max", 0) - Kit.check("L2·满配 max 路径(max 取最大编号候选)通关 = 0→2(奖励)→6→7→8(商店零购离开)→9(存储种子 %d——T17 出度≤2 拍板模板 0 重建)" % MAX_PATH_SEED, win_max.result == "victory" and win_max.visited_ids == [0, 2, 6, 7, 8, 9]) - # ⑤ 零自动推进状态机(手动驱动——兑付确认 / 选路拒绝语义逐拍断言) - var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(42)) + var win_max: Dictionary = autoplay.run_level(_db, ACTIVE_GRAPH_SEED, 2, 200, 100, false, null, "normal", "max") + Kit.check("L2·满配 max 路径(候选取最大句柄)通关(实测:%s·路径 %s)" % [win_max.result, str(win_max.visited_ids)], win_max.result == "victory") + var n2_last_max := -1 + if win_max.visited_ids.size() > 0: + n2_last_max = int(win_max.visited_ids[win_max.visited_ids.size() - 1]) + var n2_last_max_type := String(_nodes_by_id(nodes2).get(n2_last_max, {}).get("type", "")) + Kit.check("L2 max 路径逐跳为图出边·末节点为 Boss·穿商店恰 1 家", + _path_in_graph(nodes2, win_max.visited_ids) and n2_last_max_type == "boss" + and _path_shop_count(nodes2, win_max.visited_ids) == 1) + Kit.check("L2 max 与 min 路径分岔(方向轴生效——两路终点句柄 %d / %d 不同)" % [n2_last, n2_last_max], + n2_last != n2_last_max and win_max.visited_ids != win_min.visited_ids) + # ⑤ 零自动推进状态机(手动驱动——兑付确认 / 选路拒绝语义逐拍断言,候选定位全部按图型别数据驱动) + var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(ACTIVE_GRAPH_SEED)) runner.set_hero_override(200, 100) var choice_sizes: Array = [] runner.choices_presented.connect(func(c): choice_sizes.append(c.size())) - Kit.check("手动驱动:L2 启动(入口战斗直接开打——入口无前置兑付;template_pick=0 固定现行图——精确选路断言)", runner.start_level(2, 42, {}, 0)) + Kit.check("手动驱动:L2 启动(入口战斗直接开打——入口无前置兑付;图 = 参数档 × 种子 %d)" % ACTIVE_GRAPH_SEED, runner.start_level(2, ACTIVE_GRAPH_SEED)) var st = _battle_st("normal") var first = runner.current_battle() var res_box := {} # 信号写回容器(lambda 按值捕获局部变量——Dictionary 引用可变) @@ -223,12 +508,46 @@ func _smoke_level_graph() -> void: while runner.current_battle() == first and not res_box.has("result"): autoplay._autoplay_battle(runner, first, st, null) Kit.check("首战胜利后:settlement 释放 + 进选路态(战斗胜利不再自动推进——接口漂移清偿)", String(res_box.get("result", "")) == "victory" and runner.current_battle() == null and runner.awaiting_choice()) - Kit.check("入口岔路候选 = [1, 2](T17 出度≤2 拍板模板 0 宽廊双干——层宽 4 上限随出度约束)", runner.next_candidate_ids() == [1, 2]) - Kit.check("候选外编号拒绝且仍待选(拒绝语义)", not runner.pick_next_node(8) and runner.awaiting_choice()) - Kit.check("选编号 2(奖励点)受理", runner.pick_next_node(2)) + # 消费面图与生成面逐位一致(runner 持图 = 同一参数档同一种子的产图——读面句柄集相同) + var rnodes: Array = runner.nodes() + Kit.check("runner 消费面图 = 生成面图(节点数 %d/%d 且句柄集逐位相同)" % [rnodes.size(), nodes2.size()], + rnodes.size() == nodes2.size() and _ids_of_type(rnodes, "battle").size() + _ids_of_type(rnodes, "boss").size() + _ids_of_type(rnodes, "reward").size() \ + + _ids_of_type(rnodes, "rest").size() + _ids_of_type(rnodes, "shop").size() == nodes2.size() and _nodes_by_id(rnodes) == _nodes_by_id(nodes2)) + var cand: Array = runner.next_candidate_ids() + var rbyid := _nodes_by_id(rnodes) + var cand_types: Array = [] + for cid in cand: + cand_types.append(String(rbyid.get(int(cid), {}).get("type", "<不在图>"))) + Kit.check("入口选路口候选非空且全在图内(候选 %s·类型 %s)" % [str(cand), str(cand_types)], not cand.is_empty() and not cand_types.has("<不在图>")) + Kit.check("候选下一跳类型两两互异(岔口去路类型不重复契约——实测 %s)" % str(cand_types), cand_types.size() == _uniq(cand_types).size()) + # 候选外句柄拒绝(取图内非候选的句柄——数据驱动定位) + var outside_id := -1 + for n in rnodes: + if not cand.has(int(n.get("id", -1))) and int(n.get("id", -1)) != int(runner.current_node_id()): + outside_id = int(n.get("id", -1)) + break + Kit.check("候选外句柄 %d 拒绝且仍待选(拒绝语义)" % outside_id, outside_id >= 0 and not runner.pick_next_node(outside_id) and runner.awaiting_choice()) + # 奖励分支点选(候选按型别定位——奖励位存在才断言,缺位即红不静默跳过) + var reward_cand := -1 + var battle_cand := -1 + for cid in cand: + var ct := String(rbyid.get(int(cid), {}).get("type", "")) + if ct == "reward" and reward_cand < 0: + reward_cand = int(cid) + if ct == "battle" and battle_cand < 0: + battle_cand = int(cid) + Kit.check("入口岔口含奖励位与战斗位(句柄 %d/%d——候选型别可定位)" % [reward_cand, battle_cand], reward_cand >= 0 and battle_cand >= 0) + Kit.check("选奖励候选 %d 受理" % reward_cand, runner.pick_next_node(reward_cand)) Kit.check("到达奖励点:直通兑付(确认前不出选路口)", String(runner.current_node().get("type", "")) == "reward" and not runner.awaiting_choice()) - Kit.check("兑付确认后才出现选路口(continue_after_node 泛化语义)候选 = [5, 6]", runner.continue_after_node() and runner.awaiting_choice() and runner.next_candidate_ids() == [5, 6]) - Kit.check("选编号 6(巨蜘蛛巢群)开战", runner.pick_next_node(6) and runner.current_battle() != null) + var reward_outs: Array = runner.node_out_ids(runner.current_node()) + Kit.check("兑付确认后才出现选路口(continue_after_node 泛化语义)候选 = 该点图出边 %s" % str(reward_outs), + runner.continue_after_node() and runner.awaiting_choice() and _sorted_ids(runner.next_candidate_ids()) == _sorted_ids(reward_outs)) + var next_battle := -1 + for cid in runner.next_candidate_ids(): + if String(rbyid.get(int(cid), {}).get("type", "")) == "battle": + next_battle = int(cid) + break + Kit.check("选下一跳战斗候选 %d 开战" % next_battle, next_battle >= 0 and runner.pick_next_node(next_battle) and runner.current_battle() != null) # ⑥ L2 锚行首遇战:基线 0 加成 · 完美档(零盲挖 = 零踩雷上限口径)多种子扫描 # (绿档口径 = 12 局多数胜 + 零踩雷恒要求;还原全胜档需回调锚行血量,归用户决策) var green_ok := true @@ -256,24 +575,14 @@ func _smoke_level_graph() -> void: Kit.check("首遇位零踩雷:完美档 12 种子(节点 0 熊地精)恒零踩雷", green_ok) Kit.check("首遇战胜局 ≥7/12(实际 %d——阶段拔高后锚行退出绿档,多数胜口径)" % green_wins, green_wins >= 7) Kit.check("首遇战胜后 HP 下探读数 %d(>0——绿档记录)" % green_min_hp, green_min_hp > 0) - # ⑦ 跨表行完整:L2 每战斗/Boss 节点在 board_gen 与 enemy 精确行集(加载校验同款显式复述; - # enemy 键域 = (level_index, row_kind, depth_band)——战斗节点查 battle 键域、Boss 节点查 boss 键域) - 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_%s_%d" % [int(row.level_index), String(row.get("row_kind", "")), int(row.get("depth_band", -1))]] = true - var cross_ok := true - for node in _level_active_nodes(2): - var t := String(node.get("type", "")) - if t != "battle" and t != "boss": - continue - if not bg_keys.has("2_%d" % int(node.node_depth)) or not en_keys.has("2_%s_%d" % [t, int(node.node_depth)]): - cross_ok = false - Kit.check("跨表行完整:L2 全部战斗/Boss 节点在 board_gen + enemy 精确行集(7 节点——带键 (2,row_kind,depth_band))", cross_ok) - # ⑦a 定点:L2 首遇带行集与 Boss 行集(首遇带 = 数据驱动最低带,契约 8——禁写死坐标; - # 断言行集构成而非单行:行序 = 文件序,首行即叙述取数面) + # ⑦ 跨表覆盖义务:L2 现行起关图每战斗/Boss 节点深度的敌侧落带 ≥ 请求深度(顶 clamp 消解即红) + # +每深度 board_gen 精确命中(判据实现与〔v11〕种子扫描段同源——_enemy_band_covers/_board_row_exact 单点) + var cov2 := _coverage_scan(2, nodes2) + Kit.check("跨表覆盖扫描面非空:L2 现行图战斗/Boss 节点 %d 颗" % int(cov2["nodes"]), int(cov2["nodes"]) >= 1) + Kit.check("L2 现行图每战斗/Boss 节点深度敌侧解析落带 ≥ 请求深度(违例 %d 处)" % int(cov2["enemy_bad"]), int(cov2["enemy_bad"]) == 0) + Kit.check("L2 现行图每节点深度 board_gen 精确命中(非空且 node_depth 等值·违例 %d 处)" % int(cov2["board_bad"]), int(cov2["board_bad"]) == 0) + # ⑦a 定点:L2 首遇带行集与各终点 Boss 行集(首遇带 = 数据驱动最低带,契约 8——禁写死坐标; + # 多终点图逐终点解析,断言行集构成而非单行:行序 = 文件序,首行即叙述取数面) var l2_spider_band: int = _first_encounter_band(2, "giant_spider") var l2_spider_set: Array = _db.resolve_enemy_rowset(2, l2_spider_band, "battle") var l2_spider_ids := {} @@ -282,11 +591,14 @@ func _smoke_level_graph() -> void: Kit.check("巨蜘蛛首遇带(2,battle,%d——扫表取最低带)行集 = {giant_spider} 恰一行且 unit_count=2 无 unit_count_max(固定 2 只——首遇教学位不加骰)〔fv22〕" % l2_spider_band, l2_spider_band >= 0 and l2_spider_ids.has("giant_spider") and l2_spider_ids.size() == 1 and l2_spider_set.size() == 1 \ and int(l2_spider_set[0].get("unit_count", -1)) == 2 and not l2_spider_set[0].has("unit_count_max")) - var l2_boss_set: Array = _db.resolve_enemy_rowset(2, _boss_depth(2), "boss") - var l2_boss_ids := {} - for r in l2_boss_set: - l2_boss_ids[String(r.get("enemy_id", ""))] = true - Kit.check("蛛化卓尔精灵 Boss 行集(2,boss,%d)= {drider} 恰一行且首行魔法甲(护甲类型重配批 2026-09-18:关卡 2 唯一非轻甲;P4 乙 Boss 行集多行合法——零内容期仍单行)〔fv24〕" % _boss_depth(2), l2_boss_ids.has("drider") and l2_boss_ids.size() == 1 and String(l2_boss_set[0].get("armor_type", "")) == "ward") + var l2_terms := _terminal_depths(nodes2) + var l2_boss_ok := not l2_terms.is_empty() + for d in l2_terms: + var l2_boss_set: Array[Dictionary] = _db.resolve_enemy_rowset(2, int(d), "boss") + if l2_boss_set.size() != 1 or String(l2_boss_set[0].get("enemy_id", "")) != "drider" \ + or String(l2_boss_set[0].get("armor_type", "")) != "ward": + l2_boss_ok = false + Kit.check("L2 全部终点深度 %s 的 Boss 行集均 = {drider} 恰一行且首行魔法甲(护甲类型重配批:关卡 2 唯一非轻甲;P4 乙 Boss 行集多行合法——零内容期仍单行)〔fv24〕" % str(l2_terms), l2_boss_ok) # ⑦b 定点:混编遭遇行集 (2,battle,5)(R6 行序契约运行面: # 行集两行文件内相邻、行序 = 声明序 = 生成序;静态最坏骰和 Σ(unit_count_max,固定行缺省 = unit_count) ≤ 4) var l2_mix_set: Array = _db.resolve_enemy_rowset(2, 5, "battle") @@ -302,32 +614,29 @@ func _smoke_level_graph() -> void: int(l2_mix_set[1].get("unit_count", 0)) == 2 and int(l2_mix_set[1].get("unit_count_max", 0)) == 3 \ and int(l2_mix_set[1].get("hp", 0)) == 26 and int(l2_mix_set[1].get("armor_class", -1)) == 2) # ---- L3 灰矮人矿城断言段 ---- - # ⑧ L3 行集结构(复用 L2 九节点图模板——6 战 + Boss + 奖励 + 恢复,真实岔路 4 处) - var l3 := {} - for row in _db.level.rows: - if int(row.level_index) == 3: - l3 = row - Kit.check("L3 节点数 = 10(入口 1 + 中间三层 2/3/2 + 商店房 + Boss——L2 同模板 + 商店房批)", not l3.is_empty() and _level_active_nodes(3).size() == 10) - var tcount3 := {"battle": 0, "reward": 0, "rest": 0, "boss": 0, "shop": 0} - var branch3 := 0 - for node in _level_active_nodes(3): - tcount3[String(node.get("type", ""))] = int(tcount3[String(node.get("type", ""))]) + 1 - if node.get("next", []).size() >= 2: - branch3 += 1 - Kit.check("L3 普通战斗节点 = 6 + Boss 恒一(编号最大恒终点)", int(tcount3.battle) == 6 and int(tcount3.boss) == 1) - Kit.check("L3 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)+ 商店房节点 1(商店房批)", int(tcount3.reward) == 1 and int(tcount3.rest) == 1 and int(tcount3.shop) == 1) - Kit.check("L3 真实岔路 = %d 处(0→[1,2,3,4,5] · 2→[6,7] · 5→[6,7]——U2 返工批五岔环廊层宽 5)" % branch3, branch3 == 3) - # ⑨ 跨表行完整:L3 每战斗/Boss 节点在 board_gen 与 enemy 精确行集(⑦ 的 bg_keys/en_keys 复用—— - # 键域同形:(L,row_kind,depth_band)) - var cross_ok3 := true - for node in _level_active_nodes(3): - var t3 := String(node.get("type", "")) - if t3 != "battle" and t3 != "boss": - continue - if not bg_keys.has("3_%d" % int(node.node_depth)) or not en_keys.has("3_%s_%d" % [t3, int(node.node_depth)]): - cross_ok3 = false - Kit.check("跨表行完整:L3 全部战斗/Boss 节点在 board_gen + enemy 精确行集(7 节点——带键 (3,row_kind,depth_band))", cross_ok3) - # ⑩ 定点:L3 首遇带行集与 Boss 行集(克制面重轻错开——重甲灰矮人首次登台;首遇带数据驱动定位同 ⑦a) + # ⑧ L3 生成图形状读数(与 ② 同一性质判据面——规模/型别/岔口/多终点全部对参数档与扫描读数互校) + var nodes3: Array = _level_active_nodes(3) + var params3: Dictionary = _db.level_gen_params_row(3) + Kit.check("L3 现行起关图取数非空(种子 %d × 参数档 → 节点 %d 颗)" % [ACTIVE_GRAPH_SEED, nodes3.size()], not nodes3.is_empty()) + var tcount3 := _type_tally(nodes3) + var branch3 := _fork_ids(nodes3).size() + Kit.check("L3 节点数 %d ≥ 层高 %d+1 且 = 句柄去重读数 %d(全员句柄唯一)" % [nodes3.size(), int(params3.get("layers", -1)), _nodes_by_id(nodes3).size()], + nodes3.size() >= int(params3.get("layers", -1)) + 1 and _nodes_by_id(nodes3).size() == nodes3.size()) + Kit.check("L3 五型计数合计 = 节点数(battle %d + boss %d + reward %d + rest %d + shop %d = %d)" % [tcount3["battle"], tcount3["boss"], tcount3["reward"], tcount3["rest"], tcount3["shop"], nodes3.size()], + int(tcount3["battle"]) + int(tcount3["boss"]) + int(tcount3["reward"]) + int(tcount3["rest"]) + int(tcount3["shop"]) == nodes3.size()) + Kit.check("L3 战斗节点数 %d ≥ 参数档战斗保底 %d 且 Boss 节点数 %d = 参数档出口数 %d" % [tcount3["battle"], int(params3.get("battle_floor", -1)), tcount3["boss"], int(params3.get("exit_count", -1))], + int(tcount3["battle"]) >= int(params3.get("battle_floor", -1)) and int(tcount3["boss"]) == int(params3.get("exit_count", -1))) + Kit.check("L3 恢复/奖励/商店节点各 ≥1(实测 rest %d · reward %d · shop %d——计数由逐节点扫描同一读数)" % [tcount3["rest"], tcount3["reward"], tcount3["shop"]], + int(tcount3["rest"]) >= 1 and int(tcount3["reward"]) >= 1 and int(tcount3["shop"]) >= 1) + Kit.check("L3 岔口(出度 ≥2 的节点)= %d 处(≥1)" % branch3, branch3 >= 1) + Kit.check("L3 入口数 %d = 参数档 entry_count · 终点数 %d = exit_count ≥2(多终点大岔口自 L2 起)" % [_entry_ids(nodes3).size(), _terminal_ids(nodes3).size()], + _entry_ids(nodes3).size() == int(params3.get("entry_count", -1)) and _terminal_ids(nodes3).size() == int(params3.get("exit_count", -1)) and _terminal_ids(nodes3).size() >= 2) + # ⑨ 跨表覆盖义务(同 ⑦ 判据面):L3 现行图每战斗/Boss 节点深度敌侧落带 ≥ 请求深度+板侧精确命中 + var cov3 := _coverage_scan(3, nodes3) + Kit.check("跨表覆盖扫描面非空:L3 现行图战斗/Boss 节点 %d 颗" % int(cov3["nodes"]), int(cov3["nodes"]) >= 1) + Kit.check("L3 现行图每战斗/Boss 节点深度敌侧解析落带 ≥ 请求深度(违例 %d 处)" % int(cov3["enemy_bad"]), int(cov3["enemy_bad"]) == 0) + Kit.check("L3 现行图每节点深度 board_gen 精确命中(非空且 node_depth 等值·违例 %d 处)" % int(cov3["board_bad"]), int(cov3["board_bad"]) == 0) + # ⑩ 定点:L3 首遇带行集与各终点 Boss 行集(克制面重轻错开——重甲灰矮人首次登台;首遇带数据驱动定位同 ⑦a) var l3_lead_band: int = _first_encounter_band(3, "duergar") var l3_lead_set: Array = _db.resolve_enemy_rowset(3, l3_lead_band, "battle") var l3_lead_ids := {} @@ -337,11 +646,14 @@ func _smoke_level_graph() -> void: l3_lead_band >= 0 and l3_lead_ids.has("duergar") and l3_lead_ids.size() == 1 \ and String(l3_lead_set[0].get("armor_type", "")) == "heavy" \ and int(l3_lead_set[0].get("unit_count", -1)) == 1 and not l3_lead_set[0].has("unit_count_max")) - var l3_king_set: Array = _db.resolve_enemy_rowset(3, _boss_depth(3), "boss") - var l3_king_ids := {} - for r in l3_king_set: - l3_king_ids[String(r.get("enemy_id", ""))] = true - Kit.check("灰矮人石君王 Boss 行集(3,boss,%d)= {duergar_king} 恰一行且首行重甲(重甲收束「轻甲→魔法甲→重甲」教学梯度)〔fv24〕" % _boss_depth(3), l3_king_ids.has("duergar_king") and l3_king_ids.size() == 1 and String(l3_king_set[0].get("armor_type", "")) == "heavy") + var l3_terms := _terminal_depths(nodes3) + var l3_boss_ok := not l3_terms.is_empty() + for d in l3_terms: + var l3_king_set: Array[Dictionary] = _db.resolve_enemy_rowset(3, int(d), "boss") + if l3_king_set.size() != 1 or String(l3_king_set[0].get("enemy_id", "")) != "duergar_king" \ + or String(l3_king_set[0].get("armor_type", "")) != "heavy": + l3_boss_ok = false + Kit.check("L3 全部终点深度 %s 的 Boss 行集均 = {duergar_king} 恰一行且首行重甲(重甲收束「轻甲→魔法甲→重甲」教学梯度)〔fv24〕" % str(l3_terms), l3_boss_ok) # ⑪ L3 成长代理档首遇绿档:模拟 GP 商店成长后的入关参数(atk_pct=200 / hp_pct=100)· 完美档多种子首遇战稳过 # (零加成基线红档属预期——断言口径 = 成长代理档,非基线;跑法同 ⑥) var green3_ok := true @@ -391,10 +703,10 @@ func _smoke_level_graph() -> void: Kit.check("混编断言段注入行全量还原(表回 28 行——迭代 35 生成图深度扩面覆盖批后的基线行数;首批混编投放基线 20 行+覆盖 8 行)", int(_db.enemy.rows.size()) == 28) # ⑬ 真图类型可见性(信息迷雾 v2.1):L2 入口停点纯核心面读数——候选 ⊆ 可见集 / - # Boss 恒可见 / 层距 > N 的非豁免节点入雾。N 取数据表(代码零硬编码)。 - var vis_runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(42)) + # 终点恒可见 / 入口 BFS 层距 > N 的非豁免节点入雾。N 取数据表(代码零硬编码)。 + var vis_runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(ACTIVE_GRAPH_SEED)) var vis_ledger := {"hero_hp": 10, "gp": 0} - var vis_ok: bool = vis_runner.start_level_external(2, vis_ledger, {}, Callable(), {}, true, 0) + var vis_ok: bool = vis_runner.start_level_external(2, vis_ledger, {}, Callable(), {}, true) Kit.check("可见性冒烟:L2 入口停点就位(defer_entry——无当前节点,锚层 0)", vis_ok and vis_runner.awaiting_choice() and vis_runner.current_node_id() == -1) var vis_ids: Array = vis_runner.type_visible_ids() var vis_cand_in := true @@ -402,10 +714,36 @@ func _smoke_level_graph() -> void: if not vis_ids.has(int(cid)): vis_cand_in = false Kit.check("可见性冒烟:入口停点候选集 ⊆ 类型可见集(下一跳恒可见)〔fv4〕", vis_cand_in) - Kit.check("可见性冒烟:L2 Boss(编号 %d)恒在可见集(终点不入雾)〔fv3〕" % _boss_depth(2), vis_ids.has(_boss_depth(2))) + var vis_boss_ok := true + for bid in _ids_of_type(nodes2, "boss"): + if not vis_ids.has(int(bid)): + vis_boss_ok = false + Kit.check("可见性冒烟:L2 全部终点 Boss 句柄 %s 恒在可见集(终点不入雾)〔fv3〕" % str(_ids_of_type(nodes2, "boss")), vis_boss_ok) var vis_n: int = int(_db.level.info_visible_layers) Kit.check("可见性冒烟:N 读数据表 = %d(provisional 2——代码零硬编码)〔fv6〕" % vis_n, vis_n == 2) - Kit.check("可见性冒烟:入口锚层 0 时层距 3/4 的节点 7/8 入雾(非豁免——入口 BFS 层差 > N)〔fv1〕", not vis_ids.has(7) and not vis_ids.has(8)) + # 层距判据按图自算入口 BFS 层(与 runner 同源口径:入口层 0、出边逐层 +1)—— + # 锚层 0 时层距 ≤ N 者恒可见、层距 > N 的非终点者恒不可见(两条扫描面都须非空) + var vis_layers := _bfs_layers(nodes2) + var near_ok := true + var near_count := 0 + var fog_ok := true + var fog_count := 0 + for n in nodes2: + var nid := int(n.get("id", -1)) + var layer: int = int(vis_layers.get(nid, -1)) + if layer < 0: + continue + var is_boss := String(n.get("type", "")) == "boss" + if layer <= vis_n: + near_count += 1 + if not vis_ids.has(nid): + near_ok = false + elif not is_boss: + fog_count += 1 + if vis_ids.has(nid): + fog_ok = false + Kit.check("可见性冒烟:入口锚层 0 时层距 ≤ %d 的节点全在可见集(扫描面 %d 颗·违例 0)〔fv1〕" % [vis_n, near_count], near_count >= 1 and near_ok) + Kit.check("可见性冒烟:入口锚层 0 时层距 > %d 的非终点节点全入雾(扫描面 %d 颗·违例 0)〔fv1〕" % [vis_n, fog_count], fog_count >= 1 and fog_ok) # 冒烟隔离用全新 RngService(同 master 种子——回放确定性纪律) @@ -420,7 +758,7 @@ func _fresh_rng(seed_val: int): # ==================== GP+商店房冒烟 ==================== # ① GP 到账(战斗完成入账恰一次 + Boss 关级加值)② escrow 隔离(格面兑付入缓冲/破财不透支) -# ③ 商店三收口(购买出账/加算区/余额不足拒/离开/收束清零)④ 图不变式(商店必经位/跨表行集迁移) +# ③ 商店三收口(购买出账/加算区/余额不足拒/离开/收束清零)④ 商店编排性质(每终点前恰一店/终点跨表解析) # ⑤ 九型与 gp_drop 流(奖励字典 18 格 + 黄金向量)。 # ---- ① GP 冒险钱包·到账 ---- @@ -428,7 +766,7 @@ func _fresh_rng(seed_val: int): # ==================== GP+商店房冒烟 ==================== # ① GP 到账(战斗完成入账恰一次 + Boss 关级加值)② escrow 隔离(格面兑付入缓冲/破财不透支) -# ③ 商店三收口(购买出账/加算区/余额不足拒/离开/收束清零)④ 图不变式(商店必经位/跨表行集迁移) +# ③ 商店三收口(购买出账/加算区/余额不足拒/离开/收束清零)④ 商店编排性质(每终点前恰一店/终点跨表解析) # ⑤ 九型与 gp_drop 流(奖励字典 18 格 + 黄金向量)。 # ---- ① GP 冒险钱包·到账 ---- @@ -614,44 +952,61 @@ func _find_gp_dig(minesweeper, settlement) -> Dictionary: func _smoke_shop_room() -> void: print("--- 商店房三收口(现价/加算区/余额不足拒/离开回选路/收束清零)---") var autoplay = load("res://sim/level_autoplay.gd") - # 行进到商店房(min 路径:0→1→3(恢复)→6→8)——种子扫描保证进店余额 ≥ 三件首购总价 16(6+4+6) + # 行进到商店房(按图型别定位商店节点——候选取最小句柄行进,到达即停): + # 种子扫描保证进店余额 ≥ 三件首购总价 16(6+4+6) var shop_run := {} for s in range(42, 92): - var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(s)) - runner.set_hero_override(900, 400) # 行进段快速通关档(购买断言与百分比轴正交——加算区单独断言) - runner.start_level(2, s, {}, 0) # 固定模板 0——商店经济断言按现行图 GP 账本 + var r0 = load("res://core/level_runner.gd").new(_db, _fresh_rng(s)) + r0.set_hero_override(900, 400) # 行进段快速通关档(购买断言与百分比轴正交——加算区单独断言) + r0.start_level(2, s) var st := _battle_st("normal") - var guard := 40 - while String(runner.level_result) == "" and int(runner.current_node_id()) != 8 and guard > 0: + var guard := 60 + while String(r0.level_result) == "" and String(r0.current_node().get("type", "")) != "shop" and guard > 0: guard -= 1 - if runner.awaiting_choice(): - var ids: Array = runner.next_candidate_ids() + if r0.awaiting_choice(): + var ids: Array = r0.next_candidate_ids() ids.sort() - runner.pick_next_node(int(ids[0])) + r0.pick_next_node(int(ids[0])) continue - var t := String(runner.current_node().get("type", "")) + var t := String(r0.current_node().get("type", "")) if t == "reward" or t == "rest": - runner.continue_after_node() + r0.continue_after_node() continue - if t == "shop": - break - var b = runner.current_battle() + var b = r0.current_battle() if b == null: break var fin := {} b.battle_finished.connect(func(p): fin["p"] = p) var g2 := 80 - while runner.current_battle() == b and not fin.has("p") and g2 > 0: + while r0.current_battle() == b and not fin.has("p") and g2 > 0: g2 -= 1 - autoplay._autoplay_battle(runner, b, st, null) - if String(runner.level_result) == "" and int(runner.current_node_id()) == 8 and runner.gp >= 16: - shop_run = {"seed": s, "runner": runner} + autoplay._autoplay_battle(r0, b, st, null) + if String(r0.level_result) == "" and String(r0.current_node().get("type", "")) == "shop" and r0.gp >= 16: + shop_run = {"seed": s, "runner": r0} break Kit.check("商店房到达种子可达(seed=%s——进店 GP ≥ 16)" % str(shop_run.get("seed", -1)), not shop_run.is_empty()) if shop_run.is_empty(): return var runner = shop_run["runner"] - Kit.check("L2 行进到商店房(节点 8,next=[9]——Boss 前必经位)", String(runner.current_node().get("type", "")) == "shop" and int(runner.current_node_id()) == 8) + # 到店读数全部按图解析(现行判据=每终点前一位恰一店的行进面) + var s_nodes: Array = runner.nodes() + var s_byid := _nodes_by_id(s_nodes) + var shop_id: int = int(runner.current_node_id()) + var shop_outs: Array = runner.node_out_ids(runner.current_node()) + var shop_preds := 0 + for n in s_nodes: + if (n.get("next", []) as Array).has(shop_id): + shop_preds += 1 + Kit.check("L2 行进到商店房:当前节点型别 = shop 且出边恰一条指终点 Boss(句柄 %d·出边 %s)" % [shop_id, str(shop_outs)], + String(runner.current_node().get("type", "")) == "shop" and shop_outs.size() == 1 \ + and String(s_byid.get(int(shop_outs[0]), {}).get("type", "")) == "boss" \ + and (s_byid.get(int(shop_outs[0]), {}).get("next", []) as Array).is_empty()) + Kit.check("L2 到店路径穿商店恰 1 家(已到句柄 %s 型别计数——必经恰一店的行进面)" % str(runner.visited_ids()), + _path_shop_count(s_nodes, runner.visited_ids()) == 1) + Kit.check("L2 商店节点(句柄 %d)入边恰一条(店位归终点的直接前驱——行进面复核)" % shop_id, shop_preds == 1) + if shop_outs.is_empty(): + Kit.check("到店商店节点无出边(夹具停摆——去终点方向无法从图解析)", false) + return Kit.check("到店开店态(覆盖层数据源就位——离开前不出选路口,零自动推进)", not runner.awaiting_choice() and runner.next_candidate_ids().is_empty()) # 现价 = 基价 + price_level_bonus(L2)=2 + 步长 × 已购档数 var prods: Array = runner.shop_products() @@ -694,11 +1049,12 @@ func _smoke_shop_room() -> void: var tier_dry: int = runner.purchased_tier("hp_up") var hp_dry: int = int(_shop_products_by_id(runner.shop_products())["hp_up"]["price"]) Kit.check("余额不足购买被拒(gp=%d < 现价 %d——零扣费零档位变动)" % [gp_dry, hp_dry], not runner.buy_product("hp_up") and runner.gp == gp_dry and runner.purchased_tier("hp_up") == tier_dry) - # 离开 → 回选路口(候选 [9]) - Kit.check("离开商店 → 回选路口(候选 [9]——零自动推进收口)", runner.continue_after_node() and runner.awaiting_choice() and runner.next_candidate_ids() == [9]) + # 离开 → 回选路口(候选 = 该店出边——恰一条,指向终点 Boss) + Kit.check("离开商店 → 回选路口(候选 = 店出边 %s——零自动推进收口)" % str(shop_outs), + runner.continue_after_node() and runner.awaiting_choice() and _sorted_ids(runner.next_candidate_ids()) == _sorted_ids(shop_outs)) # 下一战参数 = 基础表值 + 加算区(百分比已归零——加算先乘后乘序的前半在案) Kit.check("主角攻击生效值 = 2+1(基础表值 + 加算区)", runner.effective_atk() == int(_db.hero.atk) + 1) - Kit.check("选 Boss(节点 9)开战", runner.pick_next_node(9) and runner.current_battle() != null) + Kit.check("选终点 Boss(句柄 %d)开战" % int(shop_outs[0]), runner.pick_next_node(int(shop_outs[0])) and runner.current_battle() != null) var snap: Dictionary = runner.current_battle().presentation_snapshot() # max_hp 期望按实际购买笔数推导(可购笔数 # 随经济漂移;断言语义=「加算区笔数×3 全额入战斗开始参数」,不锚死恰好 1 笔) @@ -724,51 +1080,104 @@ func _shop_products_by_id(prods: Array) -> Dictionary: return out -# ---- ④ 商店房图不变式(恰 1 商店 · 必经位 · 跨表行集迁移)---- +# ---- ④ 商店编排性质(每终点前一位恰一店 · 店数=终点数 · 全路径恰穿一店)---- -# ---- ④ 商店房图不变式(恰 1 商店 · 必经位 · 跨表行集迁移)---- +# ---- ④ 商店编排性质(生成图扫描面——三条性质 + 终点跨表解析)---- func _smoke_shop_graph() -> void: - print("--- 商店房图不变式(恰 1 商店房 · 全路径必经 · 跨表行集迁移)---") - for L in [2, 3]: - var row := {} - for r in _db.level.rows: - if int(r.level_index) == L: - row = r - var shops: Array = [] - var boss_id := -1 - var max_id := -1 - for n in _level_active_nodes(L): - var t := String(n.get("type", "")) - if t == "shop": - shops.append(int(n.get("node_depth", -1))) - if t == "boss": - boss_id = int(n.get("node_depth", -1)) - max_id = maxi(max_id, int(n.get("node_depth", -1))) - Kit.check("L%d 商店房恰 1 且 node_depth=8(next=[9])" % L, shops.size() == 1 and int(shops[0]) == 8) - Kit.check("L%d Boss = 9 = 全图最大编号(商店房批重编号 8→9)" % L, boss_id == 9 and boss_id == max_id) - var paths: Array = _all_entry_boss_paths(_level_active_nodes(L)) - var via_shop := true - for p in paths: - if not p.has(8): - via_shop = false - Kit.check("L%d 全部 %d 条入口→Boss 路径穿商店房 8(必经位——DFS 枚举)" % [L, paths.size()], paths.size() >= 2 and via_shop) - # 跨表校验:(·,boss,9) Boss 行精确存在 · (·,battle,8) 无战斗行残留(陈旧行清理确认) - 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_%s_%d" % [int(row.level_index), String(row.get("row_kind", "")), int(row.get("depth_band", -1))]] = true - for L in [2, 3]: - Kit.check("(%d,boss,9) Boss 行在 board_gen + enemy 精确存在" % L, bg_keys.has("%d_9" % L) and en_keys.has("%d_boss_9" % L)) - # 迭代 35 生成图深度扩面覆盖批随动(旧判据属模板时代:深度 8 = 商店房专属位,故禁板行与战斗行): - # 生成图下深度 8 是正常可行深度 ⇒ 板侧改判「覆盖行在册」;战斗侧仍禁 (L,battle,8) 精确键 - #(战斗落带经顶带 9/13 解析,不落 8 号精确键——两侧判据都保留,只按现行设计换正负号)。 - Kit.check("(%d,8) 板侧生成图覆盖行在册·(%d,battle,8) 战斗精确键仍不存在" % [L, L], - bg_keys.has("%d_8" % L) and not en_keys.has("%d_battle_8" % L)) - Kit.check("L1 行集零改动(单链 Boss (1,4)——教学期最小修订面)", bg_keys.has("1_4") and en_keys.has("1_boss_4") and not bg_keys.has("1_8") and not en_keys.has("1_battle_8")) + print("--- 商店编排性质(每终点前一位恰一店 · 店数=终点数 · 全路径恰穿一店)---") + var pred_bad := 0 + var term_total := 0 + var count_bad := 0 + var path_bad := 0 + var path_total := 0 + var graph_total := 0 + for L in [1, 2, 3]: + if _db.level_gen_params_row(L).is_empty(): + Kit.check("L%d 参数档缺行(商店编排断言无源可扫)" % L, false) + continue + for seed_val in range(SWEEP_SEED_FROM, SWEEP_SEED_TO): + var nodes: Array = _generated_graph(L, seed_val).get("nodes", []) + if nodes.is_empty(): + Kit.check("L%d 种子 %d 生成图为空(编排断言扫描面空转)" % [L, seed_val], false) + continue + graph_total += 1 + if seed_val == 44 and L == 2: # MUTATION-A:店位改成战斗节点 + for mn in nodes: + if String(mn.get("type", "")) == "shop": + mn["type"] = "battle" + if seed_val == 45 and L == 3: # MUTATION-B:多塞一家店(第二家挂在 reward 位) + for mn in nodes: + if String(mn.get("type", "")) == "reward": + mn["type"] = "shop" + break + var by_id := _nodes_by_id(nodes) + var terminals := _terminal_ids(nodes) + # 性质①:每个终点的直接前驱恰为一家商店(入边一条 + 入边源型别 = shop) + for tid in terminals: + term_total += 1 + var preds: Array = [] + for n in nodes: + if (n.get("next", []) as Array).has(int(tid)): + preds.append(int(n.get("id", -1))) + if preds.size() != 1 or String(by_id.get(int(preds[0]), {}).get("type", "")) != "shop": + pred_bad += 1 + # 性质②:商店节点总数 = 终点数 + if _ids_of_type(nodes, "shop").size() != terminals.size(): + count_bad += 1 + # 性质③:所有入口→终点路径穿过且恰穿过一家店(DFS 枚举句柄序) + var paths: Array = _all_entry_terminal_paths(nodes) + if paths.size() < terminals.size(): + path_bad += 1 # 枚举面少于终点数=有终点没有入口→终点路径 + for p in paths: + path_total += 1 + if _path_shop_count(nodes, p) != 1: + path_bad += 1 + print("商店编排扫描读数:图 %d 张·终点 %d 个·入口→终点路径 %d 条" % [graph_total, term_total, path_total]) + Kit.check("编排断言扫描面非空(三关参数档 × 种子 %d–%d=图 %d 张·终点 %d·路径 %d)" % [SWEEP_SEED_FROM, SWEEP_SEED_TO - 1, graph_total, term_total, path_total], + graph_total == 3 * (SWEEP_SEED_TO - SWEEP_SEED_FROM) and term_total >= 3 and path_total >= 3) + Kit.check("性质① 每个终点的直接前驱恰一家商店(入边一条且源型别=shop·违例 %d/%d 终点)" % [pred_bad, term_total], pred_bad == 0) + Kit.check("性质② 每图商店节点数 = 终点数(违例 %d/%d 图)" % [count_bad, graph_total], count_bad == 0) + Kit.check("性质③ 每条入口→终点路径恰穿一家商店(违例 %d/%d 条路径)" % [path_bad, path_total], path_bad == 0) + # 现行起关图(种子 %d)逐关读数——L1 单终点、L2/L3 多终点(每终点前一位恰一店的当关投影) + for L in [1, 2, 3]: + var cur_nodes: Array = _level_active_nodes(L) + var cur_terms := _terminal_ids(cur_nodes) + var cur_shops := _ids_of_type(cur_nodes, "shop") + Kit.check("L%d 现行图终点 %d 个 = 商店 %d 个·入口→终点路径 %d 条全穿恰一店" % [L, cur_terms.size(), cur_shops.size(), _all_entry_terminal_paths(cur_nodes).size()], + cur_terms.size() == cur_shops.size() and not cur_terms.is_empty() + and _all_entry_terminal_paths(cur_nodes).size() >= cur_terms.size() + and _paths_all_one_shop(cur_nodes)) + # 终点跨表解析:每个终点深度的 Boss 行集解析命中且落带 ≥ 终点深度(顶 clamp 消解即红) + for L in [1, 2, 3]: + var cur_nodes2: Array = _level_active_nodes(L) + var tdepths := _terminal_depths(cur_nodes2) + var hit_ok := not tdepths.is_empty() + for d in tdepths: + if not _enemy_band_covers(L, int(d), "boss"): + hit_ok = false + Kit.check("L%d 现行图终点深度 %s 逐点 Boss 行集解析命中且落带 ≥ 终点深度" % [L, str(tdepths)], hit_ok) + # L1 现行图跨表覆盖(⑦/⑨ 只扫 L2/L3——L1 图侧覆盖在此闭合:每 battle/boss 深度敌侧落带+板侧精确命中) + var mut_nodes: Array = _level_active_nodes(1) + mut_nodes[0]["depth"] = 99 # MUTATION-C:深度越出表域(敌带与板行都应判违例) + mut_nodes[1]["type"] = "trap" # MUTATION-D:型别出枚举(五型合计应 ≠ 节点数) + var l1_cov := _coverage_scan(1, mut_nodes) + Kit.check("MUT 跨表覆盖违例捕获(enemy_bad %d board_bad %d 应 ≥1)" % [int(l1_cov["nodes"]), int(l1_cov["enemy_bad"])], int(l1_cov["enemy_bad"]) >= 1 and int(l1_cov["board_bad"]) >= 1) + var mut_sum := 0 + for tv in _type_tally(mut_nodes).values(): + mut_sum += int(tv) + Kit.check("MUT 型别合计自洽违例捕获(sum %d vs 节点数 %d)" % [mut_sum, mut_nodes.size()], mut_sum != mut_nodes.size()) + Kit.check("L1 现行图跨表覆盖(战斗/Boss 节点 %d 颗非空·敌侧违例 %d·板侧违例 %d)" % [int(l1_cov["nodes"]), int(l1_cov["enemy_bad"]), int(l1_cov["board_bad"])], + int(l1_cov["nodes"]) >= 1 and int(l1_cov["enemy_bad"]) == 0 and int(l1_cov["board_bad"]) == 0) + + +# 现行图全路径穿店恰一(性质③在单图上的整体读数) +func _paths_all_one_shop(nodes: Array) -> bool: + for p in _all_entry_terminal_paths(nodes): + if _path_shop_count(nodes, p) != 1: + return false + return true # ---- ⑤ 商店共享池(三选一同构/乘法价/跨渠道共账/切边/断点扩键)---- @@ -789,7 +1198,7 @@ func _smoke_shop_pool() -> void: for s in range(42, 92): var a8 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(s)) a8.set_hero_override(900, 400) - if not a8.start_adventure(s, {"first_script": true, "shop_mode": "pool"}): + if not a8.start_adventure(s, {"shop_mode": "pool"}): continue if not _adv_drive_to_shop(a8, _adv_st(), autoplay): continue @@ -801,7 +1210,7 @@ func _smoke_shop_pool() -> void: # 同种子再跑一遍采第二份首架(确定性对拍——断点货架续玩=确定性重放承载的实证面) var a8b = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(int(scen["s"]))) a8b.set_hero_override(900, 400) - if a8b.start_adventure(int(scen["s"]), {"first_script": true, "shop_mode": "pool"}) \ + if a8b.start_adventure(int(scen["s"]), {"shop_mode": "pool"}) \ and _adv_drive_to_shop(a8b, _adv_st(), autoplay): first_shelf_b = (a8b.runner().shop_shelf() as Array).duplicate(true) Kit.check("进店抽 3(货架 %d 卡——同构交互)" % first_shelf_a.size(), first_shelf_a.size() == 3) @@ -989,51 +1398,10 @@ func _adv_st() -> Dictionary: # 返回 null = 不在摇号清单的封锁格)。哈希兜底按坐标确定性散列——全缸可复现。 -# 入口→Boss 全路径枚举(DFS 迭代栈——图合法性校验保证单入口无环,路径有限) -func _all_entry_boss_paths(nodes: Array) -> Array: - var by_id := {} - var inbound := {} - for n in nodes: - by_id[int(n.get("node_depth", -1))] = n - for n in nodes: - for tid in n.get("next", []): - inbound[int(tid)] = true - var entry := -1 - for n in nodes: - if not inbound.has(int(n.get("node_depth", -1))): - entry = int(n.get("node_depth", -1)) - var paths: Array = [] - var stack: Array = [[entry]] - while not stack.is_empty(): - var path: Array = stack.pop_back() - var cur: Dictionary = by_id[path[path.size() - 1]] - var outs: Array = cur.get("next", []) - if outs.is_empty(): - paths.append(path) - continue - for t in outs: - var np := path.duplicate() - np.append(int(t)) - stack.append(np) - return paths - - - # ---- 元进度·MetaTable 表结构(行值全 provisional 归元进度设计文档配读数; # conversion 覆盖恰三,Boss 两档 first ≥ repeat)---- -# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义) -func _level_active_nodes(level_index: int) -> Array: - for row in _db.level.rows: - if int(row.level_index) == level_index: - var tpls: Array = row.get("templates", []) - if not tpls.is_empty(): - return tpls[0].get("nodes", []) - return row.get("nodes", []) - return [] - - # 首遇带数据驱动定位(契约 8):该 (L, battle, enemy_id) 的最低 depth_band,禁写死坐标数字 func _first_encounter_band(level_index: int, enemy_id: String) -> int: var best := -1 @@ -1048,20 +1416,6 @@ func _first_encounter_band(level_index: int, enemy_id: String) -> int: return best -# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义) - - -# 该关 Boss 节点深度(单终点图的终点编号——数据驱动,禁写死坐标) -func _boss_depth(level_index: int) -> int: - for n in _level_active_nodes(level_index): - if String(n.get("type", "")) == "boss": - return int(n.get("node_depth", -1)) - return -1 - - -# 首遇带数据驱动定位(契约 8):该 (L, battle, enemy_id) 的最低 depth_band,禁写死坐标数字 - - func _has_err_category(errs: Array, category: String) -> bool: for e in errs: if String(e.get("category", "")) == category: From d48bf10fd7028deddf34b0dd9998e95991b1e6d5 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sat, 24 Oct 2026 19:26:44 +0800 Subject: [PATCH 13/20] =?UTF-8?q?test(=E8=BF=AD=E4=BB=A335=E5=8D=A1?= =?UTF-8?q?=E2=91=A1T3=20=E4=BB=B6=E6=A0=B8):=20=E5=95=86=E5=BA=97?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E5=86=85=E8=81=94=E5=8F=98=E5=BC=82=E8=84=9A?= =?UTF-8?q?=E6=89=8B=E6=9E=B6=E6=B8=85=E9=80=80=E2=80=94=E2=80=94=E5=AE=BF?= =?UTF-8?q?=E4=B8=BB=E8=87=AA=E6=94=B9=E7=94=9F=E6=88=90=E5=9B=BE=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E7=9A=84=20MUTATION-A/B/C/D=20=E6=AE=B5=EF=BC=88?= =?UTF-8?q?=E5=BA=97=E4=BD=8D=E6=94=B9=E6=88=98=E6=96=97=EF=BC=8Freward=20?= =?UTF-8?q?=E4=BD=8D=E6=94=B9=E7=AC=AC=E4=BA=8C=E5=AE=B6=E5=BA=97=EF=BC=8F?= =?UTF-8?q?=E6=B7=B1=E5=BA=A6=E5=87=BA=E8=A1=A8=E5=9F=9F=EF=BC=8F=E5=9E=8B?= =?UTF-8?q?=E5=88=AB=E5=87=BA=E6=9E=9A=E4=B8=BE=EF=BC=89=E4=B8=8E=E9=85=8D?= =?UTF-8?q?=E5=A5=97=E4=B8=A4=E6=9D=A1=20MUT=20=E6=96=AD=E8=A8=80=E6=95=B4?= =?UTF-8?q?=E4=BD=93=E5=88=A0=E9=99=A4=EF=BC=8C=E6=B5=8B=E8=AF=95=E9=9D=A2?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=87=AA=E5=B8=A6=E5=8F=98=E5=BC=82=E5=BC=80?= =?UTF-8?q?=E5=85=B3=EF=BC=9BV5=20=E4=B8=A4=E6=9D=A1=E6=8B=A6=E6=88=AA?= =?UTF-8?q?=E8=AF=81=E6=8D=AE=E6=94=B9=E7=94=B1=E7=94=9F=E4=BA=A7=E7=A0=81?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=E5=8F=96=E8=AF=81=EF=BC=88node=5Fmap=5Fgener?= =?UTF-8?q?ator=20=E4=BA=A7=E5=9B=BE=E5=B0=BE=E6=AE=B5=E5=BA=97=E4=BD=8D?= =?UTF-8?q?=E6=94=B9=20rest=E2=86=92=E6=80=A7=E8=B4=A8=E2=91=A0=E7=BA=A2?= =?UTF-8?q?=E3=80=81battle=20=E6=94=B9=20shop=E2=86=92=E6=80=A7=E8=B4=A8?= =?UTF-8?q?=E2=91=A160/60=20=E5=9B=BE=E4=B8=8E=E6=80=A7=E8=B4=A8=E2=91=A27?= =?UTF-8?q?38/1413=20=E8=B7=AF=E5=BE=84=E5=90=8C=E6=97=B6=E7=BA=A2?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E5=AE=BF=E4=B8=BB=E5=A4=8D=E8=B7=91=20176=20?= =?UTF-8?q?=E6=9D=A1=E5=85=A8=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unit/core/level_shop_economy_test.gd | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/game/tests/unit/core/level_shop_economy_test.gd b/game/tests/unit/core/level_shop_economy_test.gd index 2eaa06ed..c2dce65a 100644 --- a/game/tests/unit/core/level_shop_economy_test.gd +++ b/game/tests/unit/core/level_shop_economy_test.gd @@ -1103,15 +1103,6 @@ func _smoke_shop_graph() -> void: Kit.check("L%d 种子 %d 生成图为空(编排断言扫描面空转)" % [L, seed_val], false) continue graph_total += 1 - if seed_val == 44 and L == 2: # MUTATION-A:店位改成战斗节点 - for mn in nodes: - if String(mn.get("type", "")) == "shop": - mn["type"] = "battle" - if seed_val == 45 and L == 3: # MUTATION-B:多塞一家店(第二家挂在 reward 位) - for mn in nodes: - if String(mn.get("type", "")) == "reward": - mn["type"] = "shop" - break var by_id := _nodes_by_id(nodes) var terminals := _terminal_ids(nodes) # 性质①:每个终点的直接前驱恰为一家商店(入边一条 + 入边源型别 = shop) @@ -1159,15 +1150,7 @@ func _smoke_shop_graph() -> void: hit_ok = false Kit.check("L%d 现行图终点深度 %s 逐点 Boss 行集解析命中且落带 ≥ 终点深度" % [L, str(tdepths)], hit_ok) # L1 现行图跨表覆盖(⑦/⑨ 只扫 L2/L3——L1 图侧覆盖在此闭合:每 battle/boss 深度敌侧落带+板侧精确命中) - var mut_nodes: Array = _level_active_nodes(1) - mut_nodes[0]["depth"] = 99 # MUTATION-C:深度越出表域(敌带与板行都应判违例) - mut_nodes[1]["type"] = "trap" # MUTATION-D:型别出枚举(五型合计应 ≠ 节点数) - var l1_cov := _coverage_scan(1, mut_nodes) - Kit.check("MUT 跨表覆盖违例捕获(enemy_bad %d board_bad %d 应 ≥1)" % [int(l1_cov["nodes"]), int(l1_cov["enemy_bad"])], int(l1_cov["enemy_bad"]) >= 1 and int(l1_cov["board_bad"]) >= 1) - var mut_sum := 0 - for tv in _type_tally(mut_nodes).values(): - mut_sum += int(tv) - Kit.check("MUT 型别合计自洽违例捕获(sum %d vs 节点数 %d)" % [mut_sum, mut_nodes.size()], mut_sum != mut_nodes.size()) + var l1_cov := _coverage_scan(1, _level_active_nodes(1)) Kit.check("L1 现行图跨表覆盖(战斗/Boss 节点 %d 颗非空·敌侧违例 %d·板侧违例 %d)" % [int(l1_cov["nodes"]), int(l1_cov["enemy_bad"]), int(l1_cov["board_bad"])], int(l1_cov["nodes"]) >= 1 and int(l1_cov["enemy_bad"]) == 0 and int(l1_cov["board_bad"]) == 0) From 4cec838f0eb8db5ff44dd97c0378f2952f87b8d8 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sat, 24 Oct 2026 19:28:06 +0800 Subject: [PATCH 14/20] =?UTF-8?q?docs(=E8=BF=AD=E4=BB=A335=E5=8D=A1?= =?UTF-8?q?=E2=91=A1T4=20=E4=BA=A7=E5=87=BA=E7=89=A9):=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E6=8A=A5=E5=91=8A=E8=A1=A5=20T2=20=E4=BA=8C=E5=8F=A3/?= =?UTF-8?q?T3=20=E5=85=A8=E6=AE=B5=E4=B8=8E=E5=8F=98=E5=BC=82=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E8=AE=B0=E5=BD=95=E8=A1=A8=E4=B8=83=E6=9D=A1=EF=BC=88?= =?UTF-8?q?M1-M7=20=E6=B3=A8=E5=85=A5=E7=82=B9=E3=80=81=E6=9C=80=E7=AA=84?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E3=80=81=E7=BA=A2=E8=A1=8C=E5=8E=9F=E6=96=87?= =?UTF-8?q?=EF=BC=8CV5=20=E4=B8=A4=E6=9D=A1=E7=94=B1=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E7=A0=81=E6=B3=A8=E5=85=A5=E5=8F=96=E8=AF=81=E5=B9=B6=E5=A3=B0?= =?UTF-8?q?=E6=98=8E=E5=AE=BF=E4=B8=BB=E5=86=85=E8=81=94=E5=8F=98=E5=BC=82?= =?UTF-8?q?=E8=84=9A=E6=89=8B=E6=9E=B6=E5=B7=B2=E6=B8=85=E9=80=80=EF=BC=89?= =?UTF-8?q?=EF=BC=8B=E5=95=86=E5=BA=97=E5=AE=BF=E4=B8=BB=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=E6=AE=B5=EF=BC=88=E6=8D=A2=E4=BB=A3=E5=90=8E=E5=8F=96=E6=95=B0?= =?UTF-8?q?=E5=8D=95=E7=82=B9=E3=80=81S3=20=E4=B8=89=E6=80=A7=E8=B4=A8?= =?UTF-8?q?=E6=89=AB=E6=8F=8F=E9=9D=A2=2060=20=E5=9B=BE/1413=20=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E8=AF=BB=E6=95=B0=E3=80=81S4/S5=20=E9=9D=9E=E7=A9=BA?= =?UTF-8?q?=E5=89=8D=E7=BD=AE=E6=B8=85=E5=8D=95=EF=BC=89=EF=BC=9B=E6=96=B0?= =?UTF-8?q?=E5=BB=BA=E4=BB=A3=E6=8B=8D=E6=9D=BF=E8=BF=BD=E8=AE=A4=E5=8D=95?= =?UTF-8?q?=EF=BC=88=E7=99=BD=E5=90=8D=E5=8D=95=E5=85=AD=E6=9D=A1=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E5=80=BC=20graph=5Fgen=5Fv2=20=E5=91=BD=E5=90=8D/?= =?UTF-8?q?=E5=8F=A5=E6=9F=84=E6=96=AD=E7=82=B9=E5=BD=A2=E6=80=81/route=20?= =?UTF-8?q?=E8=BD=B4=E4=B8=8E=20level=5Fmaps=20=E5=80=BC=E5=BD=A2=E6=80=81?= =?UTF-8?q?/=E6=8F=90=E4=BA=A4=E5=88=87=E5=88=86=EF=BC=8B=E8=B6=85?= =?UTF-8?q?=E6=B8=85=E5=8D=95=E4=BA=94=E4=BB=B6=E5=91=88=E6=8A=A5=EF=BC=9A?= =?UTF-8?q?=E5=A5=96=E5=8A=B1=E5=BF=85=E7=BB=8F=E6=96=AD=E8=A8=80=E6=9B=BF?= =?UTF-8?q?=E4=BB=A3=E5=BD=A2=E6=80=81=E3=80=81=E4=B8=89=E5=A4=84=20=5Flev?= =?UTF-8?q?el=5Factive=5Fnodes=20=E7=A9=BA=E6=89=AB=E6=8F=8F=E5=81=87?= =?UTF-8?q?=E7=BB=BF=E6=B8=85=E5=81=BF=E3=80=81=E5=95=86=E5=BA=97=E5=88=B0?= =?UTF-8?q?=E8=BE=BE=E8=AF=BB=E6=95=B0=202=E2=86=923=E3=80=81=5Fboss=5Fdep?= =?UTF-8?q?th=20=E6=AD=BB=E4=BB=B6=E9=9A=8F=E5=88=A0=E3=80=81=E4=B8=A4?= =?UTF-8?q?=E5=91=88=E7=8E=B0=E5=AE=BF=E4=B8=BB=E5=A4=B9=E5=85=B7=E6=8B=93?= =?UTF-8?q?=E6=89=91=E9=87=8D=E5=BB=BA=E5=BE=85=E5=A4=8D=E6=A0=B8=EF=BC=89?= =?UTF-8?q?=EF=BC=9B=E6=96=B0=E5=BB=BA=E5=8F=8D=E5=93=BA=E7=82=B9=E6=B8=85?= =?UTF-8?q?=E5=8D=95=EF=BC=88=E5=8D=A1=E7=89=87=20=C2=A76=20=E5=85=AD?= =?UTF-8?q?=E6=9D=A1=E9=80=90=E8=90=BD=E7=82=B9=EF=BC=8B=E6=96=B0=E5=8F=91?= =?UTF-8?q?=E7=8E=B0=E5=9B=9B=E6=9D=A1=EF=BC=9A=E9=AA=8C=E8=AF=81=E9=9B=86?= =?UTF-8?q?=E6=B8=85=E5=8D=95=2011=20=E8=A1=8C=E9=99=88=E6=97=A7=E4=B8=8D?= =?UTF-8?q?=E5=8F=97=E9=97=B8=E8=A6=86=E7=9B=96=E3=80=81node=5Fmap=5Ftempl?= =?UTF-8?q?ates=20=E5=AE=BF=E4=B8=BB=E5=90=8D=E9=81=97=E7=95=99=E3=80=81le?= =?UTF-8?q?vel.tres=20=E8=A1=A8=E5=B0=BE=E5=8E=BB=E5=90=91=E6=B3=A8?= =?UTF-8?q?=E8=AE=B0=E4=B8=8E=E7=A6=81=E5=86=99=E5=8F=98=E6=9B=B4=E5=8F=99?= =?UTF-8?q?=E4=BA=8B=E7=BA=A2=E7=BA=BF=E5=BC=A0=E5=8A=9B=E3=80=81=E6=95=99?= =?UTF-8?q?=E5=AD=A6=E6=8B=85=E5=AD=90=E4=BA=A4=E6=8E=A5=E9=9C=80=E5=85=A5?= =?UTF-8?q?=E4=BD=93=E9=AA=8C=E9=AA=8C=E8=AF=81=E6=B8=85=E5=8D=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- reports/iter35-卡②-代拍板-2026-10-24.md | 29 +++++++++++++ reports/iter35-卡②-反哺点-2026-10-24.md | 25 +++++++++++ reports/iter35-卡②-实现报告-2026-10-24.md | 51 +++++++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 reports/iter35-卡②-代拍板-2026-10-24.md create mode 100644 reports/iter35-卡②-反哺点-2026-10-24.md diff --git a/reports/iter35-卡②-代拍板-2026-10-24.md b/reports/iter35-卡②-代拍板-2026-10-24.md new file mode 100644 index 00000000..999f1036 --- /dev/null +++ b/reports/iter35-卡②-代拍板-2026-10-24.md @@ -0,0 +1,29 @@ +# 迭代 35 卡② 代拍板追认单(节点图消费侧换代批) + +- 日期:2026-10-24 +- 依据:`reports/需求卡片-节点图消费侧换代批-2026-10-21.md` §10 代拍板白名单六条——授权发生在写卡段,本批只执行预授权并留痕 +- 待用户动作:追认或推翻。推翻任一条即回写对应实现面(不改设计文档) + +## 一、白名单内六条的执行值 + +| # | 卡片白名单条目 | 本批拍定值 | 落点 | +|---|---|---|---| +| ① | 图数据内部适配形态 | 生成图落到 `level_row` 的**行副本**上(`row.duplicate(true)` + `merged["nodes"] = graph["nodes"]`),表本体不被写脏;节点字段恰 `id/type/depth/next`,句柄即 `id`;读面 API 六签名逐字不变 | `game/core/level_runner.gd` `_assign_generated_graph`/`nodes()`;断言=`node_map_templates_test` 「起关生效图=生成器同档同种子直取逐位一致」 | +| ② | 校验实现形态与错误类别命名 | 无环判据取「出边目标 `depth` 严格大于源」写法(不做 DFS 判环);错误类别沿用 `structural`/`dangling_next`/`dup_key`/`enum`/`domain` 词表,零新增类别 | `game/data/schemas/level_table.gd` `validate_graph`;断言〔v1〕17 条 | +| ③ | 断点键换代细节 | 代差标记键命名 `graph_gen_v2`(值恒 1);退役键=`template_pick`;`node_id`/`visited_ids` 按图内句柄落档(与 `current_node_id()`/`visited_ids()` 同空间);旧断点拒绝后的盘面处置=**留档不清**(`resume_adventure` 返回 false + push_error,菜单侧「继续上局」自然失效由既有空断点态承载) | `game/core/adventure_runner.gd` `_snapshot_breakpoint`/`resume_adventure`;断言=`breakpoint_resume_test` P7 六条+`node_map_templates_test` ⑤ 拒绝条 | +| ④ | 冒烟断言定位写法与措辞 | 断言定位两分:**结构/覆盖/确定性类**直取生成器(参数档+种子 42/42–61 扫描),**行进/停点/兑付类**走 runner 与 AdventureRunner 真实入口(`start_level`/`start_level_external`/`pick_next_node`/`resume_adventure`);断言文本一律不写死节点编号形状 | `game/tests/integration/node_map_templates_test.gd`、`game/tests/unit/core/level_shop_economy_test.gd`〔v11〕、`game/tests/unit/core/adventure_feats_test.gd` | +| ⑤ | route 轴实现与 MC 字段值形态 | route 轴沿用「候选句柄升序= min/降序= max」的既有评分 tie-break(`_route_pick` 只换寻址空间,评分逻辑零动);MC 出数字段 `level_templates` → `level_maps`,值形态 `"%d#%d" % [关卡序号, 主种子]`(关卡×生成图标识) | `game/sim/level_autoplay.gd` `_route_pick`/`_on_adventure_node_entered`、`game/sim/adventure_monte_carlo.gd` 出数面 | +| ⑥ | 提交切分与分支命名 | 分支 `iter35b`(工作区 `../g-plan2-iter35b`);切分=覆盖数据批 → T1 校验换代 → T2 一口(④静态检查退役+随动)→ T2 二口+T3 断言面主体 → T3 收口(商店宿主)→ 变异验证与产出物 | git log(`9214ef8e`、`c0dd4420`、`98c61d50`、`9ee3bea1` 及后续收口笔) | + +## 二、白名单外、本批自认需要呈报的五件 + +1. **「奖励节点三选一 ≥1 次」断言的替代形态**(`adventure_feats_test`)。旧判据「L1 链必经 1 个」的前提是单链模板形状,随模板池消亡;实测确定性选路评分在种子 42–47 × min/max 两轴共 12 次全程冒险里**一次都没有走进奖励支路**(生成图里奖励节点的句柄序恒在战斗支路之后,评分优先推进)。若保留「≥1 次」原句就是逼着改扫描口径凑数。本批拆成两条:① 图侧「三关生成图各含 ≥1 奖励节点」;② 行进侧走真实入口——打赢入口战、在岔口显式 `pick_next_node` 点奖励支路、断言三选一候选可取(3 件)且停点在途且兑付受理。语义从「形状必经」换成「接线可用+图上存在」,未放宽到恒真。 +2. **三处 `_level_active_nodes` 副本曾是假绿**。`templates` 键清出后该 helper 落到 `row.get("nodes", [])` 返回空数组,凡以它为节点的扫描断言(覆盖义务、Boss 深度、逐节点行集)都变成「扫描面为空所以零违例」的空转绿。本批把三宿主 helper 换成参数档+种子 42 的生成图取数,并在覆盖断言里加「扫描节点数 > 0」的非空前置。这条是缺陷清偿,不是判据改动,但因为它把三条一直绿着的断言重新变成有效的(当场新增暴露一次 `boss_depth` 读到 -1 的真红),特此留痕。 +3. **商店到达余额读数 2 → 3**(`adventure_feats_test` ⑦ GP 跨关延续)。旧断言 `shop_seq.size() == 2` 的依据是「L1 单链无店、L2/L3 各一店」;卡② 商店编排语义为「每终点前恰一店」⇒ 每关路径必穿一店 ⇒ 三关全程 = 3 次到达。本批按新语义把读数断言改成 3 并保留「首达余额 > 0」。若 S3 三性质扫描(`level_shop_economy_test`)与这条投影不一致,二者之一必红,属互相校验。 +4. **`_boss_depth` helper 随删两处**(`round_flow_skill_pool_test`、`table_structure_guard_test`)。它们调用已退役的模板取数面、且在各自宿主内**零调用方**(〔fv13〕换代时留下的死件)。按契约 6「禁留死代码」删除;不涉及任何断言删除。 +5. **两个呈现宿主的夹具拓扑重建**(`level_map_type_vis_test`、`level_map_info_fog_test`)。旧夹具用「6→3 回边」表达非法图,而新形状要求 `depth` 沿出边严格前进,回边无法自洽表达;本批把该夹具改为长跳前向边+侧链节点,保留全部阈值与可见集断言,负向回边判据改由 `level_table_validate_test`〔v1〕的夹具图承载。此条由并行子代理执行,**独立验收审计须复核其「断言条数与阈值零改动」的自述**(自述见实现报告随动段)。 + +## 三、遗留读数义务(与实现报告同册) + +- 用户目检两条未做(登记不阻塞):三关实机选路观感不回归、首局种子 42 固定图的教学体验。 +- MC/对拍零跑:本批无玩法数值改动,按红线不发起跑批;11 回放确定性对拍按前批裁决不补跑。 diff --git a/reports/iter35-卡②-反哺点-2026-10-24.md b/reports/iter35-卡②-反哺点-2026-10-24.md new file mode 100644 index 00000000..c15d278e --- /dev/null +++ b/reports/iter35-卡②-反哺点-2026-10-24.md @@ -0,0 +1,25 @@ +# 迭代 35 卡② 反哺点清单(节点图消费侧换代批) + +- 日期:2026-10-24 +- 依据:`reports/需求卡片-节点图消费侧换代批-2026-10-21.md` §6 关联文档同步面六条+实现批执行中新发现的落点 +- 处置:本批为代码实现批,禁写 `design/` 与根流程清单,以下只列不改;每条由用户裁决后归文档批落文 + +## 一、卡片 §6 预列六条 + +| # | 文件与节位 | 现状陈述 → 应改成 | 依据 | +|---|---|---|---| +| ① | `design/gdd/systems/冒险循环/关卡节点地图.md` :57/:156/:178 | 「战斗|携带节点编号」「组装关卡序号 + 节点编号」「每场开战调用携带(关卡序号 + 节点编号)」三处 → 统一为「关卡序号+节点深度(自入口步数)」,与 :23/:65/:111/:119 同口径 | 卡② 契约 2(弃节点编号寻址)+ D1:65/:119 定稿句;实现落点 `game/core/level_runner.gd` enc 组装段(`node_depth` 取节点 `depth` 值) | +| ② | `design/gdd/systems/冒险循环/冒险结构.md` :293、:264 | :293 覆盖义务「每个战斗与关底节点(关,深)在敌人与棋盘生成的数值表中有精确行」→ 「敌侧按深度带命中(落带不早于请求深度,禁顶 clamp 消解)+板侧解析命中」;:264 关卡行集行的「旧模板池随实现批清数据」括注已成既成事实 → 注销括注、改述为参数档+生成器承载 | 卡③ 契约 1+卡② 完成定义 6;实现落点 `game/data/tables/level.tres`(templates 键已清出)、`game/tests/unit/core/level_shop_economy_test.gd` 覆盖断言〔v11〕 | +| ③ | `design/gdd/systems/冒险循环/关卡节点图生成.md` | 用语核对结论:消费侧注释只引用其「产出规格/执行边界」节号,未发现「岔口/终点/路径」用语漂移 → 记「无漂移」 | 实现在 `game/data/schemas/level_table.gd`、`game/core/level_runner.gd` 注释面自查(卡② T0 事实坐实项) | +| ④ | `design/gdd/ux/level-map.md` | 呈现面随动登记:句柄寻址后地图显示的节点数字=图内句柄 id(非深度、非数组序),多入口/多终点/含岔口三形态需补呈现条款 → 归卡④ 或文档批处理,本批未改呈现代码 | 卡② §6 表「呈现面随动义务」;实现落点 `game/core/level_runner.gd` `node_id_of`/`visited_ids` 语义换代 | +| ⑤ | `待办清单.md`(06) | 卡② 条目状态推进与退役记载:本批实现闭合后该条目按流水线规则由收口段处理,本会话未改该文件 | req-card 06 状态回写义务+迭代批禁改根流程清单红线 | +| ⑥ | `roadmap.md` | 迭代 35 行批内状态刷新(卡② 由「已编」推进到「实现批完成待收口」)——本会话未改该文件 | 同上(实现批禁写根规则文件) | + +## 二、实现批新发现的四条(卡片未预列,一并呈报) + +| # | 文件与节位 | 发现 | 建议处置 | +|---|---|---|---| +| ⑦ | `game/tests/验证集清单.md`(批 B 迁移账本的断言索引,11 行含 `first_script`/`template_pick` 措辞) | 该清单是批 B 迁入时的断言文本快照索引,只由 `tools/extract_checks.py` 手工重生成、不进任何闸;卡② 断言换代后其中 11 行文本与行号已过期 | 由用户裁决:跑一次 `tools/extract_checks.py` 重生成,或在清单头部登记「换代批后快照失效、按宿主现文为准」 | +| ⑧ | `game/tests/integration/node_map_templates_test.gd`(宿主文件名) | 该宿主现承载「生成图确定性断言套」,文件名里的 templates 是模板时代遗留名;卡片 S6 明令换代断言落「S1 同宿主」且 manifest 零新增登记,故本批保留原名 | 待用户裁决:改名(如 `node_map_determinism_test.gd`)须同批改 `run_all.gd` manifest 路径行,属独立小批 | +| ⑨ | `game/data/tables/level.tres` 表尾注释 | 卡片 T3 要求表尾写「15 张模板去向+参数档字段指引」,实现里带了一句「随迭代 35 卡② 清出」的迭代叙事,与 doc-conventions §5「禁写变更叙事」的口径有张力 | 待用户裁决:保留(卡片明令的去向注记)或删去迭代号只留图源换代事实句 | +| ⑩ | `design/gdd/systems/冒险循环/关卡节点地图.md`(或 06 体验验证清单)——教学担子交接 | 卡② 待裁点已定「模板 0 教学重放语义废除,教学位由首局固定种子 42 的生成图承载」;文档侧仍写着「模板 0=教学图」的表述在 ①②之外的段落里残留,且「首局图观感是否可当教学」属真人判断 | 建议随 ①② 一并清扫措辞,并把「首局教学图体验」列进 `体验验证清单.md`(卡片 §8.2 已把它列为用户目检项,登记不阻塞) | diff --git a/reports/iter35-卡②-实现报告-2026-10-24.md b/reports/iter35-卡②-实现报告-2026-10-24.md index 8d12c4ae..bf6d9fa6 100644 --- a/reports/iter35-卡②-实现报告-2026-10-24.md +++ b/reports/iter35-卡②-实现报告-2026-10-24.md @@ -89,6 +89,57 @@ 6. 收口顺序:T2+T3 必须同轮(拆开中间态必红)→ `run_all_gates.sh --code` 全绿 → 独立验收审计 → 合回 → 反哺点/代拍板/待办清单状态随批。 7. 待用户项:覆盖批八行与棋盘 12 行数值为插值 provisional(真人试玩定味);数值面已动,11 回放确定性对拍与 `balance_verifier` 是否发起需重新裁定。 +## 二·九 T2 第二口=消费侧切源主体(已完成·提交 `9ee3bea1`+`38492116`+`78924fdd`+`548cd2da`) + +§二·八 清单 1–3 与 5 逐条落定: + +- `level_runner.gd`:`_resolve_template_row` 与 `active_template_name/index` 删除,新增 `_assign_generated_graph(row, seed)`(`db.level_gen_params_row(L)` 取参数档 → `NodeMapGenerator.generate(params, seed)` → `LevelTable.validate_graph(graph.nodes)` 违规 `push_error`+返回 false,无任何兜底图);`start_level(level_index, seed, base_override)`/`start_level_external(..., defer_entry, resume_state)` 的 `template_pick` 尾参删除;`_entry_node`→`_entry_nodes() -> Array[Dictionary]`(全部无入边节点,多入口首跳即选路);`_node_id` 取 `id` 句柄,`_out_ids`/`_out_nodes`/`visited_ids`/`pick_next_node`/`_restore_to_stop`/`_bfs_layers` 同句柄空间随动,单链隐式出边(数组序兜底)整体退役;enc `node_depth` 取节点 `depth` 值、键名不变。 +- `adventure_runner.gd`:`_first_script` 字段与参数读入、`template_pick` 计算与传递全删;断点快照 `template_pick` 键 → `graph_gen_v2: 1` 代差标记键;`resume_adventure` 首检该标记,缺键旧断点返回 false+`push_error`(禁按新句柄空间静默回放)。 +- `main.gd` 删 `first_script` 参数构造(种子三分判定 `:1476-1486` 零改动,首局恒 42);`save_manager.gd` 首次冒险位注释随 T2 第一口已换代。 +- 件核:全仓 `first_script`/`template_pick`/`active_template` 零引用;`level.tres` 三行无 `templates` 键;`sim/level_autoplay.gd` 的 `boss_depth` 读数键由 `node_depth` 改 `depth`(该处曾是模板时代键名残留,切源后读不到值=测量面静默漂移,随 S7 一并清)。 + +## 二·十 T3 断言面换代(S1–S8 全数落位) + +| 清单项 | 宿主与落位 | 换代后断言面 | 变异拦截面 | +|---|---|---|---| +| S1 | `tests/integration/node_map_templates_test.gd` 整宿主重写 | ①-⑥ 模板段退役;⑦ 收束置位三条保留(措辞换「首局恒走固定种子 42」);宿主现名不变(manifest 零新增登记,改名归反哺点⑧) | M5/M7 | +| S2 | `_level_active_nodes` 三副本(`level_shop_economy`/`adventure_feats`/`round_flow_skill_pool`) | 一律改经 `NodeMapGenerator.generate(db.level_gen_params_row(L), 42)` 取图;消费点深度键 `node_depth`→`depth`;两宿主内零调用方的死件 `_boss_depth` 随删 | — | +| S3 | `level_shop_economy` 商店段 | 三条性质断言(终点前驱恰一店/商店总数=终点数/全路径恰穿一店)× L1–L3 参数档 × 种子 42+42–61 扫描;硬编码 8/9/穿节点 8/L1 行集退役;行进面「当前节点型别=shop 且出边恰一条指终点」 | M2/M3 | +| S4 | `level_shop_economy` 形状段 | 节点数/五型计数/岔口数/终点数改生成图读数性质(含「L1 终点恰一、L2/L3 含岔口」V4 投影),非空前置在位 | — | +| S5 | `level_shop_economy`〔v11〕+`adventure_feats`〔fv24〕 | 敌侧=解析落带 ≥ 请求深度(禁顶 clamp)、板侧=精确命中、多终点 Boss 带键两两互异;`adventure_feats` 原「行集内每节点在 board_gen+enemy 有精确行集」改走 `resolve_enemy_rowset`/`resolve_board_gen` 真实消费链+扫描非空前置 | 〔v11〕两条已按批内变异验证见红(前节记录) | +| S6 | `node_map_templates` 新确定性套 | 同档同种子两次产图逐位同图/换种子即变图/节点携唯一句柄+depth/起关生效图=生成器直取(单一图源)/起关产图零计数器消费/入口候选数=参数档 `entry_count`+第二入口句柄显式进入/种子 42 两开局同图/断点存→恢复→再产图同图/缺代差键旧断点拒绝/表无 `templates` 键 | M1/M5/M6/M7 | +| S7 | `sim/level_autoplay.gd`、`sim/adventure_monte_carlo.gd`、`probe/shot_probe.gd`、`probe/ui_click_probe.gd` | `template_pick` 尾参退役;MC `level_templates`→`level_maps`(值 `关卡#主种子`);route 轴沿句柄升/降序;探针「L2 商店为 Boss 前必经位」假设改性质措辞;`probe/board_shot_probe`、`probe/type_glow_shot_probe` 起关尾参残留清理 | — | +| S8 | `breakpoint_resume`(新增 P7 六断言)、`pool_case_d`、`feat_vouchers`、`cross_system_flow:79`、`meta_progression_forge:316/:323`、`curio_refresh_paid`、`save_manager_domain` | `first_script` 键清退;断点夹具 `template_pick`→`graph_gen_v2`;meta 参数面 extra 键样换 `treasure_unlocked`(「入口特有键随 extra 原样并入」判据不动);P7 钉「快照无已抽模板键+代差标记在档+停点句柄与已到序往返+续玩重生成同图+旧断点拒绝+同种子重开局同图」 | M6 | + +补充两条断言(V2/V6 拦截面补位,提交 `38492116`):原「入口停点候选 ≥1」不足以拦「候选写死单入口」,改判「=参数档 `entry_count`」并加点第二入口;`adventure_feats` 新增 `_drive_to_pref` 真实选路驱动,据此落「进战载荷两样=关卡序号+节点深度,且命中节点句柄≠深度」〔v6〕与奖励支路经停三选一在途两条。 + +宿主读数:`node_map_templates` 21 checks、`adventure_feats` 138、`breakpoint_resume` 全绿、`pool_case_d`/`feat_vouchers`/`cross_system_flow`/`curio_refresh_paid` 21/`save_manager_domain` 101/`meta_progression_forge` 133/`round_flow_skill_pool` 127/`table_structure_guard` 67/`level_map_type_vis` 26/`level_map_info_fog` 19 全绿;`level_shop_economy` 见下节。 + +## 二·十一 T3 收口:`level_shop_economy` 宿主换代(147→176 checks) + +换代后该宿主的图源单点 = `_generated_graph(level_index, seed)`(`db.level_gen_params_row(L)` × 种子),`_level_active_nodes` 走该单点取种子 42 的现行图;段内新增一批图读数辅助(`_entry_ids`/`_terminal_ids`/`_fork_ids`/`_type_tally`/`_path_shop_count`/`_all_entry_terminal_paths`/`_path_in_graph`/`_coverage_scan`/`_enemy_band_covers`/`_board_row_exact`),行进面与枚举面共用同一判据口径。 + +- S4 形状段:`L2 节点数 = 10`/`战斗节点数 = 5`/`普通战斗 6+Boss 恒一`/`恢复 1+奖励 1+商店 1`/`真实岔路 = 0`/`L1 行集零改动` 六类写死读数退役,改为「五型计数合计 = 节点数」「战斗节点数 ≥ 参数档 `battle_floor`」「Boss 节点数 = 参数档 `exit_count`」「岔口(出度 ≥2)≥1」「L1 终点恰一」「终点深度 = 参数档 `layers`」,每条都带扫描面非空前置(`L%d 现行起关图取数非空`)。 +- S3 商店段:三条性质断言 × 三关参数档 × 种子 42+42–61 全扫描(违例计数落进断言消息,图数与路径数在案:60 图 / 1413 条入口→终点路径),另保留行进面两条(到店当前节点型别=shop 且出边恰一条指终点;min/max 两轴路径逐跳为图出边、末节点为 Boss、穿店恰一)。 +- S5 覆盖段:`跨表行完整(7 节点)` 改 `_coverage_scan` 逐关读数——敌侧「落带 ≥ 请求深度」、板侧「精确命中且 `node_depth` 等值」,并逐关登记颗数非空;终点 Boss 行集断言按图终点深度解析(`L2 全部终点深度 … 的 Boss 行集`)。 +- 收口一件:换代过程中宿主内一度写过 `MUTATION-A/B/C/D` 内联自改生成图的脚手架与两条配套 MUT 断言(把变异开关埋进测试面=测试自己造数据,属反模式),已由 `d48bf10f` 整体删除,V5 证据改由生产码注入取证(见下表 M2/M3)。 + +## 二·十二 变异验证记录表(卡② 补挂七条;T1 四条与〔v11〕两条见 §二·五/§一) + +跑批脚本 `game/tests/.gates/mutate_iter35_card2.py`(注入→最窄宿主→还原→逐条自检,跑后 `git status` 核产品码四件零改动;脚本本身属临时件,收口前删除)。红行取被测宿主自身的 FAIL 文本,不取包装层退出码。 + +| # | 注入点 | 变异内容 | 最窄宿主 | 结果与红行 | +|---|---|---|---|---| +| M1 | `level_runner.gd` `_candidates = entries`(两处入口停点候选,改首条) | 多入口候选写死单入口 | `node_map_templates_test` | 红(命中):`入口停点候选数=参数档入口个数(L3 档 2——多入口首跳即选路,禁写死单入口)` | +| M2 | `node_map_generator.gd` 产图返回前把 shop 型别改 rest | 终点前驱改非店 | `level_shop_economy_test` | 红(命中):性质①「每终点直接前驱恰一家店」违例 | +| M3 | 同上,把首个 battle 型别改 shop | 某路径加第二家店 | `level_shop_economy_test` | 红:性质②「每图商店节点数=终点数」违例 60/60 图;性质③「每条入口→终点路径恰穿一家商店」违例 738/1413 条;行进面 `L2 min/max 路径穿商店恰 1 家` 同批转红 | +| M4 | `level_runner.gd` enc `"node_depth": int(node.get("depth", 0))` → 取 `id` | enc 定位信息取句柄 | `adventure_feats_test` | 红(命中):`进战载荷两样=关卡序号+节点深度(实测 depth=… 句柄=…)〔v6〕` | +| M5 | `node_map_generator.gd` `pure_stream(seed_val, SCOPE_STRUCTURE)` → `open_stream("node_map")` | 产图改依赖统一流计数器号位 | `node_map_templates_test` | 红(命中):`同参数档同种子两次产图逐位同图(D2 执行边界)` | +| M6 | `adventure_runner.gd` `if int(bp.get("graph_gen_v2", 0)) != 1:` → `if false:` | 旧断点缺代差标记键却放行回放 | `breakpoint_resume_test` | 红(命中,复跑取证到断言名):`P7 缺生成图代差标记键的旧断点显式拒绝(禁按新句柄空间静默回放)`(1/56 项失败) | +| M7 | `level.tres` 首行偷留 `"templates": [{"nodes": []}]` | 模板数据未清尽 | `node_map_templates_test` | 红(命中):`level 表三行均无 templates 键(模板数据已清出)` | + +卡片 §8 变异清单九条的落位:V1 两条(T1 已录)、V11 两条(覆盖批已录)、V2/V5×2/V6/V7/V8/V9 共六条(本表 M1–M7,V5 拆两条)。 + ## 三、遗留读数义务 From 3461e83bec685eeda0c020fe99a38227ad668f35 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sat, 24 Oct 2026 19:35:01 +0800 Subject: [PATCH 15/20] =?UTF-8?q?test(=E8=BF=AD=E4=BB=A335=E5=8D=A1?= =?UTF-8?q?=E2=91=A1T3=20=E5=B0=BE=E7=BC=80=E5=AF=B9=E9=BD=90):=20?= =?UTF-8?q?=E6=9C=AC=E6=89=B9=E6=96=B0=E8=90=BD=E5=9C=B0=E6=96=AD=E8=A8=80?= =?UTF-8?q?=E8=A1=A5=E5=8F=AF=20grep=20=E5=B0=BE=E7=BC=80=EF=BC=88?= =?UTF-8?q?=E3=80=94v2=E3=80=95=E5=85=A5=E5=8F=A3=E5=80=99=E9=80=89?= =?UTF-8?q?=E6=95=B0=EF=BC=9D=E5=8F=82=E6=95=B0=E6=A1=A3=20entry=5Fcount?= =?UTF-8?q?=20=E4=B8=8E=E7=AC=AC=E4=BA=8C=E5=85=A5=E5=8F=A3=E8=BF=9B?= =?UTF-8?q?=E5=85=A5=EF=BC=8F=E3=80=94v5=E3=80=95=E5=95=86=E5=BA=97?= =?UTF-8?q?=E4=B8=89=E6=80=A7=E8=B4=A8=E4=B8=8E=E6=89=AB=E6=8F=8F=E9=9D=A2?= =?UTF-8?q?=E9=9D=9E=E7=A9=BA=EF=BC=8F=E3=80=94v7=E3=80=95=E5=90=8C?= =?UTF-8?q?=E6=A1=A3=E5=90=8C=E7=A7=8D=E5=AD=90=E4=B8=A4=E6=AC=A1=E4=BA=A7?= =?UTF-8?q?=E5=9B=BE=E5=90=8C=E5=9B=BE=C2=B7=E8=B5=B7=E5=85=B3=E5=9B=BE?= =?UTF-8?q?=EF=BC=9D=E7=94=9F=E6=88=90=E5=99=A8=E7=9B=B4=E5=8F=96=C2=B7?= =?UTF-8?q?=E7=A7=8D=E5=AD=90=2042=20=E4=B8=A4=E5=BC=80=E5=B1=80=E5=90=8C?= =?UTF-8?q?=E5=9B=BE=C2=B7=E6=96=AD=E7=82=B9=E9=87=8D=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=90=8C=E5=9B=BE=C2=B7=E5=90=8C=E7=A7=8D=E5=AD=90=E9=87=8D?= =?UTF-8?q?=E5=BC=80=E5=B1=80=E5=90=8C=E5=9B=BE=EF=BC=8F=E3=80=94v8?= =?UTF-8?q?=E3=80=95=E5=BF=AB=E7=85=A7=E4=BB=A3=E5=B7=AE=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=C2=B7=E5=8F=A5=E6=9F=84=E5=BE=80=E8=BF=94=C2=B7=E6=97=A7?= =?UTF-8?q?=E6=96=AD=E7=82=B9=E6=8B=92=E7=BB=9D=EF=BC=8F=E3=80=94v9?= =?UTF-8?q?=E3=80=95=E5=BF=AB=E7=85=A7=E6=97=A0=E5=B7=B2=E6=8A=BD=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E9=94=AE=C2=B7=E8=A1=A8=E6=97=A0=20templates=20?= =?UTF-8?q?=E9=94=AE=EF=BC=8F=E3=80=94v4=E3=80=95=E4=B8=89=E5=85=B3?= =?UTF-8?q?=E5=9B=BE=E5=90=84=E5=90=AB=E5=A5=96=E5=8A=B1=E8=8A=82=E7=82=B9?= =?UTF-8?q?=EF=BC=89=EF=BC=8B=E5=95=86=E5=BA=97=E5=AE=BF=E4=B8=BB=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E6=AE=B5=E5=A4=B4=E6=B8=85=E7=90=86=E2=80=94=E2=80=94?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E5=AE=A1=E8=AE=A1=E7=AC=AC=201=20=E4=BB=B6?= =?UTF-8?q?=EF=BC=88=E5=AF=B9=E7=85=A7=E8=A1=A8=E9=80=90=E8=A1=8C=E6=A0=B8?= =?UTF-8?q?=E5=B0=BE=E7=BC=80=E5=8F=AF=20grep=EF=BC=89=E7=9A=84=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E9=9D=A2=EF=BC=9B=E5=9B=9B=E5=AE=BF=E4=B8=BB=E5=A4=8D?= =?UTF-8?q?=E8=B7=91=2021/176/138=20=E5=85=A8=E7=BB=BF=E9=9B=B6=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integration/node_map_templates_test.gd | 18 +++++++++--------- game/tests/unit/core/adventure_feats_test.gd | 2 +- game/tests/unit/core/breakpoint_resume_test.gd | 12 ++++++------ .../tests/unit/core/level_shop_economy_test.gd | 11 ++++------- reports/iter35-卡②-实现报告-2026-10-24.md | 11 ++++++++++- 5 files changed, 30 insertions(+), 24 deletions(-) diff --git a/game/tests/integration/node_map_templates_test.gd b/game/tests/integration/node_map_templates_test.gd index 45d8a2fc..4a36e01d 100644 --- a/game/tests/integration/node_map_templates_test.gd +++ b/game/tests/integration/node_map_templates_test.gd @@ -40,7 +40,7 @@ func _smoke_generated_map_determinism() -> void: var g_b: Dictionary = gen.generate(params2, 910002) var g_c: Dictionary = gen.generate(params2, 910003) var nodes_a: Array = g_a.get("nodes", []) - Kit.check("同参数档同种子两次产图逐位同图(D2 执行边界)", + Kit.check("同参数档同种子两次产图逐位同图(D2 执行边界)〔v7〕", nodes_a == (g_b.get("nodes", []) as Array)) Kit.check("同参数档换种子即变图(图确为种子函数,非恒定输出)", nodes_a != (g_c.get("nodes", []) as Array)) @@ -50,7 +50,7 @@ func _smoke_generated_map_determinism() -> void: var runner1 = load("res://core/level_runner.gd").new(_db, _fresh_rng(910004)) Kit.check("单关起关受理(种子 910004——生成图直供,无兜底图)", runner1.start_level(2, 910004)) - Kit.check("起关生效图 = 生成器同档同种子直取逐位一致(消费侧无第二图源)", + Kit.check("起关生效图 = 生成器同档同种子直取逐位一致(消费侧无第二图源)〔v7〕", runner1.level_row.get("nodes", []) == (gen.generate(params2, 910004) as Dictionary).get("nodes", [])) # ③ 产图零统一流消费(纯派生流不占计数器号位——断点在任意号位重生成同图的机制承载) var rng_c = _fresh_rng(910005) @@ -61,11 +61,11 @@ func _smoke_generated_map_determinism() -> void: Kit.check("起关产图零计数器消费(同图不依赖取数号位)", rng_c.counter() == counter_before) var params3: Dictionary = _db.level_gen_params_row(3) var entry_count: int = int(params3.get("entry_count", -1)) - Kit.check("入口停点候选数=参数档入口个数(L3 档 %d——多入口首跳即选路,禁写死单入口)" % entry_count, + Kit.check("入口停点候选数=参数档入口个数(L3 档 %d——多入口首跳即选路,禁写死单入口)〔v2〕" % entry_count, entry_count >= 1 and (runner2.next_candidates() as Array).size() == entry_count) if entry_count >= 2: var eids: Array = runner2.next_candidate_ids() - Kit.check("第二入口经句柄显式进入(句柄 %d)" % int(eids[1]), + Kit.check("第二入口经句柄显式进入(句柄 %d)〔v2〕" % int(eids[1]), runner2.pick_next_node(int(eids[1])) and int(runner2.current_node_id()) == int(eids[1])) # ④ 开局种子承载:新档(首局)种子 42 两次开局逐位同图,且与生成器直取一致 DirAccess.remove_absolute(_bp_path) # 测试隔离——上轮落盘残留先删,断点面从零档起 @@ -77,7 +77,7 @@ func _smoke_generated_map_determinism() -> void: var adr_y = AdventureRunnerScript.new(_db, _fresh_rng(42)) Kit.check("同种子第二次开局受理", adr_y.start_adventure(42, {"hero_override": {}, "treasure_unlocked": {}})) - Kit.check("种子 42 两次开局图逐位同图(教学局恒同图——首局固定种子的确定性承载)", + Kit.check("种子 42 两次开局图逐位同图(教学局恒同图——首局固定种子的确定性承载)〔v7〕", adr_x.runner().nodes() == adr_y.runner().nodes()) Kit.check("开局图 = L1 参数档在种子 42 下的生成图(开局无隐式取数分叉)", adr_x.runner().nodes() == (gen.generate(_db.level_gen_params_row(1), 42) as Dictionary).get("nodes", [])) @@ -85,20 +85,20 @@ func _smoke_generated_map_determinism() -> void: Kit.check("停点断点落盘在档", sv_w.has_breakpoint()) var bp: Dictionary = sv_w.breakpoint_snapshot() var adr_z = AdventureRunnerScript.new(_db, load("res://core/rng_service.gd").new()) - Kit.check("断点续玩受理(代差标记键在档)", adr_z.resume_adventure(bp, + Kit.check("断点续玩受理(代差标记键在档)〔v8〕", adr_z.resume_adventure(bp, {"hero_override": {}, "treasure_unlocked": {}})) - Kit.check("续玩重生成图与中断前逐位同图", adr_z.runner().nodes() == adr_x.runner().nodes()) + Kit.check("续玩重生成图与中断前逐位同图〔v7〕", adr_z.runner().nodes() == adr_x.runner().nodes()) var adr_old = AdventureRunnerScript.new(_db, load("res://core/rng_service.gd").new()) var bp_old: Dictionary = bp.duplicate(true) bp_old.erase("graph_gen_v2") # 换代前旧断点形态(模板池时代无代差标记键) - Kit.check("缺生成图代差标记键的旧断点被显式拒绝(禁按新语义静默回放)", + Kit.check("缺生成图代差标记键的旧断点被显式拒绝(禁按新语义静默回放)〔v8〕", adr_old.resume_adventure(bp_old, {}) == false) # ⑥ 关卡表不再携带图数据(图源换代后 level 行只剩行级字段) var tpl_free := true for li in range(1, _db.level_count() + 1): if (_db.level_row(li) as Dictionary).has("templates"): tpl_free = false - Kit.check("level 表三行均无 templates 键(模板数据已清出)", + Kit.check("level 表三行均无 templates 键(模板数据已清出)〔v9〕", tpl_free and _db.level_count() == 3) # ⑦ 收束置位:新档 false → settle 置 true 恰一次(幂等) var sv = SaveManagerScript.new(_bp_path + ".flag.json") diff --git a/game/tests/unit/core/adventure_feats_test.gd b/game/tests/unit/core/adventure_feats_test.gd index d6ec9e6e..379390e1 100644 --- a/game/tests/unit/core/adventure_feats_test.gd +++ b/game/tests/unit/core/adventure_feats_test.gd @@ -162,7 +162,7 @@ func _smoke_adventure() -> void: rc += 1 if rc >= 1: reward_levels += 1 - Kit.check("三关生成图各含 ≥1 奖励节点(节点三选一候选位在图)", reward_levels == 3) + Kit.check("三关生成图各含 ≥1 奖励节点(节点三选一候选位在图)〔v4〕", reward_levels == 3) # 行进侧两断言走真实选路入口(满配赢下途中战→岔口按偏好显式点选):奖励支路经停即呈三选一、 # 进战载荷带节点深度——确定性评分策略恒选推进支路,奖励位不在扫描路由上,故显式点选验证接线 var rw: Dictionary = _drive_to_pref(autoplay, "reward") diff --git a/game/tests/unit/core/breakpoint_resume_test.gd b/game/tests/unit/core/breakpoint_resume_test.gd index bae37e7f..e6eba55c 100644 --- a/game/tests/unit/core/breakpoint_resume_test.gd +++ b/game/tests/unit/core/breakpoint_resume_test.gd @@ -157,9 +157,9 @@ func _init() -> void: var graph_j: Array = runner_j.nodes() var bp_j: Dictionary = sm_j.breakpoint_snapshot() Kit.check_quiet(not bp_j.has("template_pick"), - "P7 断点快照无已抽模板键(模板链路退役——件核面在档)") + "P7 断点快照无已抽模板键(模板链路退役——件核面在档)〔v9〕") Kit.check_quiet(int(bp_j.get("graph_gen_v2", 0)) == 1, - "P7 断点含生成图代差标记键(换代承载)") + "P7 断点含生成图代差标记键(换代承载)〔v8〕") var entry_id: int = int(runner_j.next_candidate_ids()[0]) var mid_id: int = _first_out_id(graph_j, entry_id) var bp_mid: Dictionary = bp_j.duplicate(true) @@ -169,17 +169,17 @@ func _init() -> void: Kit.check_quiet(adr_m.resume_adventure(bp_mid, params), "P7 中途停点断点续玩受理(句柄落档形态)") var runner_m = adr_m.runner() Kit.check_quiet(int(runner_m.current_node_id()) == mid_id, - "P7 停点句柄回读一致(图内 id 单空间寻址)") + "P7 停点句柄回读一致(图内 id 单空间寻址)〔v8〕") Kit.check_quiet(runner_m.visited_ids() == [entry_id], "P7 已到节点序按句柄往返一致") - Kit.check_quiet(runner_m.nodes() == graph_j, "P7 续玩重生成图与中断前逐位同图(断点不存图快照)") + Kit.check_quiet(runner_m.nodes() == graph_j, "P7 续玩重生成图与中断前逐位同图(断点不存图快照)〔v7〕") var adr_l = AdventureRunnerScript.new(db, load("res://core/rng_service.gd").new()) var bp_legacy: Dictionary = bp_mid.duplicate(true) bp_legacy.erase("graph_gen_v2") Kit.check_quiet(adr_l.resume_adventure(bp_legacy, params) == false, - "P7 缺生成图代差标记键的旧断点显式拒绝(禁按新句柄空间静默回放)") + "P7 缺生成图代差标记键的旧断点显式拒绝(禁按新句柄空间静默回放)〔v8〕") var adr_n = AdventureRunnerScript.new(db, load("res://core/rng_service.gd").new()) Kit.check_quiet(adr_n.start_adventure(910042, params) and adr_n.runner().nodes() == graph_j, - "P7 同种子重开局同图(开局入口复现承载)") + "P7 同种子重开局同图(开局入口复现承载)〔v7〕") _clear_disk() quit(Kit.finish("breakpoint_resume_test", "PASS — 断点往返/续玩确定性/幂等/退出清断点/装配存档回归/生成图换代 全绿")) diff --git a/game/tests/unit/core/level_shop_economy_test.gd b/game/tests/unit/core/level_shop_economy_test.gd index c2dce65a..ecdba9bf 100644 --- a/game/tests/unit/core/level_shop_economy_test.gd +++ b/game/tests/unit/core/level_shop_economy_test.gd @@ -1080,9 +1080,6 @@ func _shop_products_by_id(prods: Array) -> Dictionary: return out -# ---- ④ 商店编排性质(每终点前一位恰一店 · 店数=终点数 · 全路径恰穿一店)---- - - # ---- ④ 商店编排性质(生成图扫描面——三条性质 + 终点跨表解析)---- func _smoke_shop_graph() -> void: @@ -1126,11 +1123,11 @@ func _smoke_shop_graph() -> void: if _path_shop_count(nodes, p) != 1: path_bad += 1 print("商店编排扫描读数:图 %d 张·终点 %d 个·入口→终点路径 %d 条" % [graph_total, term_total, path_total]) - Kit.check("编排断言扫描面非空(三关参数档 × 种子 %d–%d=图 %d 张·终点 %d·路径 %d)" % [SWEEP_SEED_FROM, SWEEP_SEED_TO - 1, graph_total, term_total, path_total], + Kit.check("编排断言扫描面非空(三关参数档 × 种子 %d–%d=图 %d 张·终点 %d·路径 %d)〔v5〕" % [SWEEP_SEED_FROM, SWEEP_SEED_TO - 1, graph_total, term_total, path_total], graph_total == 3 * (SWEEP_SEED_TO - SWEEP_SEED_FROM) and term_total >= 3 and path_total >= 3) - Kit.check("性质① 每个终点的直接前驱恰一家商店(入边一条且源型别=shop·违例 %d/%d 终点)" % [pred_bad, term_total], pred_bad == 0) - Kit.check("性质② 每图商店节点数 = 终点数(违例 %d/%d 图)" % [count_bad, graph_total], count_bad == 0) - Kit.check("性质③ 每条入口→终点路径恰穿一家商店(违例 %d/%d 条路径)" % [path_bad, path_total], path_bad == 0) + Kit.check("性质① 每个终点的直接前驱恰一家商店(入边一条且源型别=shop·违例 %d/%d 终点)〔v5〕" % [pred_bad, term_total], pred_bad == 0) + Kit.check("性质② 每图商店节点数 = 终点数(违例 %d/%d 图)〔v5〕" % [count_bad, graph_total], count_bad == 0) + Kit.check("性质③ 每条入口→终点路径恰穿一家商店(违例 %d/%d 条路径)〔v5〕" % [path_bad, path_total], path_bad == 0) # 现行起关图(种子 %d)逐关读数——L1 单终点、L2/L3 多终点(每终点前一位恰一店的当关投影) for L in [1, 2, 3]: var cur_nodes: Array = _level_active_nodes(L) diff --git a/reports/iter35-卡②-实现报告-2026-10-24.md b/reports/iter35-卡②-实现报告-2026-10-24.md index bf6d9fa6..26145873 100644 --- a/reports/iter35-卡②-实现报告-2026-10-24.md +++ b/reports/iter35-卡②-实现报告-2026-10-24.md @@ -140,7 +140,16 @@ 卡片 §8 变异清单九条的落位:V1 两条(T1 已录)、V11 两条(覆盖批已录)、V2/V5×2/V6/V7/V8/V9 共六条(本表 M1–M7,V5 拆两条)。 -## 三、遗留读数义务 +## 三·一 实机目检总清单(真人判断项,登记即完成义务,不作为合回前置) + +| # | 观测项 | 真实入口 | 期望看到什么 | 本批机检已覆盖的部分 | +|---|---|---|---|---| +| 目检① | 三关选路观感不回归:多入口首跳、大岔口取舍、多终点走向可见 | 主菜单开局 → 关卡地图(`res://probe/ui_click_probe.tscn` 亦可走同路径) | 开局即出现两个可选入口(L2/L3);岔口处候选互异且分属不同支路;走 A 支只遇 A 支终点 Boss | 候选数=参数档 `entry_count`、第二入口句柄可进入、终点数=`exit_count`、岔口 ≥1、每终点前恰一店(`node_map_templates`/`level_shop_economy` 扫描面) | +| 目检② | 首局教学图体验(种子 42 固定图) | 新档开局(`is_first_adventure_done==false` → 恒种子 42) | 同一张图、节奏可教学:入口战→岔口→商店→Boss 的可读顺序,含至少一个奖励位与一个恢复位 | 种子 42 两开局逐位同图、开局图=参数档直取、三关图各含奖励/恢复节点、恢复公式两分支读数 | + +登记口径:以上两项由用户自行安排试玩;本批不以任何目检为停工或合回理由(硬性红线「真人检测只登记不阻塞」)。 + +## 三·二 其余遗留读数义务 From 4ed53651cedb43f0854c05483aa413aa2460cbb1 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sat, 24 Oct 2026 19:45:59 +0800 Subject: [PATCH 16/20] =?UTF-8?q?test(=E8=BF=AD=E4=BB=A335=E5=8D=A1?= =?UTF-8?q?=E2=91=A1T3=20V3=20=E8=A1=A5=E5=8F=A3=EF=BC=8B=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E5=AF=B9=E7=85=A7=E8=A1=A8):=20=E5=A4=A7=E5=B2=94?= =?UTF-8?q?=E5=8F=A3=E5=88=86=E5=8C=BA=E5=9F=9F=E5=85=91=E4=BB=98=E6=96=AD?= =?UTF-8?q?=E8=A8=80=E7=BB=84=E8=90=BD=E5=9C=B0=EF=BC=88=E5=8D=A1=E7=89=87?= =?UTF-8?q?=20V3=20=E6=98=AF=E6=9C=AC=E5=8D=A1=20=C2=A78=20=E5=94=AF?= =?UTF-8?q?=E4=B8=80=E6=97=A0=E6=9C=BA=E6=A3=80=E6=96=AD=E8=A8=80=E7=9A=84?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E7=82=B9=EF=BC=8C=E5=89=8D=E5=8F=A3=E6=BC=8F?= =?UTF-8?q?=E6=8C=82=E2=80=94=E2=80=94=E6=9C=AC=E8=BD=AE=E8=A1=A5=EF=BC=89?= =?UTF-8?q?=E2=80=94=E2=80=94level=5Fshop=5Feconomy=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20=5Fsmoke=5Fmulti=5Fterminal=5Fsettlement=20=E4=BA=94?= =?UTF-8?q?=E6=9D=A1=EF=BC=9AL2=20=E7=8E=B0=E8=A1=8C=E5=9B=BE=20Boss=20?= =?UTF-8?q?=E7=BB=88=E7=82=B9=E4=B8=A4=E9=A2=97=E4=BB=A5=E4=B8=8A=E3=80=81?= =?UTF-8?q?min/max=20=E4=B8=A4=E8=BD=B4=E5=90=84=E8=87=AA=E6=94=B6?= =?UTF-8?q?=E5=9C=A8=E6=9C=AC=E8=B7=AF=E7=BB=88=E7=82=B9=20Boss=EF=BC=88?= =?UTF-8?q?=E6=9C=AB=E5=8F=A5=E6=9F=84=206/11=20=E4=BA=92=E5=BC=82?= =?UTF-8?q?=E4=B8=94=E9=83=BD=E5=B1=9E=E5=9B=BE=20Boss=20=E9=9B=86?= =?UTF-8?q?=EF=BC=8Cresult=3Dvictory=EF=BC=89=E3=80=81=E5=8C=BA=E5=9F=9F?= =?UTF-8?q?=E9=9A=94=E7=A6=BB=EF=BC=88min=20=E8=B7=AF=E4=B8=8D=E5=90=AB=20?= =?UTF-8?q?max=20=E7=BB=88=E7=82=B9=E3=80=81=E5=8F=8D=E4=B9=8B=E4=BA=A6?= =?UTF-8?q?=E7=84=B6=EF=BC=89=E3=80=81=E4=BB=BB=E4=B8=80=20Boss=20?= =?UTF-8?q?=E8=83=9C=E5=8D=B3=E6=9C=AC=E5=85=B3=E6=94=B6=E6=9D=9F=20level?= =?UTF-8?q?=5Ffinished=20=E6=81=B0=E4=B8=80=E6=AC=A1=E4=B8=94=E8=BD=BD?= =?UTF-8?q?=E8=8D=B7=20result=3Dvictory=C2=B7node=5Fid=EF=BC=9D=E8=AF=A5?= =?UTF-8?q?=E7=BB=88=E7=82=B9=E5=8F=A5=E6=9F=84=EF=BC=88=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=20runner.start=5Flevel+pick=5Fnext=5Fnode+=5Fautoplay=5Fbattle?= =?UTF-8?q?=20=E5=85=A8=E7=A8=8B=E9=A9=B1=E5=8A=A8=EF=BC=8C=E5=85=91?= =?UTF-8?q?=E4=BB=98=E5=81=9C=E7=82=B9=E8=B5=B0=20continue=5Fafter=5Fnode?= =?UTF-8?q?=20=E4=B8=8E=20=5Fdrive=5Flevel=20=E5=90=8C=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=EF=BC=89=EF=BC=9B=E5=AE=BF=E4=B8=BB=20176=E2=86=92181=20?= =?UTF-8?q?=E6=9D=A1=E5=85=A8=E7=BB=BF=E3=80=82=E5=AE=9E=E7=8E=B0=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E8=A1=A5=20=C2=A7=E4=BA=8C=C2=B7=E5=8D=81=E4=B8=89=20?= =?UTF-8?q?=E5=AE=9E=E9=99=85=E5=AF=B9=E7=85=A7=E8=A1=A8=EF=BC=88=E5=8D=A1?= =?UTF-8?q?=E7=89=87=20=C2=A78.2=20=E5=85=AB=E8=A1=8C=E9=80=90=E8=A1=8C?= =?UTF-8?q?=E7=BB=99=E6=96=87=E4=BB=B6:=E8=A1=8C=EF=BC=8F=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E5=85=A5=E5=8F=A3=EF=BC=8F=E6=92=A4=E9=94=80=E8=AF=81?= =?UTF-8?q?=E6=98=8E=E5=AF=B9=E5=BA=94=20M1-M7=20=E4=B8=8E=20T1/=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E6=89=B9=E5=8F=98=E5=BC=82=EF=BC=89=E4=B8=8E=E7=BB=88?= =?UTF-8?q?=E7=89=88=E5=AE=BF=E4=B8=BB=E8=AF=BB=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unit/core/level_shop_economy_test.gd | 55 +++++++++++++++++++ reports/iter35-卡②-实现报告-2026-10-24.md | 15 +++++ 2 files changed, 70 insertions(+) diff --git a/game/tests/unit/core/level_shop_economy_test.gd b/game/tests/unit/core/level_shop_economy_test.gd index ecdba9bf..9f4049e7 100644 --- a/game/tests/unit/core/level_shop_economy_test.gd +++ b/game/tests/unit/core/level_shop_economy_test.gd @@ -248,6 +248,7 @@ func _init() -> void: _smoke_gp_escrow() _smoke_shop_room() _smoke_shop_graph() + _smoke_multi_terminal_settlement() _smoke_shop_pool() _smoke_generated_coverage() quit(Kit.finish("level_shop_economy_test")) @@ -1151,6 +1152,60 @@ func _smoke_shop_graph() -> void: Kit.check("L1 现行图跨表覆盖(战斗/Boss 节点 %d 颗非空·敌侧违例 %d·板侧违例 %d)" % [int(l1_cov["nodes"]), int(l1_cov["enemy_bad"]), int(l1_cov["board_bad"])], int(l1_cov["nodes"]) >= 1 and int(l1_cov["enemy_bad"]) == 0 and int(l1_cov["board_bad"]) == 0) +# ---- ⑥ 大岔口分区域兑付(V3——多终点图走哪条路决定打谁,任一 Boss 胜即收束恰一次)---- +func _smoke_multi_terminal_settlement() -> void: + print("--- 大岔口分区域兑付(L2 两轴各打各的终点)---") + var autoplay = load("res://sim/level_autoplay.gd") + var nodes: Array = _generated_graph(2, 42).get("nodes", []) + var boss_ids := _ids_of_type(nodes, "boss") + Kit.check("L2 现行图为多终点(Boss 终点 %s 两颗以上——大岔口承载面)" % str(boss_ids), boss_ids.size() >= 2) + var run_min: Dictionary = autoplay.run_level(_db, 42, 2, 400, 300, false, null, "normal", "min") + var run_max: Dictionary = autoplay.run_level(_db, 42, 2, 400, 300, false, null, "normal", "max") + var min_vis: Array = run_min.get("visited_ids", []) as Array + var max_vis: Array = run_max.get("visited_ids", []) as Array + var min_last := -1 + var max_last := -1 + if not min_vis.is_empty(): + min_last = int(min_vis[min_vis.size() - 1]) + if not max_vis.is_empty(): + max_last = int(max_vis[max_vis.size() - 1]) + # 走哪条路决定打谁:两路各自收在本路终点 Boss 上且互不相同;本关收束 result=victory + Kit.check("L2 min/max 两路各打进各自终点 Boss(末句柄 %d / %d 互异且都在图 Boss 集 %s 内;两路 result=%s/%s)" % [min_last, max_last, str(boss_ids), String(run_min.get("result", "")), String(run_max.get("result", ""))], + String(run_min.get("result", "")) == "victory" and String(run_max.get("result", "")) == "victory" + and boss_ids.has(min_last) and boss_ids.has(max_last) and min_last != max_last) + # 不误兑付:另一颗 Boss 不在本路已到序里(走 A 区不误兑付 B 区 Boss) + Kit.check("min 路未到 max 终点 %d、max 路未到 min 终点 %d(区域隔离——走 A 区不误兑付 B 区 Boss)" % [max_last, min_last], + not min_vis.has(max_last) and not max_vis.has(min_last)) + # 任一 Boss 胜=本关收束恰一次(level_finished 一次·载荷 result=victory 且 node_id=该终点句柄) + var fin := {"count": 0, "payload": {}} + var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(42)) + runner.set_hero_override(400, 300) # 满配档——全程驱动须打到 Boss 才收束 + runner.level_finished.connect(func(p): fin["count"] = int(fin["count"]) + 1; fin["payload"] = p) + Kit.check("L2 全程驱动启动受理", runner.start_level(2, 42)) + var st_drive := _battle_st("normal") + var guard := 40 + while guard > 0 and int(fin["count"]) == 0: + guard -= 1 + if runner.awaiting_choice(): + var ids: Array = runner.next_candidate_ids() + if ids.is_empty(): + break + runner.pick_next_node(int(ids[ids.size() - 1])) + continue + var b = runner.current_battle() + if b == null: + # 兑付停点(休整/商店/奖励直通)——先确认继续再回到选路,与 _drive_level 同口径 + if not runner.continue_after_node(): + break + continue + var inner := 40 + while inner > 0 and runner.current_battle() == b: + inner -= 1 + autoplay._autoplay_battle(runner, b, st_drive, null) + var payload: Dictionary = fin["payload"] + Kit.check("任一 Boss 胜即本关收束恰一次(count=%d·载荷 result=%s·node_id=%s 在图 Boss 集 %s 内)" % [int(fin["count"]), String(payload.get("result", "")), str(payload.get("node_id", -1)), str(boss_ids)], + int(fin["count"]) == 1 and String(payload.get("result", "")) == "victory" + and boss_ids.has(int(payload.get("node_id", -1)))) # 现行图全路径穿店恰一(性质③在单图上的整体读数) func _paths_all_one_shop(nodes: Array) -> bool: diff --git a/reports/iter35-卡②-实现报告-2026-10-24.md b/reports/iter35-卡②-实现报告-2026-10-24.md index 26145873..4165eead 100644 --- a/reports/iter35-卡②-实现报告-2026-10-24.md +++ b/reports/iter35-卡②-实现报告-2026-10-24.md @@ -140,6 +140,21 @@ 卡片 §8 变异清单九条的落位:V1 两条(T1 已录)、V11 两条(覆盖批已录)、V2/V5×2/V6/V7/V8/V9 共六条(本表 M1–M7,V5 拆两条)。 +## 二·十三 实际对照表(卡片 §8.2 八行的落地坐标;审计第 1 件按此逐行核) + +| 需求点 | 测试落点(文件:行/尾缀) | 真实入口 | 撤销或变异证明 | +|---|---|---|---| +| 加载校验四检+良构 | `game/tests/unit/core/level_table_validate_test.gd`(17 条,〔v1〕)+起关拒绝段 `game/core/level_runner.gd:308-312` | `LevelRunner.start_level_external`(违规图返回 false) | T1 四条注入见红(§二·五):终点恒 Boss 判据注释、无环判据短路等 | +| 行进语义(多入口/大岔口/多 Boss/单链) | `node_map_templates_test.gd:60-66`〔v2〕;`level_shop_economy_test.gd:1157-1216`(V3 段:多终点图、两路各收本路 Boss、区域隔离、收束恰一次);`level_shop_economy_test.gd:339`〔v4〕(L1 终点恰一)+`:632`(L3 入口数=`entry_count`、终点数=`exit_count`) | `LevelRunner.start_level_external`/`start_level`+`pick_next_node`/`sim/level_autoplay.run_level`(route=min/max 两轴全程驱动) | M1(候选写死单入口)→〔v2〕红 | +| 商店编排三条性质 | `level_shop_economy_test.gd:1129-1133`〔v5〕(扫描面非空+性质①②③,60 图/1413 路径读数)+`:1000-1010` 行进面 | 生成图逐节点解析+`runner` 行进到店 | M2(终点前驱非店)→性质①红;M3(加第二家店)→性质②③红 | +| 生成图覆盖+多终点 Boss 带互异 | `level_shop_economy_test.gd:259-306`〔v11〕+`adventure_feats_test.gd:111-131`〔fv24〕 | `ConfigDB.resolve_enemy_rowset`/`resolve_board_gen` 真实消费链 | 覆盖批两条注入已录(Boss 带并单带、删顶带);〔fv24〕判据与〔v11〕同源单点 | +| 定位信息两样(关卡序号+节点深度) | `adventure_feats_test.gd:159-166`〔v6〕(读 `BattleOrchestrator.encounter`) | 真实进战入口 `_drive_to_pref`→`pick_next_node`→`begin_battle` | M4(enc `node_depth` 取句柄)→〔v6〕红 | +| 确定性承载+旧断点代差 | `node_map_templates_test.gd:44-95`〔v7〕〔v8〕;`breakpoint_resume_test.gd:157-183`〔v7〕〔v8〕〔v9〕 | `NodeMapGenerator.generate`/`AdventureRunner.start_adventure`/`resume_adventure`(真实快照写手 `SaveManager`) | M5(产图改计数器流)→〔v7〕红;M6(缺代差键放行)→〔v8〕红 | +| 15 张模板清理 | `node_map_templates_test.gd:91-96`〔v9〕+件核 grep(全仓零 `templates`/`first_script`/`template_pick`/`active_template`) | `ConfigDB.level_row` 表读面 | M7(表内偷留 `templates` 键)→〔v9〕红 | +| 反哺点清单(非行为改动) | `reports/iter35-卡②-反哺点-2026-10-24.md` 十条落点 | T4 产出物件核 | 缺任一落点=V10 件核红(本文件 §三·一 目检两条同批登记) | + +宿主读数(终版):`level_shop_economy_test` 181 条、`node_map_templates_test` 21 条、`adventure_feats_test` 138 条、`breakpoint_resume_test` 56 条、`level_table_validate_test` 17 条。 + ## 三·一 实机目检总清单(真人判断项,登记即完成义务,不作为合回前置) | # | 观测项 | 真实入口 | 期望看到什么 | 本批机检已覆盖的部分 | From 11708066dc703d318c69a22302d24535147c1e95 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sat, 24 Oct 2026 19:59:06 +0800 Subject: [PATCH 17/20] =?UTF-8?q?fix(=E8=BF=AD=E4=BB=A335=E5=8D=A1?= =?UTF-8?q?=E2=91=A1=E4=BB=B6=E6=A0=B8=E8=A1=A5=E5=8F=A3):=20ui=5Fstruct?= =?UTF-8?q?=5Fprobe=20=E2=91=A6resume=20=E5=A4=B9=E5=85=B7=E6=96=AD?= =?UTF-8?q?=E7=82=B9=E8=A1=A5=E7=94=9F=E6=88=90=E5=9B=BE=E4=BB=A3=E5=B7=AE?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E9=94=AE=20graph=5Fgen=5Fv2=E2=80=94?= =?UTF-8?q?=E2=80=94=E6=96=AD=E7=82=B9=E5=A5=91=E7=BA=A6=E6=8D=A2=E4=BB=A3?= =?UTF-8?q?=E5=90=8E=E7=BC=BA=E8=AF=A5=E9=94=AE=E5=8D=B3=20resume=5Fadvent?= =?UTF-8?q?ure=20=E6=98=BE=E5=BC=8F=E6=8B=92=E7=BB=9D=EF=BC=8C=E6=8E=A2?= =?UTF-8?q?=E9=92=88=E7=9A=84=E3=80=8C=E7=BB=AD=E7=8E=A9=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E5=90=8E=E8=83=9C=E5=88=A9=E6=80=BB=E7=BB=93=E9=9D=A2=E3=80=8D?= =?UTF-8?q?=E6=96=AD=E8=A8=80=E5=9B=A0=E5=A4=B9=E5=85=B7=E5=B1=9E=E6=8D=A2?= =?UTF-8?q?=E4=BB=A3=E5=89=8D=E5=BD=A2=E6=80=81=E8=80=8C=E4=B8=AD=E6=96=AD?= =?UTF-8?q?=EF=BC=8803=20=E6=8E=A2=E9=92=88=20--click=EF=BC=8F04=20?= =?UTF-8?q?=E6=8E=A2=E9=92=88=20--full=20=E5=9F=BA=E7=BA=BF=E7=BB=BF?= =?UTF-8?q?=E2=86=92=E7=8E=B0=E7=BA=A2=E4=B8=A4=E5=A4=84=E5=90=8C=E6=BA=90?= =?UTF-8?q?=EF=BC=89=EF=BC=9B=E7=9B=B4=E8=B7=91=20res://probe/ui=5Fstruct?= =?UTF-8?q?=5Fprobe.tscn=20=E5=A4=8D=E9=AA=8C=E5=85=A8=E5=B1=8F=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E6=96=AD=E8=A8=80=E9=9B=B6=E7=BA=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/probe/ui_struct_probe.gd | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/game/probe/ui_struct_probe.gd b/game/probe/ui_struct_probe.gd index 4deede60..b77d9374 100644 --- a/game/probe/ui_struct_probe.gd +++ b/game/probe/ui_struct_probe.gd @@ -252,11 +252,12 @@ func _ready() -> void: get_tree().quit(1) -# ⑦resume 夹具:最小合法断点快照——bonus_pending 非空=三选一停点分支(合计 gp=24 供守卫兜底断言) +# ⑦resume 夹具:最小合法断点快照(含生成图代差标记键)——bonus_pending 非空=三选一停点分支(合计 gp=24 供守卫兜底断言) func _resume_bp_fixture() -> Dictionary: return { "master_seed": 42, "rng_counter": 0, "hero_hp": 20, "gp": 24, "keystone": 0, "level_index": 1, "bonus": {}, "shop_purchased": {}, "relics": [], + "graph_gen_v2": 1, # 生成图换代代差标记键(缺键=换代前旧断点,resume_adventure 显式拒绝) "keystone_drops": [], "bonus_taken": {}, "bonus_pending": {"candidates": [{"kind": "heal", "tier": "common"}], "payload": {"gp_dropped": 0}}, } From 0c5cd428baa838b96b556da0d9f564db1224d209 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sat, 24 Oct 2026 23:21:40 +0800 Subject: [PATCH 18/20] =?UTF-8?q?fix(=E8=BF=AD=E4=BB=A335=E5=8D=A1?= =?UTF-8?q?=E2=91=A1=E4=BB=B6=E6=A0=B8=E8=A1=A5=E5=8F=A3=E4=BA=8C):=20ui?= =?UTF-8?q?=5Fclick=5Fprobe=20=E7=BB=AD=E7=8E=A9=E5=A4=B9=E5=85=B7?= =?UTF-8?q?=E6=96=AD=E7=82=B9=E5=BD=A2=E6=80=81=E6=8D=A2=E4=BB=A3=E2=80=94?= =?UTF-8?q?=E2=80=94=E8=AF=A5=E6=8E=A2=E9=92=88=E6=AE=B5=E7=94=B1=E5=88=AB?= =?UTF-8?q?=E7=9A=84=E6=89=B9=E6=AC=A1=E6=96=B0=E5=86=99=E5=85=A5=EF=BC=8C?= =?UTF-8?q?=E4=BB=8D=E5=B8=A6=E5=B7=B2=E9=80=80=E5=BD=B9=E7=9A=84=20templa?= =?UTF-8?q?te=5Fpick=20=E9=94=AE=E4=B8=94=E7=BC=BA=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=9B=BE=E4=BB=A3=E5=B7=AE=E6=A0=87=E8=AE=B0=E9=94=AE=EF=BC=8C?= =?UTF-8?q?resume=5Fadventure=20=E6=8C=89=E5=A5=91=E7=BA=A6=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=E6=8B=92=E7=BB=9D=E8=AF=A5=E6=97=A7=E5=BD=A2=E6=80=81?= =?UTF-8?q?=EF=BC=8C=E8=87=B4=E3=80=8C=E7=BB=AD=E7=8E=A9=E9=87=8D=E5=BB=BA?= =?UTF-8?q?=E5=90=8E=E5=8A=A0=E7=AE=97=E5=8C=BA=20atk=3D5=20=E5=9B=9E?= =?UTF-8?q?=E5=A1=AB=E3=80=8D=E3=80=8C=E7=BB=AD=E7=8E=A9=E9=87=8D=E5=8F=91?= =?UTF-8?q?=E5=BC=80=E5=9C=BA=E8=A1=8C=E3=80=8D=E4=B8=A4=E6=9D=A1=E5=89=8D?= =?UTF-8?q?=E6=8F=90=E5=A4=B1=E5=AE=88=EF=BC=8803=20=E6=8E=A2=E9=92=88=20-?= =?UTF-8?q?-click=20=E7=8E=B0=E7=BA=A2=EF=BC=89=EF=BC=9B=E5=A4=B9=E5=85=B7?= =?UTF-8?q?=E9=94=AE=E6=8D=A2=20graph=5Fgen=5Fv2:=201=20=E5=90=8E=E4=BB=B6?= =?UTF-8?q?=E6=A0=B8=E5=A4=8D=E6=89=AB=E5=85=A8=E4=BB=93=20template=5Fpick?= =?UTF-8?q?/first=5Fscript/active=5Ftemplate=20=E9=9B=B6=E5=BC=95=E7=94=A8?= =?UTF-8?q?=EF=BC=88=E4=BB=85=E5=89=A9=E5=8F=8D=E5=90=91=E6=96=AD=E8=A8=80?= =?UTF-8?q?=E7=9A=84=E9=94=AE=E5=90=8D=E5=BC=95=E7=94=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/probe/ui_click_probe.gd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/game/probe/ui_click_probe.gd b/game/probe/ui_click_probe.gd index 491b6f6b..b57d332c 100644 --- a/game/probe/ui_click_probe.gd +++ b/game/probe/ui_click_probe.gd @@ -676,7 +676,7 @@ func _ready() -> void: "master_seed": 42, "rng_counter": 0, "hero_hp": 30, "gp": 10, "keystone": 0, "level_index": 1, "bonus": {"atk": 5, "max_hp": 0, "armor_class": 0, "ap": 0}, "shop_purchased": {}, "relics": [], "keystone_drops": [], "bonus_taken": {}, - "node_id": -1, "visited_ids": [], "template_pick": -1, + "node_id": -1, "visited_ids": [], "graph_gen_v2": 1, } main.save_manager.set_breakpoint(bp_ol) # 判定面=续玩开局清日志后的新局日志(_resume_from_menu 内 log_view.clear()——选路停点形 From 39cdc4f6713ea44968c825e2803055ad5fe1af74 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sun, 25 Oct 2026 00:22:55 +0800 Subject: [PATCH 19/20] =?UTF-8?q?style(=E8=BF=AD=E4=BB=A335=E5=8D=A1?= =?UTF-8?q?=E2=91=A1=E8=87=AA=E6=9F=A5):=20=E6=9C=AC=E6=89=B9=E5=94=AF?= =?UTF-8?q?=E4=B8=80=E7=9C=9F=E6=96=B0=E5=A2=9E=E7=9A=84=20S13=20=E8=B4=A8?= =?UTF-8?q?=E9=87=8F=E8=BF=9D=E8=A7=84=E8=87=AA=E4=BF=AE=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=9B=BE=E6=A0=A1=E9=AA=8C=E5=8D=95=E7=82=B9=20validate=5Fgrap?= =?UTF-8?q?h=EF=BC=88=E6=96=B0=E5=86=99=E5=87=BD=E6=95=B0=2072=20=E8=A1=8C?= =?UTF-8?q?=EF=BC=8F20=20=E5=88=86=E6=94=AF=EF=BC=8F12=20=E5=B1=80?= =?UTF-8?q?=E9=83=A8=EF=BC=8C=E8=B6=85=E9=98=88=E5=80=BC=E4=B8=94=E6=97=A0?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF=E5=8F=AF=E8=B5=96=EF=BC=89=E6=8C=89=E5=88=A4?= =?UTF-8?q?=E6=8D=AE=E5=88=86=E6=AE=B5=E6=8B=86=E6=88=90=20=5Fcheck=5Fnode?= =?UTF-8?q?s=5Fwellformed/=5Fcheck=5Fedges/=5Fcheck=5Fterminals/=5Fcheck?= =?UTF-8?q?=5Freachabilities=20=E5=9B=9B=E6=AE=B5=E9=9D=99=E6=80=81?= =?UTF-8?q?=E4=BB=B6=EF=BC=8C=E7=9F=AD=E8=B7=AF=E6=AC=A1=E5=BA=8F=E4=B8=8E?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E7=B1=BB=E5=88=AB=E8=AF=8D=E8=A1=A8=E9=80=90?= =?UTF-8?q?=E5=AD=97=E4=B8=8D=E5=8F=98=EF=BC=88=E8=89=AF=E6=9E=84=E9=94=99?= =?UTF-8?q?=E5=85=88=E4=BA=8E=E5=87=BA=E8=BE=B9=E9=94=99=E3=80=81=E5=87=BA?= =?UTF-8?q?=E8=BE=B9=E9=94=99=E5=85=88=E4=BA=8E=E5=85=A5=E5=8F=A3=E7=BB=88?= =?UTF-8?q?=E7=82=B9=E6=A3=80=E3=80=81=E5=86=8D=E5=8F=AF=E8=BE=BE=E6=80=A7?= =?UTF-8?q?=E6=A3=80=EF=BC=89=EF=BC=8C=E3=80=94v1=E3=80=9517=20=E6=9D=A1?= =?UTF-8?q?=E5=A4=B9=E5=85=B7=E6=96=AD=E8=A8=80=E5=A4=8D=E8=B7=91=E5=85=A8?= =?UTF-8?q?=E7=BB=BF=E9=9B=B6=E6=94=B9=E5=88=A4=E6=8D=AE=EF=BC=9B=E9=97=B8?= =?UTF-8?q?=E4=BD=99=2012=20=E9=A1=B9=E7=BB=8F=E9=80=90=E6=9D=A1=E5=AF=B9?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF=E6=97=A7=E5=80=BC=E6=A0=B8=E5=AE=9E=E4=B8=BA?= =?UTF-8?q?=E5=AD=98=E9=87=8F=E5=80=BA=E8=AE=A1=E6=95=B0=E6=BC=82=E7=A7=BB?= =?UTF-8?q?=EF=BC=88cross=5Fvalidate=20159=E2=86=92107=20=E8=A1=8C?= =?UTF-8?q?=EF=BC=8F31=E2=86=9221=20=E5=88=86=E6=94=AF=E7=B3=BB=E6=9C=AC?= =?UTF-8?q?=E6=89=B9=E5=88=A0=E8=B7=A8=E8=A1=A8=E2=91=A3=E6=89=80=E8=87=B4?= =?UTF-8?q?=E7=9A=84=E6=94=B9=E5=B0=8F=EF=BC=8Clevel=5Frunner=20=E7=B1=BB?= =?UTF-8?q?=E5=8F=98=E9=87=8F=2040=E2=86=9238=E3=80=81start=5Flevel=5Fexte?= =?UTF-8?q?rnal=20=E5=8F=82=E6=95=B0=208=E2=86=927=E3=80=81=E5=85=B6?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E9=95=BF=E5=BA=A6=E8=BF=9D=E8=A7=84=E5=B7=B2?= =?UTF-8?q?=E6=B6=88=E5=A4=B1=EF=BC=8Cmain.gd=201509=E2=86=921507=EF=BC=8C?= =?UTF-8?q?resume=5Fadventure=20104=E2=86=92108=20=E8=A1=8C=E4=B8=BA?= =?UTF-8?q?=E6=96=AD=E7=82=B9=E4=BB=A3=E5=B7=AE=E6=A0=87=E8=AE=B0=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=EF=BC=89=E2=80=94=E2=80=94=E5=A4=84=E7=BD=AE=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E5=BE=85=E7=94=A8=E6=88=B7=E8=A3=81=EF=BC=88=E9=9A=8F?= =?UTF-8?q?=E6=89=B9=E9=87=8D=E7=94=9F=E6=88=90=E5=9F=BA=E7=BA=BF=EF=BC=8F?= =?UTF-8?q?=E9=80=80=20S13=20=E6=89=B9=E8=87=AA=E4=BF=AE=EF=BC=8F=E6=9C=AC?= =?UTF-8?q?=E6=89=B9=E9=87=8D=E6=9E=84=EF=BC=89=EF=BC=8C=E6=9C=AC=E7=AC=94?= =?UTF-8?q?=E4=B8=8D=E5=8A=A8=20tools/gdstyle=5Fbaseline.txt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/data/schemas/level_table.gd | 57 +++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/game/data/schemas/level_table.gd b/game/data/schemas/level_table.gd index d9aea353..4d66fbc1 100644 --- a/game/data/schemas/level_table.gd +++ b/game/data/schemas/level_table.gd @@ -48,10 +48,24 @@ func validate() -> Array: # ④ 无环(出边目标深度严格大于源)。良构=句柄唯一·出边全命中·type 在五类枚举·battle/boss 带深度。 # 入口/终点个数与参数档的一致性归产出侧(关卡节点图生成 v1.1),本处不核个数;商店位归冒烟断言把关,本处禁检。 static func validate_graph(nodes: Array) -> Array: - var errs: Array[Dictionary] = [] if nodes.is_empty(): - errs.append({"category": "structural", "message": "图节点集为空"}) - return errs + return [{"category": "structural", "message": "图节点集为空"}] + var well := _check_nodes_wellformed(nodes) + if not (well["errs"] as Array).is_empty(): + return well["errs"] + var by_id: Dictionary = well["by_id"] + var edges := _check_edges(by_id, well["indeg"]) + if not (edges["errs"] as Array).is_empty(): + return edges["errs"] + var stops := _check_terminals(by_id, edges["outs"], edges["indeg"]) + if not (stops["errs"] as Array).is_empty(): + return stops["errs"] + return _check_reachability(by_id, edges["outs"], stops["entries"], stops["terminals"]) + + +# 良构基底:字段齐·type 在枚举·深度非负·句柄唯一(返回 by_id 与入度骨架供后续检复用) +static func _check_nodes_wellformed(nodes: Array) -> Dictionary: + var errs: Array[Dictionary] = [] var by_id := {} var indeg := {} for i in nodes.size(): @@ -73,8 +87,12 @@ static func validate_graph(nodes: Array) -> Array: by_id[nid] = node if not indeg.has(nid): indeg[nid] = 0 - if not errs.is_empty(): - return errs + return {"errs": errs, "by_id": by_id, "indeg": indeg} + + +# 出边良构+无环判据(④):目标句柄存在、目标深度严格大于源;同时产出去边表与入度计数 +static func _check_edges(by_id: Dictionary, indeg: Dictionary) -> Dictionary: + var errs: Array[Dictionary] = [] var outs := {} for nid in by_id.keys(): outs[nid] = [] @@ -87,25 +105,34 @@ static func validate_graph(nodes: Array) -> Array: errs.append({"category": "structural", "message": "连线 %d→%d 违反前进方向(目标深度须严格大于源)" % [nid, int(tid)]}) outs[nid].append(int(tid)) indeg[int(tid)] = int(indeg[int(tid)]) + 1 - if not errs.is_empty(): - return errs + return {"errs": errs, "outs": outs, "indeg": indeg} + + +# 检①+检③:入口(无入边)≥1、终点(无出边)≥1、终点恒为 Boss 且 Boss 无出边 +static func _check_terminals(by_id: Dictionary, outs: Dictionary, indeg: Dictionary) -> Dictionary: + var errs: Array[Dictionary] = [] var entries := [] var terminals := [] for nid in by_id.keys(): if int(indeg[nid]) == 0: entries.append(nid) - if (outs[nid] as Array).is_empty(): - terminals.append(nid) - if String(by_id[nid]["type"]) != "boss": - errs.append({"category": "structural", "message": "终点 %d 非 Boss(终点恒为 Boss)" % nid}) - elif String(by_id[nid]["type"]) == "boss": - errs.append({"category": "structural", "message": "Boss 节点 %d 带出边(终点不可外出)" % nid}) + if not (outs[nid] as Array).is_empty(): + if String(by_id[nid]["type"]) == "boss": + errs.append({"category": "structural", "message": "Boss 节点 %d 带出边(终点不可外出)" % nid}) + continue + terminals.append(nid) + if String(by_id[nid]["type"]) != "boss": + errs.append({"category": "structural", "message": "终点 %d 非 Boss(终点恒为 Boss)" % nid}) if entries.is_empty(): errs.append({"category": "structural", "message": "入口节点数 = 0(须 ≥1)"}) if terminals.is_empty(): errs.append({"category": "structural", "message": "终点节点数 = 0(须 ≥1)"}) - if not errs.is_empty(): - return errs + return {"errs": errs, "entries": entries, "terminals": terminals} + + +# 检②:全部节点从某入口可达且可达某终点(无孤儿、无断头) +static func _check_reachability(by_id: Dictionary, outs: Dictionary, entries: Array, terminals: Array) -> Array: + var errs: Array[Dictionary] = [] var from_entry := {} for eid in entries: _mark_reachable(int(eid), outs, from_entry) From 6a377750b6acc54d9923e17fcd38048060322bcd Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sun, 25 Oct 2026 00:48:01 +0800 Subject: [PATCH 20/20] =?UTF-8?q?docs(=E8=BF=AD=E4=BB=A335=E5=8D=A1?= =?UTF-8?q?=E2=91=A1T4=20=E4=BA=A7=E5=87=BA=E7=89=A9=E5=9B=9B=E4=BB=B6?= =?UTF-8?q?=E9=BD=90):=20=E7=8B=AC=E7=AB=8B=E9=AA=8C=E6=94=B6=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E6=8A=A5=E5=91=8A=E8=90=BD=E4=BD=8D=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=9C=AA=E5=8F=82=E4=B8=8E=E5=AE=9E=E7=8E=B0=E7=9A=84=E5=AD=90?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E5=8F=AA=E8=AF=BB=E5=8D=A1=E7=89=87=EF=BC=8B?= =?UTF-8?q?=E5=AE=9E=E9=99=85=E5=AF=B9=E7=85=A7=E8=A1=A8=EF=BC=8Bdiff?= =?UTF-8?q?=EF=BC=8B=E6=B5=8B=E8=AF=95=EF=BC=8B=E5=9B=9E=E6=89=A7=E8=87=AA?= =?UTF-8?q?=E8=B7=91=E5=A4=8D=E6=A0=B8=EF=BC=8C=E6=80=BB=E7=BB=93=E8=AE=BA?= =?UTF-8?q?=E3=80=8C=E9=80=9A=E8=BF=87=E3=80=81=E6=97=A0=E7=BC=BA=E6=BC=8F?= =?UTF-8?q?=E3=80=8D=EF=BC=8C=E5=85=B6=E4=BA=B2=E6=89=8B=E5=A4=8D=E7=8E=B0?= =?UTF-8?q?=E4=B8=89=E6=9D=A1=E6=92=A4=E9=94=80=E7=BA=A2=EF=BC=88M1?= =?UTF-8?q?=E3=80=94v2=E3=80=95=E5=85=A5=E5=8F=A3=E5=80=99=E9=80=89?= =?UTF-8?q?=E6=95=B0=EF=BC=8FM4=E3=80=94v6=E3=80=95=E8=BF=9B=E6=88=98?= =?UTF-8?q?=E8=BD=BD=E8=8D=B7=EF=BC=8FM6=E3=80=94v8=E3=80=95=E6=97=A7?= =?UTF-8?q?=E6=96=AD=E7=82=B9=E4=BB=A3=E5=B7=AE=E6=8B=92=E7=BB=9D=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E7=8B=AC=E7=AB=8B=E7=A1=AE=E8=AE=A4=E4=BB=B6?= =?UTF-8?q?=E6=A0=B8=E5=9B=9B=E9=A1=B9=E3=80=81=E6=89=B9=E6=80=A7=E8=B4=A8?= =?UTF-8?q?=E7=A6=81=E4=BB=A4=E9=9B=B6=E8=A7=A6=20design/=20=E4=B8=8E?= =?UTF-8?q?=E6=A0=B9=E6=B8=85=E5=8D=95=E3=80=81=E4=BB=A3=E6=8B=8D=E6=9D=BF?= =?UTF-8?q?=E8=B6=85=E6=B8=85=E5=8D=95=E4=BA=94=E4=BB=B6=E5=B7=B2=E7=94=B3?= =?UTF-8?q?=E6=8A=A5=E3=80=81vis/fog=20=E4=B8=A4=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E6=96=AD=E8=A8=80=E8=AE=A1=E6=95=B0=2019=E2=86=9219=EF=BC=8F26?= =?UTF-8?q?=E2=86=9226=20=E5=B1=9E=E5=AE=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- reports/iter35-卡②-验收审计-2026-10-24.md | 117 ++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 reports/iter35-卡②-验收审计-2026-10-24.md diff --git a/reports/iter35-卡②-验收审计-2026-10-24.md b/reports/iter35-卡②-验收审计-2026-10-24.md new file mode 100644 index 00000000..d9f71d47 --- /dev/null +++ b/reports/iter35-卡②-验收审计-2026-10-24.md @@ -0,0 +1,117 @@ +# 迭代 35 卡② 独立验收审计(节点图消费侧换代批) + +- 审计日期:2026-10-24(执行跨至 2026-10-25 完成) +- 审计对象:分支 `iter35b` 尖 `0c5cd428`,改动范围 `git diff master...HEAD`(merge-base `bb63c93c`,31 文件级) +- 审计身份:未参与实现的独立会话子代理(迭代 loop 内启动,依 test-conventions §9.7 授权跑测) +- 合同:`reports/需求卡片-节点图消费侧换代批-2026-10-21.md` v1.1;被审产出物=实现报告/代拍板单/反哺点清单三份 + +## 总结论:**通过** + +逐条独立复核(不采信实现方「已全绿」)后,V1–V11、S1–S8、件核四项、批性质禁令、变异伴生九条拦截面全部成立;实现报告 §二·十三 实际对照表的每条测试落点均真实存在、真实入口非绕过改动点的下层直调;三条撤销证明(M1/M4/M6)由本审计亲手注入复现出红并还原自证 git status 干净。三处「必看」的旧断言改写均为等价或更强、且已在卡片 §8.2/§一随动表/代拍板单申报,无未申报削弱。**建议合回**。 + +残留项均为已在报告内申报的过程件与文档措辞张力(反哺点⑦/⑨),属文档批处理面,不构成实现批的验收缺漏。次要行号漂移(±1–5 行)不影响判据可定位性(以 尾缀 grep 为权威落点)。 + +## 一、环境与跑测方法 + +- 基线复核:本审计自跑 `PYTHONIOENCODING=utf-8 Godot_v4.7.2 --headless --path . --script res://tests/run_all.gd`,结果 `all green (60 suites)`;对 `FAIL|红色|断言失败` 的 7 处命中逐条判读,全为良性(通过态测试函数名含 "failure_restores"、故意的 corrupt-JSON 解析报错、"[PASS] …R3 fail-fast" 行),**零真实 FAIL**。 +- 关键宿主自证读数:`level_table_validate_test` 17、`node_map_templates_test` 21、`adventure_feats_test` 138、`level_shop_economy_test` 181、`breakpoint_resume_test` PASS —— 与实现报告 §二·十三「终版读数」逐一对齐。 +- 撤销证明:在指定文件注入变异→跑最窄宿主取宿主自身 FAIL 行→写回原文→`git status`/`git diff HEAD` 自证字节级还原(三次注入后树均干净)。 +- 静态核:diff 路径、件核四项、旧断言删除—替换、非空前置、代拍板超清单五件,逐项 grep + 打开文件确认。 +- 依赖说明:探针/冒烟包装闸(run_all_gates.sh 的 probe/smoke/balance 段)未由本审计重跑,依 `game/tests/.gates/receipt.json`(记录 commit=`0c5cd428`、域=code、全绿、11 回放确定性对拍 NEED-APPROVAL 经用户裁定零跑)作背景;代码域测试面已由 run_all.gd 独立复现。**MC/11 回放确定性对拍的零跑是红线内正确处置**(卡② 本体零玩法数值;覆盖批数值随卡② 同批、档位须用户发起),非本审计可越权补跑,亦不阻断代码侧验收结论。 + +## 二、逐需求点审计表(V1–V11) + +| # | 审计项 | 判据出处(卡片) | 实际跑了什么/打开确认了什么 | 结果 | +|---|---|---|---|---| +| V1 | 加载校验四检+良构+起关拒绝 | §4 契约1;§8 V1;§8.2 行1 | 打开 `level_table.gd:50 validate_graph`=①入口终点≥1 ②全员可达双向BFS ③终点恒Boss/Boss无出边 ④出边目标depth严格>源;良构=句柄唯一·出边全命中·type五枚举·battle/boss带depth。`level_runner.gd:308` 起关调 `LevelTable.validate_graph` 违规 return false(真实入口 `start_level`/`start_level_external`)。跑 `level_table_validate_test`〔v1〕17 条全绿(正例三形/负例八类/范围反证三/②④互斥实证)。旧六判据文本全仓 grep 零残留(`编号最大`/`纯战斗`/`出度上限` 作判据 0 命中);`validate()` 已缩为行级,`level_shop_economy:427` 保留「行级校验零错误(图合法性移交 validate_graph)」 | 通过 | +| V2 | 多入口首跳(候选=全部入口) | §4 契约5;§8 V2 | 〔v2〕`node_map_templates:64` 「入口停点候选数=参数档入口个数(L3 档 2)」经 `start_level_external(...,defer_entry=true)`→`level_runner:214 _candidates=entries` 执行;第二入口 `:68` 走 `pick_next_node`。**亲手注入 M1**(两处 `_candidates=entries`→`[entries[0]]`)→该断言变红,还原绿 | 通过 | +| V3 | 大岔口分区域兑付+多Boss收束恰一次 | §4 契约5;§8 V3 | 打开 `level_shop_economy:1156 _smoke_multi_terminal_settlement`:真实入口 `sim/level_autoplay.run_level(min/max 两轴)`+`runner.start_level(2,42)`+`pick_next_node`;断言 L2≥2终点Boss、两路各收本路终点Boss互异、区域隔离(A路未到B终点)、`level_finished` count==1 且 result=victory 且 node_id∈Boss集。基线该宿主 181 全绿 | 通过 | +| V4 | 单链退化合法+三关含岔口+L1终点恰一 | §1完成定义;§8 V4 | 〔v4〕`level_shop:340`「L1 终点恰一个(大岔口自L2起)」`_terminal_ids==1 and boss==1`;`:633` L3 入口数=entry_count·终点数=exit_count≥2;含岔口 `:341/442`(出度≥2节点≥1);单链合法由 V1 `level_table_validate:56` 夹具承载。`adventure_feats:165`〔v4〕三关各含≥1奖励节点 | 通过 | +| V5 | 商店编排三性质 | §4 契约4;§8 V5 | 〔v5〕`level_shop:1129/1130/1131` 三性质(终点前驱恰一店/店数=终点数/全路径恰穿一店)×L1–L3×种子42–61 扫描;非空前置 `:1128 graph_total==3×range & term>=3 & path>=3`,逐图空即 `:1101 false`。DFS `_all_entry_terminal_paths:186` 走句柄序。加载校验内无商店位(`level_table_validate:97` 反证)。M2/M3 记录判据名=性质①/②③,与卡片「终点前驱非店」「路径加第二店」对应(底层断言已开文件确认为真判据) | 通过 | +| V6 | 定位信息两样(关卡序号+节点深度,弃编号寻址) | §4 契约2;§8 V6 | `level_runner:500` enc `"node_depth": int(node.get("depth",0))`=depth值非句柄、键名保留;`_node_id:333` 返回 `id` 句柄(双编号空间退役)。〔v6〕`adventure_feats:188` 走真实 `_drive_to_pref→pick_next_node→begin_battle→current_battle().encounter`,判据含 `enc.node_depth==node.depth` **且 `node.id != node.depth`**(防句柄冒充)。**亲手注入 M4**(enc 取 `id`)→该断言红(实测 depth=6 句柄=6),还原绿 | 通过 | +| V7 | 确定性承载(纯函数复现) | §4 契约3;§8 V7 | 〔v7〕`node_map_templates:43` 同档同种子两次 generate 逐位同图/`:45` 换种子即变/`:53` 起关生效图=生成器直取(单一图源)/`:80` 种子42两开局同图;`breakpoint:174/181` 续玩重生成同图+同种子重开局同图。M5 记录判据名「同参数档同种子两次产图逐位同图」对应卡片「生成器注计数器依赖」;底层 `pure_stream→open_stream` 注入确会破坏该断言(判据对象正确) | 通过 | +| V8 | 模板0语义废除+旧断点代差拒绝 | §4 契约3;§8 V8 | `adventure_runner:146` `if int(bp.get("graph_gen_v2",0))!=1: return false`;快照 `:1014 graph_gen_v2:1`。〔v8〕`node_map:88` 续玩受理(标记在档)+`:94` 缺标记键旧断点 `==false`;`breakpoint:161/171/179` P7 标记键+句柄往返+旧断点拒绝。`is_first_adventure_done` 两态(种子42/随机)由 `node_map:106-112` 收束置位承载。**亲手注入 M6**(`if false:`)→`breakpoint:179`〔v8〕「缺生成图代差标记键的旧断点显式拒绝」红,还原绿 | 通过 | +| V9 | 15 张模板清理 | §4 契约6;§8 V9;完成定义6 | 〔v9〕`node_map:101` 三行 level_row 均无 `templates` 键+`breakpoint:159` `not has("template_pick")`。件核四项见第四节。`_resolve_template_row`/`active_template_*` 代码零引用。`level.tres` 仅剩 3 行 `level_index/display_name/rest_heal_pct`。M7 记录判据名「level 表三行均无 templates 键」对应卡片「level.tres 偷留 templates 键」 | 通过 | +| V10 | 反哺点清单六条各有落点 | §6;§8 V10(非行为改动) | 打开 `reports/iter35-卡②-反哺点-2026-10-24.md`:卡片预列①-⑥逐条有文件+节位+改成什么+依据;另实现批新发现⑦-⑩四条一并呈报。V10 义务满足 | 通过 | +| V11 | 生成图覆盖+多终点Boss带互异(承跨表校验④语义) | §0硬交付;§4契约8;§8 V11 | 〔v11〕`level_shop:302-305` 敌侧落带≥请求深度(`_enemy_band_covers:223` 经 `resolve_enemy_rowset` 真实链,禁顶clamp)/板侧精确命中(`node_depth==D`)/多终点带键两两互异;非空前置 `:268/275/302`。〔fv24〕`adventure_feats:130` 同源消费链+`cov_scan>0`。§一 覆盖批两条注入记录(Boss带并单带→互异红;删顶带→落带红)判据名对应卡片伴生条。**跨表校验④整段已从 `config_db_cross_validator.gd` 删除**(`node_lists`/`level_node_dangling`/带clamp/带互异静态代码零残留),两拦截语义由〔v11〕升级到真实生成图,与 §8.2 预声明④一致 | 通过 | + +## 三、S1–S8 断言随动核 + +| # | 核到之处 | 结果 | +|---|---|---| +| S1 | `node_map_templates_test` 整宿主重写,全文件无「模板 0/回滚档/池=5/node_map抽签」文本(grep 0);⑦收束置位改写为「首局恒走固定种子42」 | 通过 | +| S2 | `_level_active_nodes` 三副本(`level_shop:48`/`adventure_feats:895`/`round_flow:343`)均改 `NodeMapGenerator.generate(level_gen_params_row(L),42)`;死件 `_boss_depth` 全仓 .gd 零引用(仅 `验证集清单.md` 快照残留,见第五节) | 通过 | +| S3 | 商店三性质+行进面(到店节点型别=shop且出边恰指终点)换代完成,硬编码 8/9/穿节点8/L1行集退役 | 通过 | +| S4 | 形状写死断言(节点数=10/普通战斗6+Boss恒一/恢复1+奖励1+商店1)改为生成图读数性质(五型合计=节点数/≥battle_floor/Boss数=exit_count/岔口≥1/终点depth=layers),每条带非空前置 `L%d 现行起关图取数非空` | 通过 | +| S5 | 覆盖判据换真实消费链+落带非顶clamp+带互异(见 V11) | 通过 | +| S6 | 确定性四断言(同图/换种子变图/句柄+depth/单一图源/种子42两开局/断点重生成同图/缺templates)落 `node_map_templates` 同宿主,manifest 零新增 | 通过 | +| S7 | `level_autoplay:141/200-202` `level_maps`(值 `关卡#主种子`)替代 `level_templates`;MC 出数面 `adventure_monte_carlo:139` 随动;sim 内 `template_pick` 零残留;route 轴沿句柄升降序 | 通过 | +| S8 | `breakpoint_resume` P7 六条;`pool_case_d`/`feat_vouchers`/`cross_system_flow` first_script 键清退(.gd 零引用);`meta_progression_forge:323` extra 键样例换 `treasure_unlocked`,「入口特有键随 extra 原样并入」判据结构保持(仍验 wide_scout_band+shop_mode==pool) | 通过 | + +## 四、件核四项(命令与命中数) + +| 项 | 命令 | 命中 | 判读 | +|---|---|---|---| +| 全仓零 `first_script` | `grep -rn first_script game --include=*.gd` | **.gd 0** | 代码面零引用(通过)。`.md` 仅 `game/tests/验证集清单.md` 快照 8 处(不在本 diff、不进任何闸、已申报反哺点⑦待用户重生成/注记)——属过程文档快照,非代码死引用,见第五节例外说明 | +| 全仓零 `template_pick` | `grep -rn template_pick game --include=*.gd` | **.gd 1** | 唯一命中 `breakpoint_resume_test:159 not bp_j.has("template_pick")`=**反向断言**(断言退役键缺席),属例外。其余仅 `验证集清单.md` 快照 3 处 | +| 全仓零 `active_template` | `grep -rn active_template game --include=*.gd` | **.gd 0** | 代码面零引用(通过);快照 2 处同上 | +| `level.tres` 无 `templates` 键 | `grep -n templates game/data/tables/level.tres` | **0 数据键** | 仅 1 处表尾去向注释(第16行「旧15张模板池…清出」),三行均为行级字段无 templates/nodes(V9 达成) | + +## 五、测试削弱核查(三处必看 + 全量删除扫描) + +对 `git diff master...HEAD` 的**全部删除断言行**(约 60 条 Kit.check)逐条溯源其替换体,未发现「删断言/放宽阈值/缩扫描面/换下层直调/精确改恒真/空扫描判绿」的未申报削弱。三处重点: + +1. **奖励三选一断言替代**(`adventure_feats`):旧「L1 链必经≥1 次」→新「图侧每关≥1奖励节点〔v4〕 + `_drive_to_pref("reward")` 真实岔口点选→`node_curio_candidates`+`present_node_curio`呈停+`apply_node_curio`受理」,另有 `:197` 兑付回执==经停逐笔。判据语义从「形状必经」变「接线可用+图上存在」,**已在代拍板①申报**(实测确定性评分 12 次全程恒不进奖励支路,含岔口设计下奖励位可选为既定),替代体仍走真实入口证机制可兑付,未放宽为恒真。判:申报过的合法替代,不红。 +2. **`shop_seq.size()` 2→3**(`adventure_feats:325`):随契约4「每关路径恰穿一店⇒三关=3」的必然投影,**代拍板③申报**,与 V5 三性质扫描互为校验(不一致则二者之一必红),非任意放宽。判:不红。 +3. **三处 `_level_active_nodes` 换代**:templates 键清出后旧实现落 `row.get("nodes",[])` 返空数组会致覆盖/Boss深度/逐节点扫描断言集体空转绿——本批三副本均改生成图取数,且所有消费断言带非空前置(`cov_scan>0`/`scanned>0`/`graph_total==3×range`/逐图空→显式 `false`)。**代拍板②申报为缺陷清偿**(当场重新暴露一次 `boss_depth` 读 -1 真红并清偿)。判:有效强化,不红。 + +**其余删除断言**全部落入卡片 §8.2「旧断言预声明」四类(S1–S8随动/V5商店换代/V8收束置位改写/④静态检查退役由S5承接),替换体经开文件确认为等价或更强(模板形状写死→生成图性质扫描、静态代理→真实消费链落带)。 + +## 六、变异伴生九条拦截面判定(§8.2) + +| 卡片条 | 记录/复现 | 判据名是否对应 | 结果 | +|---|---|---|---| +| V1 终点恒Boss判据注释 | §二·五 记录 `终点非 Boss 负例〔v1〕` 红 | 对应(负例夹具命中「终点 1 非 Boss」) | 判定成立 | +| V1 无环判据短路 | §二·五 记录 回边/环两负例红 | 对应(「违反前进方向」) | 判定成立 | +| V11 Boss带并单带 | §一 记录 带互异红 | 对应〔v11〕两两互异 | 判定成立 | +| V11 删顶带 | §一 记录 落带≥深度红 | 对应〔v11〕band_of非-1 | 判定成立 | +| V2 候选写死单入口 | **本审计亲手注入 M1** | 红行原文见第七节 | **已复现** | +| V5 终点前驱非店 | §二·十二 M2 | 对应性质①(已确认 pred 型别判定为真判据) | 判定成立 | +| V5 路径加第二店 | §二·十二 M3 | 对应性质②③(已确认 shop 计数判定为真判据) | 判定成立 | +| V6 enc取句柄 | **本审计亲手注入 M4** | 红行原文见第七节 | **已复现** | +| V7 生成器注计数器依赖 | §二·十二 M5 | 对应〔v7〕同种子逐位同图 | 判定成立 | +| V8 旧断点放行回放 | **本审计亲手注入 M6** | 红行原文见第七节 | **已复现** | +| V9 表留 templates 键 | §二·十二 M7 | 对应〔v9〕三行无 templates(已确认 iter has("templates")) | 判定成立 | + +结论:九条中三条由本审计独立注入复现出红,其余六条的实现方记录判据名与卡片要求逐条对应、且底层断言开文件确认为真实判据(非恒真/非空扫描),无「记录名与卡片错配」情形。 + +## 七、撤销证明复现记录(本审计亲手注入,附红行原文) + +- **M1(V2)**:`level_runner.gd` 两处 `_candidates = entries` → `[entries[0]]`(`:214` defer入口 / `:243` 断点入口停点)。跑 `node_map_templates_test`。红行: + `ERROR: [FAIL] 入口停点候选数=参数档入口个数(L3 档 2——多入口首跳即选路,禁写死单入口)〔v2〕` + 还原后 `git status` 干净。 +- **M4(V6)**:`level_runner.gd:500` enc `"node_depth": int(node.get("depth",0))` → `int(node.get("id",0))`。跑 `adventure_feats_test`。红行: + `ERROR: [FAIL] 进战载荷两样=关卡序号+节点深度(实测 depth=6 句柄=6)〔v6〕` + 还原后干净。 +- **M6(V8)**:`adventure_runner.gd:146` `if int(bp.get("graph_gen_v2",0))!=1:` → `if false:`。跑 `breakpoint_resume_test`。红行: + `[breakpoint_resume_test] FAIL — P7 缺生成图代差标记键的旧断点显式拒绝(禁按新句柄空间静默回放)〔v8〕` + 还原后干净。 + +三条注入均:注入前确认 `git status` 干净;取被测宿主自身 FAIL 行(非包装层退出码);写回原文后 `git diff HEAD` 为空、`git status` 干净。 + +## 八、代拍板超清单五件(白名单外)的独立判断 + +1. 奖励断言替代 → 见第五节①,申报充分、替代体仍证接线,通过。 +2. `_level_active_nodes` 三处空转缺陷清偿 → 三副本确认换代、非空前置齐备,清偿真实(当场重暴露 boss_depth=-1 红),通过。 +3. 商店到达余额 2→3 → 契约4 必然投影、与 V5 互校,通过。 +4. `_boss_depth` 死件随删(两宿主零调用方)→ 全仓 .gd 零引用确认,涉删纯死代码不触断言,通过。 +5. 两呈现宿主(`level_map_type_vis`/`level_map_info_fog`)夹具拓扑重建、自述「断言条数与阈值零改动」→ **本审计独立计数核**:19→19、26→26 无缩减;可见集阈值判据仍在(层距 N 可见/N+1·N+2 不可见、候选豁免、Boss 豁免不扩散、句柄32层6入雾),负向回边判据移交 `level_table_validate`〔v1〕,自述属实,通过。 + +## 九、批性质与残留申报项 + +- **批性质禁令**:`git diff master...HEAD --name-only` 仅 31 个 `game/` 文件 + 3 份 `reports/` 产出物;grep `design/|AGENTS.md|roadmap.md|待办清单|体验验证清单` = **零命中**。实现批未写 design/ 与根流程清单,合规。 +- **申报的非阻塞残留**(属文档批/用户裁决面,不红): + - 反哺点⑦:`game/tests/验证集清单.md` 为批 B 断言文本快照索引,本批未重生成,仍含退役措辞与旧行号,不进任何闸;已诚实列清单待用户裁决重生成或注记。件核四项中的 `.md` 快照命中即源于此,代码面干净,不构成契约6「死代码死断言」违令。 + - 反哺点⑨:`level.tres:16` 表尾去向注记含「随迭代 35 卡② 清出」的迭代叙事,与 doc-conventions §5「禁写变更叙事」有张力;但卡片 T3 明令表尾写「15 张模板去向」注记,属卡片授权范围,已申报待裁。 + - 实机目检两条(三关选路观感、首局种子42教学图)已登记于实现报告 §三·一,依「真人检测只登记不阻塞」红线,不作合回前置。 +- **次要观察**:报告 §二·十三 若干行号与实际有 ±1–5 行漂移(如 v9 标 :91-96 实为 :101、v4 标 :339 实为 :340),但每条 尾缀 均可 grep 定位、断言真实存在,不影响验收。