chore: initial commit

This commit is contained in:
DKChan committed 2026-09-14 18:18:49 +08:00
commit ddd2960fa6
317 files changed
+41717

No files matched your search

@@ -0,0 +1 @@
@@ -0,0 +1,201 @@
[editor_states]
$selected_nodes=Array[NodePath]([NodePath(".")])
2D={
"grid_offset": Vector2(0, 0),
"grid_snap_active": false,
"grid_step": Vector2(8, 8),
"grid_visibility": 1,
"ofs": Vector2(-166, -111),
"primary_grid_step": Vector2i(8, 8),
"show_group_gizmos": true,
"show_guides": true,
"show_helpers": false,
"show_lock_gizmos": true,
"show_origin": true,
"show_position_gizmos": true,
"show_rulers": true,
"show_transformation_gizmos": true,
"show_viewport": true,
"show_zoom_control": true,
"smart_snap_active": false,
"snap_guides": true,
"snap_node_anchors": true,
"snap_node_center": true,
"snap_node_parent": true,
"snap_node_sides": true,
"snap_other_nodes": true,
"snap_pixel": true,
"snap_relative": false,
"snap_rotation": false,
"snap_rotation_offset": 0.0,
"snap_rotation_step": 0.2617994,
"snap_scale": false,
"snap_scale_step": 0.1,
"zoom": 1.0
}
3D={
"fov": 70.01,
"gizmos_status": {
"AudioListener3D": 0,
"AudioStreamPlayer3D": 0,
"CPUParticles3D": 0,
"CSGShape3D": 0,
"Camera3D": 0,
"ChainIK3D": 0,
"CollisionObject3D": 0,
"CollisionPolygon3D": 0,
"CollisionShape3D": 0,
"Decal": 0,
"FogVolume": 0,
"GPUParticles3D": 0,
"GPUParticlesCollision3D": 0,
"Joint3D": 0,
"Light3D": 0,
"LightmapGI": 0,
"LightmapProbe": 0,
"Marker3D": 0,
"MeshInstance3DCustomAABB": 0,
"NavigationLink3D": 0,
"NavigationObstacle3D": 0,
"NavigationRegion3D": 0,
"OccluderInstance3D": 0,
"Particles3DEmissionShape": 0,
"Path3D": 0,
"PhysicalBone3D": 0,
"RayCast3D": 0,
"ReflectionProbe": 0,
"ShapeCast3D": 0,
"Skeleton3D": 0,
"SoftBody3D": 0,
"SpringArm3D": 0,
"SpringBoneCollision3D": 0,
"SpringBoneSimulator3D": 0,
"TwoBoneIK3D": 0,
"VehicleWheel3D": 0,
"VisibleOnScreenNotifier3D": 0,
"VoxelGI": 0
},
"local_coords": false,
"preserve_children_transform": false,
"preview_sun_env": {
"environ_ao_enabled": false,
"environ_enabled": true,
"environ_energy": 1.0,
"environ_gi_enabled": false,
"environ_glow_enabled": false,
"environ_ground_color": Color(0.2, 0.169, 0.133, 1),
"environ_sky_color": Color(0.385, 0.454, 0.55, 1),
"environ_tonemap_enabled": false,
"sun_color": Color(1, 1, 1, 1),
"sun_enabled": true,
"sun_energy": 1.0,
"sun_rotation": Vector2(-1.0471976, 2.6179938),
"sun_shadow_max_distance": 100.0
},
"rotate_snap": 15.0,
"scale_snap": 10.0,
"show_grid": true,
"show_origin": true,
"snap_enabled": false,
"trackball_enabled": false,
"translate_snap": 1.0,
"vertex_snap_origin_mode": false,
"vertex_snap_use_collision": false,
"viewport_mode": 1,
"viewport_splits": {
"first": 0.0,
"main": 0.0,
"second": 0.0
},
"viewports": [{
"auto_orthogonal": false,
"auto_orthogonal_enabled": true,
"cinematic_preview": false,
"display_mode": 22,
"distance": 4.0,
"doppler": false,
"frame_time": false,
"gizmos": true,
"grid": true,
"half_res": false,
"information": false,
"listener": true,
"lock_rotation": false,
"orthogonal": false,
"position": Vector3(0, 0, 0),
"transform_gizmo": true,
"use_environment": false,
"view_type": 0,
"x_rotation": 0.5,
"y_rotation": -0.5
}, {
"auto_orthogonal": false,
"auto_orthogonal_enabled": true,
"cinematic_preview": false,
"display_mode": 22,
"distance": 4.0,
"doppler": false,
"frame_time": false,
"gizmos": true,
"grid": true,
"half_res": false,
"information": false,
"listener": false,
"lock_rotation": false,
"orthogonal": false,
"position": Vector3(0, 0, 0),
"transform_gizmo": true,
"use_environment": false,
"view_type": 0,
"x_rotation": 0.5,
"y_rotation": -0.5
}, {
"auto_orthogonal": false,
"auto_orthogonal_enabled": true,
"cinematic_preview": false,
"display_mode": 22,
"distance": 4.0,
"doppler": false,
"frame_time": false,
"gizmos": true,
"grid": true,
"half_res": false,
"information": false,
"listener": false,
"lock_rotation": false,
"orthogonal": false,
"position": Vector3(0, 0, 0),
"transform_gizmo": true,
"use_environment": false,
"view_type": 0,
"x_rotation": 0.5,
"y_rotation": -0.5
}, {
"auto_orthogonal": false,
"auto_orthogonal_enabled": true,
"cinematic_preview": false,
"display_mode": 22,
"distance": 4.0,
"doppler": false,
"frame_time": false,
"gizmos": true,
"grid": true,
"half_res": false,
"information": false,
"listener": false,
"lock_rotation": false,
"orthogonal": false,
"position": Vector3(0, 0, 0),
"transform_gizmo": true,
"use_environment": false,
"view_type": 0,
"x_rotation": 0.5,
"y_rotation": -0.5
}],
"zfar": 4000.01,
"znear": 0.05
}
Anim={
"visible": false
}
@@ -0,0 +1,80 @@
[docks]
dock_3="Scene,Import"
dock_3_selected_tab_idx=0
dock_4="FileSystem,History"
dock_4_selected_tab_idx=0
dock_5="Inspector,Signals,Groups"
dock_5_selected_tab_idx=0
dock_9="Output,Debugger,Audio,Animation,Shader Editor,Search Results,AnimationTree,MeshLibrary,ResourcePreloader,ShaderFile,SpriteFrames,Theme,Polygon,TileSet,TileMap,Replication,GridMap"
dock_floating={}
dock_closed=[]
dock_split_2=0
dock_split_3=0
dock_hsplit_1=0
dock_hsplit_2=280
dock_hsplit_3=-220
dock_hsplit_4=0
dock_main_split=0
dock_bottom_split=0
dock_9_selected_tab_idx=0
[docks/FileSystem]
h_split_offset=240
v_split_offset=0
display_mode=0
file_sort=0
file_list_display_mode=1
selected_paths=PackedStringArray("res://data/tables/enemy.tres")
uncollapsed_paths=PackedStringArray("res://", "res://presentation/", "res://presentation/views/", "res://data/", "res://data/tables/", "res://data/schemas/", "res://core/")
[docks/History]
include_scene=true
include_global=true
[docks/Theme]
split_offset=520
[EditorNode]
open_scenes=PackedStringArray("res://presentation/main.tscn")
current_scene="res://presentation/main.tscn"
bottom_panel_offsets={
"Audio": -450.0,
"Debugger": -369.0,
"Output": -270.0,
"Search Results": 0.0
}
selected_default_debugger_tab_idx=1
selected_main_editor_idx=2
[EditorWindow]
screen=0
mode="windowed"
position=Vector2i(405, 80)
size=Vector2i(1897, 1048)
[ScriptEditor]
open_scripts=["res://core/board_generator.gd", "res://core/reward_economy.gd", "res://core/reward_supply_controller.gd", "res://data/schemas/reward_table.gd", "res://presentation/views/status_slot_row.gd"]
selected_script="res://core/board_generator.gd"
open_help=[]
script_split_offset=200
list_split_offset=0
zoom_factor=1.0
[GameView]
floating_window_rect=Rect2i(665, 277, 1292, 849)
floating_window_screen=0
[ShaderEditor]
open_shaders=[]
split_offset=200
selected_shader=""
text_shader_zoom_factor=1.0
@@ -0,0 +1,3 @@
[folding]
sections_unfolded=PackedStringArray()
@@ -0,0 +1,93 @@
78098b89c88a74ebe8c20117b8754620
::res://::1789291459
README.md::TextFile::-1::1789245234::0::1::::<><><>0<>0<><>::
::res://core/::1789236746
action_economy.gd::GDScript::539248926336522464::1789233852::0::1::::ActionEconomy<>RefCounted<><>0<>0<><>::
battle_orchestrator.gd::GDScript::6153665499356077672::1789236632::0::1::::BattleOrchestrator<>RefCounted<><>0<>0<><>::
board_data.gd::GDScript::7569800284505832826::1789232949::0::1::::BoardData<>RefCounted<><>0<>0<><>::
board_generator.gd::GDScript::3603363749200663606::1789232934::0::1::::BoardGenerator<>RefCounted<><>0<>0<><>::
config_db.gd::GDScript::5132721691389947172::1789232958::0::1::::<>Node<><>0<>0<><>::
damage_math.gd::GDScript::190709078776196050::1789232474::0::1::::DamageMath<>RefCounted<><>0<>0<><>::
level_runner.gd::GDScript::4382564976711397872::1789236746::0::1::::LevelRunner<>RefCounted<><>0<>0<><>::
minesweeper_rules.gd::GDScript::7936287674986956201::1789233939::0::1::::MinesweeperRules<>RefCounted<><>0<>0<><>::
reward_economy.gd::GDScript::2149614533123690078::1789232919::0::1::::RewardEconomy<>RefCounted<><>0<>0<><>::
reward_supply_controller.gd::GDScript::7501430662356378774::1789232984::0::1::::RewardSupplyController<>RefCounted<><>0<>0<><>::
rng_service.gd::GDScript::1338675472946526387::1789232968::0::1::::<>Node<><>0<>0<><>::
status_container.gd::GDScript::7936126502523773580::1789129899::0::1::::StatusContainer<>RefCounted<><>0<>0<><>::
unit_container.gd::GDScript::7462364046193057341::1789236551::0::1::::UnitContainer<>RefCounted<><>0<>0<><>::
::res://data/::1789034260
::res://data/schemas/::1789207599
action_economy_table.gd::GDScript::8522817588810408628::1789144440::0::1::::ActionEconomyTable<>Resource<><>0<>0<><>::
board_gen_table.gd::GDScript::290673939920197678::1789034188::0::1::::BoardGenTable<>Resource<><>0<>0<><>::
difficulty_table.gd::GDScript::107972707759898139::1789148819::0::1::::DifficultyTable<>Resource<><>0<>0<><>::
enemy_table.gd::GDScript::4876593032319850172::1789207522::0::1::::EnemyTable<>Resource<><>0<>0<><>::
hero_table.gd::GDScript::9222491661618750738::1789061434::0::1::::HeroTable<>Resource<><>0<>0<><>::
level_table.gd::GDScript::1724087523887552828::1789113333::0::1::::LevelTable<>Resource<><>0<>0<><>::
reward_config_table.gd::GDScript::7355091203371621038::1789125672::0::1::::RewardConfigTable<>Resource<><>0<>0<><>::
reward_table.gd::GDScript::5669541951934428507::1789125710::0::1::::RewardTable<>Resource<><>0<>0<><>::
settlement_table.gd::GDScript::6131581376971031268::1789207599::0::1::::SettlementTable<>Resource<><>0<>0<><>::
status_effect_table.gd::GDScript::4437475637910601838::1789034138::0::1::::StatusEffectTable<>Resource<><>0<>0<><>::
variation_table.gd::GDScript::3386413835420127059::1789034137::0::1::::VariationTable<>Resource<><>0<>0<><>::
::res://data/tables/::1789280172
action_economy.tres::Resource/ActionEconomyTable::-1::1789144440::0::1::::<><><>0<>0<><>::res://data/schemas/action_economy_table.gd
board_gen.tres::Resource/BoardGenTable::-1::1789118328::0::1::::<><><>0<>0<><>::res://data/schemas/board_gen_table.gd
difficulty.tres::Resource/DifficultyTable::-1::1789148819::0::1::::<><><>0<>0<><>::res://data/schemas/difficulty_table.gd
enemy.tres::Resource/EnemyTable::-1::1789207507::0::1::::<><><>0<>0<><>::res://data/schemas/enemy_table.gd
hero.tres::Resource/HeroTable::-1::1789064873::0::1::::<><><>0<>0<><>::res://data/schemas/hero_table.gd
level.tres::Resource/LevelTable::-1::1789113345::0::1::::<><><>0<>0<><>::res://data/schemas/level_table.gd
reward.tres::Resource/RewardTable::-1::1789280009::0::1::::<><><>0<>0<><>::res://data/schemas/reward_table.gd
reward_config.tres::Resource/RewardConfigTable::-1::1789125741::0::1::::<><><>0<>0<><>::res://data/schemas/reward_config_table.gd
settlement.tres::Resource/SettlementTable::-1::1789280154::0::1::::<><><>0<>0<><>::res://data/schemas/settlement_table.gd
status_effects.tres::Resource/StatusEffectTable::-1::1789280182::0::1::::<><><>0<>0<><>::res://data/schemas/status_effect_table.gd
variation.tres::Resource/VariationTable::-1::1789034260::0::1::::<><><>0<>0<><>::res://data/schemas/variation_table.gd
::res://presentation/::1789246033
battle_narrator.gd::GDScript::5226435456607670966::1789229051::0::1::::<>RefCounted<><>0<>0<><>::
debug_session.gd::GDScript::8434422127986491287::1789243501::0::1::::<>RefCounted<><>0<>0<><>::
hud_presenter.gd::GDScript::266080447502779628::1789233101::0::1::::HudPresenter<>RefCounted<><>0<>0<><>::
main.gd::GDScript::7302531409199717733::1789243501::0::1::::<>Control<><>0<>0<><>::
main.tscn::PackedScene::6193690700815517790::1789034097::0::1::::<><><>0<>0<><>::res://presentation/main.gd
modal_stack.gd::GDScript::3147898925570088454::1789229050::0::1::::<>RefCounted<><>0<>0<><>::
playtest_logger.gd::GDScript::2237693618347780217::1789243222::0::1::::PlaytestLogger<>RefCounted<><>0<>0<><>::
tune_sim_controller.gd::GDScript::1034382086574245163::1789243536::0::1::::<>RefCounted<><>0<>0<><>::
ui_assembler.gd::GDScript::7013135001962184004::1789246033::0::1::::<>RefCounted<><>0<>0<><>::
::res://presentation/views/::1789233039
ap_bar_view.gd::GDScript::6706481391074465845::1789203369::0::1::::ApBarView<>PanelContainer<><>0<>0<><>::
battle_end_overlay.gd::GDScript::3399056977949525852::1789203797::0::1::::BattleEndOverlay<>Control<><>0<>0<><>::
battle_log_view.gd::GDScript::789450573482474203::1789207803::0::1::::BattleLogView<>PanelContainer<><>0<>0<><>::
battle_stage_view.gd::GDScript::5667172321656987572::1789205728::0::1::::BattleStageView<>PanelContainer<><>0<>0<><>::
board_fx_view.gd::GDScript::1601194353712525323::1789202137::0::1::::BoardFxView<>Control<><>0<>0<><>::
board_view.gd::GDScript::2101785913681906214::1789233039::0::1::::BoardView<>Control<><>0<>0<><>::
board_zone_view.gd::GDScript::5694869616755791188::1789202681::0::1::::BoardZoneView<>PanelContainer<><>0<>0<><>::
desaturation.gdshader::Shader::3539526562288928378::1789202028::0::1::::<><><>0<>0<><>::
enemy_card_view.gd::GDScript::5658106289382993794::1789228090::0::1::::EnemyCardView<>PanelContainer<><>0<>0<><>::
hero_card_view.gd::GDScript::7848267529495934977::1789202280::0::1::::HeroCardView<>PanelContainer<><>0<>0<><>::
hero_info_panel_view.gd::GDScript::890950290308946654::1789228091::0::1::::HeroInfoPanelView<>PanelContainer<><>0<>0<><>::
inventory_panel_view.gd::GDScript::5693348628536103891::1789205728::0::1::::InventoryPanelView<>PanelContainer<><>0<>0<><>::
legend_overlay.gd::GDScript::8747339015513470369::1789202163::0::1::::LegendOverlay<>Control<><>0<>0<><>::
level_strip_view.gd::GDScript::887444383034759579::1789203296::0::1::::LevelStripView<>HBoxContainer<><>0<>0<><>::
node_overlay.gd::GDScript::4207691819312229744::1789128470::0::1::::NodeOverlay<>Control<><>0<>0<><>::
settlement_review_overlay.gd::GDScript::7219371183860710574::1789202239::0::1::::SettlementReviewOverlay<>Control<><>0<>0<><>::
status_slot_row.gd::GDScript::3482792166004692461::1789215866::0::1::::StatusSlotRow<>HBoxContainer<><>0<>0<><>::
toast_stack.gd::GDScript::4052418265818498251::1789202048::0::1::::ToastStack<>Control<><>0<>0<><>::
tune_panel.gd::GDScript::8773926940492071944::1789228138::0::1::::TunePanel<>Control<><>0<>0<><>::
ui_kit.gd::GDScript::3178727357685254666::1789128242::0::1::::UiKit<>RefCounted<><>0<>0<><>::
::res://smoke/::1789280205
auto_player.gd::GDScript::5451104337629630247::1789237174::0::1::::<>RefCounted<><>0<>0<><>::
defense_scan.gd::GDScript::8379610624300424582::1789280205::0::1::::<>SceneTree<><>0<>0<><>::
dump_battle.gd::GDScript::5133704044948130493::1789237266::0::1::::<>SceneTree<><>0<>0<><>::
experiment_density.gd::GDScript::5260599749047087749::1789129898::0::1::::<>SceneTree<><>0<>0<><>::
level_autoplay.gd::GDScript::662970221026420953::1789243327::0::1::::<>RefCounted<><>0<>0<><>::
level_debug.gd::GDScript::2760977088613608031::1789126166::0::1::::<>SceneTree<><>0<>0<><>::
level_monte_carlo.gd::GDScript::549864169145451125::1789243370::0::1::::<>SceneTree<><>0<>0<><>::
monte_carlo.gd::GDScript::7869543635707275019::1789237266::0::1::::<>SceneTree<><>0<>0<><>::
reward_expectation.gd::GDScript::6689296240354685181::1789129898::0::1::::<>SceneTree<><>0<>0<><>::
smoke_all.gd::GDScript::3155837053821105962::1789237237::0::1::::<>SceneTree<><>0<>0<><>::
supply_scan.gd::GDScript::8762258276062492019::1789243580::0::1::::<>SceneTree<><>0<>0<><>::
ui_click_probe.gd::GDScript::8163827295684929025::1789237453::0::1::::<>Node<><>0<>0<><>::
ui_click_probe.tscn::PackedScene::-1::1789117732::0::1::::<><><>0<>0<><>::res://smoke/ui_click_probe.gd
ui_layout_probe.gd::GDScript::3805933281686222633::1789246033::0::1::::<>Node<><>0<>0<><>::
ui_layout_probe.tscn::PackedScene::-1::1789245918::0::1::::<><><>0<>0<><>::res://smoke/ui_layout_probe.gd
ui_probe.gd::GDScript::7006810144725363874::1789243535::0::1::::<>Node<><>0<>0<><>::
ui_probe.tscn::PackedScene::-1::1789116662::0::1::::<><><>0<>0<><>::res://smoke/ui_probe.gd
ui_tune_probe.gd::GDScript::1332260224320123543::1789237453::0::1::::<>Node<><>0<>0<><>::
ui_tune_probe.tscn::PackedScene::-1::1789127580::0::1::::<><><>0<>0<><>::res://smoke/ui_tune_probe.gd
@@ -0,0 +1,6 @@
res://core/battle_orchestrator.gd
res://core/reward_economy.gd
res://smoke/defense_scan.gd
res://smoke/level_monte_carlo.gd
res://smoke/auto_player.gd
res://smoke/level_autoplay.gd
@@ -0,0 +1,201 @@
[editor_states]
$selected_nodes=Array[NodePath]([])
2D={
"grid_offset": Vector2(0, 0),
"grid_snap_active": false,
"grid_step": Vector2(8, 8),
"grid_visibility": 1,
"ofs": Vector2(-166, -111),
"primary_grid_step": Vector2i(8, 8),
"show_group_gizmos": true,
"show_guides": true,
"show_helpers": false,
"show_lock_gizmos": true,
"show_origin": true,
"show_position_gizmos": true,
"show_rulers": true,
"show_transformation_gizmos": true,
"show_viewport": true,
"show_zoom_control": true,
"smart_snap_active": false,
"snap_guides": true,
"snap_node_anchors": true,
"snap_node_center": true,
"snap_node_parent": true,
"snap_node_sides": true,
"snap_other_nodes": true,
"snap_pixel": true,
"snap_relative": false,
"snap_rotation": false,
"snap_rotation_offset": 0.0,
"snap_rotation_step": 0.2617994,
"snap_scale": false,
"snap_scale_step": 0.1,
"zoom": 1.0
}
3D={
"fov": 70.01,
"gizmos_status": {
"AudioListener3D": 0,
"AudioStreamPlayer3D": 0,
"CPUParticles3D": 0,
"CSGShape3D": 0,
"Camera3D": 0,
"ChainIK3D": 0,
"CollisionObject3D": 0,
"CollisionPolygon3D": 0,
"CollisionShape3D": 0,
"Decal": 0,
"FogVolume": 0,
"GPUParticles3D": 0,
"GPUParticlesCollision3D": 0,
"Joint3D": 0,
"Light3D": 0,
"LightmapGI": 0,
"LightmapProbe": 0,
"Marker3D": 0,
"MeshInstance3DCustomAABB": 0,
"NavigationLink3D": 0,
"NavigationObstacle3D": 0,
"NavigationRegion3D": 0,
"OccluderInstance3D": 0,
"Particles3DEmissionShape": 0,
"Path3D": 0,
"PhysicalBone3D": 0,
"RayCast3D": 0,
"ReflectionProbe": 0,
"ShapeCast3D": 0,
"Skeleton3D": 0,
"SoftBody3D": 0,
"SpringArm3D": 0,
"SpringBoneCollision3D": 0,
"SpringBoneSimulator3D": 0,
"TwoBoneIK3D": 0,
"VehicleWheel3D": 0,
"VisibleOnScreenNotifier3D": 0,
"VoxelGI": 0
},
"local_coords": false,
"preserve_children_transform": false,
"preview_sun_env": {
"environ_ao_enabled": false,
"environ_enabled": true,
"environ_energy": 1.0,
"environ_gi_enabled": false,
"environ_glow_enabled": false,
"environ_ground_color": Color(0.2, 0.169, 0.133, 1),
"environ_sky_color": Color(0.385, 0.454, 0.55, 1),
"environ_tonemap_enabled": false,
"sun_color": Color(1, 1, 1, 1),
"sun_enabled": true,
"sun_energy": 1.0,
"sun_rotation": Vector2(-1.0471976, 2.6179938),
"sun_shadow_max_distance": 100.0
},
"rotate_snap": 15.0,
"scale_snap": 10.0,
"show_grid": true,
"show_origin": true,
"snap_enabled": false,
"trackball_enabled": false,
"translate_snap": 1.0,
"vertex_snap_origin_mode": false,
"vertex_snap_use_collision": false,
"viewport_mode": 1,
"viewport_splits": {
"first": 0.0,
"main": 0.0,
"second": 0.0
},
"viewports": [{
"auto_orthogonal": false,
"auto_orthogonal_enabled": true,
"cinematic_preview": false,
"display_mode": 22,
"distance": 4.0,
"doppler": false,
"frame_time": false,
"gizmos": true,
"grid": true,
"half_res": false,
"information": false,
"listener": true,
"lock_rotation": false,
"orthogonal": false,
"position": Vector3(0, 0, 0),
"transform_gizmo": true,
"use_environment": false,
"view_type": 0,
"x_rotation": 0.5,
"y_rotation": -0.5
}, {
"auto_orthogonal": false,
"auto_orthogonal_enabled": true,
"cinematic_preview": false,
"display_mode": 22,
"distance": 4.0,
"doppler": false,
"frame_time": false,
"gizmos": true,
"grid": true,
"half_res": false,
"information": false,
"listener": false,
"lock_rotation": false,
"orthogonal": false,
"position": Vector3(0, 0, 0),
"transform_gizmo": true,
"use_environment": false,
"view_type": 0,
"x_rotation": 0.5,
"y_rotation": -0.5
}, {
"auto_orthogonal": false,
"auto_orthogonal_enabled": true,
"cinematic_preview": false,
"display_mode": 22,
"distance": 4.0,
"doppler": false,
"frame_time": false,
"gizmos": true,
"grid": true,
"half_res": false,
"information": false,
"listener": false,
"lock_rotation": false,
"orthogonal": false,
"position": Vector3(0, 0, 0),
"transform_gizmo": true,
"use_environment": false,
"view_type": 0,
"x_rotation": 0.5,
"y_rotation": -0.5
}, {
"auto_orthogonal": false,
"auto_orthogonal_enabled": true,
"cinematic_preview": false,
"display_mode": 22,
"distance": 4.0,
"doppler": false,
"frame_time": false,
"gizmos": true,
"grid": true,
"half_res": false,
"information": false,
"listener": false,
"lock_rotation": false,
"orthogonal": false,
"position": Vector3(0, 0, 0),
"transform_gizmo": true,
"use_environment": false,
"view_type": 0,
"x_rotation": 0.5,
"y_rotation": -0.5
}],
"zfar": 4000.01,
"znear": 0.05
}
Anim={
"visible": false
}
@@ -0,0 +1,6 @@
[folding]
node_unfolds=[NodePath("."), PackedStringArray("Layout")]
resource_unfolds=[]
nodes_folded=[]
animation_groups_folded=[]
@@ -0,0 +1,13 @@
[game_view]
select_mode=0
hide_selection=false
[editor_metadata]
executable_path="D:/code/godot/Godot_v4.7.2-stable_win64.exe"
[recent_files]
scenes=["res://presentation/main.tscn"]
scripts=["res://core/board_generator.gd", "res://data/schemas/reward_table.gd", "res://core/reward_supply_controller.gd", "res://core/reward_economy.gd", "res://presentation/views/status_slot_row.gd", "res://core/a1_board_generator.gd", "res://presentation/main.gd"]
@@ -0,0 +1,87 @@
[res://presentation/main.gd]
state={
"bookmarks": PackedInt32Array(),
"breakpoints": PackedInt32Array(),
"column": 0,
"folded_lines": PackedInt32Array(),
"h_scroll_position": 0,
"row": 433,
"scroll_position": 395.8,
"selection": false,
"syntax_highlighter": "GDScript"
}
[res://presentation/views/status_slot_row.gd]
state={
"bookmarks": PackedInt32Array(),
"breakpoints": PackedInt32Array(),
"column": 6,
"folded_lines": PackedInt32Array(),
"h_scroll_position": 0,
"row": 121,
"scroll_position": 112.0,
"selection": false,
"syntax_highlighter": "GDScript"
}
[res://core/reward_economy.gd]
state={
"bookmarks": PackedInt32Array(),
"breakpoints": PackedInt32Array(),
"column": 0,
"folded_lines": PackedInt32Array(),
"h_scroll_position": 0,
"row": 317,
"scroll_position": 289.0,
"selection": false,
"syntax_highlighter": "GDScript"
}
[res://core/reward_supply_controller.gd]
state={
"bookmarks": PackedInt32Array(),
"breakpoints": PackedInt32Array(),
"column": 0,
"folded_lines": PackedInt32Array(),
"h_scroll_position": 0,
"row": 27,
"scroll_position": 5.0,
"selection": false,
"syntax_highlighter": "GDScript"
}
[res://data/schemas/reward_table.gd]
state={
"bookmarks": PackedInt32Array(),
"breakpoints": PackedInt32Array(),
"column": 64,
"folded_lines": PackedInt32Array(),
"h_scroll_position": 0,
"row": 11,
"scroll_position": 11.0,
"selection": true,
"selection_from_column": 51,
"selection_from_line": 11,
"selection_to_column": 64,
"selection_to_line": 11,
"syntax_highlighter": "GDScript"
}
[res://core/board_generator.gd]
state={
"bookmarks": PackedInt32Array(),
"breakpoints": PackedInt32Array(),
"column": 24,
"folded_lines": PackedInt32Array(),
"h_scroll_position": 0,
"row": 59,
"scroll_position": 50.0,
"selection": false,
"syntax_highlighter": "GDScript"
}
@@ -0,0 +1,345 @@
list=[{
"base": &"RefCounted",
"class": &"ActionEconomy",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://core/action_economy.gd"
}, {
"base": &"Resource",
"class": &"ActionEconomyTable",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://data/schemas/action_economy_table.gd"
}, {
"base": &"PanelContainer",
"class": &"ApBarView",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://presentation/views/ap_bar_view.gd"
}, {
"base": &"Control",
"class": &"BattleEndOverlay",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://presentation/views/battle_end_overlay.gd"
}, {
"base": &"PanelContainer",
"class": &"BattleLogView",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://presentation/views/battle_log_view.gd"
}, {
"base": &"RefCounted",
"class": &"BattleOrchestrator",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://core/battle_orchestrator.gd"
}, {
"base": &"PanelContainer",
"class": &"BattleStageView",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://presentation/views/battle_stage_view.gd"
}, {
"base": &"RefCounted",
"class": &"BoardData",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://core/board_data.gd"
}, {
"base": &"Control",
"class": &"BoardFxView",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://presentation/views/board_fx_view.gd"
}, {
"base": &"Resource",
"class": &"BoardGenTable",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://data/schemas/board_gen_table.gd"
}, {
"base": &"RefCounted",
"class": &"BoardGenerator",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://core/board_generator.gd"
}, {
"base": &"Control",
"class": &"BoardView",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://presentation/views/board_view.gd"
}, {
"base": &"PanelContainer",
"class": &"BoardZoneView",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://presentation/views/board_zone_view.gd"
}, {
"base": &"RefCounted",
"class": &"DamageMath",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://core/damage_math.gd"
}, {
"base": &"Resource",
"class": &"DifficultyTable",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://data/schemas/difficulty_table.gd"
}, {
"base": &"PanelContainer",
"class": &"EnemyCardView",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://presentation/views/enemy_card_view.gd"
}, {
"base": &"Resource",
"class": &"EnemyTable",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://data/schemas/enemy_table.gd"
}, {
"base": &"PanelContainer",
"class": &"HeroCardView",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://presentation/views/hero_card_view.gd"
}, {
"base": &"PanelContainer",
"class": &"HeroInfoPanelView",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://presentation/views/hero_info_panel_view.gd"
}, {
"base": &"Resource",
"class": &"HeroTable",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://data/schemas/hero_table.gd"
}, {
"base": &"RefCounted",
"class": &"HudPresenter",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://presentation/hud_presenter.gd"
}, {
"base": &"PanelContainer",
"class": &"InventoryPanelView",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://presentation/views/inventory_panel_view.gd"
}, {
"base": &"Control",
"class": &"LegendOverlay",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://presentation/views/legend_overlay.gd"
}, {
"base": &"RefCounted",
"class": &"LevelRunner",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://core/level_runner.gd"
}, {
"base": &"HBoxContainer",
"class": &"LevelStripView",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://presentation/views/level_strip_view.gd"
}, {
"base": &"Resource",
"class": &"LevelTable",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://data/schemas/level_table.gd"
}, {
"base": &"RefCounted",
"class": &"MinesweeperRules",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://core/minesweeper_rules.gd"
}, {
"base": &"Control",
"class": &"NodeOverlay",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://presentation/views/node_overlay.gd"
}, {
"base": &"RefCounted",
"class": &"PlaytestLogger",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://presentation/playtest_logger.gd"
}, {
"base": &"Resource",
"class": &"RewardConfigTable",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://data/schemas/reward_config_table.gd"
}, {
"base": &"RefCounted",
"class": &"RewardEconomy",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://core/reward_economy.gd"
}, {
"base": &"RefCounted",
"class": &"RewardSupplyController",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://core/reward_supply_controller.gd"
}, {
"base": &"Resource",
"class": &"RewardTable",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://data/schemas/reward_table.gd"
}, {
"base": &"Control",
"class": &"SettlementReviewOverlay",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://presentation/views/settlement_review_overlay.gd"
}, {
"base": &"Resource",
"class": &"SettlementTable",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://data/schemas/settlement_table.gd"
}, {
"base": &"RefCounted",
"class": &"StatusContainer",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://core/status_container.gd"
}, {
"base": &"Resource",
"class": &"StatusEffectTable",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://data/schemas/status_effect_table.gd"
}, {
"base": &"HBoxContainer",
"class": &"StatusSlotRow",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://presentation/views/status_slot_row.gd"
}, {
"base": &"Control",
"class": &"ToastStack",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://presentation/views/toast_stack.gd"
}, {
"base": &"Control",
"class": &"TunePanel",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://presentation/views/tune_panel.gd"
}, {
"base": &"RefCounted",
"class": &"UiKit",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://presentation/views/ui_kit.gd"
}, {
"base": &"RefCounted",
"class": &"UnitContainer",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://core/unit_container.gd"
}, {
"base": &"Resource",
"class": &"VariationTable",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://data/schemas/variation_table.gd"
}]
Whitespace-only changes.
@@ -0,0 +1,405 @@
# Dungeon Sweeper · 垂直切片(v2:第一关完整关卡)
> // VERTICAL SLICE - NOT FOR PRODUCTION
> // Validation Question(v2,2026-09-11):线性第一关(战×2→道具→战×2→恢复→Boss)能否承载
> // "数值提升→通关期望改善"的 Roguelike 元进度验证——基线属性应该难到打不过、天赋数值提升把
> // 通关期望拉回可玩带,且这条曲线能用数值调整板 + 批量模拟**测量出来**
> // (通关攻击期望 / 败北血量数学期望);v1 的单战斗循环问题(逐行归因 P2 / Core headless 可跑)
> // 已于 2026-09-10 验证并保持回归(smoke_all 前 46 项)。
> // Date: 2026-09-10(v1)· 2026-09-11(v2 关卡扩展)· 引擎 Godot 4.7.2 · 本目录仅作设计验证参考,禁止 import 进生产代码
## 运行
1. 用 Godot 4.7.2 打开本目录(含 `project.godot` 的文件夹)
2. 按 F5 运行(主场景 `presentation/main.tscn`)
3. **主入口:`▶ 第一关 · 苔藓回廊`**——线性节点链:战→战→赏(随机主动道具)→战→战→疗(血量恢复点)→王(Boss 遗迹守卫)
4. `测试用 · 角色数值调整板`:攻击/生命百分比加成(模拟天赋树解锁)+ 批量模拟(输出通关率/承伤期望/败北节点分布)
headless 冒烟(55 项断言,含关卡线性推进/满配通关/裸装败北/回放确定性,约 30 秒):
```
Godot_v4.7.2-stable_win64_console.exe --headless --path . --script res://smoke/smoke_all.gd
```
场景级 UI 探针(项目运行模式实例化 main.tscn 驱动第一关,断言棋盘真实渲染——D1 教训"呈现层零测试承载"的原型兑现):
```
Godot_v4.7.2-stable_win64_console.exe --headless --path . res://smoke/ui_probe.tscn
```
数值调整板场景探针(缺陷⑤回归:打开可见性/滑条联动/应用写表/重置/批量模拟全链路):
```
Godot_v4.7.2-stable_win64_console.exe --headless --path . res://smoke/ui_tune_probe.tscn
```
关卡期望扫描(C3 调参期关卡侧批量工具原型——atk%×hp% 网格,每格输出通关率/承伤期望/败北节点):
```
Godot_v4.7.2-stable_win64_console.exe --headless --path . --script res://smoke/level_monte_carlo.gd -- [N] [seed0]
```
可选类供给跨种子扫描(playtest-2026-09-12 Top1 复核——每局治疗包/小刀/精工短刀获取数、构成占比对 9:1 配比、"≥3 治疗包"局数分布;单种子坏抽与系统性偏置由此分割):
```
Godot_v4.7.2-stable_win64_console.exe --headless --path . --script res://smoke/supply_scan.gd -- [N] [seed0]
```
**日志合规(2026-09-12 用户要求)**:任何模拟运行与真人试玩均落全量 JSONL(user://logs/)——逐操作(op/cell/accepted/round)、逐结算明细行、轮次状态快照、battle_end、level_end。模拟(level_monte_carlo / 调整板批量模拟 / supply_scan)一文件多局,run 行携带种子/档位切分局次。
单关诊断:`--script res://smoke/level_debug.gd -- [seed] [atk%] [hp%]`。
战斗级工具(v1 遗产):`dump_battle.gd`(结算流水)/ `monte_carlo.gd`(战斗期望)/ `experiment_density.gd`(密度调参)。
## 范围(v1 范围 + v2 追加,用户 2026-09-10/11 确认)
- **v1 在范围内**:D2(10 表 + ConfigDB + RngService)· A1(single_subset 求解器 + 双判据验收)· A2 · A3(AP8/RAP)· A4(迷雾,L2)· B1(七段结算 + 13 类行协议 + 高光双落)· B2(grub/ambusher/伏击池 cap4)· B3(四行状态容器)· B4(install 预分配/F5 保底/道具窗口)· D1-lite 程序化呈现
- **v2 追加**:LevelTable(线性节点链数据表)· C1-lite 关卡推进器 `level_runner.gd`(原 c1_level_runner,2026-09-11 领域命名批更名)(关卡域生命周期:共享 B4 关内延续 + HP 跨战斗携带 + 奖励/恢复节点兑付 + 关卡末清库)· Boss 遭遇行(Dmax=4)· 节点奖励 API(B4 `grant_node_active`)· **测试用角色数值调整板**(atk%/hp% 覆盖 + 批量模拟,模拟天赋树——正式侧归 C4)
- **裁剪不变**:A5 / C1 路径分支选择(用户拍板线性)· C2–C4 / B5 战斗动画 / D3–D6 / A1 黄金向量与 F4 批量工具 / gdUnit / 标错惩罚(A2 OQ1)
## 2026-09-11 可选类供给控制器(用户拍板批——修改记录)
**背景**:供给控制器批前实测每关可选类 ≈24.8 件(≈18.5 把刀,刀总伤 ≈180 > Boss 130 HP)——"道具获取期望太高"定量坐实。用户重新定义三尺度指标并拍板实现:
| 尺度 | 指标 | 实现机制 |
|---|---|---|
| 安全格 | 期望极小(全图摊平 < 0.01/格) | 可选类**退出格子权重池**——格子池只剩即时类四行 |
| 雷图 | ≈0.78/板 | 战斗首件保证 ÷ 每战板数(≈1.28)折算 |
| 战斗 | 期望 ≈1,**首件保证**:获取首件前每张新板必注入 1 件;之后按 **p0×decay^(j-1) 指数递减**(永不绝育) | `RewardSupplyController`(B4 私有组合件,战斗域计数) |
**拍板四条 → 落点**:① 注入占比 小刀 60% / 精工短刀 30% / **治疗包 10%**(治疗资源宝贵)→ `reward.tres` active_inject 池;② **清图奖励改一次性高额伤害**(clear_knife 入库刀 → clear_blast instant_aoe 40,当场兑付全体存活敌,不入库、不占可选类供给——清图与供给脱钩);③ 注入选格**均匀随机**(雾格不加权);④ **指数递减**(active_p0=0.4 / active_decay=0.5 → 0.4/0.2/0.1/0.05…,RewardConfigTable 新参数,provisional 归 C3 标定)。
**实现结构**(对象封装——伪代码审阅后落地):
- `core/reward_supply_controller.gd`(新):`notify_battle_start()`(计数清零)/ `should_inject(stream)`(j==0 恒真;否则 `randf < p0×decay^(j-1)`,取号经传入封闭流)/ `notify_granted()` / `granted_count()`。行值快照抗热重载;零自有随机数。
- `B4RewardEconomy`(改造):`_supply` 组合件 · `notify_battle_start()`(B1.begin_battle 转发)· `roll_board` 重写(身份批即时类单遍 → 清图槽 → 注入判定/选格;旧 F5 草稿重 roll 循环删除)· `grant()` 挂钩 `notify_granted()`(**发放即获取**——预分配未挖到不计数;去重幂等天然防双计)· `supply_granted_count()` 观测面。
- `B1BattleOrchestrator`:begin_battle 门序列增 `b4.notify_battle_start()`(编排唯一性不变)。
- 表/护栏:RewardTable `POOL_TAGS` + `active_inject`;RewardConfigTable + `active_p0/active_decay`、k_dmg 1→0(F5 休眠);ConfigDB 跨表护栏更新(clear_no_value 含即时类 / no_damage_row 扫描全池活行 / 新增 inject_pool 护栏:非可选类拒绝 + 空池致命)。
**实测(N=200 板/深度,工具 reward_expectation.gd)**:注入 1.00 件/板(j=0,多件板 0)· 占比 小刀 59–65% / 精工 25–29% / 治疗包 7–17%(名义 60/30/10)· 格池零可选类泄漏 ✓。**实际获取期望**(level_monte_carlo「可选类/战」列,N=15):基线 0.88 → +200% 攻击 0.68 件/战(低于分配保证 1.0 的机理 = 注入格均匀随机,落在残余格/速杀未挖区时该件不获取;战斗末无兜底补发——**是否加"战斗末 j==0 补发 1 件"待用户拍板**)。每关合计 ≈ 4.5–6.5 件(原 24.8)✓ 拍板目标达成。
**⚠️ 供给侧副作用(本轮扫描发现,待拍板)**:可选类退出格子池后,其 25% 概率质量被动流向即时类——攻防 buff 每格频率从 44% 升至 59%,基线通关率 55%→80%(N=15)、整关承伤 127→80:**难度不降反升方向反了**。即时类是"每格必有货"锁定设计下无法清空的(除非引入空白格 = 违反锁定事实)。候选杠杆:① 接受更快的 buff 节奏,敌 HP 曲线上调对冲(C3 主杠杆)② 下调 attack_up/defense_up 在即时池内的相对权重(压 buff 频率)③ buff 幅度下调(触碰 B3 表值)。
**正式侧义务**:B4 GDD「install 期 roll / F5」章节修订(F5 → 战斗首件保证语义)+ registry reward_table/reward_config 条目改写 + RewardConfigTable knob 登记(active_p0/active_decay)——接口漂移批随实现落位。
## 2026-09-12 轮内刷新(方案 A,用户拍板批——修改记录)
**背景**:用户发现刷新实现(仅 Waiting 结算窗口受理)与设计预期不符——预期为**扫雷阶段遇到推理死路时,有剩余 AP 且 RAP 足够即可当场刷新继续挖**。影响分析(三方案对比 + 16 代码触点 + 8 文档 + 5 数值连锁)后用户拍板方案 A。
**拍板语义**:① Active 相位可刷(RAP ≥ 8 且本轮未达上限)→ 当场 retire+install 换新图,**轮次继续**:AP 保留不重置、相位不动、不触发结算(敌方时间税语义变更——GDD 修订义务);② Waiting 相位刷新保持现状(登记意图,exit_distribution 换图);③ **每轮兜底上限 2 次**(Active/Waiting 合计,`ActionEconomyTable.refresh_max_per_round = 2` 表值);④ RAP 来源不变(停手 1:1 转存)。
**实现落点**:A3 `request_refresh_active()`(仅扣 RAP,相位/AP 不动)· A2 `install_board/retire_board` 增 `during_round` 显式参数(Active 通道,其余相位守卫不变——fail-fast 保留)· B4 `query_full_snapshot()` · B1 `can_request_refresh()` 查询面 + `player_request_refresh()` 双相位重写 + `_refresh_used_this_round` 计数 + **facts 身份快照隔离**(缓冲期捕获身份快照、授予走快照——防旧图已挖格在轮末错拿新图同坐标身份;`_grant_cell` 增 snap_override)· 呈现层按钮"弃图刷新(RAP -8 · 每轮至多 2 次)"。
**核心实现难点(分析批未暴露)**:轮内换图后旧图已挖未结算格与新图**坐标重叠**——轮末授予 `b4.grant(cell)` 会错拿新图身份。解法 = facts 缓冲期用 `query_full_snapshot` 冻结身份,授予段直通快照(绕过现行 mapping/_granted,批次隔离;库存语义不变)。
**验证**:冒烟 88/88(+14 轮内刷新断言:当场换图/相位 AP 保留/RAP 扣减/上限拒绝/跨图 facts 完整兑现〔4 格跨三图快照授予〕/回放确定性)· 三探针全 PASS · 期望扫描与刷新前持平(80%/0.88——模拟策略不主动刷图,真人专用出口)。**调试教训**(规范 4.6 补充案例):蛆虫 HP 已调 24 而"18 未掉血"误判——排查数值问题先核对当前表值;GDScript lambda 对局部 int 按值捕获(计数器必须用容器)。
**正式侧义务(GDD/registry 修订清单)**:A3 GDD Rule 11(受理相位 Waiting → Active∪Waiting + 相位机表 + 时间税语义注记)· A2 GDD install 四不变式轮内场景 · B1 GDD 编排专章(轮内换图 + facts 快照协议)· B4 GDD E5/E6(query_full_snapshot/grant 快照直通)· game-concept 轮次流程 + "战斗:扫雷图 = 1:M"措辞(一轮跨多图新形态)· registry `action_economy`(refresh_max_per_round)· 数值连锁重跑(清图支配性条款⑤/获取期望——模拟不覆盖真人刷图行为,需 playtest 实测)。
## 2026-09-11 关卡扩展批 · 切片级偏差登记(实现期决策,正式化时须回 GDD/ADR)
1. **C1 线性推进器 provisional**:节点链来自 LevelTable(结构位 = 未来 C1 的数据承载面);路径分支/奖励点分布定稿权归 C1/C3。节点类型 closed enum {battle, reward, rest, boss};末节点必须 Boss(LevelTable schema 校验)。
2. **B4 新增 `grant_node_active()`**(接口漂移):关卡奖励点在可选类活行 {damage_item, heal_pack} 中 roll 一件直接入库;消耗 `reward_roll` 恰一 scope(与 board install 批同流异 scope,流集合封闭不变)。正式侧应走 B4 GDD 修订(节点奖励 = clear_reward 插槽族的新来源候选)。
3. **B2 spawn max_hp 语义修复**(切片 bug):原实现 `max_hp = hero_hp 入参`——跨战斗携带 HP 时 max_hp 被压扁成当前 HP,恢复点/治疗包满血守卫失效。修复为 `max_hp = HeroTable.max_hp`(接口语义澄清,正式侧回 B2 GDD Rule 注记)。
4. **A4 迷雾纪律遵守**:L1 全程无雾(迷雾 `unlock_level=2` 已锁事实——迷雾自第二层解锁);Boss 板以 12×12 大板区分。
5. **属性覆盖 = 测试专用**:调整板直接写运行时 HeroTable(atk 域 [1,999] 内 clamp ≥1);关卡结束/回菜单恢复基线。正式天赋树归 C4,禁止生产代码引用本机制。
6. **UI 复用 b1 引用**:关卡模式下呈现层 `b1 = runner.b1`(与调试模式共用 HUD/棋盘路径);runner 在关卡结束置 `b1 = null`。
7. **runner 信号时序(playtest 缺陷①修复登记)**:`node_entered` 必须先于 `begin_battle` 发出——后发时首板安装的 `board_changed` 到达呈现层时 `b1` 尚未接手,首战棋盘不渲染。场景级探针 `smoke/ui_probe.tscn` 为该缺陷的回归断言载体。
8. **A2 旗动作末点 AP 软锁(playtest 缺陷②修复登记——A2 核心 bug,v1 即存在)**:`_do_flag_place/_do_flag_pull` 花完末点 AP 后 A3 相位翻 Waiting 但不发 round_end——B1 的 Waiting 结算点依赖 round_end 到达,事件缺席 = 整局软锁(Boss 图插旗多,末点 AP 花在旗上即触发)。修复 = 旗动作检查相位翻转并补发 `_emit_round_end_flags_and_payload()`(与 `_do_dig` 同款)。v1 冒烟盲区根源:自动玩家不用旗。回归断言入 smoke_all(旗动作耗尽 AP 必须进入第 2 轮)+ 行为探针 `smoke/ui_click_probe.tscn`(合成鼠标事件走真实 UI 路径,随机混合插旗/拔旗/停手/窗口刷新/无效点击,4 种子全关通扫)。**正式侧义务**:A2 GDD F4 事件序契约须补"旗动作路径的 round_end 发射"措辞(接口漂移)。
9. **呈现层固定分区(playtest 缺陷③修复登记)**:侧栏全部动态区(RAP/敌人/增益/库存/悬停状态)固定槽高 + `clip_contents`——悬停文案切换与库存增长不再推挤整体布局(此前"第 N 轮"库存行被挤出视口 + 全 UI 抖动)。区域独立,超长裁剪不回流。
10. **L1 12×12 板生成余量**:D2–D4(及 L2)`max_gen_attempts` 16→24(F2c 24×144=3456 ≤ 16384 合法)——16 次尝试下偶发"重试耗尽接受最优"(best≈0.85–0.87 < target 0.88);24 次后仍偶发(best 0.866),密度×推理率的根本解归 C3。
11. **道具使用后不消失(playtest 缺陷④修复登记——UI 层契约违例)**:B1 窗口选择契约 = `_window_items` **位置**(义务④"选定序"),UI 选用按钮却传 `item.index`(库存数组句柄)——未消耗过时二者巧合相等;一旦消耗过一件,已耗条目仍占库存数组位,后续窗口条目"句柄 = 位置 + 已耗数"错位 → 选中件不消耗、邻件被误耗(可用次数语义本身存在且正确:`use()` 一次性标记 Used 不可逆)。修复 = UI/行为探针一律传窗口位置;探针加"选用必生效"断言(选用件库存态变 Used,失效即退出码非零)。
12. **数值调整板被遮挡不可见(playtest 缺陷⑤修复登记 + 专属测试补建)**:调整板覆盖层在场景树中位于**不透明的开始覆盖层之前**,`_show_tune_panel` 只置 visible 不隐藏开始层 → 面板被完全遮住 = "点击无反应"。修复 = 打开隐藏开始层 / 关闭(含"应用并关闭")恢复。**专属测试**:`smoke/ui_tune_probe.tscn`(场景级全链路:打开可见性〔缺陷⑤回归〕/ 滑条→生效预览联动 / 应用写 hero 表 12/75 / 重置回基线 6/50 / 批量模拟 N=10 全链路〔与 headless 扫描同口径〕/ 关闭回开始界面 / 加成跨模式持久)。
13. **呈现层模块化重构(2026-09-11 用户批——"上帝对象"拆分)**:`main.gd` 随功能追加长成 ~1100 行上帝对象(棋盘渲染/HUD/日志/四种覆盖层/调整板/模拟器/关卡流程全糊在一个类)。拆分为 **9 个单一职责单元**(总行数不变,最大单类 433 行):`views/board_view.gd`(棋盘渲染+输入意图翻译,动作经信号外发)· `views/hud_view.gd`(侧栏纯视图,setter+定长槽)· `hud_presenter.gd`(核心状态→文案装配,MVP 静态装配器)· `views/battle_log_view.gd`(13 类行协议格式化)· `views/item_window_overlay.gd`(窗口,选择=位置语义收口)· `views/node_overlay.gd` · `views/level_strip_view.gd` · `views/tune_panel.gd`(调整板自持加成语义+批量模拟)· `views/ui_kit.gd`(共享小部件)。**组合根纪律**:main.gd(433 行)= 模式流转 + 信号接线,核心写调用(settle_batch/submit_window_selection/runner 推进)只在此处发生;视图零核心写、装配器无状态。**回归验证**:冒烟 74/74 + ui_probe/ui_tune_probe PASS + 行为探针 2 种子(通关 + 选用生效 1/2 失效 0)。
## 切片调参登记 v2(2026-09-12 起已由 C3 GDD 转正为 MVP 默认曲线——下方登记保留作溯源)
**EnemyTable L1 深度轴(D0–D4,Boss=Dmax=4——A1 第三轴登记约定)**,设计意图 = 基线难、天赋拉回可玩带:
| 节点 | 行 | 敌种×数量 | hp | atk | 环 |
|---|---|---|---|---|---|
| 战1 | (1,0) | 蛆虫×1 | 24 | 4 | [attack] |
| 战2 | (1,1) | 蛆虫群×2 | 24 | 4 | [attack] |
| 战3 | (1,2) | 伏击兽×1 | 36 | 6 | [attack,charge,heavy] |
| 战4 | (1,3) | 伏击兽群×2 | 26 | 4 | [attack,charge,heavy] |
| Boss | (1,4) | 遗迹守卫×1 | 130 | 7 | [attack,attack,charge,heavy] |
首版(敌 HP 总量 208:18/36/26/40/88)实测**基线通关率 73%**——预警⑥速杀退化在关卡尺度依旧成立,与"基线非常难"意图相反;v2 上调至 290(24/48/36/52/130)+ Boss atk 7。**群战对价**:unit_count=2 行单位 hp/atk 下调(B1 条款③满配语境约束;C3 正式调参须按 clause3_supply 复算——registry 已登记 unit_count>1 行须降密度或降 m_amb)。
**BoardGenTable L1 深度行**:D0/D1 = 10×10、D2/D3/D4 = 12×12,全部 d0.25/t0.88。**登记**:d0.28/t0.87 首测**不达标**(best≈0.805–0.817,降级告警"接受最优出图")——密度轴上调与求解器能力一起解,留给 C3。
**奖励获取期望实测(问题2 汇报批,工具 `smoke/reward_expectation.gd`,N=200 板/深度,走真实 B4 roll 管线含 F5 保底条件化)**:
| 深度 | 板面 | 资格格均(min/max) | 可选类/板 均 | 其中刀 | 治疗包 |
|---|---|---|---|---|---|
| D0 | 10×10 | 12.4(1/36) | 3.32 | 2.57 | 0.75 |
| D1 | 10×10 | 11.1(2/32) | 3.00 | 2.40 | 0.60 |
| D2 | 12×12 | 22.8(4/63) | 5.70 | 4.42 | 1.27 |
| D3 | 12×12 | 23.8(5/61) | 5.87 | 4.55 | 1.31 |
| D4 | 12×12 | 24.1(6/64) | 5.87 | 4.54 | 1.33 |
**公式**:每资格格 P(可选类) = 25/100 = 25%(小刀 15% / 精工短刀 4% / 治疗包 6%);每板期望 ≈ 25%×|资格格| + F5 保底条件化加成(k_dmg=1 整批重 roll 至少 1 刀——12×12 板无刀概率 0.81²³<1%,加成可忽略;10×10 板 ≈+0.2);清图奖励 clear_knife 仅真清图兑付(理性策略不整清 ≈0);完美排雷 ×2 条件苛刻不计基线;**节点奖励恒为 1 件可选类**。
**每关合计 ≈ 23.8(板)+ 1(节点奖励)≈ 24.8 件可选类**(≈18.5 把刀 + ≈5.3 治疗包)——刀总伤 ≈180,超过 Boss 单体 130 HP。**用户"道具获取期望太高"的定量坐实**:关内不清库(2026-09-11 裁决③)× 每格必有货(B4 锁定)× 12×12 深板资格格翻倍,三者相乘。**候选杠杆(全部 C3 所有权,等拍板再动)**:① 可选类占比 25%→8–12%(改 knife_t1/knife_adv/heal_pack 权重)② 敌 HP 曲线上调对冲 ③ 刀伤下调 ④ 库存上限(触碰 B4 E 节结构,需修订)。
**期望扫描数据(N=20/格,种子 4200+20,工具 level_monte_carlo.gd;理性玩家策略近似)**:
| atk% / hp% | 通关率 | 整关承伤 均/p90 | 通关剩余HP均(胜) | 进BossHP均 | 平均轮数 | 败北节点分布 |
|---|---|---|---|---|---|---|
| +0 / +0(基线) | **55%** | 126.8 / 190 | 33.1 | 44.6 | 18.4 | 蛆虫群×4 · 伏击兽×2 · 伏击兽群×3 |
| +0 / +100 | 100% | 165.8 / 219 | 61.5 | 88.7 | 26.1 | — |
| +50 / +0 | 70% | 109.8 / 155 | 27.1 | 45.5 | 17.1 | 分散(含 Boss×1) |
| +100 / +0 | **100%** | 96.3 / 121 | 33.5 | 47.4 | 17.4 | — |
| +200 / +0 | 95% | 61.1 / 99 | 36.6 | 49.0 | 12.9 | 伏击兽群×1 |
读法:**基线=明显吃紧(55%,死亡集中在前中期群战)→ 攻击+100% 即满通关带**;HP 翻倍是独立有效的第二杠杆(战斗拖长 → 承伤期望上涨 165 但预算翻倍)。v1 敌数值(HP 208)时基线通关 73%——预警⑥速杀退化在关卡尺度坐实,v2(HP 290)压到 55%。注意:模拟者 = 理性策略近似(免费链式开格、无避雷推理),真人基线通关率应显著低于 55%——**目标带定在哪儿(30%?10%?)待 playtest 拍板,杠杆 = EnemyTable hp/atk 深度曲线(C3)**。
## 已知热点(playtest 观察项)
- **基线难度体感**:基线(无面板加成)应"明显吃紧、可能打不过";如果基线就能轻松通关 → 敌数值仍偏弱(找 C3 杠杆:EnemyTable hp/atk 曲线);如果 +100% 攻击还打不过 → 奖励供给或恢复点数值偏弱。
- **奖励点随机主动道具的体感**:roll 池 = 小刀 8 / 精工短刀 16 / 治疗包 12(等权);到手的刀是否改变了 Boss 战规划(囤刀 vs 即用)。
- **恢复点(30% max_hp)时机**:位于战4 与 Boss 之间——是否制造"要不要省治疗包给 Boss"的决策。
- **Boss 战节拍**:遗迹守卫环 [attack,attack,charge,heavy]——蓄力拍是否被玩家用作"安全挖掘拍";重击 14 点是否构成规划压力。
- 节点进度条/奖励恢复覆盖层可读性;v1 遗留观测项(结算逐行归因、雾池体感、完美排雷可达性)继续有效。
## 2026-09-11 用户拍板(四条,已落地本切片)
| # | 拍板 | 切片实现落点 |
|---|---|---|
| ① | 基础攻击恒存在:atk 域 [1,999]、0 拒绝加载、MVP=6;战斗段主角先手攻击顺位最前敌,attack_up 唯一吃修正 | `hero.tres` atk=6 · `hero_table.gd` 域校验 · B1 战斗段⓪先手(`hero_attack` 行);attack_up 经 `effect_pool_map → hero_offense` 活行 |
| ② | 奖励两类:即时 75%(当场兑付不进库存)/ 可选 25%(刀/治疗包入库走窗口);heal_pack 稀缺化 w6 p12 | `reward.tres` 行集 w75/25;B1 `_resolve_instant` |
| ③ | 库存关内延续:跨战斗不清,关卡末 level_clear 清空 | B1 `_battle_over` 不清库;v2 起由 C1 runner 在关卡末统一调用 |
| ④ | 道具窗口至多选一件 | B1 `submit_window_selection` 截断 `slice(0, 1)` |
**文档对齐**:四条拍板的 GDD/registry 回写已由文档会话先行完成(b1/b2/b4 GDD、registry、systems-index、d2、game-concept、b3);本切片程序与文档逐项对齐核查通过(详见 session-state 条目 16)。语义注:本切片为参考实现,与文档冲突以 GDD/registry 为准。
## 2026-09-12 试玩日志器 + 诊断转储(playtest 数据采集基建)
**新增**:① **`PlaytestLogger`**(呈现层组件)——订阅 B1 新信号 `settlement_snapshot`(每结算末尾全量状态:关卡/深度/轮次/verdict/英雄/敌人/行动经济/板序号统计/buff/库存/本轮 facts)+ battle_finished/node_entered/level_finished,逐行写 **JSONL**:`user://logs/playtest_<seed>_<时间戳>.jsonl`(每行一个完整状态,直接可喂 pandas);② **诊断转储按钮**(侧栏底部"🩺 诊断转储")——卡死时点击,全量核心状态(B1 相位机/计数器/facts、A2 棋盘、A3、B4、runner)+ 最近 200 结算行/100 操作环形缓冲 → `user://logs/diag_*.json`(行为探针 _dump_state 的产品化)。
**顺手修复(日志首秀即抓真 bug)**:_start_full_level 面板加成**双写叠加**——tune_panel.apply() 先写表(6→18),runner.set_hero_override 再以 18 为基线 ×3(→54)。修复 = 关卡模式单一应用路径(runner 写表),日志快照 atk 54→18 证实。**教训(规范 5.x 补充案例)**:同一加成的多应用路径必须单一事实源;JSONL 快照是数值回归的最低成本探测器。
**冒烟 103/103**(+7:快照信号/字段齐备/板序号/JSONL 解析/诊断文件)。日志目录:`%APPDATA%/Godot/app_userdata/DungeonSweeperVerticalSlice/logs/`。
## 2026-09-12 C3 GDD 落地 + 试玩采集清单
**落地**:C3 GDD(`design/gdd/c3-difficulty-curve.md`,solo 出稿未评审)的设计承诺落回切片——① **DifficultyTable 结构位**(schema + 空表 + ConfigDB 挂载 + B2.spawn 套乘钩子:行值快照前施加、越域 clamp 至 [0.1,10];MVP 空表零行为——AC-10 冒烟断言含注入/越域验证);② 切片调参登记(敌深度轴/板参数/供给参数)**转正为 MVP 默认曲线**——切片当前数值 = C3 GDD 背书的设计态,试玩即体验 C3 定稿难度。冒烟 96/96。
**试玩数据采集清单(反哺 C3 六个 OQ——试玩时请记录)**:
1. **OQ3 真人修正系数(首要)**:你无面板加成打第一关的通关/败北结果(模拟基线 80% 通关——真人预期显著更低,落差就是修正系数)。
2. **OQ2 Boss 血量**:Boss 战实际打了多少轮、是否觉得血量压迫合理还是拖沓(模拟值 130HP 占比 0.81 越带)。
3. **OQ6 轮内刷新**:你实际用了几次"弃图刷新"、是否觉得"挖-刷-挖"太顺滑(免结算=敌人不出手)。
4. **OQ4 弃图倾向**:有没有"挖一半就想弃图刷 RAP"的冲动(模拟测不到真人策略)。
5. **难度台阶可感知性**:D2 伏击兽/D4 Boss 相比 D0 蛆虫的变强是否有具体面孔(新敌种=新图像线索)。
6. **奖励供给体感**:可选类道具(刀/治疗包)获取频率与囤积感受(供给控制器批后每关 ≈4.5–6.5 件)。
试玩方式:F5 → 「▶ 第一关 · 苔藓回廊」基线打一局 → 调整板 +100% 攻击再打一局(对照"数值提升→通关期望改善"的元进度体感)。
## 2026-09-12 试玩批次 1 → v3 数值批(F7 流程实践)
**试玩反馈(v2 数值)**:满血全通关(三策略均通)/ Boss 血量 OK 或可上调 / 刷新零使用 / 零弃图倾向 / 治疗包零使用 / DND 命名指令(地精/熊地精/食人魔酋长)。
**v3 数值批**:atk 4/4/6/4/7 → **7/5/9/6/9**(承伤主杠杆);D0 goblin 24 保持(20 会被首轮粉尘秒杀——冒烟旗回归断言抓获)、D1 群 13×2、D2 bugbear 40/9、D3 群 26×2、**Boss ogre_chief 150/9**(用户拍板可上调);L2 锚行同步。**DND 命名**:goblin 地精 → bugbear 熊地精(DND 原型即伏击手)→ ogre_chief 食人魔酋长;enemy_id 同步更名。
**v3 扫描对照 F5**:基线 **60%**(带 40–60% 上沿 ✓,v2 80%)· 败北 4/6 集中 Boss(压迫预期兑现)· +50%→87% / +100%→93% / +200%→100%(天赋曲线成立)· 承伤 90(v2 80)。C3 GDD 回写:F3 带 [0.5,1.1](OQ2 裁决)、OQ2/3/4/6 试玩读数、试玩批次记录。
**冒烟 103/103**(旗回归断言改用 Boss 行——D0 20HP 级被盲挖粉尘秒杀无法承载场景;DifficultyTable 断言对齐 v3 行值)。**下一批 = v3 真人试玩**(OQ3 定量/OQ2 终裁)。
## 2026-09-12 遭遇随机化(Roguelike 局感引入批——Peglin 对比报告落地项)
**触发**:用户试玩反馈"关卡完全没有 Roguelike 的体现"→ Peglin 对比报告(`design/research/peglin-comparison-2026-09-12.md`)→ 用户拍板先做遭遇随机化(节点分支/多波/遗物协同后置)。
**设计**(C3 GDD Core Rule 8):EnemyTable 行 `unit_count` 升级为**区间语义** `[unit_count, unit_count_max]`(新字段可省略=定值,旧行零迁移);B1.begin_battle 实例化期按区间均匀骰,**RngService 新增 `encounter` 封闭流**(registry 接口漂移登记——挖掘决策域之外 P1 合规);**B2 零随机纪律保持**(spawn 收 override 即确定值);同种子全程可回放;新敌种首遇节点固定数量(教学位不加骰)。
**L1 区间**:地精 [1,2] · 地精一伙 [2,3](hp 13)· 熊地精固定(教学位)· 熊地精双雄 [2,3](hp 18)· Boss 固定——期望总量 ≈304(+8%),F6 带内。
**扫描(N=15)**:基线通关 **47%**(区间期望总量上移 +8% 的叠加效应——F5 带 40-60% 带内 ✓);+50% → 67%;败北分散(Boss×3/战1×4/战3×1)。**注意**:区间骰使难度分布更宽(运气成分进入遭遇层——Roguelike 体的核心),真人体感待批次 2。
**冒烟 149/149**(+46:区间双值出现/全域内/同种子可回放/D2 教学位固定)。
## 2026-09-12 试玩批次 1 → v3 数值批(F7 流程实践)
## 2026-09-12 伤害类型 × 护甲克制批(用户拍板——三段伤害 + 标错惩罚落地)
**触发**:设计会话(本日)定稿"怪物护甲类型(无甲/护甲A/护甲B)× 角色伤害三段构成(基础/伤害A/伤害B)"的克制对策系统(穿甲弹/高爆弹式三角)+ 标错旗随机惩罚。设定表经用户多轮审阅确认"没有问题"后落程序(GDD 正式修订另批进行——本批以表格结论为实现依据)。
**拍板结构 → 落点**:
| 设定 | 实现落点 |
|---|---|
| 敌人护甲类型:无甲 / 护甲A / 护甲B | EnemyTable 新字段 `armor_type`({none,a,b},缺省 none 向后兼容)· 映射:goblin=none / bugbear=a / ogre_chief=b(教学梯度候选,provisional)· B2.spawn 行值快照入单位字典 |
| 伤害三段:①基础 ②伤害A(开格充能)③伤害B(标雷充能) | B1 战斗段主角先手攻击改三分量:基础 = atk×(1+attack_up)(**不吃克制**,attack_up 消费面不变);伤害A = 本轮开出的资格格计数 ×`damage_a_per_cell`(初始展开格不计——reward_eligible=false);伤害B = 首次标中雷 ×`damage_b_per_flag`(与 weaken 同键每雷实例一次去重——忘拔旗不重复充能);三分量各自 floor 后相加,hero_attack 行携带 base/dmg_a/dmg_b 分量(P2 归因) |
| 克制矩阵:特化 ×150 / 无甲 ×100 / 对位甲 ×50 | SettlementTable 新字段 `armor_effectiveness`({base,dmg_a,dmg_b}×{none,a,b} percent,缺项回退 100)——刀/AOE/清图奖励**恒定表值不吃克制**(道具=万能稳定伤害,暂定待拍板) |
| 伤害A 单轮封顶 | `damage_a_cap = 8`(与 ap_per_round 对齐——防连锁轮一次充能十几个格把特化甲怪一拍秒杀;0 = 不封顶) |
| 标错旗 → 随机轻惩罚三选一(时效 1 结算) | `_apply_miss_backlash()`:① `enrage` 暴走(敌 +25% 进 enemy_output 池)② 敌方伏击 +2 层(复用 ambush 行)③ `off_guard` 失衡(我方 +25% 进 hero_defense 池 = 多挨打);新 B3 行 ×2(dur=1 与 weaken/ambush 同款先兑付后衰减);沿用 `_flag_miss_seen` 每雷格实例一次去重 |
| 随机载体 | **零新流确定性派生**:(x×7 + y×13 + 板序号×5) mod 3——同种子可回放、不扰动既有三条封闭流取号序;正式侧候选 = RngService 新增封闭流(待拍板) |
| 呈现 | 敌人卡环点行常驻护甲标签(无甲/护甲A/护甲B——克制对策可读前提)· 结算明细主角攻击行拆"(基础 X + 开格 Y + 标雷 Z)" · 反噬效果行入明细流 · settlement_snapshot 增 armor/charge 字段(JSONL 数值分析面) |
**数值全部 provisional 待拍板/调参**(登记于 enemy.tres / settlement.tres 注释):damage_a_per_cell=1 · damage_a_cap=8 · damage_b_per_flag=2 · 矩阵 150/100/50 · enrage/off_guard 25% · 伏击 +2 层 · 护甲映射 goblin/bugbear/ogre——C3 调参时按 F6 供给平衡式复算(三段充能是新增伤害供给,裸装败北/基线通关率两条冒烟路径已复验仍成立)。
**正式侧义务(GDD/registry 修订清单,待文档批)**:B2 GDD(EnemyTable armor_type 字段 + schema)、B1 GDD(F3 主角攻击三分量构成 + flag_miss 反噬编排 + 克制矩阵载体)、B3 GDD(六行 MVP 状态表 + source_class flag_miss 授予)、A2 OQ1(标错惩罚候选升级为三选一随机池 + 重复性口径=每雷格实例一次)、registry(enemy/settlement_table/status_effect 条目 + A3 条款②锚与条款③不等式重推导——标中旗收益从纯 weaken 变"weaken+伤害B 充能")、C3(数值 provisional 接管)。lore 侧:`design/research/lore-nest-metaphor-2026-09-12.md`(伤害A=地利/伤害B=封印暴露、反噬=封印失败——待新会话拍板后写回 game-concept)。
**验证**:冒烟 **161/161**(+12:护甲快照三档传播 / 克制矩阵三行表值 / hero_attack 行三分量字段〔全战斗回放 ×2〕/ 标错旗 flag_miss+反噬效果行 / 标中雷充能进主角攻击 / 反噬派生同种子回放)· ui_probe PASS · ui_tune_probe PASS · ui_click_probe(done=true、选用失效=0、问题=0)。
## 2026-09-12 战斗屏五区改造批(battle.md 落地——ui-programmer 批)
**背景**:`design/ux/battle.md`(/ux-review APPROVED)+ `design/ux/interaction-patterns.md`(核心 8 pattern)落地到切片呈现层。**core/ 与 data/ 零改动**(本批纯 presentation/ + smoke/)。
**五区布局**(1280×800 基座):A 区 28px 顶栏关卡进度条(`level_strip_view.gd` 改造:暗金已过/奖励金当前高亮);B 区 252px 战斗画面(`battle_stage_view.gd` 新建 = `hero_card_view.gd` + 轮次·阶段指示 + 兑现演出区 + `enemy_card_view.gd` ×1–3〔顺位徽标/意图徽标常驻/环点拍数/伏击共享池/死亡灰化+碎裂残位禁瞬消〕+ 底部结算明细带);C 区 240px `hero_info_panel_view.gd`(直角细线数据框 + 修正来源角标 + RAP 点阵 + `status_slot_row.gd` ×4〔形状+色相双通道/层数角标/剩余结算点/悬停 tooltip 来源+语义+剩余/空态槽隐藏〕);D 区 `board_zone_view.gd`(`ap_bar_view.gd` 44px〔AP 点阵+停手居左;图例+弃图刷新居右——确认 chips 异位 pattern 7〕+ `board_view.gd` 改造〔悬停三语义自绘光标/目标格边框/BFS 路径蓝点预览/可挖域 8 向呼吸微光/奖励徽章 R₀ 域外/已开雾格 tint 残留/雾区全开一次性信号/去饱和 shader〕+ `toast_stack.gd` 2.5s 消退 + `board_fx_view.gd` 演出层)+ `desaturation.gdshader`(去饱和+金橙偏移、明度不变);E 区 280px `inventory_panel_view.gd`(常读列表固定槽高 + 原位窗口态 pattern 2/8:描边金光/标题切换/时机指示/usable_now 禁选+原因/跳过/政策注记;属性简述投影、来源不呈现)。**旧 `hud_view.gd` 删除**(HUDView 职责由 B/C/D 三区视图承接)。
**全屏 States 与模态**:`legend_overlay.gd`(图例,「迷雾 = 无雷盲区」恒在 + 状态槽空态说明)/ `settlement_review_overlay.gd`(明细回看)/ `battle_end_overlay.gd`(胜=金蜡封落于棋盘中央+判定行+回看入口;败=暮蓝 #5A6C8C+旗化光点收回)——统一模态登记序管理(`_open_modal` 打开时显式隐藏上方遮挡层,缺陷⑤纪律)。清图金粒喷泉+彩纸 / 完美排雷金光带+横幅 / 标中旗加重弹道 / Boss 紫赤影子爬棋盘 / 零合法板诚实 toast / 雾首遇·chord·非相邻一次性提示(每 run 去重,`ux_hint_shown` 遥测)。**发现并修复历史 quirk**:TunePanel 默认 `visible=true` 靠被不透明开始层遮蔽的树序巧合——已显式初始化 false(缺陷⑤家族)。
**遥测**(playtest_logger 增 ux 行族,呈现层观测不改 core):`ux_round_input_ratio`(原始输入:AP 消耗,快照驱动)/ `ux_round_exit{path_tag}`(快照 round_facts.path——呈现层只订 B1 的 ADR 纪律下唯一合法源)/ `ux_refresh_attempt{result, round_refresh_index}` / `ux_window_choice` / `ux_reject{source, sub_reason}` / `ux_hint_shown{hint_id}`。
**探针**:ui_probe 增五区构建断言 + L2 雾图段(雾首遇提示/雾区连通分量);ui_click_probe 增刷新确认链/图例开关/tooltip 触发/模态遮挡断言(图例/回看/窗口态/终局)/窗口期去饱和断言/首格可点计时(进入战斗屏口径 133–151ms ≤500ms AC;场景冷启动 ~700ms 为脚本首载编译域,观测项)——既有全部断言保留。** Waiting 刷新代理已按 OQ12 退役**:UI 刷新按钮仅挖掘期 Active 受理(presenter 门禁),原探针"窗口期点刷新"行为由 Active 确认链测试替代。
**回归**:smoke_all 149/149 · ui_probe PASS · ui_tune_probe PASS · ui_click_probe PASS(PROBE_SEED 7/11/23/99/42/77 六种子 done=true、选用失效=0、问题=0)。
**占位登记**:敌人意图视觉映射按 enemy_id 程序化占位(资产定稿归 B5);D区演出(喷泉/彩纸/弹道/影子)全部程序化占位;CJK 字体依赖系统回退(Windows 可用,随包字体归 D1 正式化硬性前置——ADR-0003);金蜡封/暮蓝氛围为色块占位级。
## 2026-09-12 呈现层 code-review 修复批(/code-review CHANGES REQUIRED → 全项清偿)
**触发**:全切片 53 脚本 ~9400 行 /code-review(CLAUDE.md 代码规范 + ADR-0001/0003/0004 对照),裁决 CHANGES REQUIRED(7 项必改)。core/ 12 文件与 smoke 断言面整体健康;病灶集中于 tune_panel 封装破口、main.gd 结构回涨、三处呈现契约错位。
**BLOCKING 修复**:
| # | 缺陷 | 修复 |
|---|---|---|
| 1 | **TunePanel 视图写核心表**(`apply()` 直写 `_db.hero.atk/.max_hp`)+ `run_simulation` 内 `load("res://smoke/...")` 驱动 core——面板模拟按 pct² 口径、模拟后表值残留污染真实对局(runner 把放大值当基线再乘) | tune_panel 重构为纯视图(`apply_requested`/`reset_requested`/`simulate_requested` 信号外发 + setter 注入);加成落表收口组合根唯一路径 `_apply_hero_override`(每次从 `_ready` 捕获基线重算,幂等禁叠加);模拟编排拆 `tune_sim_controller.gd`(先复位基线再移交);报告文案归 `HudPresenter.tune_sim_report` |
| 2 | **意图徽标双翻译**:presenter 先把 beat key 译中文投影、EnemyCardView 再按英文 key 匹配 → 色相/重击"▼"通道永久失效;`BEAT_NAMES` 两处重复 | presenter 直传 B2 原键(attack/charge/heavy);名/色/标记渲染映射唯一定义处 = EnemyCardView(set_intent 契约头注成文) |
| 3 | **C 区 RAP 死分支**:`_refresh_economy` 从不投影 `a3.rap`,"RAP 滚动归零"反馈(A3 D1 义务)不可达;视图靠解析自身 label 文本当状态 | presenter 持续投影 `a3.rap`;`hero_info_panel_view` 改 `_last_rap` 成员存储,删除 `_rap_value()` 文本解析 |
| 4 | **smoke_all 恒真断言**(`check(true, "全部计数在域内")`——越域也 PASS) | 改带分布标签的真实域内断言 |
**结构/封装修复**:
- **main.gd 761 → 667 行**(评审记录基线 433 行,400 行审视线):节点构建拆 `ui_assembler.gd`(只 new/add_child/布局,零接线零核心写);模态层级状态机拆 `modal_stack.gd`(缺陷⑤纪律载体);战斗流程叙事文案拆 `battle_narrator.gd`(兑现 main 头注"不拼呈现字符串");模拟编排拆 `tune_sim_controller.gd`。剩余行数 = ADR-0001 规定的接线 + 模式流转 + 核心写调用唯一出口(再压需移动 ADR 规定职责,登记为 D1 正式化批候审)。
- **level_runner core↔core 接线 lambda → 方法 Callable ×4**(ADR-0001 静态规则 S6——lambda 捕获 self 延长 RefCounted 生命周期/成环)。
- **B1 新增 `diagnostic_snapshot()` 诊断观测面**:组合根诊断转储与 click_probe 停滞诊断全部改走成文 API,零下划线私有字段直读(此前直读 8 个)。
- **信号处理体去 lambda 化**:main 三处多行/单行 lambda(inter 层继续/单场入口/库存拒绝)改命名方法 + `bind`(规范 4.1)。
- 顺带:`can_request_refresh()` 断行格式修复(原单行内嵌 tab 不可读);`_build_inter_overlay` 死参 `parent_root` 删除;`_start_battle` 重复 `_start_overlay.visible = false` 去重;调试两层入口改由 `_start_battle` 统一取加成后满血(原传参在加成前取表值)。
**探针同步(缺陷回归断言随契约更新——3.3)**:ui_tune_probe 重写——应用/重置走真实按钮信号路径(`press_apply_for_probe` 接缝,ADR-0004"合成输入走真实翻译出口"口径);**新增两条回归断言**:⑦模拟后 hero 表保持基线(无残留)⑧应用加成 + 启动完整关卡后 runner 写表值 = 基线 × 单次加成(12/75;旧缺陷表现 24/112 在此被抓获);ui_click_probe 停滞状态与诊断转储改走 `diagnostic_snapshot()`。
**验证**:smoke_all **141/141** · ui_probe PASS · ui_tune_probe PASS(含新回归⑦⑧)· ui_click_probe 六种子(7/11/23/99/42/77)全 PASS(done=true、选用失效=0、问题=0)。新增 4 脚本 `.uid` 经 headless 编辑器扫描生成(语言纪律⑤)。
**登记待办(非本批范围)**:自动玩家策略四份复制收口(评审 W-3);schema 溯源行补齐(W-9);S1–S8 静态扫描脚本化后 S1 自建 RNG 豁免口径登记(W-1);views 转发层信号契约头注补齐(W-2)。
## 2026-09-13 封装收口批(代码架构审查 E1–E5/O1–O5/P3 修复——ADR-0009 候选项的切片侧预收口)
**触发**:/architecture-review 改编批——以 architecture.md + ADR-0001~0005 + code-conventions 为基线做代码组织/封装专项审查(报告:`docs/architecture/code-architecture-review-2026-09-13.md`,Verdict: CONCERNS),用户拍板 P1+P2+P3 全修(P2 中涉接口漂移两项另出设计稿待审)。
**核心封装修复(P1)**:
| # | 项 | 修复 |
|---|---|---|
| E1 | **B1 协作对象全公开 → 呈现层链式穿透**(`b1.b2.hero`、`b1.a3.phase` 式层层下钻 main×18 / presenter×15 处) | a1/b2/b3/b4 私有化(a2/a3 保留公有 = 架构文档 API Boundaries 的 D1 契约面);新增 `presentation_snapshot()`(载荷 schema 头注成文)/`is_battle_live()`/`stock_count()`/`query_reward_identity()` 四个成文只读出口——diagnostic_snapshot 同款模式 |
| E2 | **A2 状态字典公开且被视图绕过查询面直读**(board_view:278/303 直读 `.revealed`,头注自称"只读查询面"自相矛盾) | `board/revealed/flags` 私有化;新增 `current_board()`/`reveal_record()` 查询面;B1/组合根/探针全部改走命名面 |
| E3 | **B2 裸字典容器公开**(`hero/enemies` 可被任意写;presenter 为死亡残位直读原始数组) | 私有化 + `hero_unit()`/`all_enemies()`(含死亡残位,句柄语义与 query_alive 同构)投影面;presenter 残位渲染需求收进 API |
| E5 | **"只读"字段仅靠纪律**(phase/ap/rap/last_round_end/master_seed 架构文档定只读,实际公开可变) | getter-only 属性 + 下划线后备字段(外部读法 `a3.phase` 不变,写入仅剩方法通道——GDScript 层面机制化) |
**组织修复(P1/P2/P3)**:
- **O1/O2 调试模式抽离**:main.gd 调试分支(单场/连续两层/HP 携带/共享 B4)拆 `presentation/debug_session.gd`——调试语义单一实现点(O2 双实现漂移收口);组合根保留接线(battle_created 先于 begin_battle 发出,ADR-0001 接线唯一归组合根 + 缺陷①时序义务)与终局 UI 决策。ui_probe 抓获初版时序缺陷(battle_ready 才接手 b1 → 雾首遇提示丢失)——即缺陷①同款,battle_created 接手修复。
- **O3 DamageMath 抽取**:B1 伤害公式族(F2 乘区/充能分量,零状态纯算术)拆 `core/damage_math.gd`(规范 1.2 工具函数挂宿主类);board_generator 同一合法板重复调用两次 measure_inferability 合并为一次(全库最贵求解器)。
- **P3 清债**:头注溯源补齐 ×4(board_data/config_db/rng_service/reward_supply_controller——规范 1.4);B4 grant/grant_clear/stock/use 失败返回统一 `false`→`null`(E6 消费方 `is Dictionary` 判定天然兼容);equivalence_supply 表行 duplicate 防配置源泄漏;load()→preload/类名直引 ×3(main logger/runner、tune_sim autoplay)。
**加固(基线既有噪声根治)**:main.gd `_on_board_action` 的清图演出判定在 settle_batch 同步重入(结算→battle_over→关卡终局置空 b1)后仍摸 `b1.a2` → SCRIPT ERROR(基线 click_probe 每跑必现)——补 `b1 != null` 守卫,探针 stderr 归零。
**验证**:冒烟 **141/141**(与基线一致)· ui_probe PASS · ui_tune_probe PASS · ui_click_probe PASS(done=true、选用失效=0、问题=0,**SCRIPT ERROR 0**——优于基线)· 新增脚本 .uid 经 headless 编辑器扫描生成(语言纪律⑤)· presentation 层残留穿透引用 grep 归零(呈现面调用 19 处全部为命名 API)。
**登记待办(P2 设计稿待审——见审查报告 §4)**:E4 `db.hero` 表值运行时改写参数化(涉 B2.spawn 契约 = 接口漂移,须 GDD/registry 修订批);O4 自动玩家策略四份复制收口(与既有待办 W-3 合并)。
## 2026-09-13 P2 收口批(E4 表值参数化 + O4 自动玩家单一实现——用户拍板两项均做)
**E4(db.hero 表值运行时改写 → 参数显式传递)**:
- `B2.spawn` 增 `atk_override/max_hp_override` 参数(-1 = 表值兜底;沿用 unit_count_override 先例;EC16 越界守卫改对生效上限判);
- `B1.begin_battle` encounter 增 `hero_atk/hero_max_hp` 字段(缺省表值——冒烟直呼路径零改动),战斗段 `_hero_atk` 快照替代 `db.hero.atk` 读取;
- `LevelRunner`:`start_level` 表写删除、生效值进 encounter、`restore_hero_base` 只清百分比(表值不可变后无基线可恢复)、`_base_*` 字段删除(直接读恒定的表);
- main:`_apply_hero_override/_scaled_override` 表写路径废除 → `_debug_hero_override()` 生效值供给 Callable(面板百分比现算经 encounter 入战斗);`_on_tune_apply/_on_tune_simulate` 去复位步骤;`_db_base_*` 字段删除。
- **收益**:双写叠加(6→18→54)与双基线同步整类隐患随表写移除;D2「单一事实源」原则机制化(表值运行期不可变)。
- **登记义务**:registry architecture.yaml 新增 `hero_override_parameter_passing` 条目(ADR-0009 候选;B2.spawn 签名 +2 / B1 encounter +2 = 接口漂移,正式侧 B1/B2 GDD 修订随 ADR-0009 批)。
- **探针语义升级**:ui_tune_probe ⑦「表值残留」→「hero 表运行期不可变」(reintroduce 表写即 FAIL);⑧ 双写叠加回归改为完整关卡路径 + 呈现快照单次生效断言(12/75;旧缺陷形态 24/112 仍被抓获)。ui_probe 的 `_start_single` 语义注记:调试单场恒基线(入口清零滑条——原行为,非缺陷)。
**O4(自动玩家策略四份复制 → 单一实现,合并既有待办 W-3)**:
- 新增 `smoke/auto_player.gd`:`choose_dig`(最近可挖格)+ `window_selection`(治疗优先于刀;差异参数化 heal_hp_div 2/3 · round_gate 0/3 · knife_compare best/sum——四调用点原语义逐一保留:smoke_all 3/3/best · monte_carlo 2/0/sum · dump_battle 3/3/sum · level_autoplay 2/3/best);
- 四消费方删除各自的 `_choose_dig`/窗口策略块改走单一实现;click_probe 的 `level_autoplay._choose_dig` 引用同步改指 AutoPlayer;
- **等价性证明**:smoke_all 同种子回放断言(步骤流逐行相同)重构后保持全绿。
**验证**:冒烟 **141/141** · ui_probe PASS · ui_tune_probe PASS(含 E4 新断言)· ui_click_probe PASS(done=true、选用失效=0、问题=0)。
## 2026-09-13 零承伤根因分割探针 + playtest-09-12 Bug #1–#3 处置批(报告 Top3 授权任务)
**触发**:playtest-2026-09-12-designer 报告 Top 3 Priorities(探针分割 → Bug 建档修复 → 多种子复扫基线);数值拍板与设计变更不在授权范围(待拍板清单见文末)。
**① 零承伤根因分割(新探针 `smoke/damage_probe.gd`——boss/level 两模式)**:
- **快照盲区发现**:settlement_snapshot 取在衰减段之后,时效 1 的 defense_up 当轮授予当轮衰减——真人局 JSONL `buffs[]` 恒空 ≠ 池为空(报告悬置的直接原因)。探针改从结算行(effect 行 pool_value)重建战斗段时点池值,池归属经 settlement.effect_pool_map 表驱动(零字面量)。
- **Run A(fresh 池零挖掘裸验)**:Boss 攻击拍 d_tick = atk.total 全额兑现(attack 9/9 ×2 · charge 0 设计语义 · heavy 18/18),6 轮击倒英雄(defeat)——**「Boss/敌攻击链缺陷」否证**;拍名轮转与真人局逐拍一致(attack→attack→charge→heavy→attack)。
- **Run B(自动挖掘墙行为)**:池 -30 → d_tick 21→14、池 -60 → 残余 ×0.40,墙轮承伤恒 >0;表值判定墙最深 -60 > pool_clamp_lo -100 = **归零不可达(09-13 调参批在当前数据上重证)**。
- **结论**:真人局 12/12 轮零承伤 = 旧数据 defense_up -50×2 = 池触 clamp 下限 ×0(设计语义)——任何 atk 曲线乘在 ×0 上(原报「v3 主杠杆乘在 ×0 上」坐实)。报告 Bug #2 = 设计问题非 bug。
**② Bug #1–#3 建档与处置(BUG-0002/0003/0004,`production/qa/bugs/`,编号自 BUG-0001 顺延)**:
- **BUG-0002(报告 #1 浅深度空遭遇)——Rejected as coded**:seed 42 当前构建全关回放复现同款遥测签名(d0 spawn=1 / d1 spawn=3 + round-1 victory + unit_count 0)但实为**首轮全灭**(英雄爆发 + 爆裂粉尘 instant_aoe 全歼低 HP 敌群;与 20:41 局 d1 双地精各剩 1 HP 逐点吻合);遭遇骰数学下限 ≥1(B1 roll_lo)+ B2.spawn count<1 fail-fast 双闸,100 场扫描零越域。**快照盲区修复**:settlement_snapshot 增 `spawn_count`(实例化总数含死亡残位)——unit_count=0 时「全灭 vs 空场」可判别,同类误读机制化排除。
- **BUG-0003(报告 #2 Boss 攻击零兑现)——Rejected as coded**:根因见①;候选改法(clamp 收紧 / 乘法衰减 / 重击拍穿防)列待设计批。
- **BUG-0004(报告 #3 grant_node_active 绕过 _supply.notify_granted)——Fixed**:stock 受理成功后补入账(与 grant() 同一「发放即获取」口径);计数器战斗域清零语义不变 = **零战斗数值影响**,纯计数口径修复(报告 Top3 #3 的上报义务执行)。
- **回归断言(代码规范 3.3)**:smoke_all +7——快照 spawn_count 字段与等值断言;遭遇全深度 × 12 种子全域内扫描(60 场 spawn ≥1 且 ∈[unit_count, unit_count_max]);`_smoke_attack_chain_fresh_pool`(fresh 池非蓄力拍 d_tick == atk.total 全额兑现——攻击链健康钉死,未来真缺陷在此被抓);`_smoke_supply` ⑦ 节点奖励发放即入账。
**③ 多种子关卡复扫(Q1 调参批基线——level_monte_carlo 增 F4 两列「普通承伤/场% · Boss承伤%」:逐战斗承伤切分 = round 回卷分段 + 末段 Boss 判定(须抵达 Boss 节点))**:
- N=30 × 10 档(种子 4200–4229;F7 切片样本量口径)。**结论:全档位越带**——F5 基线通关率 **17%**(带 40–60%)· +100% 档 43%(中天赋 ≥95%)· +200% 档 63%(高天赋 100%);F4 普通场承伤 **35.1–43.1%/场**(带 ≤20%)、Boss 场 **71.3–134.9%**(带 ≤50%)(hp+0 档;hp+100 档普通 13.9–22.6 带内 / Boss 35.8–76.7 仍多数越带)。拆墙(-50→-30 且权重 24→16)与 v3 atk 曲线叠加后,难度由严重不足摆向**严重过剩**。**本批未动任何数值**(敌 hp 曲线与奖励供给禁独立调参——registry 预警⑥联动义务;杠杆方向列待拍板,本基线 = F7 流程的测量输入)。
**验证**:冒烟 **148/148**(141 既有 + 7 新回归)· 三探针 PASS(ui_probe / ui_click_probe 6 种子 done=true、选用失效=0、问题=0 / ui_tune_probe)· damage_probe boss 模式 7/7 + level 模式(种子 42/43/44)4/4 全 PASS。
**待拍板/待设计批清单(本会话零数值变更、零 GDD 变更)**:
1. **难度杠杆重拍板**(F4/F5 全档位越带)——候选:enemy atk 曲线回撤部分 v3 增量 / defense_up 权重回升 / heal 供给联动——归 C3 按 F7 流程(本基线为测量输入);
2. **墙语义候选改法**(clamp 收紧至 -60/-70 / stack 乘法衰减 / heavy 拍穿防)→ C3 + /propagate-design-change(连带 B1 池 clamp 语义 + B3 GDD "-50" 文字同步);
3. **供给量重拍板**:「每战斗首件保证」的总量语义(候选:关卡域首件)→ C3(BUG-0004 已封计数漏洞,关卡供给账目可完整观测);
4. **浅深度首轮跳战可玩性**(autoplay/真人 d0-d3 round-1 全灭跳过战斗过程)→ C3 结合 F4 复验数据裁决。
## 2026-09-13 拍板落地批(四项拍板——难度杠杆/墙语义/供给量/跳战)
**拍板结果**(用户四问裁决):①难度主杠杆 = **先真人试玩再拍**(F5 真人修正系数标定优先于数值调整);②墙语义 = **维持现状 + 归零护栏**;③供给量语义 = **先观测再拍**;④跳战 = **并入难度复测再议**。本批零玩法数值变更。
**落地变更**:
- smoke_all 新增 `_smoke_wall_zero_guard`(表级不变式:`2×defense_up.magnitude > pool_clamp_lo`)——BUG-0003 事故类(叠满触池底 = ×0 全额免伤)永久免疫:任何未来 magnitude/clamp 调参重新造出 ×0 墙即冒烟红灯,强制显式重审;
- level_monte_carlo 增「**跳战率**」列(普通战斗 round-1 即胜占比——拍板④观测面,此后每次复测自动携带);`_damage_segments` 升级为逐段 {承伤, r1_win} 结构;
- defense_scan 输出标签修正:「墙轮承伤(应为 0)」→「(合法——-30 批墙帽 ×0.40)」(旧 -50 数据时代的期望文案过时清理)。
**跳战率基线(N=30,拍板④"再议"数据)**:基线 **8%** · +50% 13% · +100% 24% · +150% 27% · +200% 38%(普通场 round-1 全灭占比)。**seed 42(试玩固定种子)为高跳战轨迹**(autoplay d0–d3 四场全部 round-1 全灭)——真人局 d0–d3 全跳的体验含显著种子成分;基线 talent 下跳战是少数派,拍板④"复测再议"的数据前提成立。其余各列与 Q1 基线逐值一致(collect_rows=true 开启不改变行为——确定性交叉验证)。
**验证**:冒烟 **149/149**(+1 护栏)。
**下一步(用户侧)**:开一局真人试玩(任意种子;JSONL 自动落 user://logs/)。观测清单(服务拍板①③复议):通关与否 + 败北节点(F5 修正系数 = 真人通关率 ÷ 模拟 17%)· 普通场掉血体感(对照 ≤20% 带)· Boss 战撑过几轮 · d0–d3 是否发生跳战 · 治疗包是否出现缺血时刻与库存残留(拍板③数据)。事后由会话分析 JSONL 出修正系数。
## 2026-09-14 完美策略档测量批(OQ7 技能轴上界仪——用户拍板"增加完美策略档测试验证",零玩法数值变更)
**背景**:用户(扫雷强玩家)指出"完全正确开图/标雷、零踩雷 ⇒ 存活无压力"——现行扫描只有弱策略基线档(不标旗/邻格盲挖/朴素选道具),技能轴不可测;registry `calibration_gap`(真人显著强于模拟者)此前只有方向无机制。本批 = 测量工具批。
**落地变更**:
- `auto_player.gd` 增完美策略求解器(`perfect_action`/`_deduce`):single + subset 推理不动点(A1 F1 `measure_inferability` 同族规则集,**去 safe_set oracle**——约束未知邻格含雷格,比生成期闸保守);**零盲挖**(只挖已证安全格 = 构造保证零踩雷,扫描末断言兜底违例 quit(1))· **已证雷格全标旗**(旗先于挖——轮末 weaken + 伤害B 首次充能,雷上旗不阻连锁)· 卡死且资源可 → `b1.player_request_refresh()` 轮内刷新 · 否则停手(残 AP 1:1 入 RAP)。输入只经 A2 查询面(reveal_record/is_revealed/has_flag),**禁读棋盘雷区真值**——测推理上界,非全知作弊。core 层零改动;
- `window_selection` 增 `heal_no_waste` 选项(缺口 < 治疗量不用;完美档启用)+ 完美档轮次门解除(round_gate=0);
- `level_autoplay.gd` 增 `policy` 参数(默认 "normal"——tune_sim_controller/defense_scan/level_debug 既有调用零改动);完美档驱动 `_autoplay_battle_perfect`(move/dig/flag 走 A2 settle_batch、refresh 走 B1 编排、连续拒绝 ≥3 停手防死循环);st 增 `mine_hits`/`refreshes` 计数(mine_hit 证据行埋点);
- `level_monte_carlo.gd` 增第 3 CLI 参 `[policy]`(normal/perfect/both,缺省 normal 兼容既有文档命令)+ 策略/踩雷/刷新列;脚注 F5 带文案同步 [30,50] 带锚定稿。
**完美档基线(N=30 · 种子 4200–4229 · 耗时 597.6s)**:**0 加成通关率 100%**(弱策略基线档 60%)、通关剩余 HP 均 **47.4/50**、整关承伤均 3.5(p90 18.0)、普通场承伤 0.5%(带 ≤20%)、Boss 场 5.3%(带 ≤50%)、踩雷 0.00/局(断言 ✓)、刷新 0.23/局、跳战率 20%。+50%~+200% 全档位恒 100% 满血通关(+200% 承伤均 0.4)——**技能轴上界完全碾压现行曲线:F5 带与 F6 S/K 区间对完美档零分辨力**(区间模型无策略水平轴的结构性证实;calibration_gap 获得机制解释与定量上界)。
**验证**:冒烟 **149/149** 全绿(默认行为零变更——弱策略档既有断言全通过)。
**待拍板**:① 0 加成完美档通关率的目标带(现行 [30,50] 按弱策略口径定义——是否改为按完美档口径验收?);② 杠杆选择(已预登记:r_boss 走廊 180–207 / 群战上探 / decay 0.6;本批新增候选:"上限掺运气"系——供给量值随机化(power 区间化)/ 承伤硬地板——均待完美档复测验证效果)。
## 2026-09-14 伤害量值区间批(用户拍板②——"上限掺运气"两步全落地 + 完美档无效性实测,宽度 provisional)
**拍板**:验收口径改完美策略档(0 加成通关 ≤40%);杠杆 = 伤害值区间随机(上阶/下阶,范围内 roll);覆盖面 = 奖励侧五行先行、不足扩敌方侧(分步授权已用尽)。roll 时机 = 身份预分配批(E14 快照口径不变);中点恒等现值(A3 条款①当量锚不漂移);宽度 provisional 归测量迭代。
**落地变更**:
- **第一步·奖励侧五行**(reward.tres + reward_table.gd schema + reward_economy.gd):heal_minor [2,6] / knife_t1 [5,11] / heal_pack [6,18] / knife_adv [10,22] / clear_blast [25,55]——power_min/power_max 可选配对字段(缺省定值行向后兼容,armor_type 先例);schema 校验 = 成对/buff 禁止/中点锚;roll 在 roll_board 身份批内**追加取号**(既有取号序零扰动)+ grant_node_active 同路径;快照 power = roll 值,B1/呈现零改动,授予期零随机纪律不变;
- **第二步·敌方攻击侧**(enemy.tres 六行 + enemy_table.gd schema + unit_container.spawn + battle_orchestrator):atk [min,max] = 行值 ±3 provisional(d0 7[4,10] / d1 5[2,8] / d2 9[6,12] / d3 6[3,9] / Boss 9[6,12] / L2 7[4,10]);roll = 遭遇骰同流(encounter 恰一 scope,数量骰先、atk 骰后)spawn 期**整战持久**——每拍抖动否决(数字可读性锚),B2 零随机纪律保持(收 override 即确定值);spawn 签名 +1 参数(接口漂移登记,ADR-0009 同批);
- smoke_all 新增 `_smoke_power_interval`(4 断言:五行中点锚/roll 域内含清图槽/定值行零外溢/节点路径域内)。
**实测(N=30,种子 4200–4229)**:
- 奖励侧单独:完美档 0 加成 **100%**(承伤 3.5——与区间前逐值一致);基线档 17%(不变);
- 敌方侧叠加:完美档 0 加成仍 **100%**(承伤 3.5→3.9 / p90 18→19——方差真实流动但被血缓冲全吞);剩余 HP 均 47.4 不变;
- **验收结论:≤40% 线用数值区间杠杆族到不了**——完美玩家结构性优势(零踩雷 = 零直伤 + 全标旗 = weaken −65% 顶格 + 快杀 = 敌出手次数最少)不受值域方差影响。三选待裁决:①承伤硬地板(每结算不可减免平伤——S_min 压缩,B1/B2 域);②weaken 饱和/递减语义(拆"全标旗顶格"基石,B3/B1 域);③极端宽度(数值可用但毁基线档与可读性锚——建议否决)。**本批区间机制保留**(对普通玩家方差体验有效,中点锚不伤当量)。
**验证**:冒烟 **154/149+5 全绿**(区间批 5 新断言 + 既有全部回归含遭遇骰分布/回放)。
@@ -0,0 +1,163 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# A3 行动经济:被动账本(AP 硬重置 / 统一价目 / 三路径结束仲裁 cleared > stopped > ap_exhausted /
# RAP 池战斗域 / 双发射源之 stopped 直发)。相位域 = {Idle, Active, Waiting}。
# 实现 GDD:design/gdd/a3-action-economy.md(系统 ID A3——registry 追溯锚,命名规范见 CLAUDE.md)
class_name ActionEconomy
extends RefCounted
signal round_end_stopped(payload: Dictionary) # 双发射源之停手路径(A3 直发;payload 与 A2 棋盘路径同构)
const PHASE_IDLE := "Idle"
const PHASE_ACTIVE := "Active"
const PHASE_WAITING := "Waiting"
var db
# 相位与账本只读出口(E5 封装收口 2026-09-13):外部读取经 getter-only 属性(架构文档
# "ap/rap/phase 只读" 的机制化),写入只经 notify_* / request_* 方法通道。
var _phase: String = PHASE_IDLE
var phase: String:
get: return _phase
var _ap := 0
var ap: int:
get: return _ap
var _rap := 0
var rap: int:
get: return _rap
var _last_round_end: Dictionary = {} # {path_tag, continuation_hint}——A2 组包 round_end 的合法数据源
var last_round_end: Dictionary:
get: return _last_round_end
var _settlement_consumed := false # AC-14 门:notify_settlement_done 消费后才受理 round_start
var _next_round_mode := "" # 调试观测量(同板续轮 / 换新图)
func _init(db_ref) -> void:
db = db_ref
func _cost(kind: String) -> int:
var ae = db.action_economy
match kind:
"dig":
return ae.dig_ap_cost
"flag_place":
return ae.flag_place_ap_cost
"flag_pull":
return ae.flag_pull_ap_cost
"move":
return ae.move_ap_cost
"chain":
return ae.chain_ap_cost
assert(false, "未知动作价目: " + kind)
return 0
# 通用扣减(A2 用于旗动作;挖握走 request_dig——cleared 仲裁需要 clears_map 参数)
func try_spend(action_kind: String) -> bool:
if phase != PHASE_ACTIVE:
_reject("phase")
return false
return _spend_and_arbitrate(action_kind, false)
# A2 挖掘专用:chain_size = 连锁附加格数(不含首格);clears_map = 本次动作后全图安全格开完
func request_dig(chain_size: int, clears_map: bool) -> bool:
if phase != PHASE_ACTIVE:
_reject("phase")
return false
return _spend_and_arbitrate("dig", clears_map)
func _spend_and_arbitrate(action_kind: String, clears_map: bool) -> bool:
var cost := _cost(action_kind)
if _ap < cost:
_reject("resource_face")
return false
_ap -= cost
if clears_map:
# cleared 胜出(同帧仲裁 cleared > ap_exhausted;剩余 AP 作废不入 RAP——EC7/EC10)
_finish_round("cleared", "new_board")
elif _ap == 0:
# AP 归 0 瞬间轮次结束——不存在 0-AP 操作窗口
_finish_round("ap_exhausted", "same_board")
return true
# 停手:剩余 AP 1:1 转存 RAP;A3 直发 stopped 路径 round_end(双发射源)
func request_stop() -> bool:
if phase != PHASE_ACTIVE:
_reject("phase")
return false
_rap += _ap
_ap = 0
_finish_round("stopped", "same_board")
round_end_stopped.emit({"path_tag": "stopped", "continuation_hint": "same_board"})
return true
# 玩家发起的刷新(B1 在 Waiting 编排期代理调用;禁止 RAP 满自动刷)
func request_refresh() -> bool:
if phase != PHASE_WAITING:
_reject("phase")
return false
if _rap < db.action_economy.rap_refresh_cost:
return false # EC13 资源不足拒绝——合法相位内资源拒绝,非编排错误
_rap -= db.action_economy.rap_refresh_cost
return true
# 轮内刷新(2026-09-12 方案 A 用户拍板):Active 相位受理——仅扣 RAP,相位与 AP 均不动。
# 刷新不打断轮次、不触发结算(敌方时间税语义变更已登记 GDD 修订义务);换图编排归 B1
# (retire + install 立即执行,玩家同轮继续挖新图)。决策主体 = 玩家(经 D1 发起)纪律不变。
func request_refresh_active() -> bool:
if phase != PHASE_ACTIVE:
_reject("phase")
return false
if _rap < db.action_economy.rap_refresh_cost:
return false # EC13 资源不足拒绝——合法相位内资源拒绝,非编排错误
_rap -= db.action_economy.rap_refresh_cost
return true
# B1 回发:结算完成(仅记录编排意图;phase 停留 Waiting——AC-14 门不被绕过)
func notify_settlement_done(next_round_mode: String) -> bool:
if phase != PHASE_WAITING:
_reject("phase")
return false
_settlement_consumed = true
_next_round_mode = next_round_mode
return true
func notify_round_start() -> bool:
if phase == PHASE_IDLE:
pass # 战斗首轮唯一出口
elif phase == PHASE_WAITING and _settlement_consumed:
pass
else:
_reject("phase")
return false
_ap = db.action_economy.ap_per_round # 每轮硬重置(RAP 不动——唯一跨轮携带量)
_phase = PHASE_ACTIVE
_settlement_consumed = false
return true
func notify_combat_end() -> bool:
if phase != PHASE_WAITING:
_reject("phase")
return false
_phase = PHASE_IDLE
_rap = 0 # RAP 战斗域清零(用户拍板;D1 可见反馈义务)
_ap = 0
return true
func _finish_round(path_tag: String, continuation_hint: String) -> void:
_last_round_end = {"path_tag": path_tag, "continuation_hint": continuation_hint}
_phase = PHASE_WAITING
func _reject(source: String) -> void:
push_warning("[A3] 动作被拒(%s 相位守卫/资源)" % source if source == "phase" else "[A3] 资源不足拒绝")
@@ -0,0 +1 @@
uid://hxhl617bokyi
@@ -0,0 +1,792 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# B1 战斗结算编排中枢:轮次生命周期唯一 owner · 段序骨架(授予→窗口→增益/减益→道具→战斗→衰减→判定)·
# 13 类行协议可回放步骤流(零随机零 await)· 高光双落(完美排雷 ×2 / 标中旗 highlight)· 伏击预截流 ·
# Waiting 相位结算触发(不依赖事件到达序)。
# 切片登记:① 事件总线载体 = 直连信号(实现期 ADR 项,provisional)② 多次刷新 EC15 简化为每 Waiting 至多
# 一次(UI 禁二次发起;多刷契约义务不触)③ 效果行协议 = 授予段逐 grant 行 + 增益/减益段续存实例组合行。
# 实现 GDD:design/gdd/b1-settlement.md(系统 ID B1——registry 追溯锚,命名规范见 CLAUDE.md)
class_name BattleOrchestrator
extends RefCounted
signal step_row(row: Dictionary)
signal window_opened(items: Array)
signal battle_finished(payload: Dictionary) # {result, hero_hp, remaining_items}——hero 在 despawn 前捕获(呈现层销毁后读状态的时序防护)
signal round_started(round_index: int)
signal board_changed()
signal settlement_snapshot(snap: Dictionary) # 每结算末尾的全量状态快照(playtest_logger 消费——JSONL 数值分析)
var db
var rng
# E1 封装收口(2026-09-13):协作对象 a1/b2/b3/b4 私有化——跨层读取一律经 presentation_snapshot()/
# diagnostic_snapshot() 成文出口(呈现层链式穿透 b1._b2.hero_unit() 的根因收口)。
# a2/a3 保持公有 = 架构文档 API Boundaries 的 D1 消费面(意图提交 settle_batch + compute_move_path
# 只读;ap/rap/相位读取)——非本文档登记的契约面,勿增新用途。
var _a1: BoardGenerator
var a2: MinesweeperRules
var a3: ActionEconomy
var _b2: UnitContainer
var _b3: StatusContainer
var _b4: RewardEconomy
var encounter: Dictionary = {}
var round_index := 0
var battle_result := "" # "" / "victory" / "defeat"
var _facts_reveals: Array = []
var _facts_flags: Array = []
var _facts_path := ""
var _settling := false
var _awaiting_window := false
var _window_items: Array = []
var _refresh_used_this_waiting := false
var _refresh_used_this_round := 0 # 每轮刷新计数(Active+Waiting 合计,兜底上限 = ActionEconomyTable.refresh_max_per_round)
var _board_seq := 0 # 板序号(本战斗第几张板——快照归因用)
var _flag_consumed := {} # 兑付资格以雷格实例为键(板级;换图重置)
var _flag_miss_seen := {} # 标旗未中行去重(每雷格实例一次)
var _perfect_hit_true := 0 # 板级完美排雷计数(install 重置)
var _perfect_hit_false := 0
var _enemy_cap := 0 # EnemyTable 行值快照(伏击 cap)
var _m_amb := 0 # StatusEffectTable.ambush.magnitude 快照
var _hero_atk := 0 # 主角攻击基值快照(encounter.hero_atk;E4 收口——表值运行期不可变)
var _charge_dmg_a := 0 # 伤害A 充能:本轮开出的资格格计数(轮末结算消费后随 facts 重置)
var _charge_dmg_b := 0 # 伤害B 充能:本轮首次标中雷计数 ×damage_b_per_flag(每雷实例一次)
func _init(db_ref, rng_ref, shared_b4 = null) -> void:
db = db_ref
rng = rng_ref
_a1 = BoardGenerator.new()
a3 = ActionEconomy.new(db)
a2 = MinesweeperRules.new(db, a3)
_b2 = UnitContainer.new(db)
_b3 = StatusContainer.new(db)
# 库存关卡域(2026-09-11 关内延续):连续关卡模式注入共享 B4 容器,库存跨战斗延续
_b4 = shared_b4 if shared_b4 != null else RewardEconomy.new(db, rng)
a2.cell_revealed.connect(_on_cell_revealed)
a2.flag_judged.connect(_on_flag_judged)
a2.round_end_board.connect(_on_round_end)
a3.round_end_stopped.connect(_on_round_end)
# ---- 战斗开始序列(编排专章①:门 → B3 → B2 → install+roll → A3)----
func begin_battle(enc: Dictionary) -> bool:
if round_index != 0:
push_warning("[B1] begin_battle 重复受理")
return false
encounter = enc
db.set_reload_gate(true)
if not _b3.notify_battle_start():
_fatal("B3 开域被拒(编排违例)")
return false
_b4.notify_battle_start() # 可选类供给控制器战斗域清零(2026-09-11 供给控制器批——首件保证按战重置)
# 遭遇骰(C3 GDD Core Rule 8——2026-09-12 用户拍板):行有 unit_count_max 时按 [unit_count, max] 均匀骰;
# 2026-09-14 敌方攻击区间批扩展:行有 atk_min 时按 [atk_min, atk_max] 骰 spawn 期整战持久值
# (每拍抖动否决——数字可读性锚;中点 = 行值)。encounter 封闭流恰一 scope(数量骰先、atk 骰后);
# 挖掘决策域之外——P1 合规;B2 零随机纪律保持(收 override 即确定值)。
var erow_pre: Dictionary = db.resolve_enemy_rowset(int(enc.level_index), int(enc.node_depth))
var roll_lo: int = int(erow_pre.get("unit_count", 1))
var roll_hi: int = int(erow_pre.get("unit_count_max", roll_lo))
var needs_count: bool = roll_hi > roll_lo
var needs_atk: bool = erow_pre.has("atk_min")
var rolled := roll_lo
var rolled_atk := -1
if needs_count or needs_atk:
var estream: RandomNumberGenerator = rng.open_stream("encounter")
if needs_count:
rolled = roll_lo + estream.randi_range(0, roll_hi - roll_lo)
if needs_atk:
rolled_atk = int(erow_pre.atk_min) + estream.randi_range(0, int(erow_pre.atk_max) - int(erow_pre.atk_min))
# E4 收口:加成经 encounter 显式携带(hero_atk/hero_max_hp;缺省 = 表值——冒烟/工具直呼路径)
_hero_atk = int(enc.get("hero_atk", int(db.hero.atk)))
if not _b2.spawn(int(enc.level_index), int(enc.node_depth), int(enc.hero_hp), rolled,
_hero_atk, int(enc.get("hero_max_hp", -1)), rolled_atk):
_fatal("B2 spawn 被拒")
return false
var erow: Dictionary = db.resolve_enemy_rowset(int(enc.level_index), int(enc.node_depth))
_enemy_cap = int(erow.get("advantage_per_round_cap", 0))
_m_amb = int(db.status_effect_row("ambush").get("magnitude", 0))
if not _install_new_board():
return false
_reset_round_facts() # 首板 install 的初始展开事件不入轮 facts(reward_eligible 恒 false 的双保险)
if not a3.notify_round_start():
_fatal("A3 首轮 notify_round_start 被拒")
return false
round_index = 1
round_started.emit(round_index)
return true
func _install_new_board(during_round := false) -> bool:
var stream: RandomNumberGenerator = rng.open_stream("board_gen")
var board: BoardData = _a1.generate(int(encounter.level_index), int(encounter.node_depth), stream, db)
if board == null:
return false
var res: Dictionary = a2.install_board(board, during_round)
if not res.accepted:
_fatal("A2 install 拒绝(不变式违例): %s" % str(res))
return false
# B4 义务⑥:roll_board 恰一次(install 后、挖掘前);资格格 = S ∖ R₀
var r0 := {}
for c in board.compute_r0():
r0[c] = true
var eligible: Array = []
for c in board.safe_cells():
if r0.has(c):
continue
eligible.append({"cell": c, "fog": board.variant_ids(c).size() > 0})
if not _b4.roll_board(eligible):
_fatal("B4 roll_board 被拒")
return false
_flag_consumed.clear() # 换图新雷 = 新实例,资格自然重置(A3 Rule 8)
_flag_miss_seen.clear()
_perfect_hit_true = 0 # 板级完美计数随 install 重置
_perfect_hit_false = 0
_board_seq += 1
board_changed.emit()
return true
# ---- 事件缓冲(轮 facts 收集;结算触发 = Waiting 相位编排点,不依赖到达序)----
func _on_cell_revealed(evt: Dictionary) -> void:
# 轮 facts 缓冲不设相位守卫——轮末最后一挖的揭示事件在 request_dig 翻相之后才到(事件序:
# cell_revealed 批 → flag_judged 批 → round_end),phase==Active 守卫会丢最后一挖的事实;
# initial 通道事件(install 初始展开)reward_eligible=false,进入授予段被资格检查自然跳过。
if battle_result == "":
if evt.kind == "safe" and bool(evt.get("reward_eligible", false)):
# 身份快照随缓冲捕获(批次隔离——轮内刷新后 mapping 已换批,授予必须用缓冲期身份,
# 防旧图已挖格在轮末错拿新图同坐标身份)
var snap = _b4.query_full_snapshot(evt.cell)
if snap != null:
evt = evt.duplicate()
evt["reward_snapshot"] = snap
_facts_reveals.append(evt)
func _on_flag_judged(evt: Dictionary) -> void:
_facts_flags.append(evt)
func _on_round_end(_payload: Dictionary) -> void:
_facts_path = String(_payload.get("path_tag", ""))
_maybe_settle()
func _maybe_settle() -> void:
if _settling or battle_result != "":
return
if a3.phase != a3.PHASE_WAITING:
return
_settle()
# ---- 每轮结算序列(段序骨架——Core Rule 4)----
func _settle() -> void:
_settling = true
var ri := round_index
# ① 授予段(含 grant_clear 兑付)
var mine_cells: Array = []
for evt in _facts_reveals:
if evt.kind == "mine":
mine_cells.append(evt.cell)
elif evt.kind == "safe" and evt.reward_eligible:
_charge_dmg_a += 1 # 伤害A 充能:探索本身计费,与该格奖励是否兑付成功无关(初始展开格 reward_eligible=false 不计)
_grant_cell(evt.cell, ri, "safe_cell", evt.get("reward_snapshot"))
# reward_eligible=false(initial 通道)不入库——B1 义务②(A3 EC5 委派闭环)
for evt in _facts_flags:
_judge_flag(evt, ri)
if not mine_cells.is_empty():
_convert_ambush(mine_cells, ri)
if _facts_path == "cleared":
_grant_clear_reward(ri)
# ② 道具使用窗口(授予段后、增益/减益段前——当轮拾取当轮可用)
_window_items = _assemble_window()
if not _window_items.is_empty():
_awaiting_window = true
window_opened.emit(_window_items)
_settling = false
return # 窗口选择序列 = 结算回放输入事件
_finish_settlement([])
func _assemble_window() -> Array:
# 窗口数据组装 = B4 query_inventory 投影 + B1 侧守卫预判 usable_now(B4 零跨系统读不破)
var out: Array = []
for inst in _b4.query_inventory():
if inst.status != "InStock":
continue
var usable := true
if inst.category == "heal_pack":
usable = int(_b2.hero_unit().hp) < int(_b2.hero_unit().max_hp) # 满血治疗包禁选
out.append({
"index": inst.index,
"item_id": inst.item_id,
"display_name": inst.display_name,
"category": inst.category,
"power": inst.power,
"source_cell": inst.source_cell,
"round_index": inst.round_index,
"usable_now": usable,
})
return out
func submit_window_selection(selection: Array) -> void:
if not _awaiting_window:
push_warning("[B1] 无窗口期的选择提交(编排违例)")
return
_awaiting_window = false
_settling = true
if selection.size() > 1:
push_warning("[B1] 窗口至多选用一件(2026-09-11 用户拍板——避免结算复杂度)——超出部分忽略")
_finish_settlement(selection.slice(0, 1))
func _finish_settlement(selection: Array) -> void:
var ri := round_index
# ③ 增益/减益段(续存实例组合行;本结算新授予的已在授予段逐 grant 出行——B3 义务⑦)
_emit_composition_effects("hero", ri)
_emit_composition_effects("enemy", ri)
# ④ 道具段(窗口选定序逐件生效;逐件动态守卫)
for idx in selection:
_apply_selected_item(int(idx), ri)
# ⑤ 战斗段(调用序钉死 query_attack → apply_damage → advance;hero hp=0 短路)
_combat_segment(ri)
# ⑥ 衰减段(恰一次恒执行含末轮——先兑付后衰减)
var k: int = _b3.decay_round()
_emit_row("decay", "", Vector2i(-1, -1), ri, "状态衰减", k, {}, "")
# ⑦ 判定段(防御性优先级 hero 先判——双死结构不可达的纵深防御)
var verdict := "continue"
if int(_b2.hero_unit().get("hp", 0)) <= 0:
verdict = "defeat"
elif _b2.query_alive("enemy").is_empty():
verdict = "victory"
_emit_settlement_snapshot(verdict) # 全量状态快照(despawn 前组装——终局含 result)
if verdict == "defeat":
_emit_row("verdict", "", Vector2i(-1, -1), ri, "战斗失败", 0, {}, "")
_battle_over("defeat")
return
if verdict == "victory":
_emit_row("verdict", "", Vector2i(-1, -1), ri, "战斗胜利", 1, {}, "")
_battle_over("victory")
return
_emit_row("verdict", "", Vector2i(-1, -1), ri, "下一轮次", 0, {}, "")
_exit_distribution()
_settling = false
# ---- 授予段 helpers ----
# snap_override 非空 = 冻结快照授予(轮内刷新批——缓冲期身份,绕过现行 mapping/_granted)
func _grant_cell(cell: Vector2i, ri: int, source_class: String, snap_override = null) -> void:
var snap = snap_override if snap_override != null else _b4.grant(cell)
if not (snap is Dictionary):
return
if snap.category == "buff":
var ok: bool = _b3.apply_effect(snap.effect_id, {"source_class": source_class, "origin_cell": cell, "round_index": ri})
if ok:
var row: Dictionary = db.status_effect_row(snap.effect_id)
var pool := _pool_effective(snap.effect_id)
_emit_row("effect", source_class, cell, ri, String(row.display_name), int(row.magnitude), {"effect_id": snap.effect_id, "pool_value": pool})
else:
_emit_row("reward_ineffective", source_class, cell, ri, "奖励未生效(满层)", 0, {"batch_key": String(snap.effect_id)})
elif _resolve_instant(snap, source_class, cell, ri):
pass # 即时类:授予段当场兑付(不进库存、零决策——2026-09-11 两类奖励裁决)
else:
_b4.stock(snap, cell, ri)
_emit_row("item", source_class, cell, ri, "%s 入库" % snap.display_name, int(snap.power), {})
# 即时类当场兑付(2026-09-11 两类奖励裁决):instant_aoe = 全体存活敌各 power 点;
# instant_heal = 主角 +power(clamp,满血 → "奖励未生效"行)。返回 false = 非即时类(道具类走库存)
func _resolve_instant(snap: Dictionary, source_class: String, cell: Vector2i, ri: int, suffix: String = "", highlight: String = "") -> bool:
if snap.category == "instant_aoe":
var alive: Array = _b2.query_alive("enemy")
if alive.is_empty():
_emit_row("reward_ineffective", source_class, cell, ri, "%s 未生效(无存活敌人)%s" % [snap.display_name, suffix], 0, {"batch_key": "instant_aoe"}, highlight)
return true
for u in alive:
_b2.apply_damage(u, int(snap.power)) # 恒定表值,逐个;受击即死——死亡即不动手
_emit_row("damage", source_class, cell, ri, "%s → 全体敌人 ×%d%s" % [snap.display_name, alive.size(), suffix], int(snap.power), {"target": "all_enemies", "count": alive.size()}, highlight)
return true
if snap.category == "instant_heal":
if int(_b2.hero_unit().hp) >= int(_b2.hero_unit().max_hp):
_emit_row("reward_ineffective", source_class, cell, ri, "%s 未生效(满血)%s" % [snap.display_name, suffix], 0, {"batch_key": "instant_heal"}, highlight)
return true
var healed: int = _b2.heal(_b2.hero_unit(), int(snap.power)) # clamp 归 B1
_emit_row("heal", source_class, cell, ri, "%s 回复%s" % [snap.display_name, suffix], healed, {"target": "hero"}, highlight)
return true
return false
func _judge_flag(evt: Dictionary, ri: int) -> void:
var cell: Vector2i = evt.cell
if evt.hit:
if _flag_consumed.has(cell):
return # 去重跳过(资格以雷格实例为键——A2 诚实重复上报的对侧);零明细行
_flag_consumed[cell] = true
_perfect_hit_true += 1
_charge_dmg_b += int(db.settlement.damage_b_per_flag) # 伤害B 充能:与 weaken 消费同键(每雷实例一次——忘拔旗不重复充能)
var ok: bool = _b3.apply_effect("weaken", {"source_class": "flag_hit", "origin_cell": cell, "round_index": ri})
if ok:
var row: Dictionary = db.status_effect_row("weaken")
_emit_row("effect", "flag_hit", cell, ri, String(row.display_name), int(row.magnitude), {"effect_id": "weaken", "pool_value": _pool_effective("weaken")}, "flag_hit")
else:
_emit_row("reward_ineffective", "flag_hit", cell, ri, "奖励未生效(满层)", 0, {"batch_key": "weaken"})
else:
_perfect_hit_false += 1
if _flag_miss_seen.has(cell):
return # 每雷格实例一次去重(防逐轮惩罚轰炸)
_flag_miss_seen[cell] = true
var hl := ""
if _perfect_hit_false == 1:
hl = "perfect_broken" # 本板完美谓词已破的归因锚
_emit_row("flag_miss", "flag_miss", cell, ri, "标旗未中", 0, {}, hl)
_apply_miss_backlash(cell, ri)
# 标错惩罚(2026-09-12 用户拍板:随机给敌方一个增伤/buff,或我方 debuff;时效 1 结算——用户拍板"短点,比如 1 回合")。
# 随机载体 = 坐标×板序号确定性派生(零新流、同种子可回放;正式侧候选 = RngService 新增封闭流——待拍板,
# 见 design/research/lore-nest-metaphor-2026-09-12.md ③ 边界提醒)。三池项:enrage(敌增伤)/ ambush×2(敌 buff)/ off_guard(我方 debuff)。
func _apply_miss_backlash(cell: Vector2i, ri: int) -> void:
var kind: int = (absi(cell.x) * 7 + absi(cell.y) * 13 + _board_seq * 5) % 3
var src: Dictionary = {"source_class": "flag_miss", "origin_cell": cell, "round_index": ri}
match kind:
0:
if _b3.apply_effect("enrage", src):
var row: Dictionary = db.status_effect_row("enrage")
_emit_row("effect", "flag_miss", cell, ri, "反噬·%s(敌方)" % String(row.display_name), int(row.magnitude), {"effect_id": "enrage", "pool_value": _pool_effective("enrage")})
else:
_emit_row("reward_ineffective", "flag_miss", cell, ri, "反噬未生效(满层)", 0, {"batch_key": "enrage"})
1:
var granted := 0
for i in range(2):
if _b3.apply_effect("ambush", src):
granted += 1
if granted > 0:
_emit_row("effect", "flag_miss", cell, ri, "反噬·敌方伏击 +%d 层" % granted, granted, {"effect_id": "ambush", "pool_value": _b3.pool_layers("ambush") * _m_amb})
else:
_emit_row("reward_ineffective", "flag_miss", cell, ri, "反噬未生效(满层)", 0, {"batch_key": "ambush"})
2:
if _b3.apply_effect("off_guard", src):
var row2: Dictionary = db.status_effect_row("off_guard")
_emit_row("effect", "flag_miss", cell, ri, "反噬·%s(我方)" % String(row2.display_name), int(row2.magnitude), {"effect_id": "off_guard", "pool_value": _pool_effective("off_guard")})
else:
_emit_row("reward_ineffective", "flag_miss", cell, ri, "反噬未生效(满层)", 0, {"batch_key": "off_guard"})
func _convert_ambush(mine_cells: Array, ri: int) -> void:
# B2 义务②⑥:预截流计划面 → 前 granted 颗逐颗转化(事件规范序);每颗雷恰一次
var plan: Dictionary = _b2.ambush_grant_plan(mine_cells.size(), _enemy_cap)
for i in range(plan.granted):
var cell: Vector2i = mine_cells[i]
var ok: bool = _b3.apply_effect("ambush", {"source_class": "mine_hit", "origin_cell": cell, "round_index": ri})
if ok:
var layers := _b3.pool_layers("ambush")
_emit_row("effect", "mine_hit", cell, ri, "伏击", _m_amb, {"effect_id": "ambush", "pool_value": layers * _m_amb})
for i in range(plan.granted, plan.granted + plan.overflow):
var cell: Vector2i = mine_cells[i]
_emit_row("overflow", "mine_hit", cell, ri, "伏击溢出未生效", 0, {"batch_key": "ambush_overflow"})
func _grant_clear_reward(ri: int) -> void:
var snap = _b4.grant_clear()
if not (snap is Dictionary):
return
var perfect: bool = _perfect_hit_false == 0 and _perfect_hit_true >= 1
# 首份兑付
var first_ok := _grant_clear_identity(snap, ri)
# 完美排雷:真实双份交付(第二份走既有授予通道;grant_clear 本身仍恰一次)
var second_ok := false
if perfect and first_ok:
if snap.category == "buff":
second_ok = _b3.apply_effect(snap.effect_id, {"source_class": "clear_reward", "origin_cell": Vector2i(-1, -1), "round_index": ri})
if second_ok:
var row: Dictionary = db.status_effect_row(snap.effect_id)
_emit_row("effect", "clear_reward", Vector2i(-1, -1), ri, "%s(完美第二份)" % String(row.display_name), int(row.magnitude), {"effect_id": snap.effect_id, "pool_value": _pool_effective(snap.effect_id)})
else:
var s2 = _b4.stock(snap, Vector2i(-1, -1), ri)
second_ok = s2 is Dictionary
if second_ok:
_emit_row("item", "clear_reward", Vector2i(-1, -1), ri, "%s 入库(完美第二份)" % snap.display_name, int(snap.power), {})
if perfect and first_ok:
# 高光行 value = 实际有效兑付份数(禁记名义值;首份满层零兑付 → 降级不发)
_emit_row("highlight", "clear_reward", Vector2i(-1, -1), ri, "完美排雷!清图奖励 ×2", (2 if second_ok else 1), {}, "perfect_sweep")
func _grant_clear_identity(snap: Dictionary, ri: int) -> bool:
if snap.category == "buff":
var ok: bool = _b3.apply_effect(snap.effect_id, {"source_class": "clear_reward", "origin_cell": Vector2i(-1, -1), "round_index": ri})
if ok:
var row: Dictionary = db.status_effect_row(snap.effect_id)
_emit_row("effect", "clear_reward", Vector2i(-1, -1), ri, String(row.display_name), int(row.magnitude), {"effect_id": snap.effect_id, "pool_value": _pool_effective(snap.effect_id)}, "clear_reward_h2")
return true
_emit_row("reward_ineffective", "clear_reward", Vector2i(-1, -1), ri, "清图奖励未生效(满层)", 0, {"batch_key": snap.effect_id})
return false
if _resolve_instant(snap, "clear_reward", Vector2i(-1, -1), ri, "(清图奖励)", "clear_reward_h2"):
return true
var s = _b4.stock(snap, Vector2i(-1, -1), ri)
if not (s is Dictionary):
return false
_emit_row("item", "clear_reward", Vector2i(-1, -1), ri, "%s 入库(清图奖励)" % snap.display_name, int(snap.power), {}, "clear_reward_h2")
return true
# ---- 窗口 / 道具段 ----
func _apply_selected_item(window_index: int, ri: int) -> void:
if window_index < 0 or window_index >= _window_items.size():
return
var item: Dictionary = _window_items[window_index]
if item.category == "heal_pack":
# 逐件动态守卫:前件推满后本件拦截(不烧包——B1 不调 use)
if int(_b2.hero_unit().hp) >= int(_b2.hero_unit().max_hp):
_emit_row("use_rejected", "item", item.source_cell, ri, "%s 被拒(满血)" % item.display_name, 0, {})
return
var receipt = _b4.use(item.index)
if not (receipt is Dictionary):
return
var healed: int = _b2.heal(_b2.hero_unit(), receipt.power) # clamp 归 B1
_emit_row("heal", "item", receipt.source_cell, ri, "%s 回复" % item.display_name, healed, {"target": "hero"})
else:
# 伤害道具:恒定表值不吃修正;零效果判定读 B2 存活集(照常消耗——E10)
if _b2.query_alive("enemy").is_empty():
var receipt0 = _b4.use(item.index)
if not (receipt0 is Dictionary):
return
_emit_row("consumed_ineffective", "item", receipt0.source_cell, ri, "已消耗未生效(无存活敌人)", 0, {})
return
var receipt = _b4.use(item.index)
if not (receipt is Dictionary):
return
var target = _b2.default_target()
var killed_before: bool = not target.alive
_b2.apply_damage(target, receipt.power)
var killed: bool = not killed_before and not target.alive
_emit_row("damage", "item", receipt.source_cell, ri, "%s 命中" % item.display_name, receipt.power, {"target": target.display_name, "killed": killed})
# ---- 战斗段 ----
func _combat_segment(ri: int) -> void:
var L := _b3.pool_layers("ambush") # 授予段后的当轮值;池共享不消耗
# ⓪ 主角先手攻击(基础攻击恒存在——2026-09-11 用户裁决;先于敌方行动)。
# 三段伤害构成(2026-09-12 用户拍板):基础(atk×(1+attack_up),不吃克制)+ 伤害A(开格充能×克制)+ 伤害B(标雷充能×克制),
# 按目标护甲类型分别乘克制系数后 floor 相加;attack_up 仍只乘基础攻击(刀/AOE 恒定表值不吃修正不变)。
var hero_target = _b2.default_target()
if hero_target != null:
var armor: String = String(hero_target.get("armor_type", "none"))
var up := clampi(_pool_effective("attack_up"), int(db.settlement.pool_clamp_lo), int(db.settlement.pool_clamp_hi))
var base := maxi(0, int(floor(float(_hero_atk) * (100.0 + float(up)) / 100.0)))
var comp_a := _charge_damage_component("dmg_a", _charge_dmg_a, armor)
var comp_b := _charge_damage_component("dmg_b", _charge_dmg_b, armor)
var d_hero: int = base + comp_a + comp_b
if d_hero > 0:
_b2.apply_damage(hero_target, d_hero)
_emit_row("damage", "hero_attack", Vector2i(-1, -1), ri, "主角攻击", d_hero, {"target": hero_target.display_name, "killed": not hero_target.alive, "base": base, "dmg_a": comp_a, "dmg_b": comp_b})
for u in _b2.query_alive("enemy"):
if int(_b2.hero_unit().hp) <= 0:
break # hero hp=0 立即短路(死尸挨打行是归因噪音——用户拍板)
var beat: String = _b2.query_beat(u)
var atk: Dictionary = _b2.query_attack(u, L, _m_amb) # 调用序①兑付读值
if beat == "charge":
_emit_row("charge", "enemy_action", Vector2i(-1, -1), ri, "%s 蓄力(本轮踩雷白喂)" % u.display_name, 0, {"target": "hero"})
else:
var d_tick := _resolve_damage(int(atk.total))
if d_tick <= 0:
_emit_row("damage", "enemy_action", Vector2i(-1, -1), ri, "%s 攻击(被减伤归零)" % u.display_name, 0, {"target": "hero", "baseline": int(atk.baseline), "attach": int(atk.attach)})
else:
_b2.apply_damage(_b2.hero_unit(), d_tick) # 调用序②受击上报
_emit_row("damage", "enemy_action", Vector2i(-1, -1), ri, "%s 攻击" % u.display_name, d_tick, {"target": "hero", "baseline": int(atk.baseline), "attach": int(atk.attach)})
_b2.advance(u) # 调用序③推进——每结算每存活敌恰一次(hero 不在推进域)
# F2 乘区:池内加算 → clamp [-100,+100] → 池间乘算 → floor(单一取整规则)——算术归 DamageMath(O3)
func _resolve_damage(d_raw: int) -> int:
var p_hero := _clamped_pool("hero_defense")
var p_enemy := _clamped_pool("enemy_output")
return DamageMath.pool_damage(d_raw, p_hero, p_enemy)
# 伤害A/B 充能分量:充能 × 每单位值 × 克制 percent → floor(伤害A 先套单轮充能上限——防连锁轮失控)
func _charge_damage_component(component: String, count: int, armor: String) -> int:
var per: int = int(db.settlement.damage_a_per_cell) if component == "dmg_a" else int(db.settlement.damage_b_per_flag)
var charge := count
if component == "dmg_a" and int(db.settlement.damage_a_cap) > 0:
charge = mini(charge, int(db.settlement.damage_a_cap))
return DamageMath.charge_component(charge, per, _armor_effectiveness(component, armor))
# 克制矩阵读值(SettlementTable.armor_effectiveness:{分量: {护甲: percent}}——缺项回退 100 中性)
func _armor_effectiveness(component: String, armor: String) -> int:
var tbl: Dictionary = db.settlement.armor_effectiveness
var row: Dictionary = tbl.get(component, {})
return int(row.get(armor, 100))
func _clamped_pool(pool: String) -> int:
var total := 0
for eff in _b3.query_active("hero") + _b3.query_active("enemy"):
var row: Dictionary = db.status_effect_row(eff.effect_id)
if row.is_empty():
continue
if String(db.settlement.effect_pool_map.get(eff.effect_id, "")) == pool and String(row.unit) == "percent":
total += eff.effective_value # 池内加算(L×m 原样——越域不 clamp 由本处收口)
return clampi(total, int(db.settlement.pool_clamp_lo), int(db.settlement.pool_clamp_hi))
func _pool_effective(effect_id: String) -> int:
var side := "hero"
var row: Dictionary = db.status_effect_row(effect_id)
if not row.is_empty():
side = String(row.target_side)
for eff in _b3.query_active(side):
if eff.effect_id == effect_id:
return eff.effective_value
return 0
func _emit_composition_effects(side: String, ri: int) -> void:
for eff in _b3.query_active(side):
if eff.round_index < ri:
# 续存实例(前轮授予)——每结算兑付一次的可见面(合并实例只持首授归因——B3 义务⑦)
_emit_row("effect", eff.source_class, eff.origin_cell, ri, eff.display_name, eff.snap_magnitude, {"effect_id": eff.effect_id, "pool_value": eff.effective_value})
# ---- 结算快照(playtest 数值分析数据面——每次结算全量状态;运行时零决策读取)----
func _emit_settlement_snapshot(verdict: String) -> void:
var enemies: Array = []
for u in _b2.query_alive("enemy"):
enemies.append({"id": String(u.enemy_id), "hp": int(u.hp), "max_hp": int(u.max_hp), "atk": int(u.atk), "beat": query_beat_of(u), "armor": String(u.get("armor_type", "none"))})
var buffs: Array = []
for eff in _b3.query_active("hero"):
buffs.append({"id": eff.effect_id, "side": "hero", "layers": eff.layers, "left": eff.remaining_settlements})
for eff in _b3.query_active("enemy"):
buffs.append({"id": eff.effect_id, "side": "enemy", "layers": eff.layers, "left": eff.remaining_settlements})
var mines_hit := 0
var safe_n := 0
for evt in _facts_reveals:
if evt.kind == "mine":
mines_hit += 1
elif evt.kind == "safe":
safe_n += 1
var board_stats := {"seq": _board_seq}
if a2.current_board() != null:
board_stats["w"] = a2.current_board().width
board_stats["h"] = a2.current_board().height
board_stats["revealed"] = a2.revealed_cells().size()
board_stats["flags"] = a2.flag_count()
settlement_snapshot.emit({
"kind": "settlement",
"level": int(encounter.get("level_index", 0)),
"depth": int(encounter.get("node_depth", 0)),
"round": round_index,
"verdict": verdict,
"unit_count": enemies.size(),
"spawn_count": _b2.all_enemies().size(), # 本战斗实例化总数(含死亡残位)——unit_count=0 时区分「首轮全灭」vs「空场」(playtest-2026-09-12 报告 Bug#1 误读教训)
"hero": {"hp": int(_b2.hero_unit().get("hp", 0)), "max_hp": int(_b2.hero_unit().get("max_hp", 0)), "atk": int(_b2.hero_unit().get("atk", 0))},
"economy": {"phase": String(a3.phase), "ap": int(a3.ap), "rap": int(a3.rap), "refresh_used": _refresh_used_this_round},
"board": board_stats,
"enemies": enemies,
"buffs": buffs,
"ambush_layers": _b3.pool_layers("ambush"),
"inventory_in_stock": _b4.in_stock_count(),
"round_facts": {"path": _facts_path, "safe": safe_n, "mines": mines_hit, "flag_events": _facts_flags.size(), "charge_dmg_a": _charge_dmg_a, "charge_dmg_b": _charge_dmg_b},
})
func query_beat_of(u: Dictionary) -> String:
return _b2.query_beat(u)
# ---- 出口分发与战斗结束序列 ----
func _exit_distribution() -> void:
var mode := "same_board"
if _facts_path == "cleared":
mode = "new_board" # cleared 轮恒走 new_board(契约义务)
elif _refresh_used_this_waiting:
mode = "new_board" # stopped/ap_exhausted 轮:仅 Waiting 期玩家刷新意图达成时换图(违约 fail-fast 的对侧)
if mode == "new_board":
_b4.retire_mapping()
a2.retire_board()
if not _install_new_board():
_fatal("换图编排失败")
return
a3.notify_settlement_done(mode)
a3.notify_round_start()
_reset_round_facts()
_refresh_used_this_waiting = false
_refresh_used_this_round = 0
round_index += 1
round_started.emit(round_index)
# 呈现层查询面(HUD 刷新按钮可用性)
func can_request_refresh() -> bool:
return battle_result == "" \
and _refresh_used_this_round < int(db.action_economy.refresh_max_per_round) \
and (a3.phase == a3.PHASE_ACTIVE or a3.phase == a3.PHASE_WAITING) \
and (a3.phase != a3.PHASE_WAITING or not _refresh_used_this_waiting) \
and a3.rap >= int(db.action_economy.rap_refresh_cost)
# 玩家经 D1 发起、B1 代理调用(禁止 RAP 满自动刷)。
# 轮内刷新(2026-09-12 方案 A 用户拍板):Active 相位立即换图继续挖(RAP -8;AP 保留、相位不动、
# 不触发结算);Waiting 相位保持现状(登记意图,exit_distribution 换图)。每轮上限 = 表值(两相位合计)。
func player_request_refresh() -> bool:
if not can_request_refresh():
return false
if a3.phase == a3.PHASE_ACTIVE:
if not a3.request_refresh_active():
return false
_refresh_used_this_round += 1
_b4.retire_mapping() # 旧图批次退役(B4 Ready→retired;E6 新批重置前提)
a2.retire_board(true) # 旧板退役(轮内通道)
return _install_new_board(true) # 当场换图——失败即编排违例 fail-fast
if not a3.request_refresh():
return false
_refresh_used_this_round += 1
_refresh_used_this_waiting = true
return true
func _battle_over(result: String) -> void:
battle_result = result
# 库存关卡域(2026-09-11 关内延续):battle_over 不再清库存——跨战斗延续,关卡末经 level_clear() 清空;
# 映射随战斗结束退役(下一场战斗 = 新板新 scope 批,roll_board 相位守卫的前提)
var hero_hp_left := int(_b2.hero_unit().get("hp", 0)) # despawn 前捕获(信号载荷供呈现层"HP 带入下一层")
_b4.retire_mapping()
_b2.despawn_all()
_b3.notify_battle_end()
a3.notify_combat_end() # → Idle,RAP 清零
db.set_reload_gate(false)
_reset_round_facts()
battle_finished.emit({"result": result, "hero_hp": hero_hp_left, "remaining_items": _b4.in_stock_count()})
# ---- 呈现状态面(E1 封装收口 2026-09-13):呈现层 / headless 自动玩家的唯一读取出口——
# 协作对象(_b2/_b3/_b4)私有化后,跨层读取一律经本快照或下列命名面(diagnostic_snapshot 同款模式,
# 禁再链式穿透 b1.b2.hero 一类路径)。载荷 schema 成文(code-conventions 2.1):
# round / battle_result / phase / ap / rap / can_request_refresh / hero{hp,max_hp,atk,alive} /
# enemies[{order,display_name,hp,max_hp,enemy_id,armor_type,beat,ring_size,ring_index,ambush_layers,alive}] /
# ambush_layers / hero_effects[](B3 query_active 投影) / inventory[](B4 query_inventory 投影) / in_stock
func presentation_snapshot() -> Dictionary:
var enemies: Array = []
var all: Array = _b2.all_enemies()
var ambush: int = _b3.pool_layers("ambush")
for i in all.size():
var u: Dictionary = all[i]
var alive: bool = bool(u.get("alive", false))
var ring: Array = u.get("pattern", [])
enemies.append({
"order": i + 1,
"display_name": String(u.display_name),
"hp": int(u.hp), "max_hp": int(u.max_hp),
"enemy_id": String(u.enemy_id),
"armor_type": String(u.get("armor_type", "none")),
"beat": String(_b2.query_beat(u)) if alive else "",
"ring_size": ring.size(),
"ring_index": int(u.get("steps", 0)) % maxi(ring.size(), 1),
"ambush_layers": ambush,
"alive": alive,
})
var hero := {}
var h: Dictionary = _b2.hero_unit()
if not h.is_empty():
hero = {"hp": int(h.hp), "max_hp": int(h.max_hp), "atk": int(h.atk), "alive": bool(h.get("alive", false))}
return {
"round": round_index,
"battle_result": battle_result,
"phase": String(a3.phase),
"ap": int(a3.ap), "rap": int(a3.rap),
"can_request_refresh": can_request_refresh(),
"hero": hero,
"enemies": enemies,
"ambush_layers": ambush,
"hero_effects": _b3.query_active("hero"),
"inventory": _b4.query_inventory(),
"in_stock": _b4.in_stock_count(),
}
# 战斗进行中谓词(hero 在场且存活)——组合根高频相位判断的低成本面
func is_battle_live() -> bool:
return battle_result == "" and not _b2.hero_unit().is_empty()
# 库存余量(层间过渡文案 / 终局清库反馈)
func stock_count() -> int:
return _b4.in_stock_count()
# 奖励身份解析(组合根注入 BoardView 徽章回调的只读代理——Ready 相位守卫归 B4)
func query_reward_identity(cell: Vector2i):
return _b4.query_identity(cell)
# 诊断观测面(组合根诊断转储 / 行为探针消费):私有编排状态的成文只读出口——
# 消费方禁止再直读本类下划线字段(code-review 2026-09-12 封装收口批)
func diagnostic_snapshot() -> Dictionary: return {
"battle_result": battle_result,
"round": round_index,
"settling": _settling,
"awaiting_window": _awaiting_window,
"refresh_used_round": _refresh_used_this_round,
"refresh_used_waiting": _refresh_used_this_waiting,
"board_seq": _board_seq,
"facts_path": _facts_path,
"facts_reveals": _facts_reveals.size(),
"facts_flags": _facts_flags.size(),
"a3": {"phase": String(a3.phase), "ap": a3.ap, "rap": a3.rap},
"a2_board_installed": a2.current_board() != null,
"a2_revealed": a2.revealed_cells().size() if a2.current_board() != null else 0,
"a2_flags": a2.flag_count() if a2.current_board() != null else 0,
"b4_phase_stock": [_b4.in_stock_count()],
"enemies_alive": _b2.query_alive("enemy").size(),
"hero_hp": int(_b2.hero_unit().get("hp", -1)) if not _b2.hero_unit().is_empty() else -1,
}
# 关卡结束编排(C1/C2 关卡结构落位前由上层显式调用——2026-09-11 关内延续):
# 清空库存并返回快照计数("剩余道具 ×N 随关卡结束清空"反馈行 / 折算结构位的数据面)
func level_clear() -> Dictionary:
var remaining := _b4.in_stock_count()
_b4.clear_inventory()
return {"remaining": remaining}
func _reset_round_facts() -> void:
_facts_reveals.clear()
_facts_flags.clear()
_facts_path = ""
_charge_dmg_a = 0 # 三段伤害充能随轮 facts 重置(每轮重新充能——结算段消费当轮值)
_charge_dmg_b = 0
# ---- 行协议(Core Rule 14:公共字段 + 行类型特有字段)----
func _emit_row(type: String, source_class: String, origin_cell: Vector2i, ri: int, display: String, value: int, extra: Dictionary, highlight: String = "") -> void:
var row := {
"type": type,
"source_class": source_class,
"origin_cell": [origin_cell.x, origin_cell.y],
"round_index": ri,
"display_name": display,
"value": value,
}
if highlight != "":
row["highlight"] = highlight
for k in extra.keys():
row[k] = extra[k]
step_row.emit(row)
func _fatal(msg: String) -> void:
push_error("[B1/fatal] " + msg)
assert(false, msg)
@@ -0,0 +1 @@
uid://ctw48bkgsuh7m
@@ -0,0 +1,122 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# A1 棋盘产物数据契约(A2 消费;生成后不可变——A2 持快照即持活引用,生成器不再触碰)。
# 实现 GDD:design/gdd/a1-board-generation.md(系统 ID A1——registry 追溯锚,命名规范见 CLAUDE.md)
class_name BoardData
extends RefCounted
var width: int = 0
var height: int = 0
var spawn_center := Vector2i.ZERO
var mines := {} # Vector2i → true
var digits := {} # Vector2i → int(安全格恒有值;数字恒真 = 8 邻雷数)
var variants := {} # Vector2i → Array[String](生成期快照,抗表重载)
var biome := ""
var level_index: int = 1
var node_depth: int = 0
var metrics := {} # 八项生成期指标
var illegal_product := false
func in_bounds(c: Vector2i) -> bool:
return c.x >= 0 and c.y >= 0 and c.x < width and c.y < height
func index_of(c: Vector2i) -> int:
return c.y * width + c.x
func is_mine(c: Vector2i) -> bool:
return mines.has(c)
func is_safe(c: Vector2i) -> bool:
return in_bounds(c) and not mines.has(c)
func digit_of(c: Vector2i) -> int:
return digits.get(c, -1)
func variant_ids(c: Vector2i) -> Array:
return variants.get(c, [])
func has_variant(c: Vector2i, vid: String) -> bool:
return variant_ids(c).has(vid)
func neighbors8(c: Vector2i) -> Array:
var out: Array = []
for dy in range(-1, 2):
for dx in range(-1, 2):
if dx == 0 and dy == 0:
continue
var n := Vector2i(c.x + dx, c.y + dy)
if in_bounds(n):
out.append(n)
return out # 行主序(规范序的邻接枚举基序)
func spawn_cells() -> Array:
var out: Array = []
for dy in range(-1, 2):
for dx in range(-1, 2):
var c := Vector2i(spawn_center.x + dx, spawn_center.y + dy)
if in_bounds(c):
out.append(c)
return out
func all_cells() -> Array:
var out: Array = []
for y in height:
for x in width:
out.append(Vector2i(x, y))
return out
func safe_cells() -> Array:
var out: Array = []
for c in all_cells():
if is_safe(c):
out.append(c)
return out
func fog_cells() -> Array:
var out: Array = []
for c in all_cells():
if not variants.get(c, []).is_empty():
out.append(c)
return out
# R₀ = 出生 3×3 ∪ (出生 3×3 经 0 格的 flood 闭包)——与 A2 F3 逐字同构(A1 F1 求解器初始态)。
func compute_r0() -> Array:
var b3 := spawn_cells()
var r0 := {}
for c in b3:
r0[c] = true
var queue: Array = b3.duplicate()
while not queue.is_empty():
var c: Vector2i = queue.pop_front()
if is_safe(c) and digit_of(c) == 0 and variants.get(c, []).is_empty():
for n in neighbors8(c):
if is_safe(n) and not r0.has(n):
r0[n] = true
queue.append(n)
return r0.keys()
# A1 Rule 4 排除域(以最终产物板计):R₀ ∪ 外扩 1 环——生成期确定性计算。
func exclusion_domain() -> Dictionary:
var r0 := compute_r0()
var dom := {}
for c in r0:
dom[c] = true
for c in r0:
for n in neighbors8(c):
dom[n] = true
return dom
@@ -0,0 +1 @@
uid://df34rfpxbxj03
@@ -0,0 +1,541 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# A1 确定性棋盘生成器:五阶段管线(参数→布雷→变异→验证循环→产出)+ F1 可推理率度量
# (single_subset 求解器)+ F3 雾预算 + F5 验收算子(达标即出/接受最优/接受最不坏)。
# 切片裁剪登记:黄金向量回归套件与 F4 批量工具不做(C3 调参期工具域)。
# 实现 GDD:design/gdd/a1-board-generation.md(系统 ID A1——registry 追溯锚,命名规范见 CLAUDE.md)
class_name BoardGenerator
extends RefCounted
const METRIC_VERSION := 1
# ==================== 生成入口 ====================
# rng_stream = 一次 open_stream("board_gen") 实例(一张图的全部取数;重试共用——attempt 边界非 scope 事件)
func generate(L: int, D: int, rng_stream: RandomNumberGenerator, db) -> BoardData:
var row: Dictionary = db.resolve_board_gen(L, D)
if row.is_empty():
_fatal("BoardGen 行缺失 (%d,%d)" % [L, D])
return null
# 生成期表值单次读取快照(防热重载撕裂 attempt 语义)
var params := {
"L": L, "D": D,
"w": int(row.board_width), "h": int(row.board_height),
"density": float(row.mine_density),
"target": float(row.inferability_target),
"solver_rules": String(row.solver_rules),
"min_safe": int(row.min_safe_count),
"max_attempts": int(row.max_gen_attempts),
"fog_count": int(row.fog_region_count),
"fog_ratio": float(row.fog_target_ratio),
"biomes": Array(row.board_biomes),
}
var best_legal: BoardData = null
var best_legal_score := -1.0
var best_bad: BoardData = null
var best_badness := [999999, 999999, 999999]
var attempts_used := 0
var accepted: BoardData = null
var accept_mode := ""
for k in range(1, params.max_attempts + 1):
attempts_used = k
var b := _attempt(params, rng_stream, db)
var badness := _badness(b, params)
if best_bad == null or _lex_less(badness, best_badness):
best_bad = b
best_badness = badness
if legality(b, params, db):
var m: Dictionary = measure_inferability(b) # 求解器每合法板恰一次(O3 收口——此前重复调用两次)
var inf: float = m["inferability"]
if best_legal == null or inf > best_legal_score:
best_legal = b
best_legal_score = inf
if inf >= params.target and m["inferability_core"] >= params.target:
accepted = b
accept_mode = "first_pass"
break
if accepted == null:
if best_legal != null:
accepted = best_legal
accept_mode = "accept_best"
push_warning("[A1] 重试耗尽无达标——接受最优出图(强告警埋点,target=%.2f best=%.3f)" % [params.target, best_legal_score])
else:
accepted = best_bad
accept_mode = "accept_worst"
accepted.illegal_product = true
push_error("[A1] attempts 耗尽零合法——接受最不坏出图 + 非法产物标记(玩家永不卡死)")
_finalize(accepted, params, attempts_used, accept_mode, db)
return accepted
# ==================== 五阶段:单次尝试 ====================
func _attempt(params: Dictionary, rng_stream: RandomNumberGenerator, db) -> BoardData:
var b := BoardData.new()
b.width = params.w
b.height = params.h
b.spawn_center = Vector2i(int((params.w - 1) / 2.0), int((params.h - 1) / 2.0))
b.biome = str(params.biomes[0])
b.level_index = params.L
b.node_depth = params.D
# ② 布雷:N = round(d×(W×H−9)) 先定数;域 = 全格 − 出生 3×3;均匀布雷后"先布后删"
var spawn := {}
for c in b.spawn_cells():
spawn[c] = true
var domain: Array = []
for c in b.all_cells():
if not spawn.has(c):
domain.append(c)
var n_mines: int = int(round(params.density * float(params.w * params.h - 9)))
# 部分 Fisher-Yates(确定性,消费 board_gen 流)
for i in range(0, mini(n_mines, domain.size())):
var j := rng_stream.randi_range(i, domain.size() - 1)
var tmp = domain[i]
domain[i] = domain[j]
domain[j] = tmp
for i in range(0, mini(n_mines, domain.size())):
b.mines[domain[i]] = true
_compute_digits(b)
# 先布后删:0 格闭包(布后基准)外扩 1 环内的雷移除(只减不增、无迭代)
var ring := {}
for c in b.compute_r0():
for n in b.neighbors8(c):
if not ring.has(n):
ring[n] = true
var removed := 0
for c in ring.keys():
if b.mines.has(c):
b.mines.erase(c)
removed += 1
_compute_digits(b)
# ③ 变异布置(先雷后雾;排除域以最终产物板计)
var shortfall := _place_variants(b, params, rng_stream, db)
b.metrics = {
"metric_version": METRIC_VERSION,
"ring_removed": removed,
"shortfall": shortfall,
}
return b
func _compute_digits(b: BoardData) -> void:
b.digits.clear()
for c in b.all_cells():
if b.is_safe(c):
var d := 0
for n in b.neighbors8(c):
if b.is_mine(n):
d += 1
b.digits[c] = d
# 变异布置:候选池门控 + cell_constraint 生成期强制 + 排除域排除 + 厚度≤2;返回 shortfall
func _place_variants(b: BoardData, params: Dictionary, rng_stream: RandomNumberGenerator, db) -> int:
if params.fog_count <= 0 or params.fog_ratio <= 0.0:
return 0
var pool: Array = db.variant_pool(params.L, params.biomes)
if pool.is_empty():
return 0 # EC1 空池静默——合法非错误
var exclusion := b.exclusion_domain()
var total_target := int(round(params.fog_ratio * float(params.w * params.h)))
var placed_total := 0
var achieved := 0
var placed := {}
for r in range(params.fog_count):
var region_target: int = total_target / params.fog_count
if r < total_target % params.fog_count:
region_target += 1
if region_target <= 0:
achieved += 1 # 零目标区视为达成(不消耗布置)
continue
var region := _grow_region(b, region_target, exclusion, placed, rng_stream)
if region.is_empty():
continue # EC2:无种子 → 该区布置失败(shortfall)
for c in region.keys():
b.variants[c] = ["fog"]
placed[c] = true
placed_total += 1
if region.size() >= region_target:
achieved += 1
return params.fog_count - achieved
# 区域生长:随机种子 + 随机边界扩张(连通域);厚度≤2 = 任意 3×3 窗口非全雾
func _grow_region(b: BoardData, target: int, exclusion: Dictionary, placed: Dictionary, rng_stream: RandomNumberGenerator) -> Dictionary:
var candidates: Array = []
for c in b.all_cells():
if _valid_fog_cell(b, c, exclusion, placed):
candidates.append(c)
if candidates.is_empty():
return {}
var region := {}
var seed_c: Vector2i = candidates[rng_stream.randi_range(0, candidates.size() - 1)]
region[seed_c] = true
while region.size() < target:
var frontier: Array = []
for c in region.keys():
for n in b.neighbors8(c):
if not region.has(n) and not placed.has(n) and _valid_fog_cell(b, n, exclusion, placed) and not _breaks_thickness(region, n, b):
frontier.append(n)
if frontier.is_empty():
break # 尽力布置——部分区域合法(shortfall 上报)
var pick: Vector2i = frontier[rng_stream.randi_range(0, frontier.size() - 1)]
region[pick] = true
return region
# cell_constraint=safe_nonzero 生成期强制:非雷、非 0(⟺ 8 邻含雷)、不在排除域
func _valid_fog_cell(b: BoardData, c: Vector2i, exclusion: Dictionary, placed: Dictionary) -> bool:
if placed.has(c) or exclusion.has(c):
return false
if not b.is_safe(c):
return false
if b.digit_of(c) <= 0:
return false
return true
func _breaks_thickness(region: Dictionary, candidate: Vector2i, b: BoardData) -> bool:
# 加入 candidate 后是否出现全雾 3×3 窗口
for dy in range(-1, 2):
for dx in range(-1, 2):
var origin := Vector2i(candidate.x + dx - 1, candidate.y + dy - 1)
var all_fog := true
for wy in range(3):
for wx in range(3):
var c := Vector2i(origin.x + wx, origin.y + wy)
var is_fog := (region.has(c) and c != candidate) or c == candidate
if not is_fog:
all_fog = false
if all_fog:
return true
return false
# ==================== F1 可推理率度量(独立入口——与内嵌调用同一实现) ====================
# 返回 {r0, r_inf, inferability, inferability_core}
func measure_inferability(b: BoardData) -> Dictionary:
var safe_set := {}
for c in b.safe_cells():
safe_set[c] = true
var fog_set := {}
for c in b.fog_cells():
fog_set[c] = true
var r := {}
for c in b.compute_r0():
r[c] = true
var known_mines := {}
var changed := true
while changed:
changed = false
# 算子① 连锁:0 格的 8 邻安全格全入 R(雾格可被链开但不传播——雾恒非 0,天然为叶)
var frontier: Array = r.keys()
for x in frontier:
if safe_set.has(x) and not fog_set.has(x) and b.digit_of(x) == 0:
for n in b.neighbors8(x):
if safe_set.has(n) and not r.has(n):
r[n] = true
changed = true
# 信息源(b)雾元知识:与 R 邻接的雾格必非雷 → 入 R
for f in fog_set.keys():
if not r.has(f):
for n in b.neighbors8(f):
if r.has(n):
r[f] = true
changed = true
break
# 信息源(a)数字约束:single + subset(solver_rules=single_subset 冻结域)
var constraints := _build_constraints(b, r, known_mines, safe_set)
# single 规则
for con in constraints:
if con.mines == 0:
for c in con.unknown:
if not r.has(c):
r[c] = true
changed = true
elif con.mines == con.unknown.size():
for c in con.unknown:
if not known_mines.has(c):
known_mines[c] = true
changed = true
# subset 规则:U1 ⊂ U2 ⇒ (d2−d1, U2∖U1)
var derived := []
for i in constraints.size():
for j in range(i + 1, constraints.size()):
var a: Dictionary = constraints[i]
var bb: Dictionary = constraints[j]
var sub := _subset_of(a.unknown, bb.unknown)
if sub == 1:
derived.append(_diff_constraint(a, bb))
elif sub == 2:
derived.append(_diff_constraint(bb, a))
for con in derived:
if con.mines == 0:
for c in con.unknown:
if safe_set.has(c) and not r.has(c):
r[c] = true
changed = true
elif con.mines == con.unknown.size():
for c in con.unknown:
if not known_mines.has(c):
known_mines[c] = true
changed = true
var r_inf := r.keys()
var s_count := safe_set.size()
var s_nonfog := 0
var r_nonfog := 0
for c in safe_set.keys():
if not fog_set.has(c):
s_nonfog += 1
if r.has(c):
r_nonfog += 1
var inferability: float = float(r_inf.size()) / float(maxi(1, s_count))
var core: float = float(r_nonfog) / float(maxi(1, s_nonfog))
return {
"r0": b.compute_r0().size(),
"r_inf_count": r_inf.size(),
"inferability": inferability,
"inferability_core": core,
}
func _build_constraints(b: BoardData, r: Dictionary, known_mines: Dictionary, safe_set: Dictionary) -> Array:
var out: Array = []
for c in r.keys():
if not safe_set.has(c) or b.variant_ids(c).size() > 0:
continue # 雾格数字不可见(hide_numbers)——零特判数据面:变体格不产约束
var d := b.digit_of(c)
var unknown := {}
var known_here := 0
for n in b.neighbors8(c):
if known_mines.has(n):
known_here += 1
elif not r.has(n) and safe_set.has(n):
unknown[n] = true
var d_eff := d - known_here
if d_eff < 0 or (d_eff > 0 and unknown.is_empty()):
continue # 矛盾态(非法板)——不产约束,验收由 legality 闸承担
if not unknown.is_empty():
out.append({"mines": d_eff, "unknown": unknown})
return out
func _subset_of(a: Dictionary, b: Dictionary) -> int:
# 1: a ⊂ b;2: b ⊂ a;0: 互不包含
var a_in_b := true
var b_in_a := true
for k in a.keys():
if not b.has(k):
a_in_b = false
for k in b.keys():
if not a.has(k):
b_in_a = false
if a.size() < b.size() and a_in_b:
return 1
if b.size() < a.size() and b_in_a:
return 2
return 0
func _diff_constraint(big: Dictionary, small: Dictionary) -> Dictionary:
var diff := {}
for k in big.unknown.keys():
if not small.unknown.has(k):
diff[k] = true
return {"mines": big.mines - small.mines, "unknown": diff}
# ==================== F3 雾预算(独立入口) ====================
func measure_fog_budget(b: BoardData, dig_ap_cost: int) -> Dictionary:
var safe_set := {}
for c in b.safe_cells():
safe_set[c] = true
# Chain₀ = R₀ 经算子①单独闭包
var chain0 := {}
for c in b.compute_r0():
chain0[c] = true
var queue: Array = chain0.keys()
while not queue.is_empty():
var c: Vector2i = queue.pop_front()
if safe_set.has(c) and b.digit_of(c) == 0 and b.variant_ids(c).is_empty():
for n in b.neighbors8(c):
if safe_set.has(n) and not chain0.has(n):
chain0[n] = true
queue.append(n)
var u_count := 0
for c in b.fog_cells():
if not chain0.has(c):
u_count += 1
var pay := 0
for c in safe_set.keys():
if not chain0.has(c):
pay += 1
return {
"chain0_count": chain0.size(),
"u_count": u_count,
"delta_budget_fog": u_count * dig_ap_cost,
"fog_burden_ratio": float(u_count) / float(maxi(1, pay)),
}
# ==================== 合法性闸(独立入口;F5 legality + R₀≠S 修订义务) ====================
func is_clearable(b: BoardData) -> bool:
var safe_set := {}
for c in b.safe_cells():
safe_set[c] = true
var seen := {}
var queue: Array = b.spawn_cells().duplicate()
for c in queue:
seen[c] = true
while not queue.is_empty():
var c: Vector2i = queue.pop_front()
if not safe_set.has(c):
continue
for n in b.neighbors8(c):
if safe_set.has(n) and not seen.has(n):
seen[n] = true
queue.append(n)
for c in safe_set.keys():
if not seen.has(c):
return false
return true
func legality(b: BoardData, params: Dictionary, db) -> bool:
var reasons := _legality_faults(b, params)
return reasons.is_empty()
func _legality_faults(b: BoardData, params: Dictionary) -> Array:
var faults: Array = []
# ① 出生区无雷 ② 出生区∪R₀ 无变异 ③ 数字恒真 ④ R₀ ≠ S(A2 修订义务)
for c in b.spawn_cells():
if b.is_mine(c):
faults.append("inv1_mine_in_spawn")
var r0 := {}
for c in b.compute_r0():
r0[c] = true
if b.variant_ids(c).size() > 0:
faults.append("inv2b_variant_in_r0")
for c in b.spawn_cells():
if b.variant_ids(c).size() > 0:
faults.append("inv2a_variant_in_spawn")
for c in b.all_cells():
if b.is_safe(c) and b.digit_of(c) != _neighbor_mine_count(b, c):
faults.append("inv3_digit_invariant")
if r0.size() == b.safe_cells().size():
faults.append("inv4_r0_equals_s")
# clearable(合法性闸——与质量度量两闸分离)
if not is_clearable(b):
faults.append("not_clearable")
# min_safe 产物侧(容错两种入参形态:generate 的 params 快照 / 原始表行)
var min_safe: int = int(params.get("min_safe", params.get("min_safe_count", 0)))
if b.safe_cells().size() < min_safe:
faults.append("min_safe_short")
# 雾结构:cell_constraint / 厚度 / f≤8d / 排除域
var fog := b.fog_cells()
var exclusion := b.exclusion_domain()
for c in fog:
if b.is_mine(c) or b.digit_of(c) <= 0:
faults.append("fog_cell_constraint")
if exclusion.has(c):
faults.append("fog_in_exclusion")
if _has_all_fog_window(b, fog):
faults.append("fog_thickness")
if fog.size() > 8 * b.mines.size():
faults.append("fog_over_8d")
return faults
func _neighbor_mine_count(b: BoardData, c: Vector2i) -> int:
var d := 0
for n in b.neighbors8(c):
if b.is_mine(n):
d += 1
return d
func _has_all_fog_window(b: BoardData, fog: Array) -> bool:
var fog_set := {}
for c in fog:
fog_set[c] = true
for y in b.height:
for x in b.width:
var all_fog := true
for wy in range(3):
for wx in range(3):
var c := Vector2i(x + wx, y + wy)
if not b.in_bounds(c) or not fog_set.has(c):
all_fog = false
if all_fog:
return true
return false
# ==================== 产出上报(八项指标) ====================
func _finalize(b: BoardData, params: Dictionary, attempts_used: int, accept_mode: String, db) -> void:
var m := measure_inferability(b)
var fog_budget := measure_fog_budget(b, db.action_economy.dig_ap_cost)
var fog := b.fog_cells()
var attempt_facts := b.metrics # 保留尝试期事实(ring_removed / shortfall)
b.metrics = {
"metric_version": METRIC_VERSION,
"inferability": m.inferability,
"inferability_core": m.inferability_core,
"actual_fog_ratio": float(fog.size()) / float(b.width * b.height),
"actual_mines": b.mines.size(),
"actual_safe_count": b.safe_cells().size(),
"fog_burden_ratio": fog_budget.fog_burden_ratio,
"delta_budget_fog": fog_budget.delta_budget_fog,
"attempts_used": attempts_used,
"accept_mode": accept_mode,
"shortfall": attempt_facts.get("shortfall", 0),
"ring_removed": attempt_facts.get("ring_removed", 0),
"r0_count": m["r0"],
}
b.illegal_product = accept_mode == "accept_worst"
func _badness(b: BoardData, params: Dictionary) -> Array:
# F5 k*** 确定性劣度字典序:(不可达安全格数, min_safe 缺口, 序号)
var unreachable := 0
var safe_set := {}
for c in b.safe_cells():
safe_set[c] = true
var seen := {}
var queue: Array = b.spawn_cells().duplicate()
for c in queue:
seen[c] = true
while not queue.is_empty():
var c: Vector2i = queue.pop_front()
if not safe_set.has(c):
continue
for n in b.neighbors8(c):
if safe_set.has(n) and not seen.has(n):
seen[n] = true
queue.append(n)
for c in safe_set.keys():
if not seen.has(c):
unreachable += 1
var gap: int = maxi(0, int(params.min_safe) - b.safe_cells().size())
return [unreachable, gap, 0]
func _lex_less(a: Array, b: Array) -> bool:
for i in a.size():
if a[i] < b[i]:
return true
if a[i] > b[i]:
return false
return false
func _fatal(msg: String) -> void:
push_error("[A1/fatal] " + msg)
assert(false, msg)
@@ -0,0 +1 @@
uid://brlq4y6ahd6so
@@ -0,0 +1,341 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# D2 ConfigDB autoload——数值表单点访问入口(Rule 2)+ 校验双通道(Rule 6)+ R1 行解析。
# 实现 GDD:design/gdd/d2-data-config.md(系统 ID D2——registry 追溯锚,命名规范见 CLAUDE.md)
# 切片注:headless --script 模式下由冒烟脚本显式实例化(D2 AC 前言);玩法代码禁止绕过本单点自建 load()。
extends Node
const SCHEMA_VERSION := 1
const TABLE_MANIFEST := {
"action_economy": "action_economy.tres",
"variation": "variation.tres",
"status_effects": "status_effects.tres",
"board_gen": "board_gen.tres",
"enemy": "enemy.tres",
"hero": "hero.tres",
"reward": "reward.tres",
"reward_config": "reward_config.tres",
"settlement": "settlement.tres",
"level": "level.tres",
"difficulty": "difficulty.tres",
}
var action_economy: ActionEconomyTable
var variation: VariationTable
var status_effects: StatusEffectTable
var board_gen: BoardGenTable
var enemy: EnemyTable
var hero: HeroTable
var reward: RewardTable
var reward_config: RewardConfigTable
var settlement: SettlementTable
var level: LevelTable
var difficulty: DifficultyTable
var is_ready := false
var _diagnostics: Array = [] # {severity, category, message}
var _warn_dedup: Dictionary = {} # R1 四元组去重注册表
var _reload_gate := false # D2 EC8——B1 唯一调用方
func push_diag(severity: String, category: String, message: String) -> void:
_diagnostics.append({"severity": severity, "category": category, "message": message})
if severity == "error":
push_error("[ConfigDB/%s] %s" % [category, message])
elif severity == "warning":
push_warning("[ConfigDB/%s] %s" % [category, message])
func reset_diagnostics() -> void:
_diagnostics.clear()
_warn_dedup.clear()
func diagnostics() -> Array:
return _diagnostics.duplicate()
func diagnostics_count(severity: String) -> int:
var n := 0
for d in _diagnostics:
if d.severity == severity:
n += 1
return n
func _ready() -> void:
load_tables("res://data/tables", false)
func load_tables(root: String, collect_only: bool) -> bool:
is_ready = false
_diagnostics.clear()
_warn_dedup.clear()
var loaded := {}
for key in TABLE_MANIFEST.keys():
var path := root.path_join(TABLE_MANIFEST[key])
if not ResourceLoader.exists(path):
push_diag("error", "missing_file", "表文件缺失: %s" % path)
continue
var res: Resource = load(path)
if res == null:
push_diag("error", "load_failed", "表加载失败: %s" % path)
continue
loaded[key] = res
for e in res.validate():
push_diag("error", e.category, "%s → %s" % [key, e.message])
# 跨表校验(悬空引用 / 池护栏——全部加载期致命)
_cross_validate(loaded)
var has_errors := diagnostics_count("error") > 0
if has_errors and not collect_only:
# 游戏通道:致命(Rule 6)——is_ready 恒 false,调用方(主场景)中止构建
return false
action_economy = loaded.get("action_economy")
variation = loaded.get("variation")
status_effects = loaded.get("status_effects")
board_gen = loaded.get("board_gen")
enemy = loaded.get("enemy")
hero = loaded.get("hero")
reward = loaded.get("reward")
reward_config = loaded.get("reward_config")
settlement = loaded.get("settlement")
level = loaded.get("level")
difficulty = loaded.get("difficulty")
is_ready = not has_errors
return not has_errors
func _cross_validate(loaded: Dictionary) -> void:
var st: StatusEffectTable = loaded.get("status_effects")
var rt: RewardTable = loaded.get("reward")
var rc: RewardConfigTable = loaded.get("reward_config")
var st_tab: SettlementTable = loaded.get("settlement")
if st == null or rt == null or rc == null or st_tab == null:
return # 单表校验已报错,不连锁
var effect_ids := {}
for row in st.rows:
effect_ids[row.get("effect_id", "")] = true
# ① RewardTable effect_id 悬空(首个引用行已回填 → 激活)
for row in rt.rows:
var eff: String = row.get("effect_id", "")
if eff != "" and not effect_ids.has(eff):
push_diag("error", "dangling_effect", "RewardTable %s 引用不存在的 effect_id: %s" % [row.get("reward_id"), eff])
# ② 池空护栏:safe_cell × 雾/非雾两态各 ≥1 活行;活 buff 行集非空
var t_fog: int = rc.t_fog_min
var safe_rows: Array = []
var clear_rows: Array = []
for row in rt.rows:
if int(row.get("weight", 0)) <= 0:
continue
if row.get("pool_tag", "") == "safe_cell":
safe_rows.append(row)
elif row.get("pool_tag", "") == "clear_default":
clear_rows.append(row)
var nonfog_pool := safe_rows
var fog_pool: Array = []
for row in safe_rows:
if int(row.get("tier", 0)) >= t_fog:
fog_pool.append(row)
if nonfog_pool.is_empty():
push_diag("error", "pool_empty_safe", "safe_cell 非雾池无活行")
if fog_pool.is_empty():
push_diag("error", "pool_empty_fog", "safe_cell 雾态池无活行(tier ≥ t_fog_min)")
var has_buff := false
for row in safe_rows:
if row.get("category", "") == "buff":
has_buff = true
if safe_rows.size() > 0 and not has_buff:
push_diag("error", "no_live_buff_row", "safe_cell 池零活 buff 行——tier_min_buff 无定义")
# ③ clear_default 恒正当量(A3 条款④——2026-09-11 供给控制器批:清图槽改即时类一次性伤害,正当量含即时类)
var clear_value_ok := false
for row in clear_rows:
if row.get("category", "") == "buff" and String(row.get("effect_id", "")) != "":
clear_value_ok = true
elif row.get("category", "") in ["damage_item", "heal_pack", "instant_aoe", "instant_heal"] and int(row.get("power", 0)) >= 1:
clear_value_ok = true
if not clear_value_ok:
push_diag("error", "clear_no_value", "clear_default 池无正当量活行(清图奖励 > 0 违例)")
# ④ floor_supply_missing(F5 护栏 i)——k_dmg ≥ 1 时才检查(供给控制器批后 k_dmg=0 休眠)
if rc.k_dmg >= 1:
var supply_ok := false
for row in safe_rows:
if row.get("category", "") == "damage_item" and int(row.get("power", 0)) >= rc.p_min:
supply_ok = true
if not supply_ok:
push_diag("error", "floor_supply_missing", "K_dmg ≥ 1 但 safe_cell 池无 power ≥ P_min 活刀行")
# ⑤ no_damage_row(F5 护栏 ii——零伤害世界后闸;2026-09-11 供给控制器批:扫描域扩至全池活行,
# 可选类退出格子池后伤害来源在 active_inject 池)
if rc.k_dmg == 0:
var any_knife := false
for row in rt.rows:
if int(row.get("weight", 0)) > 0 and row.get("category", "") == "damage_item":
any_knife = true
if not any_knife:
push_diag("error", "no_damage_row", "K_dmg = 0 且全池零活刀行——零伤害世界后闸")
# ⑨ active_inject 池护栏(可选类供给控制器批):非可选类拒绝;池须有活行(否则战斗首件保证落空)
var inject_live := 0
for row in rt.rows:
if row.get("pool_tag", "") == "active_inject":
if int(row.get("weight", 0)) > 0:
inject_live += 1
if row.get("category", "") not in ["damage_item", "heal_pack"]:
push_diag("error", "inject_pool_foreign", "active_inject 池混入非可选类行: %s" % row.get("reward_id"))
if inject_live == 0:
push_diag("error", "inject_pool_empty", "active_inject 池无活行(战斗首件保证必落空)")
# ⑥ SettlementTable.effect_pool_map 悬空
for eff in st_tab.effect_pool_map.keys():
if not effect_ids.has(eff):
push_diag("error", "poolmap_dangling", "SettlementTable.effect_pool_map 悬空 effect_id: %s" % eff)
# ⑦ source_label_map 键集封闭性(键 = B3 source_class ∪ {item, enemy_action, flag_miss})
var required_sources := ["safe_cell", "flag_hit", "mine_hit", "clear_reward", "item", "hero_attack", "enemy_action", "flag_miss"]
for s in required_sources:
if not st_tab.source_label_map.has(s):
push_diag("error", "segorder_violation", "source_label_map 缺必需键: %s" % s)
# ⑧ LevelTable 节点悬空引用(battle/boss 的 (L,node_depth) 须在 board_gen 与 enemy 两表有精确行——
# 线性推进器按精确深度行跑,floor 兜底静默降难度属调参事故,加载期直接拦)
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_keys := {}
for row in en.rows:
en_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
for lrow in lv.rows:
var li: int = int(lrow.level_index)
for node in lrow.nodes:
var t: String = String(node.get("type", ""))
if t != "battle" and t != "boss":
continue
var key := "%d_%d" % [li, int(node.get("node_depth", -1))]
if not bg_keys.has(key):
push_diag("error", "level_node_dangling", "level %d %s 节点 (%s) 在 board_gen 无精确行" % [li, t, key])
if not en_keys.has(key):
push_diag("error", "level_node_dangling", "level %d %s 节点 (%s) 在 enemy 无精确行" % [li, t, key])
# ---- R1 难度行解析(floor + warning 四元组去重;请求越域致命)----
func resolve_board_gen(L: int, D: int) -> Dictionary:
return _resolve_progress(board_gen.rows, L, D, "board_gen")
func resolve_enemy_rowset(L: int, D: int) -> Dictionary:
return _resolve_progress(enemy.rows, L, D, "enemy")
# C3 DifficultyTable 消费面(B2.spawn 调用):(L,D) 精确匹配修饰乘数,越域值 clamp(GDD Edge Case——组合事故非配置事故)
func resolve_difficulty_modifiers(L: int, D: int) -> Dictionary:
if difficulty == null:
return {}
for row in difficulty.rows:
if int(row.get("level_index", 0)) == L and int(row.get("node_depth", -1)) == D:
return {
"hp_mult": clampf(float(row.get("hp_mult", 1.0)), 0.1, 10.0),
"atk_mult": clampf(float(row.get("atk_mult", 1.0)), 0.1, 10.0),
}
return {}
func _resolve_progress(rows: Array, L: int, D: int, table_id: String) -> Dictionary:
if L < 1 or D < 0:
push_diag("error", "request_oob", "%s 请求越域 (%d,%d)" % [table_id, L, D])
return {}
var levels := {}
for row in rows:
levels[int(row.get("level_index", 0))] = true
var level_keys := levels.keys()
level_keys.sort()
var l_star: int
var mt_level := "exact"
if L < level_keys[0]:
l_star = level_keys[0]
mt_level = "ceil"
else:
l_star = level_keys[0]
for lv in level_keys:
if lv <= L:
l_star = lv
if l_star < L:
mt_level = "floor"
var depth_keys := {}
for row in rows:
if int(row.get("level_index", 0)) == l_star:
depth_keys[int(row.get("node_depth", 0))] = true
var d_keys := depth_keys.keys()
d_keys.sort()
var d_star: int
var mt_depth := "exact"
if d_keys.is_empty():
push_diag("error", "structural", "%s level %d 无行(锚行校验应已拦截)" % [table_id, l_star])
return {}
if D < d_keys[0]:
d_star = d_keys[0]
mt_depth = "floor"
else:
d_star = d_keys[0]
for dv in d_keys:
if dv <= D:
d_star = dv
if d_star < D:
mt_depth = "floor"
var missing := ""
if mt_level == "ceil":
missing = "base_ceil"
elif mt_level == "floor" and mt_depth == "floor":
missing = "both"
elif mt_level == "floor":
missing = "level_floor"
elif mt_depth == "floor":
missing = "depth_floor"
if missing != "":
var dedup_key := "%s|%s|%d|%d" % [table_id, missing, l_star, d_star]
if not _warn_dedup.has(dedup_key):
_warn_dedup[dedup_key] = true
push_diag("warning", "resolve_" + missing, "%s 请求 (%d,%d) floor 解析 → (%d,%d) [%s]" % [table_id, L, D, l_star, d_star, missing])
for row in rows:
if int(row.get("level_index", 0)) == l_star and int(row.get("node_depth", 0)) == d_star:
return row
return {}
# ---- A4 variant_pool_gate:候选池门控(∅ biome_tag = 通配)----
func variant_pool(level_index: int, board_biomes: Array) -> Array:
var pool: Array = []
for row in variation.rows:
if int(row.get("unlock_level", 999)) > level_index:
continue
var tags: Array = row.get("biome_tags", [])
if tags.is_empty():
pool.append(row)
continue
for t in tags:
if board_biomes.has(t):
pool.append(row)
break
return pool
func status_effect_row(effect_id: String) -> Dictionary:
return status_effects.row_by_id(effect_id)
# ---- D2 EC8:debug 热重载门(B1 唯一调用方;切片实现 = 门标志 + 拒绝语义)----
func set_reload_gate(gated: bool) -> void:
_reload_gate = gated
func reload() -> bool:
if _reload_gate:
push_diag("warning", "reload_gated", "战斗中热重载被拒绝(D2 EC8)")
return false
var ok := load_tables("res://data/tables", false)
if ok:
push_diag("debug", "config_reloaded", "表热重载完成")
return ok
@@ -0,0 +1 @@
uid://cfelf84soyn3y
@@ -0,0 +1,19 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# 实现 GDD:design/gdd/b1-settlement.md(系统 ID B1——registry 追溯锚,命名规范见 CLAUDE.md)
# B1 战斗段伤害公式族(零状态纯算术宿主——规范 1.2「工具性纯函数挂明确宿主类」;
# 自 battle_orchestrator 拆出 2026-09-13,封装审查 O3)。池读取与表值读取仍归 B1,本类只做算术。
class_name DamageMath
extends RefCounted
# F2 乘区(池值已由调用方 clamp 后传入):池内加算 → 池间乘算 → floor(单一取整规则)
static func pool_damage(d_raw: int, p_hero: int, p_enemy: int) -> int:
var v: int = int(floor(float(d_raw) * (100.0 + float(p_hero)) * (100.0 + float(p_enemy)) / 10000.0))
return maxi(0, v)
# 伤害A/B 充能分量:充能(调用方已套单轮充能上限)× 每单位值 × 克制 percent → floor
static func charge_component(charge: int, per_unit: int, effectiveness_percent: int) -> int:
var pts := charge * per_unit
return int(floor(float(pts) * float(effectiveness_percent) / 100.0))
@@ -0,0 +1 @@
uid://cym7k76gt61k
@@ -0,0 +1,215 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 线性第一关(战×2→道具→战×2→恢复→Boss)能否承载"数值提升→通关期望改善"的元进度验证
# Date: 2026-09-11
# C1 关卡节点地图(provisional 线性形态——节点链来自 LevelTable,路径分支/形态定稿权归 C1):
# 关卡域生命周期唯一编排者——共享 B4 容器跨战斗延续(2026-09-11 关内延续裁决)、HP 跨战斗携带、
# 奖励点/恢复点节点兑付、关卡末 level_clear 清库。Core 层零 UI 依赖(headless 可跑完整关卡——D2 Rule 7)。
# 测试专用:hero_atk_pct/hero_hp_pct 数值覆盖(调整板模拟天赋树——正式侧归 C4 元进度,切片不落盘)。
# E4 收口(2026-09-13):覆盖值经 encounter 显式传入战斗(不再写 db.hero 表——表值运行期不可变,
# 双基线同步/双写叠加 6→18→54 整类隐患随表写一并移除)。
# 对应 GDD:C1 关卡节点地图(未设计——provisional 切片先行,定稿后回写;命名规范见 CLAUDE.md)
class_name LevelRunner
extends RefCounted
signal node_entered(node: Dictionary, info: Dictionary) # info 按 type:battle/boss={} · reward={snapshot} · rest={healed,hp,max_hp}
signal step_row(row: Dictionary) # 转发当前 B1 结算行
signal window_opened(items: Array) # 转发当前 B1 道具窗口
signal round_started(round_index: int)
signal board_changed()
signal level_finished(payload: Dictionary) # {result, hp, node_index, node_type, remaining_items}
var db
var rng
var level_row: Dictionary = {}
var node_index := -1
var hero_hp := 0
# 协作对象私有(E1/E3 封装收口 2026-09-13):b1/b4 读取经 current_battle()/inventory() 成文出口
var _b1 = null # 当前战斗编排器(关卡结束置 null)
var _b4 = null # 关卡域共享 B4 容器(库存跨战斗延续)
var hero_atk_pct := 0 # 测试面板覆盖值(切片专用)
var hero_hp_pct := 0
var _running := false
var level_result := "" # "" / "victory" / "defeat"
func _init(db_ref, rng_ref) -> void:
db = db_ref
rng = rng_ref
# ---- 测试面板覆盖(模拟天赋树属性提升——仅测试用,正式侧归 C4;经 encounter 传入战斗)----
func set_hero_override(atk_pct: int, hp_pct: int) -> void:
hero_atk_pct = atk_pct
hero_hp_pct = hp_pct
func effective_atk() -> int:
return maxi(1, int(round(float(db.hero.atk) * (100.0 + float(hero_atk_pct)) / 100.0)))
func effective_max_hp() -> int:
return maxi(1, int(round(float(db.hero.max_hp) * (100.0 + float(hero_hp_pct)) / 100.0)))
# 覆盖复位(关卡结束归零——表值不可变,仅清百分比;调用方兼容保留)
func restore_hero_base() -> void:
hero_atk_pct = 0
hero_hp_pct = 0
# ---- 关卡生命周期 ----
func start_level(level_index: int, seed_val: int) -> bool:
var row := _find_level_row(level_index)
if row.is_empty():
push_error("[C1] level_index %d 在 LevelTable 无行" % level_index)
return false
level_row = row
node_index = -1
level_result = ""
hero_hp = effective_max_hp()
_b4 = RewardEconomy.new(db, rng)
rng.new_adventure(seed_val) # 关卡起点 = 冒险起点(切片口径;跨关接续归 C2)
_running = true
return _enter_next_node()
func _find_level_row(level_index: int) -> Dictionary:
for row in db.level.rows:
if int(row.level_index) == level_index:
return row
return {}
func nodes() -> Array:
return level_row.get("nodes", [])
func current_node() -> Dictionary:
if node_index < 0 or node_index >= nodes().size():
return {}
return nodes()[node_index]
func _enter_next_node() -> bool:
if not _running:
return false
node_index += 1
var node: Dictionary = current_node()
if node.is_empty():
push_error("[C1] 节点链越界(末节点 Boss 应已结束关卡)")
return false
match String(node.get("type", "")):
"battle", "boss":
return _start_battle(node)
"reward":
var inst = _b4.grant_node_active(Vector2i(-1, -1), 0)
if not (inst is Dictionary):
push_error("[C1] 奖励点入库被拒")
return false
node_entered.emit(node, {"snapshot": inst.snapshot})
return true
"rest":
var healed := _apply_rest()
node_entered.emit(node, {"healed": healed, "hp": hero_hp, "max_hp": effective_max_hp()})
return true
push_error("[C1] 未知节点类型: %s" % str(node))
return false
func _start_battle(node: Dictionary) -> bool:
_b1 = BattleOrchestrator.new(db, rng, _b4) # 共享 B4——库存跨战斗延续(关内延续裁决)
# core↔core 接线只许方法 Callable(ADR-0001 静态规则 S6——lambda 捕获 self 会延长
# RefCounted 生命周期乃至成环:b1 的信号持捕获 runner 的 lambda,runner 又持 b1)
_b1.step_row.connect(_forward_step_row)
_b1.window_opened.connect(_forward_window_opened)
_b1.round_started.connect(_forward_round_started)
_b1.board_changed.connect(_forward_board_changed)
_b1.battle_finished.connect(_on_battle_finished)
# node_entered 先于 begin_battle:呈现层须先拿到 b1 引用,才能响应 begin 期间发出的
# board_changed(首板安装)并重建棋盘——后发会导致首战棋盘不渲染(2026-09-11 playtest 缺陷①)
node_entered.emit(node, {})
if not _b1.begin_battle({"level_index": int(level_row.level_index), "node_depth": int(node.get("node_depth", 0)),
"hero_hp": hero_hp, "hero_atk": effective_atk(), "hero_max_hp": effective_max_hp()}):
push_error("[C1] 战斗启动失败(depth %s)" % str(node.get("node_depth", -1)))
return false
return true
func _forward_step_row(row: Dictionary) -> void:
step_row.emit(row)
func _forward_window_opened(items: Array) -> void:
window_opened.emit(items)
func _forward_round_started(ri: int) -> void:
round_started.emit(ri)
func _forward_board_changed() -> void:
board_changed.emit()
func _on_battle_finished(payload: Dictionary) -> void:
var node := current_node()
var result: String = String(payload.get("result", ""))
if result == "victory":
hero_hp = maxi(1, int(payload.get("hero_hp", 0))) # despawn 前捕获值(载荷自包含)
if String(node.get("type", "")) == "boss" or result == "defeat":
# Boss 胜利 = 关卡通关;任意战斗败北 = 关卡失败(Roguelike——冒险即终,重开归 C2/C4)
var left: int = _b1.level_clear()["remaining"] # 库存随关卡结束清空(关内延续裁决③)
if _b4.in_stock_count() != 0:
push_error("[C1] level_clear 后库存非空(清库违例——fail-fast)")
return
level_result = "victory" if result == "victory" else "defeat"
var hp_left := hero_hp if result == "victory" else 0
_running = false
_b1 = null
level_finished.emit({"result": level_result, "hp": hp_left, "node_index": node_index,
"node_type": String(node.get("type", "")), "remaining_items": left})
return
_enter_next_node()
func _apply_rest() -> int:
var pct: int = int(level_row.get("rest_heal_pct", 0))
var mhp := effective_max_hp()
var heal := int(floor(float(mhp) * float(pct) / 100.0))
var actual: int = mini(heal, mhp - hero_hp)
hero_hp += actual
return actual
# ---- 只读出口(E1/E3 封装收口:呈现层/工具经此读当前战斗与关卡库存,禁直读协作对象)----
func current_battle():
return _b1
func inventory() -> Array:
return _b4.query_inventory() if _b4 != null else []
# ---- 玩家输入转发(呈现层只与 runner 对话)----
# 奖励/恢复点确认继续(呈现层在覆盖层上点击"继续"后调用;自动模拟在 node_entered 回调内立即调用)
func continue_after_node() -> bool:
var t := String(current_node().get("type", ""))
if t != "reward" and t != "rest":
return false
return _enter_next_node()
func submit_window_selection(selection: Array) -> void:
if _b1 != null:
_b1.submit_window_selection(selection)
func player_request_refresh() -> bool:
if _b1 == null:
return false
return _b1.player_request_refresh()
@@ -0,0 +1 @@
uid://b3o7g73bnbsya
@@ -0,0 +1,440 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# A2 排雷玩法规则引擎:零随机/零表读取/零 UI——输入 = A1 产物 + 意图序列,输出 = 三事件流。
# 核心契约:install 四不变式 · 初始展开 = R₀ 恰等 · 连锁不越旗 · 批次语义序仲裁(cleared > stop > 其余)。
# 实现 GDD:design/gdd/a2-minesweeper-rules.md(系统 ID A2——registry 追溯锚,命名规范见 CLAUDE.md)
class_name MinesweeperRules
extends RefCounted
signal cell_revealed(evt: Dictionary)
signal flag_judged(evt: Dictionary)
signal round_end_board(payload: Dictionary) # 双发射源之棋盘路径(A2 外发;stopped 由 A3 直发)
const SUB_REASONS := ["not_adjacent", "target_revealed", "target_concealed", "flag_present", "flag_absent", "no_path", "out_of_catalog"]
var db
var a3
# 棋盘状态私有(E2 封装收口 2026-09-13):对外只暴露 current_board()/reveal_record() 等查询面,
# 禁止跨模块直读/直写原始字典——先例教训 = board_view 绕过查询面直读 revealed。
var _board: BoardData = null
var _revealed := {} # Vector2i → {"kind": "safe"/"mine", "digit": int}
var _flags := {} # Vector2i → true(棋盘单态;跨轮保留随图存续)
var _hero := Vector2i.ZERO
var _spawn: Dictionary = {}
func _init(db_ref, a3_ref) -> void:
db = db_ref
a3 = a3_ref
# ---- 板态 ----
func has_board() -> bool:
return _board != null
# 板产物只读出口(E2 收口——B1 快照 / 组合根 rebuild / 探针经此取板,禁再直读字段)
func current_board() -> BoardData:
return _board
# 已开格记录查询面(cell → {kind, digit};未开 = {})——呈现层渲染消费面(board_view 徽章/四格态)
func reveal_record(c: Vector2i) -> Dictionary:
return _revealed.get(c, {})
func hero_pos() -> Vector2i:
return _hero
func is_revealed(c: Vector2i) -> bool:
return _revealed.has(c)
func has_flag(c: Vector2i) -> bool:
return _flags.has(c)
func revealed_cells() -> Array:
return _revealed.keys()
func safe_remaining() -> int:
var n := 0
for c in _board.safe_cells():
if not _revealed.has(c):
n += 1
return n
func clear_map_cleared() -> bool:
return safe_remaining() == 0
# ---- install(编排边界相位 = A3 Idle/Waiting;Active 拒绝——防轮内换板)----
# during_round = 轮内刷新编排路径(2026-09-12 方案 A,B1 唯一调用方):Active 相位合法——
# 玩家挖掘中立即换图,棋盘状态(revealed/flags)随 install 重置,初始展开事件照发(initial 通道)
func install_board(b: BoardData, during_round := false) -> Dictionary:
if a3.phase == a3.PHASE_ACTIVE and not during_round:
return _outcome(false, "rejected", "phase", "")
if a3.phase == a3.PHASE_IDLE and during_round:
return _outcome(false, "rejected", "phase", "")
if b == null:
return _outcome(false, "rejected", "board_invalid", "out_of_catalog")
var inv := _install_invariant_faults(b)
if not inv.is_empty():
push_error("[A2] install 不变式违例: %s" % str(inv))
return _outcome(false, "rejected", "board_invalid", inv[0])
_board = b
_revealed.clear()
_flags.clear()
_spawn = {}
for c in b.spawn_cells():
_spawn[c] = true
# 初始展开:O₀ = R₀ 恰等(免费/计入清图/无奖励/永不触雷触雾);板级一次跨轮不重放
for c in _initial_expansion_order(b):
_revealed[c] = {"kind": "safe", "digit": b.digit_of(c)}
cell_revealed.emit({
"cell": c, "kind": "safe", "digit": b.digit_of(c),
"variant_ids": [], "channel": "initial", "reward_eligible": false,
})
_hero = b.spawn_center
return _outcome(true, "executed", "", "")
# during_round = 轮内刷新编排路径(2026-09-12 方案 A):Active 相位合法——换图前退役旧板
func flag_count() -> int:
return _flags.size()
func retire_board(during_round := false) -> Dictionary:
if _board == null:
return _outcome(true, "executed", "", "") # 幂等受理
if a3.phase == a3.PHASE_ACTIVE and not during_round:
return _outcome(false, "rejected", "phase", "")
_board = null
_revealed.clear()
_flags.clear()
return _outcome(true, "executed", "", "")
# 四不变式(诊断类别封闭枚举):inv1 出生区雷 / inv2a 出生区变异 / inv2b R₀ 变异 / inv3 数字恒真 / inv4 R₀=S
func _install_invariant_faults(b: BoardData) -> Array:
var faults: Array = []
var r0 := {}
for c in b.compute_r0():
r0[c] = true
for c in b.spawn_cells():
if b.is_mine(c):
faults.append("inv1_mine_in_spawn")
if b.variant_ids(c).size() > 0:
faults.append("inv2a_variant_in_spawn")
for c in r0.keys():
if b.variant_ids(c).size() > 0:
faults.append("inv2b_variant_in_r0")
for c in b.all_cells():
if b.is_safe(c):
var d := 0
for n in b.neighbors8(c):
if b.is_mine(n):
d += 1
if b.digit_of(c) != d:
faults.append("inv3_digit_invariant")
if r0.size() == b.safe_cells().size():
faults.append("inv4_r0_equals_s")
return faults
# 初始展开规范序:触发格 BFS、8 邻行主序(≡ A1 F1 R₀ 算子;禁外扩环、禁只开 3×3)
func _initial_expansion_order(b: BoardData) -> Array:
var order: Array = b.spawn_cells().duplicate()
var seen := {}
for c in order:
seen[c] = true
var queue: Array = order.duplicate()
while not queue.is_empty():
var c: Vector2i = queue.pop_front()
if b.is_safe(c) and b.digit_of(c) == 0 and b.variant_ids(c).is_empty():
for n in b.neighbors8(c):
if b.is_safe(n) and not seen.has(n):
seen[n] = true
order.append(n)
queue.append(n)
return order
# ---- 批次结算(唯一提交入口;单意图批 = 平凡形 ≡ 便捷 API)----
func settle_batch(intents: Array) -> Array:
var outcomes: Array = []
if intents.is_empty():
return outcomes
if a3.phase != a3.PHASE_ACTIVE or _board == null:
for intent in intents:
outcomes.append(_outcome(false, "rejected", "phase", ""))
return outcomes
# F4 语义序仲裁:C_dig(真模拟清图)→ C_stop → C_other;类内稳定序 = 到达序
var indexed: Array = []
for i in intents.size():
indexed.append({"i": i, "intent": intents[i], "cls": _classify(intents[i])})
indexed.sort_custom(func(a, b):
if a.cls != b.cls:
return a.cls < b.cls
return a.i < b.i)
var round_over := false
for item in indexed:
if round_over:
outcomes.append(_outcome(false, "voided", "", ""))
continue
var res := _execute(item.intent)
outcomes.append(res)
if a3.phase != a3.PHASE_ACTIVE:
round_over = true
return outcomes
func _classify(intent: Dictionary) -> int:
# 0 = C_dig(执行后清图——真模拟,禁计数器捷径);1 = C_stop;2 = C_other
if intent.get("action", "") == "dig":
var c: Vector2i = intent.get("cell", Vector2i(-9, -9))
if _board_face_dig(c) == "" and not _board.is_mine(c):
# 模拟 Chain(O, c) 后清图判定
var chain := _chain_closure(c, {})
var remaining := 0
for sc in _board.safe_cells():
if not _revealed.has(sc) and not chain.has(sc):
remaining += 1
if remaining == 0:
return 0
return 2
if intent.get("action", "") == "stop":
return 1
return 2
func _execute(intent: Dictionary) -> Dictionary:
match intent.get("action", ""):
"dig":
return _do_dig(intent.get("cell", Vector2i(-9, -9)))
"flag_place":
return _do_flag_place(intent.get("cell", Vector2i(-9, -9)))
"flag_pull":
return _do_flag_pull(intent.get("cell", Vector2i(-9, -9)))
"move":
return _do_move(intent.get("cell", Vector2i(-9, -9)))
"stop":
return _do_stop()
push_warning("[A2] 目录外意图: %s" % str(intent))
return _outcome(false, "rejected", "board_face", "out_of_catalog")
# ---- 挖掘 ----
func _do_dig(c: Vector2i) -> Dictionary:
var sub := _board_face_dig(c)
if sub != "":
return _outcome(false, "rejected", "board_face", sub)
# 真模拟连锁与清图(F2/F1——单一实现两名字)
var chain := _chain_closure(c, _flags)
var clears: bool = _cleared_after(chain)
# F2 口径:chain_size = |O′ ∖ (O ∪ {c})|——闭包含已开集 O,只数新开格(不含首格)
var chain_size := 0
for cc in chain.keys():
if cc != c and not _revealed.has(cc):
chain_size += 1
# 资源面/相位 + cleared 仲裁在 A3 事务内原子完成
if not a3.request_dig(chain_size, clears):
return _outcome(false, "rejected", "resource_face", "")
# 事务内执行:首格 + 连锁新格揭示(BFS 规范序,含链开雾格——零特判);事件 = O′ ∖ O
var reveal_order: Array = _chain_reveal_order(c, _flags)
for rc in reveal_order:
if _revealed.has(rc):
continue # 闭包可穿过已开区——已开格零事件(cell_revealed × |O′ ∖ O|)
var is_mine := _board.is_mine(rc)
var channel := "dig" if rc == c else "chain"
if is_mine:
_revealed[rc] = {"kind": "mine", "digit": -1}
cell_revealed.emit({
"cell": rc, "kind": "mine", "digit": -1,
"variant_ids": [], "channel": channel, "reward_eligible": false,
})
else:
_revealed[rc] = {"kind": "safe", "digit": _board.digit_of(rc)}
cell_revealed.emit({
"cell": rc, "kind": "safe", "digit": _board.digit_of(rc),
"variant_ids": _board.variant_ids(rc), "channel": channel, "reward_eligible": true,
})
if a3.phase != a3.PHASE_ACTIVE:
_emit_round_end_flags_and_payload()
return _outcome(true, "executed", "", "")
func _board_face_dig(c: Vector2i) -> String:
if not _board.in_bounds(c):
return "out_of_catalog"
if not _adjacent_to_hero(c):
return "not_adjacent"
if is_revealed(c):
return "target_revealed"
if has_flag(c):
return "flag_present"
return ""
# 连锁闭包:μX.[O∪{c} ⊆ X ∧ ∀x∈X:(x∈S ∧ num(x)=0 ⇒ N₈(x)∩S∖G ⊆ X)]——连锁不越旗
func _chain_closure(start: Vector2i, flag_set: Dictionary) -> Dictionary:
var chain := {start: true}
var queue: Array = [start]
while not queue.is_empty():
var c: Vector2i = queue.pop_front()
if _board.is_safe(c) and not flag_set.has(c) and _board.digit_of(c) == 0 and _board.variant_ids(c).is_empty():
for n in _board.neighbors8(c):
if _board.is_safe(n) and not flag_set.has(n) and not chain.has(n):
chain[n] = true
queue.append(n)
return chain
# 揭示规范序:首格 + 触发格 BFS、8 邻行主序
func _chain_reveal_order(start: Vector2i, flag_set: Dictionary) -> Array:
var order: Array = [start]
var seen := {start: true}
var queue: Array = [start]
while not queue.is_empty():
var c: Vector2i = queue.pop_front()
if _board.is_safe(c) and not flag_set.has(c) and _board.digit_of(c) == 0 and _board.variant_ids(c).is_empty():
for n in _board.neighbors8(c):
if _board.is_safe(n) and not flag_set.has(n) and not seen.has(n):
seen[n] = true
order.append(n)
queue.append(n)
return order
func _cleared_after(chain: Dictionary) -> bool:
for c in _board.safe_cells():
if not _revealed.has(c) and not chain.has(c):
return false
return true
# ---- 旗 ----
func _do_flag_place(c: Vector2i) -> Dictionary:
var sub := ""
if not _board.in_bounds(c):
sub = "out_of_catalog"
elif not _adjacent_to_hero(c):
sub = "not_adjacent"
elif is_revealed(c):
sub = "target_revealed"
elif has_flag(c):
sub = "flag_present"
if sub != "":
return _outcome(false, "rejected", "board_face", sub)
if not a3.try_spend("flag_place"):
return _outcome(false, "rejected", "resource_face", "")
_flags[c] = true # 旗动作零格子事件外发(发射面封闭;反馈经调用返回值命令式驱动)
if a3.phase != a3.PHASE_ACTIVE:
# 旗动作花完末点 AP 同样终结轮次(A3 三出口——AP 用完与动作种类无关;缺漏使 round_end
# 永不发出、B1 的 Waiting 结算点永不到达 = 整局软锁。playtest 缺陷②,行为探针复现)
_emit_round_end_flags_and_payload()
return _outcome(true, "executed", "", "")
func _do_flag_pull(c: Vector2i) -> Dictionary:
var sub := ""
if not _board.in_bounds(c):
sub = "out_of_catalog"
elif not _adjacent_to_hero(c):
sub = "not_adjacent"
elif not has_flag(c):
sub = "flag_absent"
if sub != "":
return _outcome(false, "rejected", "board_face", sub)
if not a3.try_spend("flag_pull"):
return _outcome(false, "rejected", "resource_face", "")
_flags.erase(c)
if a3.phase != a3.PHASE_ACTIVE:
_emit_round_end_flags_and_payload() # 同 flag_place——末点 AP 轮末发射(playtest 缺陷②)
return _outcome(true, "executed", "", "")
# ---- 移动(瞬移;免费不经账本;BFS 限已探明区含已引爆雷格)----
func _do_move(c: Vector2i) -> Dictionary:
var sub := ""
if not _board.in_bounds(c):
sub = "out_of_catalog"
elif not is_revealed(c):
sub = "target_concealed"
elif c != _hero and compute_move_path(c).is_empty():
sub = "no_path"
if sub != "":
return _outcome(false, "rejected", "board_face", sub)
_hero = c # 目标 = 当前格定义为零移动(幂等受理)
return _outcome(true, "executed", "", "")
func compute_move_path(target: Vector2i) -> Array:
if _board == null or not is_revealed(target):
return []
if target == _hero:
return []
# BFS(8 邻行主序)——路径 = 合法性载体 + D1 预览数据源
var prev := {_hero: _hero}
var queue: Array = [_hero]
while not queue.is_empty():
var c: Vector2i = queue.pop_front()
if c == target:
break
for n in _board.neighbors8(c):
if is_revealed(n) and not prev.has(n):
prev[n] = c
queue.append(n)
if not prev.has(target):
return []
var path: Array = []
var cur := target
while cur != _hero:
path.push_front(cur)
cur = prev[cur]
return path
# ---- 停手(经批次入口委托 A3;先发旗判定批再委托——事件序契约 flag_judged < round_end 全路径统一)----
func _do_stop() -> Dictionary:
_emit_flags_batch() # 基线 = 事务开始旗位(停手不改旗位)
if not a3.request_stop():
return _outcome(false, "rejected", "phase", "")
# A3 直发 stopped 路径 round_end;A2 禁双发(双发射源纪律)
return _outcome(true, "executed", "", "")
# ---- 轮末旗判定 + 棋盘路径 round_end 外发 ----
func _emit_flags_batch() -> void:
# 判定基线 = 轮次结束事务开始时旗位;行主序枚举(确定性);被拔的不判;诚实重复上报(去重归 B1)
var cells: Array = _flags.keys()
cells.sort_custom(func(a, b): return a.y * 1000 + a.x < b.y * 1000 + b.x)
for c in cells:
flag_judged.emit({"cell": c, "hit": _board.is_mine(c)})
func _emit_round_end_flags_and_payload() -> void:
_emit_flags_batch()
var payload: Dictionary = a3.last_round_end # 逐字段取自 A3(读取权 = 接口契约)
round_end_board.emit({"path_tag": payload.get("path_tag", ""), "continuation_hint": payload.get("continuation_hint", "")})
func _adjacent_to_hero(c: Vector2i) -> bool:
var d := Vector2i(absi(c.x - _hero.x), absi(c.y - _hero.y))
return maxi(d.x, d.y) == 1
func _outcome(accepted: bool, outcome: String, reason: String, sub: String) -> Dictionary:
return {"accepted": accepted, "outcome": outcome, "reject_reason": reason, "sub_reason": sub}
@@ -0,0 +1 @@
uid://dlcnq7jcxrnwr
@@ -0,0 +1,347 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 可选类供给能否按"格极稀 / 板≈0.78 / 战斗期望≈1(首件保证 + 指数递减)"三尺度被精确控制
# Date: 2026-09-10
# B4 奖励与道具经济:install 期身份预分配(roll_board 恰一次、消费 reward_roll 封闭流恰一 scope 批)·
# 授予期零随机 · 库存关卡域(battle_over 全清)· 道具伤害恒定表值 · Ready 后只读。
# 2026-09-11 可选类供给控制器批(用户拍板):格子池只剩即时类;可选类经 RewardSupplyController 注入
# (战斗首件保证 + p0×decay^(j-1) 指数递减);旧 F5 格池刀保底(k_dmg)休眠;清图槽改一次性伤害。
# 2026-09-14 伤害量值区间批(用户拍板②):区间行(power_min/max 可选配对)在身份预分配批内追加取号
# (④ 步,既有取号序零扰动);快照 E14 口径不变——快照 power = roll 值,B1/呈现零改动;授予期零随机不变。
# 切片登记:取号约定 = randi_range(0, W-1)(实现期 ADR 事实,切片内恒定)。
# 实现 GDD:design/gdd/b4-reward-item-economy.md(系统 ID B4——registry 追溯锚,命名规范见 CLAUDE.md)
class_name RewardEconomy
extends RefCounted
var db
var rng
var _supply: RewardSupplyController # 可选类供给状态机(组合件——战斗域计数)
var _phase := "unrolled" # unrolled → ready → retired;clear_inventory 后 _inventory_cleared
var _inventory_cleared := false
var _eligible: Array = [] # [{cell, fog}] 行主序规范序
var _mapping := {} # Vector2i → reward_id
var _power_roll := {} # Vector2i → 区间行 roll 值(伤害量值区间批——身份批内追加取号)
var _clear_power := -1 # 清图槽区间行 roll 值(-1 = 定值行)
var _granted := {} # Vector2i → 快照(重复受理幂等返回)
var _clear_identity := ""
var _clear_granted := false
var _clear_snapshot := {}
var _inventory: Array = [] # 逐件实例 {item_id, snapshot, source_cell, round_index, status}
func _init(db_ref, rng_ref) -> void:
db = db_ref
rng = rng_ref
_supply = RewardSupplyController.new(db.reward_config)
# ---- 战斗开始(B1.begin_battle 编排调用——B4 唯一调用方纪律不变)----
# 供给控制器战斗域计数清零;库存(关卡域)不受影响——关内延续照旧
func notify_battle_start() -> void:
_supply.notify_battle_start()
# ---- F1/F2/F5:install 期 roll(恰一次;Ready 相位再调拒绝)----
func roll_board(eligible: Array) -> bool:
# 相位守卫:Ready 再调 = double-roll 拒绝(E5);Retired(换图后)= 合法新批重置(E6 roll 半)
if _phase == "ready":
push_warning("[B4] roll_board 相位守卫拒绝(E5——double-roll 编排违约显式暴露)")
return false
if eligible.is_empty():
_fatal("roll_board 空资格清单(E1 兜底 fail-fast——A2 不变式④前置)")
return false
# 行主序规范化 + 去重(排序责任不外推给 B1)
var seen := {}
var list: Array = []
for e in eligible:
var c: Vector2i = e.cell
if seen.has(c):
push_warning("[B4] 资格清单重复格已去重: %s" % str(c))
continue
seen[c] = true
list.append({"cell": c, "fog": bool(e.get("fog", false))})
list.sort_custom(func(a, b): return a.cell.y * 1000 + a.cell.x < b.cell.y * 1000 + b.cell.x)
_eligible = list
_granted.clear() # 新批重置映射受理面(旧图快照消亡)
_power_roll.clear()
_clear_power = -1
_clear_granted = false
_clear_snapshot = {}
var stream: RandomNumberGenerator = rng.open_stream("reward_roll") # 恰一 scope 批(身份批 + 清图槽 + 注入判定/选格 + 区间行 power roll——追加式取号,既有序零扰动)
var safe_rows: Array = _pool_rows("safe_cell")
var clear_rows: Array = _pool_rows("clear_default")
var inject_rows: Array = _pool_rows("active_inject")
# ① 身份批:每资格格恰一即时类身份(可选类已退出格子池——供给控制器批;雾档过滤不变)
var draft := {}
for e in list:
var pool := safe_rows.filter(func(row):
if e.fog and int(row.tier) < int(db.reward_config.t_fog_min):
return false # F2 雾档过滤
return true)
draft[e.cell] = weighted_pick(pool, stream.randi_range(0, _pool_weight(pool) - 1))
# ② 清图槽:一次性高额伤害(当场兑付类——不入库、不占可选类供给;2026-09-11 用户拍板)
var clear_rid := weighted_pick(clear_rows, stream.randi_range(0, _pool_weight(clear_rows) - 1))
# ③ 可选类供给注入(替代旧 F5 刀保底):首件保证 / p0×decay^(j-1) 指数递减——判定恰一取号
if _supply.should_inject(stream):
var target = list[stream.randi_range(0, list.size() - 1)] # 均匀随机(拍板③——雾格不加权)
draft[target.cell] = weighted_pick(inject_rows, stream.randi_range(0, _pool_weight(inject_rows) - 1))
# ④ 伤害量值区间批(2026-09-14):身份定稿后逐格 roll power(区间行恰一取号,定值行零取号);
# 授予期零随机纪律不变——快照 E14 口径,B1/呈现零改动
for cell in draft.keys():
var row: Dictionary = _row_by_id(draft[cell])
if row.has("power_min"):
_power_roll[cell] = stream.randi_range(int(row.power_min), int(row.power_max))
var clear_row: Dictionary = _row_by_id(clear_rid)
if clear_row.has("power_min"):
_clear_power = stream.randi_range(int(clear_row.power_min), int(clear_row.power_max))
_mapping = draft
_clear_identity = clear_rid
_phase = "ready"
return true
# ---- 关卡节点奖励(2026-09-11 关卡扩展批——线性奖励点,正式归属 B4/C1 修订)----
# 奖励点 = 用户拍板的关卡节点类型:在本表全部可选类活行(damage_item / heal_pack,即"主动道具")
# 中 roll 一件并直接入库。消耗 reward_roll 恰一 scope(与 board install 批同流异 scope——流集合封闭不变)。
# cell=(-1,-1)/round_index=0 = 非格子来源标记(关卡节点授予,区别于安全格拾取的呈现面)。
func grant_node_active(cell: Vector2i, round_index: int) -> Dictionary:
var pool: Array = []
for row in db.reward.rows:
if int(row.weight) > 0 and (String(row.category) == "damage_item" or String(row.category) == "heal_pack"):
pool.append(row)
if pool.is_empty():
_fatal("节点奖励池为空(damage_item/heal_pack 零活行)")
return {}
var stream: RandomNumberGenerator = rng.open_stream("reward_roll")
var rid := weighted_pick(pool, stream.randi_range(0, _pool_weight(pool) - 1))
var row: Dictionary = _row_by_id(rid)
var rolled := -1
if row.has("power_min"):
rolled = stream.randi_range(int(row.power_min), int(row.power_max)) # 区间行恰一追加取号(伤害量值区间批)
var inst = stock(_snapshot(row, rolled), cell, round_index)
if inst is Dictionary:
_supply.notify_granted() # 节点奖励 = 可选类实际发放——与 grant() 同一入账点(playtest-2026-09-12 报告 Bug#3:绕过供给计数修正)
return inst
# ---- 查询面(Ready 只读)----
func query_identity(cell: Vector2i):
if _phase != "ready":
_diag("query_identity 非 Ready 相位")
return null
var rid: String = _mapping.get(cell, "")
if rid == "":
_diag("query_identity 清单外坐标(E19——未分配非错误态)")
return null
var row: Dictionary = _row_by_id(rid)
return {"reward_id": rid, "category": String(row.category), "tier": int(row.tier)}
# 身份完整快照(轮内刷新批次隔离用——facts 缓冲期捕获,授予期 mapping 可能已换批):
# 返回与 grant 相同结构的快照(display_name/power/effect_id 齐备),供 B1 冻结授予直通
func query_full_snapshot(cell: Vector2i):
if _phase != "ready":
_diag("query_full_snapshot 非 Ready 相位")
return null
var rid: String = _mapping.get(cell, "")
if rid == "":
return null
return _snapshot(_row_by_id(rid), int(_power_roll.get(cell, -1)))
# ---- 受理面(B1 编排期调用;E6 收口 2026-09-13:失败统一返回 null——此前 false/null 混用)----
func grant(cell: Vector2i):
if _phase != "ready":
_diag("grant 非 Ready 相位(E4/E7)")
return null
if not _mapping.has(cell):
_diag("grant 清单外坐标(E19)")
return null
if _granted.has(cell):
_diag("grant 同格重复受理(E18 幂等兜底——受理恰一次归 B1)")
return _granted[cell]
var row: Dictionary = _row_by_id(_mapping[cell])
var snap := _snapshot(row, int(_power_roll.get(cell, -1)))
if String(snap.category) == "damage_item" or String(snap.category) == "heal_pack":
_supply.notify_granted() # 发放即获取——首件保证/递减计数的唯一入账点(发放侧语义,满血拒"使用"不影响)
_granted[cell] = snap
return snap
func grant_clear():
if _phase != "ready":
_diag("grant_clear 非 Ready 相位")
return null
if _clear_granted:
_diag("grant_clear 同批次重调(E18 幂等兜底)")
return _clear_snapshot
var row: Dictionary = _row_by_id(_clear_identity)
_clear_snapshot = _snapshot(row, _clear_power)
_clear_granted = true
return _clear_snapshot
func stock(snapshot: Dictionary, source_cell: Vector2i, round_index: int):
if _inventory_cleared:
_diag("stock 战斗已清库(E7)")
return null
var cat: String = String(snapshot.get("category", ""))
if cat != "damage_item" and cat != "heal_pack":
_diag("stock 仅受理道具类(E20): %s" % cat)
return null
var inst := {
"item_id": String(snapshot.get("reward_id", "")),
"snapshot": snapshot,
"source_cell": source_cell,
"round_index": round_index,
"status": "InStock",
}
_inventory.append(inst) # 数量无上限;枚举序 = stock 受理序(E21)
return inst
# 窗口选用消耗:零游戏态守卫(照常消耗——E10;满血/零效果守卫归 B1)
func use(index: int):
if _inventory_cleared:
_diag("use 战斗已清库")
return null
if index < 0 or index >= _inventory.size():
_diag("use 句柄越界")
return null
var inst: Dictionary = _inventory[index]
if inst.status != "InStock":
_diag("use 已消耗句柄(E9 Used 不可逆)")
return null
inst.status = "Used"
return {
"item_id": inst.item_id,
"category": String(inst.snapshot.get("category", "")),
"power": int(inst.snapshot.get("power", 0)), # 授予时快照值(E14——非表读值)
"source_cell": inst.source_cell,
"round_index": inst.round_index,
}
func query_inventory() -> Array:
var out: Array = []
for i in _inventory.size():
var inst: Dictionary = _inventory[i]
out.append({
"index": i,
"item_id": inst.item_id,
"category": String(inst.snapshot.get("category", "")),
"display_name": String(inst.snapshot.get("display_name", "")),
"power": int(inst.snapshot.get("power", 0)),
"source_cell": inst.source_cell,
"round_index": inst.round_index,
"status": inst.status,
})
return out
func in_stock_count() -> int:
var n := 0
for inst in _inventory:
if inst.status == "InStock":
n += 1
return n
# 观测面(冒烟/调参工具——本战斗已实际获取的可选类件数)
func supply_granted_count() -> int:
return _supply.granted_count()
func clear_inventory() -> bool:
if _inventory_cleared:
_diag("clear_inventory 重复调用(幂等守卫)")
return false
_inventory.clear() # battle_over 全清不跨战斗
_inventory_cleared = true
return true
func retire_mapping() -> bool:
if _phase != "ready":
_diag("retire_mapping 非 Ready 相位(幂等受理族)")
return false
_phase = "retired"
_mapping.clear()
_power_roll.clear()
_clear_power = -1
_clear_identity = ""
return true
# ---- F6 当量供给(B1 条款①对照实物输入)----
func equivalence_supply() -> Dictionary:
var live: Array = []
var tier_min_buff := 99
for row in db.reward.rows:
if int(row.weight) <= 0:
continue
live.append(row.duplicate()) # E6:表行值拷贝出口——消费方禁改配置源
if row.pool_tag == "safe_cell" and row.category == "buff":
tier_min_buff = mini(tier_min_buff, int(row.tier))
return {"live_rows": live, "tier_min_buff": tier_min_buff}
# ---- F1 选行算子纯函数 ----
func weighted_pick(rows: Array, r: int) -> String:
var total := _pool_weight(rows)
assert(r >= 0 and r < total, "weighted_pick 取号越域(纯函数域守卫)")
var acc := 0
for row in rows:
acc += int(row.weight)
if r < acc:
return String(row.reward_id)
_fatal("weighted_pick 未命中(不应可达)")
return ""
func _pool_rows(pool_tag: String) -> Array:
var out: Array = []
for row in db.reward.rows:
if row.pool_tag == pool_tag and int(row.weight) > 0:
out.append(row)
return out
func _pool_weight(rows: Array) -> int:
var w := 0
for row in rows:
w += int(row.weight)
return w
func _row_by_id(rid: String) -> Dictionary:
for row in db.reward.rows:
if row.reward_id == rid:
return row
return {}
func _snapshot(row: Dictionary, rolled_power: int = -1) -> Dictionary:
var snap := {
"reward_id": String(row.reward_id),
"category": String(row.category),
"display_name": String(row.display_name),
"power": int(row.power) if rolled_power < 0 else rolled_power, # 区间行 = 身份批 roll 值(E14 授予时快照口径)
"tier": int(row.tier),
}
if String(row.get("effect_id", "")) != "":
snap["effect_id"] = String(row.effect_id)
return snap
func _diag(msg: String) -> void:
push_warning("[B4] " + msg)
func _fatal(msg: String) -> void:
push_error("[B4/fatal] " + msg)
assert(false, msg)
@@ -0,0 +1 @@
uid://5vj3p6fmval7
@@ -0,0 +1,43 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 可选类供给能否按"格极稀 <0.01 / 板≈0.78 / 战斗期望≈1(首件保证 + 指数递减)"三尺度被精确控制
# Date: 2026-09-11
# 可选类供给控制器(2026-09-11 用户拍板批——B4 私有组合件):
# 格子权重池不再产出可选类;本控制器在 install 期决定"本板是否注入 1 件可选类"——
# 战斗内尚未获取首件 → 本板必注入(首件保证);已获取 j 件 → 概率 p0×decay^(j-1)(指数下降、永不绝育)。
# 行值快照(p0/decay 构造期快照——抗热重载,B2 同款);零自有随机数(判定取号经 B4 传入的 reward_roll
# 封闭流实例——Rule 5);"获取"以实际发放入库为准(预分配未挖到不计数——授予侧经 B4.grant 挂钩上报)。
# 实现 GDD:design/gdd/b4-reward-item-economy.md(系统 ID B4——registry 追溯锚,命名规范见 CLAUDE.md)
class_name RewardSupplyController
extends RefCounted
var _granted := 0 # 本战斗内已实际获取的可选类件数(战斗域——notify_battle_start 清零)
var _p0 := 0.4
var _decay := 0.5
func _init(reward_config) -> void:
_p0 = clampf(float(reward_config.active_p0), 0.0, 1.0)
_decay = clampf(float(reward_config.active_decay), 0.001, 1.0)
# B1.begin_battle 编排调用(B4 转发)——战斗域计数重置;跨战斗库存延续不受影响
func notify_battle_start() -> void:
_granted = 0
# install 期注入判定(B4.roll_board 在身份批 + 清图槽之后调用;取号 = 传入封闭流恰一次)
func should_inject(stream: RandomNumberGenerator) -> bool:
if _granted == 0:
return true # 首件保证
var p: float = _p0 * pow(_decay, float(_granted - 1))
return stream.randf() < p
# B4.grant 实际发放可选类时上报(发放即获取;满血拒"使用"不影响——那是消耗侧语义)
func notify_granted() -> void:
_granted += 1
# 观测面(冒烟/调参工具)
func granted_count() -> int:
return _granted
@@ -0,0 +1 @@
uid://de40p45nneaw1
@@ -0,0 +1,88 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# D2 RngService autoload——封闭命名流单点(Rule 5)。玩法代码禁止 randomize()/自建 RandomNumberGenerator。
# 实现 GDD:design/gdd/d2-data-config.md(系统 ID D2——registry 追溯锚,命名规范见 CLAUDE.md)
# R2 派生公式(黄金向量已预验证):derive = (SALT XOR wrap64(master + scope×GOLDEN)) AND 0x7FFF…
# 取号语义:全新实例首个 open_stream = scope 0(B4 预推进术语的登记口径)。
extends Node
const STREAM_NAMES := ["board_gen", "reward_roll", "encounter"] # 2026-09-12 增 encounter(遭遇随机化批,用户拍板——registry rng_service 条目同步)
# 分段表达式书写(D2 R2 实现级规约——禁 ≥2^63 完整字面量)
const GOLDEN_RATIO := (0x9E3779B9 << 32) | 0x7F4A7C15 # 0x9E3779B97F4A7C15
const SALT_BOARD_GEN := (0xA0761D64 << 32) | 0x78BD642F # 0xA0761D6478BD642F
const SALT_REWARD_ROLL := (0xE7037ED1 << 32) | 0xA0B428DB # 0xE7037ED1A0B428DB
const SALT_ENCOUNTER := (0x5DEECE66D << 32) | 0x2545F491 # 0x5DEECE66D2545F491(遭遇骰——2026-09-12)
const OUT_MASK := 0x7FFFFFFFFFFFFFFF
const INT64_MIN := -9223372036854775808
# master_seed 只读出口(E5 封装收口 2026-09-13):写入只经 set_master_seed(计数器全零断言
# 不可绕过);外部读 master_seed 供 D3 快照契约观测。
var _master_seed: int = -1 # -1 = 未播种(取数即致命,EC7)
var master_seed: int:
get: return _master_seed
var _counters := {"board_gen": 0, "reward_roll": 0, "encounter": 0}
func _salt_for(stream: String) -> int:
match stream:
"board_gen":
return SALT_BOARD_GEN
"reward_roll":
return SALT_REWARD_ROLL
"encounter":
return SALT_ENCOUNTER
push_error("[RngService] 未知名流: %s" % stream)
assert(false, "未知名流(流集合封闭,EC12)")
return 0
# 纯函数(测试/预览入口,不推计数器)
func derive_at(stream: String, scope: int) -> int:
assert(scope >= 0, "负 scope 与 scope+2^63 经输出掩码别名(禁止)")
var t: int = _master_seed + scope * GOLDEN_RATIO # wrap64 自然回绕——禁 float 中间量/禁 %/禁主动修复
var seed_val: int = _salt_for(stream) ^ t
return seed_val & OUT_MASK
func set_master_seed(seed_val: int) -> void:
if seed_val == INT64_MIN:
push_error("[RngService] master_seed = INT64_MIN 致命(abs 恒等回绕,与 0 静默别名)")
assert(false, "INT64_MIN 致命")
return
if seed_val < 0:
seed_val = -seed_val
push_warning("[RngService] master_seed 负输入 abs 归一化: %d" % seed_val)
assert(_counters["board_gen"] == 0 and _counters["reward_roll"] == 0 and _counters["encounter"] == 0, "冒险中途重播种会击穿同冒险无碰撞前提(仅许全部流计数器全零时调用)")
_master_seed = seed_val
_counters["board_gen"] = 0
_counters["reward_roll"] = 0
# 切片登记:冒险开始重播种入口(正式侧调用权归 C2;切片主菜单 = 冒险起点)
func new_adventure(seed_val: int) -> void:
_counters["board_gen"] = 0
_counters["reward_roll"] = 0
_counters["encounter"] = 0
set_master_seed(seed_val)
# 有状态流门面:一次调用 = 一次 scope 取号(计数器读值 → 派生 → +1)
func open_stream(stream: String) -> RandomNumberGenerator:
if not STREAM_NAMES.has(stream):
push_error("[RngService] 未知名流: %s" % stream)
assert(false, "未知名流(EC12)")
if _master_seed < 0:
push_error("[RngService] 未设 master_seed 即取数(EC7——C2 未接线的显式暴露)")
assert(false, "未播种流取数致命(EC7)")
var scope: int = _counters[stream]
_counters[stream] = scope + 1
var rg := RandomNumberGenerator.new()
rg.seed = derive_at(stream, scope)
return rg
# 只读计数器(D3 快照契约的观测面)
func counter(stream: String) -> int:
return _counters[stream]
@@ -0,0 +1 @@
uid://tcp13u2etw2n
@@ -0,0 +1,137 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# B3 统一状态容器:三向效果同构实例 · 单一时限量纲 remaining_settlements(先兑付后衰减)·
# 按行叠层(stack 授予不触碰 R)· 授予时数值快照抗热重载 · 零随机零信号。
# 注:MVP 单敌假设经 B2 回验不成立(定多敌)——enemy 侧 side-wide 语义按 registry 改写执行。
# 实现 GDD:design/gdd/b3-status-effect.md(系统 ID B3——registry 追溯锚,命名规范见 CLAUDE.md)
class_name StatusContainer
extends RefCounted
var db
var _open := false
var _instances: Array = [] # 插入序;元素 = {effect_id, layers, remaining, source_class, origin_cell, round_index, snap_m, snap_unit, snap_display}
func _init(db_ref) -> void:
db = db_ref
func notify_battle_start() -> bool:
if _open:
push_warning("[B3] Active 下重复 notify_battle_start(EC11)")
return false
_open = true
_instances.clear()
return true
# 唯一授予入口(挖掘期零直写——B1 结算编排期统一调用)
func apply_effect(effect_id: String, source: Dictionary) -> bool:
if not _open:
push_warning("[B3] 域闭授予(EC1)")
return false
var row: Dictionary = db.status_effect_row(effect_id)
if row.is_empty():
push_warning("[B3] 未知 effect_id(EC2): %s" % effect_id)
return false
var policy: String = row.stack_policy
var dur: int = int(row.duration_settlements)
var max_stack: int = int(row.max_stack)
# 授予时快照(抗表重载——EC9)
var snap_m: int = int(row.magnitude)
var snap_unit: String = String(row.unit)
var snap_display: String = String(row.display_name)
match policy:
"independent":
_instances.append(_new_instance(effect_id, 1, dur, source, snap_m, snap_unit, snap_display))
return true
"refresh":
for inst in _instances:
if inst.effect_id == effect_id:
inst.remaining = dur # R ← D(取实例快照 D——同表值);L 不变;空刷新恒 true
return true
_instances.append(_new_instance(effect_id, 1, dur, source, snap_m, snap_unit, snap_display))
return true
"stack":
for inst in _instances:
if inst.effect_id == effect_id:
if inst.layers >= max_stack:
return false # 满层拒绝原子——容器零写入(溢出行归 B1)
inst.layers += 1 # stack 授予不触碰 R(spec 决议)
return true
_instances.append(_new_instance(effect_id, 1, dur, source, snap_m, snap_unit, snap_display))
return true
push_warning("[B3] 未知 stack_policy")
return false
func _new_instance(effect_id: String, layers: int, dur: int, source: Dictionary, m: int, unit: String, display: String) -> Dictionary:
return {
"effect_id": effect_id,
"layers": layers,
"remaining": dur,
"source_class": String(source.get("source_class", "")),
"origin_cell": source.get("origin_cell", Vector2i(-1, -1)),
"round_index": int(source.get("round_index", 0)),
"snap_m": m,
"snap_unit": unit,
"snap_display": display,
}
# 只读投影(插入序稳定枚举;六归因字段 + display_name + 授予时快照)
func query_active(side: String) -> Array:
var out: Array = []
for inst in _instances:
var row: Dictionary = db.status_effect_row(inst.effect_id)
if row.is_empty():
continue
if String(row.target_side) != side:
continue
out.append({
"effect_id": inst.effect_id,
"layers": inst.layers,
"remaining_settlements": inst.remaining,
"source_class": inst.source_class,
"origin_cell": inst.origin_cell,
"round_index": inst.round_index,
"display_name": inst.snap_display,
"snap_magnitude": inst.snap_m,
"snap_unit": inst.snap_unit,
"effective_value": inst.layers * inst.snap_m, # L×m 加算(percent 越域不 clamp——收口归 B1)
})
return out
# 池层数读值(B1 战斗段伏击池 L 的消费面)
func pool_layers(effect_id: String) -> int:
for inst in _instances:
if inst.effect_id == effect_id:
return inst.layers
return 0
# 轮末衰减:先兑付后衰减;每实例恰 −1;归 0 移除;返回移除数 k(恰一次执行由 B1 编排信任承载)
func decay_round() -> int:
if not _open:
return 0 # EC13 域闭兜底读法——容器恒空,无独立分支不诊断
var k := 0
var survivors: Array = []
for inst in _instances:
inst.remaining -= 1
if inst.remaining <= 0:
k += 1
else:
survivors.append(inst)
_instances = survivors
return k
func notify_battle_end() -> bool:
if not _open:
push_warning("[B3] BattleClosed 下重复 notify_battle_end(EC11)")
return false
_open = false
_instances.clear() # 全清不跨战斗
return true
@@ -0,0 +1 @@
uid://dlck3xxei0t61
@@ -0,0 +1,194 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# B2 战斗单位系统:hero/enemy 统一受伤死亡接口 · 行为环确定性轮转(死亡即不动手/先作用后推进)·
# 行值快照抗热重载 · side-wide 伏击池读值由调用方注入(B2 无池写面)· 编排被动零事件。
# 切片登记接口漂移:heal() 为切片新增(B1 治疗 clamp 需要写入口;B2 API 封闭枚举原本无治疗面——
# 正式侧应走 B2 GDD 修订,切片以头注登记)。
# 实现 GDD:design/gdd/b2-enemy-system.md(系统 ID B2——registry 追溯锚,命名规范见 CLAUDE.md)
class_name UnitContainer
extends RefCounted
var db
# 单位容器私有(E3 封装收口 2026-09-13):hero/enemies 原始数组不再外露——读取经 hero_unit()/
# all_enemies() 投影面(死亡残位查询包含在内),写入只经 apply_damage/heal/advance/spawn/despawn。
# 句柄语义不变:投影返回的 Dictionary 引用即单位句柄(同 query_alive 先例)。
var _hero: Dictionary = {} # {hp, max_hp, atk, is_hero: true}
var _enemies: Array = [] # 行主序生成序;元素 = 单位 Dictionary
var _spawned := false
var _despawned := false
func _init(db_ref) -> void:
db = db_ref
# E4 收口(2026-09-13):加成值经参数显式传入(atk_override/max_hp_override,-1 = 表值兜底)——
# 此前调用方写 db.hero 表值再由本类读表 = 共享可变单例传参(双写叠加 6→18→54 教训根源)。
# 接口漂移登记:spawn 签名 +2 参数(B2 GDD API 封闭枚举——正式侧 GDD/registry 修订批随 ADR-0009)。
# enemy_atk_override(2026-09-14 敌方攻击区间批):B1 遭遇骰同流的 spawn 期整战持久 roll 结果
# (-1 = 表值定值路径;≥0 = 行值替换后再乘关卡 atk_mult——修饰语义不变)。B2 零随机纪律保持。
func spawn(level_index: int, node_depth: int, hero_hp: int, unit_count_override: int = -1,
atk_override: int = -1, max_hp_override: int = -1, enemy_atk_override: int = -1) -> bool:
if _spawned:
push_warning("[B2] Alive 相位重复 spawn(相位守卫)")
return false
var row: Dictionary = db.resolve_enemy_rowset(level_index, node_depth)
if row.is_empty():
push_error("[B2] 遭遇行解析失败/越域 (%d,%d)" % [level_index, node_depth])
assert(false, "遭遇行悬空(R1 前置守卫)")
return false
var eff_atk: int = atk_override if atk_override >= 1 else int(db.hero.atk)
var eff_max_hp: int = max_hp_override if max_hp_override >= 1 else int(db.hero.max_hp)
if hero_hp > eff_max_hp or hero_hp <= 0:
push_warning("[B2] hero_hp 入参越界(EC16): %d" % hero_hp)
return false
# max_hp = 生效上限(非入参 hero_hp)——2026-09-11 关卡扩展批修复:跨战斗携带 HP 时上限不得压扁
# (恢复点/治疗包的满血守卫与治疗上限随之失效——切片登记接口语义澄清)
_hero = {"hp": hero_hp, "max_hp": eff_max_hp, "atk": eff_atk, "is_hero": true, "alive": true, "display_name": "探险者", "steps": 0, "pattern": [], "enemy_id": "hero"}
_enemies.clear() # C3 DifficultyTable 修饰(2026-09-12 结构位兑现):施加于行值、行值快照之前——MVP 空表 = 恒 1.0 无行为(AC-10)
var mod: Dictionary = db.resolve_difficulty_modifiers(level_index, node_depth)
var hp_mult: float = float(mod.get("hp_mult", 1.0))
var atk_mult: float = float(mod.get("atk_mult", 1.0))
# unit_count_override ≥1 = B1 遭遇骰结果(B2 零随机纪律——收到即确定值;-1 = 行值定值路径)
var count: int = unit_count_override if unit_count_override >= 1 else int(row.unit_count)
if count < 1 or count > 3:
push_warning("[B2] 实例化数量越域: %d" % count)
return false
for i in range(count):
var eff_row_atk: int = enemy_atk_override if enemy_atk_override >= 0 else int(row.atk)
_enemies.append({
"handle_id": i,
"enemy_id": String(row.enemy_id),
"display_name": String(row.display_name),
"hp": maxi(1, int(round(float(row.hp) * hp_mult))),
"max_hp": maxi(1, int(round(float(row.hp) * hp_mult))),
"atk": maxi(0, int(round(float(eff_row_atk) * atk_mult))),
"heavy_multiplier": int(row.heavy_multiplier),
"pattern": Array(row.pattern), # 行值快照(拷贝)
"advantage_per_round_cap": int(row.advantage_per_round_cap),
"armor_type": String(row.get("armor_type", "none")), # 护甲类型快照(克制矩阵消费面——B1 战斗段)
"steps": 0,
"alive": true,
"is_hero": false,
})
_spawned = true
_despawned = false
return true
func query_alive(side: String) -> Array:
if _despawned:
_diag("Despawned 句柄查询(域闭)")
return []
if side == "hero":
return [] if (_hero.is_empty() or not _hero.alive) else [_hero]
var out: Array = []
for u in _enemies:
if u.alive:
out.append(u)
return out
# 当前拍(Dead 句柄保留只读可查——冻结不变式观测量;hero 句柄拒绝)
func query_beat(u: Dictionary) -> String:
if u.is_empty() or _despawned:
_diag("query_beat 失效句柄")
return ""
if u.get("is_hero", false):
_diag("hero 无行为环(环接口对主角句柄拒绝)")
return ""
var p: Array = u.pattern
return String(p[u.steps % p.size()])
# F2 分量供给面:{baseline, attach, total};L/m_amb 由调用方注入(判别探针,不校验游戏态域)
func query_attack(u: Dictionary, L: int, m_amb: int) -> Dictionary:
if u.is_empty() or _despawned or not u.alive:
_diag("query_attack 失效/死亡句柄")
return {"baseline": 0, "attach": 0, "total": 0}
var beat := query_beat(u)
if beat == "charge":
return {"baseline": 0, "attach": 0, "total": 0} # charge 拍不读伏击池——空拍零兑付
var baseline: int = u.atk
if beat == "heavy":
baseline = u.atk * int(u.heavy_multiplier)
return {"baseline": baseline, "attach": L * m_amb, "total": baseline + L * m_amb}
# 受击上报:hp 递减 floor 0 → Dead;n ≤ 0 拒绝
func apply_damage(u: Dictionary, n: int) -> bool:
if n <= 0:
_diag("apply_damage n ≤ 0 非法")
return false
if u.is_empty() or _despawned or not u.alive:
_diag("apply_damage 失效/死亡句柄")
return false
u.hp = maxi(0, u.hp - n)
if u.hp == 0:
u.alive = false # Dead 不可逆(死亡判定时机归 B1)
return true
# 治疗(切片登记接口漂移——B1 治疗 clamp 的写入口):返回实际回复量
func heal(u: Dictionary, n: int) -> int:
if n < 0 or u.is_empty() or _despawned or not u.alive:
_diag("heal 非法/失效句柄")
return 0
var actual: int = mini(n, int(u.max_hp) - int(u.hp))
u.hp += actual
return actual
# 推进:存活才推进;false 两级——预期冻结(无诊断)/ 失效句柄(诊断);hero 拒绝
func advance(u: Dictionary) -> bool:
if u.is_empty() or _despawned:
_diag("advance 失效句柄")
return false
if u.get("is_hero", false):
_diag("hero 不在推进域(主角无行为环)")
return false
if not u.alive:
return false # 轮到前被杀——预期冻结(死亡即不动手),无诊断
u.steps += 1
return true
# 固定顺位:行主序生成序最前存活敌;死亡后顺延;空 → null
func default_target():
for u in _enemies:
if u.alive:
return u
return null
# ---- 只读投影面(E3 封装收口:外部读单位经此,禁直读容器数组)----
# 主角句柄(未 spawn/despawn 后 = {})——句柄即 Dictionary 引用(同 query_alive 先例)
func hero_unit() -> Dictionary:
return _hero
# 全体敌人句柄(含死亡残位——呈现层残位渲染 / 白盒断言的合法入口;数组结构拷贝、字典句柄同源)
func all_enemies() -> Array:
return _enemies.duplicate()
# F3 机制算术纯函数(B1 预截流消费面):cap 判定在授予前;守恒 granted + overflow = mines_r
func ambush_grant_plan(mines_r: int, cap: int) -> Dictionary:
var granted: int = mini(mines_r, cap)
return {"granted": granted, "overflow": mines_r - granted}
func despawn_all() -> bool:
if _despawned or not _spawned:
_diag("重复/无效 despawn_all")
return false
_despawned = true
_enemies.clear()
_hero = {}
return true
func _diag(msg: String) -> void:
push_warning("[B2] " + msg)
@@ -0,0 +1 @@
uid://dek3bsxksloan
@@ -0,0 +1,41 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# D2 ActionEconomyTable schema(A3 回填值,单行全局;Rule 4 豁免)。
# 数值域与跨字段校验依据 design/gdd/a3-action-economy.md "ActionEconomyTable Schema 定稿"。
class_name ActionEconomyTable
extends Resource
@export var ap_per_round: int = 8 # [1,32] knob
@export var dig_ap_cost: int = 1 # [1,8] knob(域下限 1 = 零预算系统失效拒绝)
@export var flag_place_ap_cost: int = 1 # [0,8] knob
@export var flag_pull_ap_cost: int = 1 # [0,8] knob
@export var rap_refresh_cost: int = 8 # [1,64] knob
@export var refresh_max_per_round: int = 2 # [1,8] knob(2026-09-12 轮内刷新批——每轮刷新兜底上限,Active/Waiting 合计)
@export var move_ap_cost: int = 0 # 冻结 0(P4 氛围承载)
@export var chain_ap_cost: int = 0 # 冻结 0(v2 定稿)
func validate() -> Array:
var errs: Array = []
if ap_per_round < 1 or ap_per_round > 32:
errs.append({"category": "domain", "message": "ap_per_round 越域 [1,32]: %d" % ap_per_round})
if dig_ap_cost < 1 or dig_ap_cost > 8:
errs.append({"category": "domain", "message": "dig_ap_cost 越域 [1,8]: %d" % dig_ap_cost})
if flag_place_ap_cost < 0 or flag_place_ap_cost > 8:
errs.append({"category": "domain", "message": "flag_place_ap_cost 越域 [0,8]: %d" % flag_place_ap_cost})
if flag_pull_ap_cost < 0 or flag_pull_ap_cost > 8:
errs.append({"category": "domain", "message": "flag_pull_ap_cost 越域 [0,8]: %d" % flag_pull_ap_cost})
if rap_refresh_cost < 1 or rap_refresh_cost > 64:
errs.append({"category": "domain", "message": "rap_refresh_cost 越域 [1,64]: %d" % rap_refresh_cost})
if refresh_max_per_round < 1 or refresh_max_per_round > 8:
errs.append({"category": "domain", "message": "refresh_max_per_round 越域 [1,8]: %d" % refresh_max_per_round})
if move_ap_cost != 0:
errs.append({"category": "frozen", "message": "move_ap_cost 结构冻结为 0: %d" % move_ap_cost})
if chain_ap_cost != 0:
errs.append({"category": "frozen", "message": "chain_ap_cost 结构冻结为 0: %d" % chain_ap_cost})
# 跨字段(A3 三轮评审增补):任一动作成本 ≤ ap_per_round,违规加载期致命
for pair in [["dig_ap_cost", dig_ap_cost], ["flag_place_ap_cost", flag_place_ap_cost], ["flag_pull_ap_cost", flag_pull_ap_cost]]:
if pair[1] > ap_per_round:
errs.append({"category": "cross_field", "message": "%s(%d) > ap_per_round(%d)——挖掘/旗动作永久不可用软锁" % [pair[0], pair[1], ap_per_round]})
return errs
@@ -0,0 +1 @@
uid://dtortb6o6p2gu
@@ -0,0 +1,77 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# D2 BoardGenTable schema(A1 回填值;键 (level_index, node_depth)——首个进度键表)。
# 依据 design/gdd/a1-board-generation.md "BoardGenTable Schema 定稿" + F2/F2b/F2c 谓词。
class_name BoardGenTable
extends Resource
@export var rows: Array = [] # Array[Dictionary]
const SOLVER_RULES := ["single", "single_subset"]
const FOG_RATIO_ABS_CAP := 0.25 # B1 修订——雾占比绝对上限
const COST_BUDGET := 16384 # F2c provisional 量级护栏(禁墙钟逃生)
const TARGET_ABS_FLOOR := 0.85 # B1 修订——target 域下限(P1 绝对锚)
func validate() -> Array:
var errs: Array = []
if rows.is_empty():
errs.append({"category": "empty_table", "message": "BoardGenTable 为空"})
return errs
var levels := {}
var anchor_ok := {}
for row in rows:
var L: int = int(row.get("level_index", 0))
var D: int = int(row.get("node_depth", -1))
var key := "%d_%d" % [L, D]
if levels.has(key):
errs.append({"category": "dup_key", "message": "BoardGen 键重复: %s" % key})
levels[key] = true
if L < 1:
errs.append({"category": "domain", "message": "level_index 须 ≥1"})
if D < 0:
errs.append({"category": "domain", "message": "node_depth 须 ≥0"})
var w: int = int(row.get("board_width", 0))
var h: int = int(row.get("board_height", 0))
if w < 8 or w > 64 or h < 8 or h > 64:
errs.append({"category": "domain", "message": "(%d,%d) 尺寸越域 [8,64]" % [L, D]})
var d: float = float(row.get("mine_density", 0.0))
if d <= 0.0 or d > 0.5:
errs.append({"category": "domain", "message": "(%d,%d) mine_density 越域 (0,0.5]" % [L, D]})
var t: float = float(row.get("inferability_target", 0.0))
if t < TARGET_ABS_FLOOR or t > 1.0:
errs.append({"category": "domain", "message": "(%d,%d) inferability_target 越域 [0.85,1](P1 绝对锚)" % [L, D]})
if not SOLVER_RULES.has(row.get("solver_rules", "")):
errs.append({"category": "enum", "message": "(%d,%d) solver_rules 出域" % [L, D]})
if row.get("solver_rules", "") != "single_subset":
errs.append({"category": "frozen", "message": "(%d,%d) solver_rules 冻结 single_subset" % [L, D]})
var ms: int = int(row.get("min_safe_count", 0))
if ms < 1 or ms > 999:
errs.append({"category": "domain", "message": "(%d,%d) min_safe_count 越域 [1,999]" % [L, D]})
var att: int = int(row.get("max_gen_attempts", 0))
if att < 1 or att > 32:
errs.append({"category": "domain", "message": "(%d,%d) max_gen_attempts 越域 [1,32]" % [L, D]})
var fc: int = int(row.get("fog_region_count", 0))
var fr: float = float(row.get("fog_target_ratio", 0.0))
# F2 规范合取形 + 雾占比绝对上限(B1 修订)
var fog_ok := (fc == 0 and fr == 0.0) or (fc >= 1 and fr > 0.0 and fr <= minf(8.0 * d, FOG_RATIO_ABS_CAP) and fr < 1.0)
if not fog_ok:
errs.append({"category": "cross_field", "message": "(%d,%d) fog_domain_ok 违例(count=%d ratio=%.3f)" % [L, D, fc, fr]})
# 布雷数下限(R3)——零雷板拒绝
var n_mines := int(round(d * float(w * h - 9)))
if n_mines < 1:
errs.append({"category": "cross_field", "message": "(%d,%d) 布雷数下限违例 round(d×(W×H−9)) ≥ 1" % [L, D]})
# F2b min_safe 可行性
if w * h - n_mines < ms:
errs.append({"category": "cross_field", "message": "(%d,%d) min_safe_feasible 违例" % [L, D]})
# F2c 成本可行性
if att * w * h > COST_BUDGET:
errs.append({"category": "cross_field", "message": "(%d,%d) cost_feasible 违例 attempts×W×H ≤ %d" % [L, D, COST_BUDGET]})
var biomes: Array = row.get("board_biomes", [])
if biomes.is_empty():
errs.append({"category": "cross_field", "message": "(%d,%d) board_biomes 非空域校验违例(A4⑮)" % [L, D]})
anchor_ok[L] = anchor_ok.get(L, false) or D == 0
for L in anchor_ok.keys():
if not anchor_ok[L]:
errs.append({"category": "anchor", "message": "level %d 缺 depth-0 锚行(EC15)" % L})
return errs
@@ -0,0 +1 @@
uid://ee2mfcg5542u
@@ -0,0 +1,32 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Date: 2026-09-12
# D2 DifficultyTable schema(C3 GDD Core Rule 1 兑现——B2 Rule 10 "接口未定 provisional" 的建表兑现,走 D2 接口漂移规则)。
# 全局修饰结构位:按 (L,D) 精确匹配施加乘数于 EnemyTable 行值(B2.spawn 实例化时、行值快照前)。
# MVP 空表(零行为——AC-10);首个使用场景 = 精英节点(C1 定稿联动,C3 OQ5);Boss 走 Dmax 直填行不走本表。
class_name DifficultyTable
extends Resource
@export var rows: Array = [] # Array[Dictionary]:{level_index, node_depth, hp_mult, atk_mult}
const MULT_MIN := 0.1
const MULT_MAX := 10.0
func validate() -> Array:
var errs: Array = []
var seen := {}
for row in rows:
var L: int = int(row.get("level_index", 0))
var D: int = int(row.get("node_depth", -1))
var key := "%d_%d" % [L, D]
if seen.has(key):
errs.append({"category": "dup_key", "message": "DifficultyTable 键重复: %s" % key})
seen[key] = true
if L < 1 or D < 0:
errs.append({"category": "domain", "message": "难度键越域 (%d,%d)" % [L, D]})
# 乘数越域 = warning 级(使用时 clamp——组合事故非配置事故,C3 GDD Edge Case 预登记)
for k in ["hp_mult", "atk_mult"]:
var v: float = float(row.get(k, 1.0))
if v < MULT_MIN or v > MULT_MAX:
errs.append({"category": "mult_oor_warning", "message": "%s (%s) 越域 [0.1,10] 使用时 clamp: %f" % [key, k, v]})
return errs
@@ -0,0 +1 @@
uid://bskkax0l0f20
@@ -0,0 +1,108 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# D2 EnemyTable / HeroTable schema(B2 回填值)。EnemyTable 键 (L,D,enemy_id),每行集恰一敌种;
# HeroTable 单行全局(Rule 4 豁免)。依据 design/gdd/b2-enemy-system.md Schema 节。
class_name EnemyTable
extends Resource
@export var rows: Array = [] # Array[Dictionary]
const PATTERN_MVP := ["attack", "charge", "heavy"]
const PATTERN_RESERVED := ["guard", "boost", "hinder", "summon"]
# 护甲类型封闭枚举(伤害类型 × 护甲克制批——克制矩阵本体在 SettlementTable)
const ARMOR_TYPES := ["none", "a", "b"]
func validate() -> Array:
var errs: Array = []
if rows.is_empty():
errs.append({"category": "empty_table", "message": "EnemyTable 为空"})
return errs
var seen := {}
var rowset_species := {}
var anchor_ok := {}
for row in rows:
var L: int = int(row.get("level_index", 0))
var D: int = int(row.get("node_depth", -1))
var eid: String = row.get("enemy_id", "")
var key := "%d_%d_%s" % [L, D, eid]
if seen.has(key):
errs.append({"category": "dup_key", "message": "EnemyTable 键重复: %s" % key})
seen[key] = true
if L < 1 or D < 0:
errs.append({"category": "domain", "message": "进度键越域 (%d,%d)" % [L, D]})
if eid == "":
errs.append({"category": "domain", "message": "enemy_id 空"})
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % eid})
var uc: int = int(row.get("unit_count", 0))
if uc < 1 or uc > 3:
errs.append({"category": "domain", "message": "%s unit_count 越域 [1,3]" % eid})
# unit_count_max(可省略——遭遇随机化批,C3 GDD Core Rule 8):区间 [unit_count, max],缺省 = 定值
if row.has("unit_count_max"):
var ucm: int = int(row.get("unit_count_max", uc))
if ucm < uc or ucm > 3:
errs.append({"category": "cross_field", "message": "%s unit_count_max(%d) 越域 [unit_count(%d),3]" % [eid, ucm, uc]})
var hp: int = int(row.get("hp", 0))
if hp < 1 or hp > 999:
errs.append({"category": "domain", "message": "%s hp 越域 [1,999]" % eid})
var atk: int = int(row.get("atk", -1))
if atk < 0 or atk > 999:
errs.append({"category": "domain", "message": "%s atk 越域 [0,999]" % eid})
# atk_min/atk_max(可省略配对——2026-09-14 敌方攻击区间批):遭遇骰同流 spawn 期整战持久 roll;
# 中点锚 = A3 条款①同款期望当量纪律(调区间必同步调 atk)
var has_amin: bool = row.has("atk_min")
var has_amax: bool = row.has("atk_max")
if has_amin != has_amax:
errs.append({"category": "cross_field", "message": "%s atk_min/atk_max 必须成对出现" % eid})
elif has_amin:
var amin: int = int(row.get("atk_min", 0))
var amax: int = int(row.get("atk_max", 0))
if amin < 0 or amax > 999 or amin > amax:
errs.append({"category": "domain", "message": "%s atk 区间越域 [0,999] 且 min≤max: %d..%d" % [eid, amin, amax]})
if atk != int((amin + amax) / 2.0):
errs.append({"category": "cross_field", "message": "%s atk(%d) ≠ 区间中点(%d)——中点恒等期望当量锚" % [eid, atk, (amin + amax) / 2]})
var hm: int = int(row.get("heavy_multiplier", 0))
if hm < 1 or hm > 16:
errs.append({"category": "domain", "message": "%s heavy_multiplier 越域 [1,16]" % eid})
var pattern: Array = row.get("pattern", [])
if pattern.size() < 1 or pattern.size() > 8:
errs.append({"category": "domain", "message": "%s pattern 环长越域 [1,8]" % eid})
for beat in pattern:
if PATTERN_RESERVED.has(beat):
errs.append({"category": "enum", "message": "%s pattern 预留值拒绝(MVP 校验域): %s" % [eid, beat]})
elif not PATTERN_MVP.has(beat):
errs.append({"category": "enum", "message": "%s pattern 未知枚举: %s" % [eid, beat]})
var cap: int = int(row.get("advantage_per_round_cap", -1))
if cap < 0 or cap > 16:
errs.append({"category": "domain", "message": "%s advantage_per_round_cap 越域 [0,16]" % eid})
# armor_type(可省略——缺省 none 向后兼容)
var at: String = String(row.get("armor_type", "none"))
if not ARMOR_TYPES.has(at):
errs.append({"category": "enum", "message": "%s armor_type 未知枚举: %s(域 {none, a, b})" % [eid, at]})
# 每行集恰一敌种(行集内 enemy_id 唯一)
var rk := "%d_%d" % [L, D]
if rowset_species.has(rk):
errs.append({"category": "rowset_rule", "message": "(%d,%d) 行集出现第二敌种(每行集恰一敌种违例)" % [L, D]})
rowset_species[rk] = eid
anchor_ok[L] = anchor_ok.get(L, false) or D == 0
for L in anchor_ok.keys():
if not anchor_ok[L]:
errs.append({"category": "anchor", "message": "EnemyTable level %d 缺 depth-0 锚行" % L})
return errs
# R1:进度键 floor + 行集恰一敌种(实体键无歧义,悬空致命由调用方处理)
func resolve_rowset(L: int, D: int) -> Dictionary:
var best: Dictionary = {}
var best_l := -1
var best_d := -1
for row in rows:
var rl: int = int(row.get("level_index", 0))
var rd: int = int(row.get("node_depth", 0))
if rl <= L and rd <= D and (rl > best_l or (rl == best_l and rd > best_d)):
best = row
best_l = rl
best_d = rd
return best
@@ -0,0 +1 @@
uid://cbqi1lvj5gjx1
@@ -0,0 +1,19 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# D2 HeroTable schema(B2 接口漂移新增;单行全局,Rule 4 豁免)。
# atk = 基础攻击恒存在(2026-09-11 用户裁决):域 [1,999]、0 = 加载拒绝——零基线死锁 schema 层杜绝。
class_name HeroTable
extends Resource
@export var max_hp: int = 50 # [1,999] knob(provisional,溯源 = 原型锚)
@export var atk: int = 6 # [1,999] knob——基础攻击(溯源 = 概念原型 HERO_BASE_ATK,provisional 归 C3);消费面 = B1 战斗段先手攻击
func validate() -> Array:
var errs: Array = []
if max_hp < 1 or max_hp > 999:
errs.append({"category": "domain", "message": "max_hp 越域 [1,999]: %d" % max_hp})
if atk < 1 or atk > 999:
errs.append({"category": "domain", "message": "atk 越域 [1,999]: %d" % atk})
return errs
@@ -0,0 +1 @@
uid://d4npdck7i4jw1
@@ -0,0 +1,57 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 线性第一关(战×2→道具→战×2→恢复→Boss)能否承载"数值提升→通关期望改善"的元进度验证
# Date: 2026-09-11
# D2 LevelTable schema(provisional——C1 关卡节点地图未设计,本表为线性切片形态的结构位:
# nodes 数组即未来节点链的数据承载面;路径分支/奖励点分布的定稿权归 C1/C3)。
# 节点类型 closed enum:battle(战斗,须带 node_depth)/ reward(随机主动道具点)/ rest(血量恢复点)/ boss(关底 Boss,须带 node_depth)。
class_name LevelTable
extends Resource
@export var rows: Array = [] # Array[Dictionary]
const NODE_TYPES := ["battle", "reward", "rest", "boss"]
func validate() -> Array:
var errs: Array = []
if rows.is_empty():
errs.append({"category": "empty_table", "message": "LevelTable 为空"})
return errs
var seen := {}
for row in rows:
var L: int = int(row.get("level_index", 0))
if seen.has(L):
errs.append({"category": "dup_key", "message": "LevelTable level_index 重复: %d" % L})
seen[L] = true
if L < 1:
errs.append({"category": "domain", "message": "level_index 须 ≥1"})
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "level %d display_name 空" % L})
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})
var nodes: Array = row.get("nodes", [])
if nodes.is_empty():
errs.append({"category": "domain", "message": "level %d 节点链为空" % L})
continue
var boss_count := 0
var battle_count := 0
for node in nodes:
var t: String = String(node.get("type", ""))
if not NODE_TYPES.has(t):
errs.append({"category": "enum", "message": "level %d 未知节点类型: %s" % [L, t]})
continue
if t == "battle" or t == "boss":
if int(node.get("node_depth", -1)) < 0:
errs.append({"category": "domain", "message": "level %d %s 节点缺 node_depth" % [L, t]})
battle_count += 1
if t == "boss":
boss_count += 1
if boss_count != 1:
errs.append({"category": "structural", "message": "level %d Boss 节点数 = %d(须恰一)" % [L, boss_count]})
if battle_count < 2:
errs.append({"category": "structural", "message": "level %d 战斗节点(含 Boss)= %d(≥2 才构成关卡)" % [L, battle_count]})
# 线性切片纪律:末节点必须是 Boss(关底收尾——C2 关卡定义)
if String(nodes[nodes.size() - 1].get("type", "")) != "boss":
errs.append({"category": "structural", "message": "level %d 末节点非 Boss(线性关卡以 Boss 收尾)" % L})
return errs
@@ -0,0 +1 @@
uid://ytfp75gipdwm
@@ -0,0 +1,30 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# D2 RewardConfigTable schema(B4 full 评审接口漂移新增;单行全局——B4 标量 knob 的 Rule 1 载体)。
class_name RewardConfigTable
extends Resource
@export var t_fog_min: int = 2 # [1,8] F2 雾档过滤门限
@export var k_dmg: int = 0 # [0,999] 旧 F5 格池刀保底——2026-09-11 可选类供给控制器批置 0(语义由"战斗首件保证"取代)
@export var n_draft: int = 32 # [1,999] 旧 F5 草稿重 roll 上限(随 k_dmg=0 休眠——字段保留)
@export var p_min: int = 4 # [1,999] K≥1 供给闸刀力下界(随 k_dmg=0 休眠——字段保留)
@export var active_p0: float = 0.4 # [0,1] 可选类供给:战斗首件之后,每张新板的注入基础概率(2026-09-11 用户拍板批)
@export var active_decay: float = 0.5 # (0,1] 可选类供给:战斗内每多获取 1 件,注入概率乘一次衰减(指数下降、永不绝育——用户拍板④)
func validate() -> Array:
var errs: Array = []
if t_fog_min < 1 or t_fog_min > 8:
errs.append({"category": "domain", "message": "t_fog_min 越域 [1,8]: %d" % t_fog_min})
if k_dmg < 0 or k_dmg > 999:
errs.append({"category": "domain", "message": "k_dmg 越域 [0,999]: %d" % k_dmg})
if n_draft < 1 or n_draft > 999:
errs.append({"category": "domain", "message": "n_draft 越域 [1,999]: %d" % n_draft})
if p_min < 1 or p_min > 999:
errs.append({"category": "domain", "message": "p_min 越域 [1,999]: %d" % p_min})
if active_p0 < 0.0 or active_p0 > 1.0:
errs.append({"category": "domain", "message": "active_p0 越域 [0,1]: %f" % active_p0})
if active_decay <= 0.0 or active_decay > 1.0:
errs.append({"category": "domain", "message": "active_decay 越域 (0,1]: %f" % active_decay})
return errs
@@ -0,0 +1 @@
uid://dc14jo26wutra
@@ -0,0 +1,73 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# D2 RewardTable / RewardConfigTable schema(B4 回填值)。
# RewardTable 行 = Dictionary,键 reward_id(Rule 4 豁免:定义表);跨字段校验族 + 池护栏。
class_name RewardTable
extends Resource
@export var rows: Array = [] # Array[Dictionary]
const CATEGORIES := ["buff", "damage_item", "heal_pack", "instant_aoe", "instant_heal"] # 2026-09-11 两类奖励裁决五值:即时类 {buff, instant_aoe, instant_heal} / 可选类 {damage_item, heal_pack}
const POOL_TAGS := ["safe_cell", "clear_default", "active_inject"] # active_inject = 可选类注入池(2026-09-11 供给控制器批——可选类退出格子权重池);clear_<biome> 命名空间预留拒绝
const USE_TIMINGS := ["pre_settlement"] # MVP 单值承重
func validate() -> Array:
var errs: Array = []
if rows.is_empty():
errs.append({"category": "empty_table", "message": "RewardTable 为空"})
return errs
var seen := {}
for row in rows:
var rid: String = row.get("reward_id", "")
if rid == "":
errs.append({"category": "domain", "message": "reward_id 空"})
continue
if seen.has(rid):
errs.append({"category": "dup_key", "message": "reward_id 重复: %s" % rid})
seen[rid] = true
var cat: String = row.get("category", "")
if not CATEGORIES.has(cat):
errs.append({"category": "enum", "message": "%s category 出域/预留值拒绝: %s" % [rid, cat]})
var pool: String = row.get("pool_tag", "")
var pool_reserved: bool = pool.begins_with("clear_") and pool != "clear_default"
if not POOL_TAGS.has(pool) and not pool_reserved:
errs.append({"category": "enum", "message": "%s pool_tag 未知: %s" % [rid, pool]})
elif pool_reserved:
errs.append({"category": "enum", "message": "%s pool_tag clear_<biome> 命名空间预留拒绝: %s" % [rid, pool]})
if not USE_TIMINGS.has(row.get("use_timing", "")):
errs.append({"category": "enum", "message": "%s use_timing 出域: %s" % [rid, row.get("use_timing")]})
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % rid})
var power: int = int(row.get("power", 0))
var weight: int = int(row.get("weight", -1))
var tier: int = int(row.get("tier", 0))
if weight < 0 or weight > 999:
errs.append({"category": "domain", "message": "%s weight 越域 [0,999]" % rid})
if tier < 1 or tier > 8:
errs.append({"category": "domain", "message": "%s tier 越域 [1,8]" % rid})
var eff: String = row.get("effect_id", "")
# 跨字段:buff ⇒ effect_id 必填;非 buff ⇒ 恒空;item 类 power ≥ 1
if cat == "buff" and eff == "":
errs.append({"category": "cross_field", "message": "%s buff 行 effect_id 必填" % rid})
if cat != "buff" and eff != "":
errs.append({"category": "cross_field", "message": "%s 非 buff 行 effect_id 恒空" % rid})
if cat != "buff" and power < 1: # 2026-09-11:即时类(aoe/heal)与道具类同 consumes power ≥ 1
errs.append({"category": "cross_field", "message": "%s item 行 power ≥ 1" % rid})
# 伤害量值区间批(2026-09-14):可选配对 power_min/power_max——缺省 = 定值行向后兼容(armor_type 先例)
var has_min: bool = row.has("power_min")
var has_max: bool = row.has("power_max")
if has_min != has_max:
errs.append({"category": "cross_field", "message": "%s power_min/power_max 必须成对出现" % rid})
elif has_min:
var pmin: int = int(row.get("power_min", 0))
var pmax: int = int(row.get("power_max", 0))
if pmin < 1 or pmax > 999 or pmin > pmax:
errs.append({"category": "domain", "message": "%s power 域违例 [1,999] 且 min≤max: %d..%d" % [rid, pmin, pmax]})
if cat == "buff":
errs.append({"category": "cross_field", "message": "%s buff 行禁区间字段(power 恒 0)" % rid})
if power != int((pmin + pmax) / 2.0):
# 中点锚:power = 区间中点(A3 条款①期望当量锚——调区间必同步调 power)
errs.append({"category": "cross_field", "message": "%s power(%d) ≠ 区间中点(%d)——中点恒等期望当量锚" % [rid, power, (pmin + pmax) / 2]})
return errs
@@ -0,0 +1 @@
uid://cm1mr66q2v8b4
@@ -0,0 +1,57 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# D2 SettlementTable schema(B1 回填值;单行全局,Rule 4 豁免)——八字段口径。
# 三行致命 manifest:segorder_violation / poolmap_dangling / pperfect_oob。
class_name SettlementTable
extends Resource
@export var equivalence_ref_damage: int = 12 # [1,999] knob——当量参考敞口 D_ref
@export var p_perfect: int = 200 # {100,200} percent——完美排雷第二份兑付触发(载体域)
@export var segment_order: PackedStringArray = [] # 结构冻结——恒等拍板段序校验违例致命
@export var source_label_map: Dictionary = {} # 结构冻结——source_class→文案键
@export var effect_pool_map: Dictionary = {} # 结构冻结——effect_id→pool(悬空加载致命)
@export var pool_clamp_lo: int = -100 # 结构冻结
@export var pool_clamp_hi: int = 100 # 结构冻结
@export var round_direction: String = "floor" # 结构冻结——单一取整规则
# ---- 伤害类型 × 护甲克制批(2026-09-12 用户拍板结构;数值 provisional 待调参)----
# 主角攻击 = 基础(atk×(1+attack_up),不吃克制)+ 开格充能×克制 + 标雷充能×克制(分量各自 floor 后相加)
@export var damage_a_per_cell: int = 1 # 伤害A 充能:本轮每开 1 资格格(reward_eligible 安全格)+N
@export var damage_a_cap: int = 8 # 伤害A 单轮充能上限(0 = 不封顶;8 与 ap_per_round 对齐)
@export var damage_b_per_flag: int = 2 # 伤害B 充能:每颗首次标中雷 +N(沿用每雷实例一次去重——结构上限)
@export var armor_effectiveness: Dictionary = {} # 克制矩阵 percent:{分量 {base|dmg_a|dmg_b}: {护甲 {none|a|b}: percent}}
const ARMOR_KEYS := ["none", "a", "b"]
const COMPONENT_KEYS := ["base", "dmg_a", "dmg_b"]
const FROZEN_SEGMENT_ORDER := ["grant", "item_window", "buff_debuff", "item", "combat", "decay", "verdict"]
const P_PERFECT_DOMAIN := [100, 200]
func validate() -> Array:
var errs: Array = []
if equivalence_ref_damage < 1 or equivalence_ref_damage > 999:
errs.append({"category": "domain", "message": "equivalence_ref_damage 越域 [1,999]: %d" % equivalence_ref_damage})
if not P_PERFECT_DOMAIN.has(p_perfect):
errs.append({"category": "pperfect_oob", "message": "p_perfect 载体域 {100,200}: %d" % p_perfect})
var seg := Array(segment_order)
if seg != FROZEN_SEGMENT_ORDER:
errs.append({"category": "segorder_violation", "message": "segment_order 违例——恒等拍板段序校验失败: %s" % str(seg)})
if round_direction != "floor":
errs.append({"category": "frozen", "message": "round_direction 结构冻结 floor: %s" % round_direction})
if pool_clamp_lo != -100 or pool_clamp_hi != 100:
errs.append({"category": "frozen", "message": "pool_clamp 域结构冻结 [-100,+100]"})
# 伤害类型 × 护甲克制批新字段域校验
if damage_a_per_cell < 0 or damage_a_per_cell > 999:
errs.append({"category": "domain", "message": "damage_a_per_cell 越域 [0,999]: %d" % damage_a_per_cell})
if damage_a_cap < 0 or damage_a_cap > 999:
errs.append({"category": "domain", "message": "damage_a_cap 越域 [0,999](0 = 不封顶): %d" % damage_a_cap})
if damage_b_per_flag < 0 or damage_b_per_flag > 999:
errs.append({"category": "domain", "message": "damage_b_per_flag 越域 [0,999]: %d" % damage_b_per_flag})
for comp in COMPONENT_KEYS:
var row: Dictionary = armor_effectiveness.get(comp, {})
for armor in ARMOR_KEYS:
var pct: int = int(row.get(armor, -1))
if pct < 0 or pct > 999:
errs.append({"category": "domain", "message": "armor_effectiveness.%s.%s 越域 [0,999]: %d" % [comp, armor, pct]})
return errs
@@ -0,0 +1 @@
uid://ctmgiqmejqe7e
@@ -0,0 +1,58 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# D2 StatusEffectTable schema(B3 回填值;行 = Dictionary,键 effect_id;Rule 4 豁免)。
# 依据 design/gdd/b3-status-effect.md "StatusEffectTable Schema 定稿"。
class_name StatusEffectTable
extends Resource
@export var rows: Array = [] # Array[Dictionary]
const TARGET_SIDES := ["hero", "enemy"]
const UNITS := ["percent", "flat"]
const STACK_POLICIES := ["stack", "refresh", "independent"]
func validate() -> Array:
var errs: Array = []
if rows.is_empty():
errs.append({"category": "empty_table", "message": "StatusEffectTable 为空"})
return errs
var seen := {}
for row in rows:
var eid: String = row.get("effect_id", "")
if eid == "":
errs.append({"category": "domain", "message": "effect_id 缺失/空"})
continue
if seen.has(eid):
errs.append({"category": "dup_key", "message": "effect_id 重复: %s" % eid})
seen[eid] = true
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % eid})
if not TARGET_SIDES.has(row.get("target_side", "")):
errs.append({"category": "enum", "message": "%s target_side 出域" % eid})
var unit: String = row.get("unit", "")
if not UNITS.has(unit):
errs.append({"category": "enum", "message": "%s unit 出域" % eid})
if not STACK_POLICIES.has(row.get("stack_policy", "")):
errs.append({"category": "enum", "message": "%s stack_policy 出域" % eid})
var m: int = int(row.get("magnitude", 0))
# 跨字段:percent ∈ [-100,100] 且 ≠0 / flat ≥ 1
if unit == "percent" and (m == 0 or m < -100 or m > 100):
errs.append({"category": "cross_field", "message": "%s percent magnitude 域错: %d" % [eid, m]})
if unit == "flat" and m < 1:
errs.append({"category": "cross_field", "message": "%s flat magnitude 须 ≥1: %d" % [eid, m]})
var dur: int = int(row.get("duration_settlements", 0))
if dur < 1 or dur > 99:
errs.append({"category": "domain", "message": "%s duration_settlements 越域 [1,99]: %d" % [eid, dur]})
var ms: int = int(row.get("max_stack", 0))
if ms < 1 or ms > 16:
errs.append({"category": "domain", "message": "%s max_stack 越域 [1,16]: %d" % [eid, ms]})
return errs
func row_by_id(effect_id: String) -> Dictionary:
for row in rows:
if row.get("effect_id", "") == effect_id:
return row
return {}
@@ -0,0 +1 @@
uid://b4hsttg27d7o1
@@ -0,0 +1,51 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# D2 VariationTable schema(A4 回填值;行 = Dictionary,键 variant_id)。
# 依据 design/gdd/a4-terrain-variation.md "VariationTable Schema 定稿"。
class_name VariationTable
extends Resource
@export var rows: Array = [] # Array[Dictionary]
const SCOPES := ["region", "global"]
const INFO_MASKS := ["none", "hide_numbers"]
const CELL_CONSTRAINTS := ["any", "safe_nonzero"]
func validate() -> Array:
var errs: Array = []
if rows.is_empty():
errs.append({"category": "empty_table", "message": "VariationTable 为空"})
return errs
var seen := {}
for row in rows:
var vid: String = row.get("variant_id", "")
if vid == "":
errs.append({"category": "domain", "message": "variant_id 缺失/空"})
continue
if seen.has(vid):
errs.append({"category": "dup_key", "message": "variant_id 重复: %s" % vid})
seen[vid] = true
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % vid})
var ul: int = int(row.get("unlock_level", 0))
if ul < 1 or ul > 999:
errs.append({"category": "domain", "message": "%s unlock_level 越域 [1,999]: %d" % [vid, ul]})
if not SCOPES.has(row.get("scope", "")):
errs.append({"category": "enum", "message": "%s scope 出域: %s" % [vid, row.get("scope")]})
if not INFO_MASKS.has(row.get("info_mask", "")):
errs.append({"category": "enum", "message": "%s info_mask 出域: %s" % [vid, row.get("info_mask")]})
if not CELL_CONSTRAINTS.has(row.get("cell_constraint", "")):
errs.append({"category": "enum", "message": "%s cell_constraint 出域: %s" % [vid, row.get("cell_constraint")]})
# 跨字段(A4 re-review 增补,加载期致命):hide_numbers ⇒ safe_nonzero
if row.get("info_mask", "") == "hide_numbers" and row.get("cell_constraint", "") != "safe_nonzero":
errs.append({"category": "cross_field", "message": "%s hide_numbers ⇒ cell_constraint 必为 safe_nonzero" % vid})
return errs
func row_by_id(variant_id: String) -> Dictionary:
for row in rows:
if row.get("variant_id", "") == variant_id:
return row
return {}
@@ -0,0 +1 @@
uid://boin0gyrpqvwd
@@ -0,0 +1,14 @@
[gd_resource type="Resource" script_class="ActionEconomyTable" load_steps=2 format=3]
[ext_resource type="Script" path="res://data/schemas/action_economy_table.gd" id="1"]
[resource]
script = ExtResource("1")
ap_per_round = 8
dig_ap_cost = 1
flag_place_ap_cost = 1
flag_pull_ap_cost = 1
rap_refresh_cost = 8
refresh_max_per_round = 2
move_ap_cost = 0
chain_ap_cost = 0
@@ -0,0 +1,94 @@
[gd_resource type="Resource" script_class="BoardGenTable" load_steps=2 format=3]
[ext_resource type="Script" path="res://data/schemas/board_gen_table.gd" id="1"]
[resource]
script = ExtResource("1")
rows = [{
"level_index": 1,
"node_depth": 0,
"board_width": 10,
"board_height": 10,
"mine_density": 0.25,
"inferability_target": 0.88,
"solver_rules": "single_subset",
"min_safe_count": 50,
"max_gen_attempts": 16,
"fog_region_count": 0,
"fog_target_ratio": 0.0,
"board_biomes": ["dungeon"]
}, {
"level_index": 1,
"node_depth": 1,
"board_width": 10,
"board_height": 10,
"mine_density": 0.25,
"inferability_target": 0.88,
"solver_rules": "single_subset",
"min_safe_count": 50,
"max_gen_attempts": 16,
"fog_region_count": 0,
"fog_target_ratio": 0.0,
"board_biomes": ["dungeon"]
}, {
"level_index": 1,
"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,
"fog_region_count": 0,
"fog_target_ratio": 0.0,
"board_biomes": ["dungeon"]
}, {
"level_index": 1,
"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,
"fog_region_count": 0,
"fog_target_ratio": 0.0,
"board_biomes": ["dungeon"]
}, {
"level_index": 1,
"node_depth": 4,
"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,
"fog_region_count": 0,
"fog_target_ratio": 0.0,
"board_biomes": ["dungeon"]
}, {
"level_index": 2,
"node_depth": 0,
"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,
"fog_region_count": 1,
"fog_target_ratio": 0.10,
"board_biomes": ["dungeon"]
}]
; 切片调参登记(SLICE-TUNED provisional,调参所有权仍归 C3):
; A1 GDD 参考行(d0.10/0.12)经"先布后删"环内移除后仅幸存 2-3 雷、R0/|S|约0.97、残余挖掘 2-4 格——
; 掘近退化板(A2 反例 B 机制的低密度放大)。实验数据(8 种子均值,smoke/experiment_density.gd):
; d0.25 → L1 幸存雷 11/残余 13/inf 0.925/att 2.9 · L2 雷 19/残余 21/inf 0.908/att 7.9(seed42 实测 core 0.889→target 定 0.88 首过),零降级告警。
; target 收窄至 0.88/0.88(不低于 0.85 绝对锚)为密度上移的对价——B1 条款③⑤对账重跑义务随调参触发。
; 2026-09-11 关卡扩展批:L1 增 D1–D4 行(Boss=Dmax=4)。L1 全程无雾——A4 迷雾 unlock_level=2(已锁事实),
; Boss 板以 12×12 区分(d0.28/t0.87 实测达不到推理率门槛——best≈0.81 降级告警,回退 0.25/0.88;
; 密度轴上调留给 C3 与求解器能力一起解)。
@@ -0,0 +1,9 @@
[gd_resource type="Resource" script_class="DifficultyTable" load_steps=2 format=3]
[ext_resource type="Script" path="res://data/schemas/difficulty_table.gd" id="1"]
[resource]
script = ExtResource("1")
rows = []
; C3 GDD Core Rule 1:全局修饰结构位——MVP 空表(零行为)。首个修饰行 = 精英节点上线(C1 联动)。
@@ -0,0 +1,113 @@
[gd_resource type="Resource" script_class="EnemyTable" load_steps=2 format=3]
[ext_resource type="Script" path="res://data/schemas/enemy_table.gd" id="1"]
[resource]
script = ExtResource("1")
rows = [{
"level_index": 1,
"node_depth": 0,
"enemy_id": "goblin",
"display_name": "地精",
"unit_count": 1,
"unit_count_max": 2,
"hp": 24,
"atk": 7,
"atk_min": 4,
"atk_max": 10,
"heavy_multiplier": 2,
"pattern": ["attack"],
"advantage_per_round_cap": 4,
"armor_type": "none"
}, {
"level_index": 1,
"node_depth": 1,
"enemy_id": "goblin",
"display_name": "地精一伙",
"unit_count": 2,
"unit_count_max": 3,
"hp": 13,
"atk": 5,
"atk_min": 2,
"atk_max": 8,
"heavy_multiplier": 2,
"pattern": ["attack"],
"advantage_per_round_cap": 4,
"armor_type": "none"
}, {
"level_index": 1,
"node_depth": 2,
"enemy_id": "bugbear",
"display_name": "熊地精",
"unit_count": 1,
"hp": 40,
"atk": 9,
"atk_min": 6,
"atk_max": 12,
"heavy_multiplier": 2,
"pattern": ["attack", "charge", "heavy"],
"advantage_per_round_cap": 4,
"armor_type": "a"
}, {
"level_index": 1,
"node_depth": 3,
"enemy_id": "bugbear",
"display_name": "熊地精双雄",
"unit_count": 2,
"unit_count_max": 3,
"hp": 18,
"atk": 6,
"atk_min": 3,
"atk_max": 9,
"heavy_multiplier": 2,
"pattern": ["attack", "charge", "heavy"],
"advantage_per_round_cap": 4,
"armor_type": "a"
}, {
"level_index": 1,
"node_depth": 4,
"enemy_id": "ogre_chief",
"display_name": "食人魔酋长",
"unit_count": 1,
"hp": 150,
"atk": 9,
"atk_min": 6,
"atk_max": 12,
"heavy_multiplier": 2,
"pattern": ["attack", "attack", "charge", "heavy"],
"advantage_per_round_cap": 4,
"armor_type": "b"
}, {
"level_index": 2,
"node_depth": 0,
"enemy_id": "bugbear",
"display_name": "熊地精",
"unit_count": 1,
"hp": 30,
"atk": 7,
"atk_min": 4,
"atk_max": 10,
"heavy_multiplier": 2,
"pattern": ["attack", "charge", "heavy"],
"advantage_per_round_cap": 4,
"armor_type": "a"
}]
; 切片调参登记 v3(2026-09-12 试玩批次 1 触发——F7 流程;C3 GDD F5 对照):
; 试玩反馈 = 基线满血全通关(模拟 80%/真人 100%)——难度严重不足,主杠杆 = atk 曲线上调(承伤侧):
; 4/4/6/4/7 → 7/5/9/6/9(约 +50%);HP 结构调整(D0 24→20 缩短战斗、Boss 130→150 用户拍板可上调)。
; D0 注:20 HP 会被首轮盲挖粉尘秒杀(12+9)——回 24(4 轮战斗/承伤 42%,有过程有痛感)。
; DND 命名(用户指令):goblin 地精(单拍杂鱼)→ bugbear 熊地精(三拍伏击环——DND 原型即伏击手)→
; ogre_chief 食人魔酋长(经典低阶 Boss)。enemy_id 同步更名(内部键,无外部引用)。
; L2 锚行同步 v3 语义(bugbear 30/7)。
; 遭遇随机化批(2026-09-12 用户拍板):D0 [1,2]/D1 [2,3] hp13/D3 [2,3] hp18——数量区间骰(encounter 流,B1 掷骰);
; D2/Boss 固定(新敌种首遇教学位不加骰——C3 GDD Core Rule 8)。期望总量 ≈304(+8%),F6 带内。
;
; 护甲类型批(2026-09-12 用户拍板:伤害类型 × 护甲类型克制——无甲/护甲A/护甲B):
; armor_type ∈ {none, a, b}(缺省 = none 向后兼容);克制矩阵本体在 settlement.tres.armor_effectiveness。
; 映射(教学梯度:无甲→甲A→甲B):goblin=none · bugbear=a · ogre_chief=b(L2 bugbear 同 a)——候选方案,
; 数值与映射均 provisional 待用户调参。
; 敌方攻击区间批(2026-09-14 用户拍板②第二步——"上限掺运气"敌方侧:atk 上阶/下阶,中点恒等行值;
; roll 时机 = 遭遇骰同流〔encounter〕spawn 期整战持久,每拍抖动否决〔数字可读性锚〕;B2 零随机纪律保持
; ——收 override 即确定值。宽度 ±3 provisional 归 C3 测量迭代;验收 = 完美策略档 0 加成通关 ≤40%。
@@ -0,0 +1,8 @@
[gd_resource type="Resource" script_class="HeroTable" load_steps=2 format=3]
[ext_resource type="Script" path="res://data/schemas/hero_table.gd" id="1"]
[resource]
script = ExtResource("1")
max_hp = 50
atk = 6
@@ -0,0 +1,37 @@
[gd_resource type="Resource" script_class="LevelTable" load_steps=2 format=3]
[ext_resource type="Script" path="res://data/schemas/level_table.gd" id="1"]
[resource]
script = ExtResource("1")
rows = [{
"level_index": 1,
"display_name": "第一层 · 苔藓回廊",
"rest_heal_pct": 30,
"nodes": [{
"node_depth": 0,
"type": "battle"
}, {
"node_depth": 1,
"type": "battle"
}, {
"type": "reward"
}, {
"node_depth": 2,
"type": "battle"
}, {
"node_depth": 3,
"type": "battle"
}, {
"type": "rest"
}, {
"node_depth": 4,
"type": "boss"
}]
}]
; 切片登记(provisional——C1/C3 未设计,结构位与数值均为 SLICE-TUNED):
; 节点链 = 用户 2026-09-11 拍板的线性第一关(战×2 → 随机主动道具 → 战×2 → 血量恢复点 → Boss)。
; node_depth 轴 = 节点深度(难度 = f(关卡序号, 节点深度)——game-concept 锁定口径);Boss = Dmax 行(A1 第三轴登记约定)。
; rest_heal_pct = 30:恢复点回复 max_hp 的百分比(随生命天赋同步放大,防高生命档恢复失效)。
; L1 全程无雾:A4 迷雾 unlock_level=2(已锁事实——迷雾自第二层解锁),Boss 板以 12×12 加密板区分。
@@ -0,0 +1,112 @@
[gd_resource type="Resource" script_class="RewardTable" load_steps=2 format=3]
[ext_resource type="Script" path="res://data/schemas/reward_table.gd" id="1"]
[resource]
script = ExtResource("1")
rows = [{
"reward_id": "defense_up_basic",
"category": "buff",
"effect_id": "defense_up",
"display_name": "石肤术",
"power": 0,
"weight": 16,
"pool_tag": "safe_cell",
"tier": 2,
"use_timing": "pre_settlement"
}, {
"reward_id": "attack_up_basic",
"category": "buff",
"effect_id": "attack_up",
"display_name": "增伤术",
"power": 0,
"weight": 20,
"pool_tag": "safe_cell",
"tier": 2,
"use_timing": "pre_settlement"
}, {
"reward_id": "spark_aoe",
"category": "instant_aoe",
"effect_id": "",
"display_name": "爆裂粉尘",
"power": 4,
"weight": 16,
"pool_tag": "safe_cell",
"tier": 1,
"use_timing": "pre_settlement"
}, {
"reward_id": "heal_minor",
"category": "instant_heal",
"effect_id": "",
"display_name": "急救敷料",
"power": 4,
"power_min": 2,
"power_max": 6,
"weight": 15,
"pool_tag": "safe_cell",
"tier": 1,
"use_timing": "pre_settlement"
}, {
"reward_id": "knife_t1",
"category": "damage_item",
"effect_id": "",
"display_name": "小刀",
"power": 8,
"power_min": 5,
"power_max": 11,
"weight": 60,
"pool_tag": "active_inject",
"tier": 1,
"use_timing": "pre_settlement"
}, {
"reward_id": "knife_adv",
"category": "damage_item",
"effect_id": "",
"display_name": "精工短刀",
"power": 16,
"power_min": 10,
"power_max": 22,
"weight": 30,
"pool_tag": "active_inject",
"tier": 2,
"use_timing": "pre_settlement"
}, {
"reward_id": "heal_pack",
"category": "heal_pack",
"effect_id": "",
"display_name": "治疗包",
"power": 12,
"power_min": 6,
"power_max": 18,
"weight": 10,
"pool_tag": "active_inject",
"tier": 2,
"use_timing": "pre_settlement"
}, {
"reward_id": "clear_blast",
"category": "instant_aoe",
"effect_id": "",
"display_name": "清障炸药",
"power": 40,
"power_min": 25,
"power_max": 55,
"weight": 1,
"pool_tag": "clear_default",
"tier": 2,
"use_timing": "pre_settlement"
}]
; 2026-09-11 可选类供给控制器批(用户拍板):
; 可选类三行移出 safe_cell 权重池 → active_inject 注入池(小刀 60 / 精工短刀 30 / 治疗包 10——治疗资源宝贵),
; 由 RewardSupplyController 在 install 期注入(首件保证 + 指数递减),不再走格子权重 roll。
; 清图槽 clear_knife(入库刀)→ clear_blast(instant_aoe 一次性高额伤害,当场兑付全体存活敌,不入库不占可选类供给)。
; 格子池现仅即时类四行(attack_up 20 / defense_up 16 / spark_aoe 16 / heal_minor 15,合计 67)。
; 减伤墙调参批(2026-09-13,playtest-2026-09-12 报告 Q1 触发):defense_up 24 → 16——
; 石肤自 32% 降至 23.9%,攻击成为池内最高行;与 settlement.tres pool_clamp_lo=-60 同批(免伤墙双杠杆)。
; 伤害量值区间批(2026-09-14 用户拍板②——"上限掺运气"第一步,奖励侧五行:上阶/下阶区间内随机;
; 中点恒等旧定值 = A3 条款①期望当量锚不漂移;宽度 provisional 归 C3 测量迭代。验收口径 = 完美策略档
; 0 加成通关率 ≤40%〔OQ7 完美档基线 100%〕)。roll 时机 = 身份预分配批(reward_roll 恰一 scope 批内
; 追加取号,既有取号序零扰动——追加式);快照 E14 口径不变,B1/呈现零改动。区间:
; heal_minor [2,6] · knife_t1 [5,11] · heal_pack [6,18] · knife_adv [10,22] · clear_blast [25,55]。
; buff 两行(attack_up/defense_up)与 spark_aoe 不在本批(即时光效类按拍板面五行为准)。
@@ -0,0 +1,16 @@
[gd_resource type="Resource" script_class="RewardConfigTable" load_steps=2 format=3]
[ext_resource type="Script" path="res://data/schemas/reward_config_table.gd" id="1"]
[resource]
script = ExtResource("1")
t_fog_min = 2
k_dmg = 0
n_draft = 32
p_min = 8
active_p0 = 0.4
active_decay = 0.5
; 2026-09-11 可选类供给控制器批(用户拍板):k_dmg 1→0(旧 F5 格池刀保底休眠,语义由"战斗首件保证"取代);
; active_p0 = 0.4 / active_decay = 0.5(首件之后每张新板注入概率 0.4 → 0.2 → 0.1 → …指数下降、永不绝育;
; provisional 起步值,标定目标 E[战斗获取] ≈ 1,level_monte_carlo 扫描验证后归 C3 定稿)。
@@ -0,0 +1,61 @@
[gd_resource type="Resource" script_class="SettlementTable" load_steps=2 format=3]
[ext_resource type="Script" path="res://data/schemas/settlement_table.gd" id="1"]
[resource]
script = ExtResource("1")
equivalence_ref_damage = 12
p_perfect = 200
segment_order = PackedStringArray("grant", "item_window", "buff_debuff", "item", "combat", "decay", "verdict")
source_label_map = {
"safe_cell": "安全格",
"flag_hit": "标中旗",
"mine_hit": "踩雷",
"clear_reward": "清图奖励",
"item": "道具",
"enemy_action": "敌方行动",
"hero_attack": "主角攻击",
"flag_miss": "标旗未中"
}
effect_pool_map = {
"attack_up": "hero_offense",
"defense_up": "hero_defense",
"weaken": "enemy_output",
"ambush": "flat_attach",
"enrage": "enemy_output",
"off_guard": "hero_defense"
}
pool_clamp_lo = -100
pool_clamp_hi = 100
round_direction = "floor"
damage_a_per_cell = 1
damage_a_cap = 8
damage_b_per_flag = 2
armor_effectiveness = {
"base": {
"none": 100,
"a": 100,
"b": 100
},
"dmg_a": {
"none": 100,
"a": 150,
"b": 50
},
"dmg_b": {
"none": 100,
"a": 50,
"b": 150
}
}
; 伤害类型 × 护甲克制批(2026-09-12 用户拍板结构,数值 provisional 待用户/C3 调参):
; 主角攻击 = 基础(atk×(1+attack_up),对全部护甲 100%)+ 伤害A(开格充能×克制)+ 伤害B(标雷充能×克制),
; 三分量各自 floor 后相加;克制语义 = 特化甲 ×150 / 无甲 ×100 / 对位甲 ×50(穿甲弹/高爆弹式对策三角)。
; damage_a_per_cell=1 · damage_a_cap=8(单轮充能封顶,防连锁轮失控;0 = 不封顶)· damage_b_per_flag=2
; (每颗首次标中雷充能——沿用每雷实例一次去重,忘拔旗不重复充能)。
; enrage(敌方暴走 +25% 进 enemy_output 池)/ off_guard(我方失衡 +25% 进 hero_defense 池)=
; 标错惩罚池两行(第三项 = 复用 ambush +2 层);时效均 1 结算(用户拍板"短点,比如 1 回合")。
; 减伤墙调参批(2026-09-13,playtest-2026-09-12 报告 Q1 触发):拟改 pool_clamp_lo=-60 被 schema
; 冻结域拦截([-100,+100] 结构冻结——B1 F2 乘区结构决策),杠杆改走 status_effects.tres
; defense_up magnitude -50 → -30(叠满两层 = -60 减伤帽,本表 clamp 不动);登记以 status_effects.tres 批注为准。
@@ -0,0 +1,66 @@
[gd_resource type="Resource" script_class="StatusEffectTable" load_steps=2 format=3]
[ext_resource type="Script" path="res://data/schemas/status_effect_table.gd" id="1"]
[resource]
script = ExtResource("1")
rows = [{
"effect_id": "attack_up",
"display_name": "增伤",
"target_side": "hero",
"magnitude": 50,
"unit": "percent",
"duration_settlements": 1,
"stack_policy": "stack",
"max_stack": 2
}, {
"effect_id": "defense_up",
"display_name": "减伤",
"target_side": "hero",
"magnitude": -30,
"unit": "percent",
"duration_settlements": 1,
"stack_policy": "stack",
"max_stack": 2
}, {
"effect_id": "ambush",
"display_name": "伏击",
"target_side": "enemy",
"magnitude": 3,
"unit": "flat",
"duration_settlements": 1,
"stack_policy": "stack",
"max_stack": 16
}, {
"effect_id": "weaken",
"display_name": "削弱",
"target_side": "enemy",
"magnitude": -65,
"unit": "percent",
"duration_settlements": 1,
"stack_policy": "stack",
"max_stack": 16
}, {
"effect_id": "enrage",
"display_name": "暴走",
"target_side": "enemy",
"magnitude": 25,
"unit": "percent",
"duration_settlements": 1,
"stack_policy": "stack",
"max_stack": 16
}, {
"effect_id": "off_guard",
"display_name": "失衡",
"target_side": "hero",
"magnitude": 25,
"unit": "percent",
"duration_settlements": 1,
"stack_policy": "stack",
"max_stack": 16
}]
; 减伤墙调参批(2026-09-13,playtest-2026-09-12 报告 Q1 触发——defense_scan 实测墙轮 39%、
; 真人局 12/12 轮零承伤满血通关):defense_up magnitude -50 → -30——叠满两层 = -60 减伤帽
; (原拟 pool_clamp_lo=-60 被 schema 冻结域拦截,杠杆移至本表);石肤权重 24→16 随批(reward.tres)。
; 承伤带对 C3 F4(≤20%/普通场、≤50%/Boss 场)复验责任随批;B3 GDD "-50" 文字同步随下次 B3 修订批。
@@ -0,0 +1,16 @@
[gd_resource type="Resource" script_class="VariationTable" load_steps=2 format=3]
[ext_resource type="Script" path="res://data/schemas/variation_table.gd" id="1"]
[resource]
script = ExtResource("1")
rows = [{
"variant_id": "fog",
"display_name": "迷雾",
"unlock_level": 2,
"scope": "region",
"info_mask": "hide_numbers",
"cell_constraint": "safe_nonzero",
"biome_tags": [],
"counter_item_ids": []
}]
@@ -0,0 +1,74 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Date: 2026-09-12(code-review 修复批——自 main.gd 拆出,规范 1.1/1.3 文案拼装独立成装配器)
# 战斗流程叙事文案装配器:组合根在模式流转点的玩家可见文案(棋盘日志横幅 / 节点行 / 终局文案 /
# 相位指示)在此统一拼装——main.gd 头注"本类不拼呈现字符串"由本类兑现。静态无状态。
# 数值口径注记:胜利文案携带的 HP/道具数 = 关卡载荷快照值(每关口径)。
extends RefCounted
# 节点进入横幅(棋盘日志——battle/reboss/reward/rest 共用区段样式)
static func node_banner(runner, node: Dictionary, t: String) -> String:
return "[color=#e0b860]── 节点 %d/%d · %s(深度 %d)──[/color]" % [
runner.node_index + 1, runner.nodes().size(),
"Boss 房" if t == "boss" else "战斗", int(node.get("node_depth", 0))]
static func reward_line(snap: Dictionary) -> String:
return "[color=#d0c090]关卡奖励:%s(+%d)入库[/color]" % [
String(snap.get("display_name", "")), int(snap.get("power", 0))]
static func rest_line(healed: int, hp: int, max_hp: int) -> String:
return "[color=#7cc47c]恢复点:回复 %d HP(→ %d/%d)[/color]" % [healed, hp, max_hp]
# 关卡开场行(种子/加成口径注记——面板加成是否生效由调用方传入)
static func level_open_line(level_display: String, seed_val: int, atk: int, max_hp: int, boosted: bool) -> String:
return "[color=#c8a860]第一关 · %s · 种子 %d · 攻击 %d / 生命 %d%s[/color]" % [
level_display, seed_val, atk, max_hp, "(含面板加成)" if boosted else ""]
# 调试开场行
static func debug_open_line(seed_val: int, level: int, level_mode: bool) -> String:
return "[color=#c8a860]种子 %d · 遭遇 %d 层%s[/color]" % [
seed_val, level, "(关卡模式 · 库存延续)" if level_mode else ""]
# 关卡终局文案(result = victory/defeat;载荷 = runner level_finished 快照)
static func level_end_text(result: String, payload: Dictionary, effective_max_hp: int) -> String:
if result == "victory":
return "关卡通关!\n剩余道具 ×%d 随关卡结束清空 · 剩余 HP %d/%d" % [
int(payload.get("remaining_items", 0)), int(payload.get("hp", 0)), effective_max_hp]
return "关卡失败……探险者倒在了节点 %d(%s)。" % [
int(payload.get("node_index", 0)) + 1, String(payload.get("node_type", ""))]
# 调试战斗终局文案(level_end = 关内两层模式的关卡末;left = 清库剩余件数)
static func battle_end_text(result: String, level_end: bool, left: int) -> String:
if level_end:
return "关卡通关!\n剩余道具 ×%d 随关卡结束清空" % left
if result == "victory":
return "战斗胜利!敌人全灭。"
return "战斗失败……探险者倒下了。"
# 相位指示文本(B 区轮次·阶段行)
static func phase_text(round_index: int, battle_result: String, window_open: bool, waiting: bool, boss_active: bool, boss_name: String) -> String:
var boss_tag := " · Boss:%s" % boss_name if boss_active else ""
if battle_result != "":
return "第 %d 轮 · 战斗结束%s" % [round_index, boss_tag]
if window_open:
return "第 %d 轮 · 道具窗口%s" % [round_index, boss_tag]
if waiting:
return "第 %d 轮 · 结算中%s" % [round_index, boss_tag]
return "第 %d 轮 · 挖掘中%s" % [round_index, boss_tag]
# Boss 名解析(runner Boss 节点 → enemy 表行名;缺省遗迹守卫)
static func boss_name(runner, db) -> String:
if runner != null:
var node: Dictionary = runner.current_node()
if String(node.get("type", "")) == "boss":
var row: Dictionary = db.resolve_enemy_rowset(int(runner.level_row.level_index), int(node.get("node_depth", -1)))
return String(row.get("display_name", ""))
return "遗迹守卫"
@@ -0,0 +1 @@
uid://cgpyvmn146x5w
@@ -0,0 +1,102 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Date: 2026-09-13(封装审查 O1/O2 修复批——自 main.gd 拆出,规范 1.1 审视线 / 单一实现纪律)
# 调试模式编排控制器:单场遭遇(第 1/2 层)与连续两层(库存与 HP 关内延续)的模式状态机与战斗生命周期。
# 职责边界:层序/携带 HP/共享 B4 状态 + B1 生命周期编排(创建 / begin_battle / 关卡末 level_clear)。
# 授权先例:与 tune_sim_controller 同款(code-review 2026-09-12 批)——控制器承接模式编排,
# 组合根仍持有全部信号接线(battle_created 先于 begin_battle 发出,ADR-0001 接线唯一归组合根)
# 与终局 UI 决策;玩家意图写调用(settle_batch / 窗口提交 / 刷新)不经本类。
# E4 收口(2026-09-13):加成生效值经注入 Callable 供给(组合根从面板百分比现算)→ encounter
# 显式传入战斗——零表写,"双写叠加/双基线同步"整类隐患随表写移除。
# O2 收口:调试两层的"库存与 HP 延续"语义唯一实现点(此前与 main.gd 调试分支双实现漂移)。
extends RefCounted
signal battle_created(b1, level: int, carry_hp: int) # begin_battle 前——组合根完成信号接线(ADR-0001)
signal battle_ready(b1, level: int, carry_hp: int) # begin_battle 完成后——组合根 rebuild 棋盘 + 刷新呈现
var db
var rng # /root/RngService(组合根注入)
var _hero_override: Callable # 组合根生效值供给(→ {atk, max_hp};E4:经 encounter 传参,零表写)
var _seed_value: Callable # 组合根种子读取(LineEdit)
var _level_mode := false
var _level_battles: Array = []
var _level_idx := 0
var _carry_hp := 0
var _shared_b4 = null
var b1 = null # 当前战斗编排器(组合根在 battle_created 接线、battle_ready 接手)
func _init(db_ref, rng_ref, hero_override: Callable, seed_value: Callable) -> void:
db = db_ref
rng = rng_ref
_hero_override = hero_override
_seed_value = seed_value
func is_level_mode() -> bool:
return _level_mode
# 单场遭遇原语(2026-09-13 用户裁决:单场用户入口已删——本方法仅 ui_probe 雾板变体段使用;
# 无库存延续、standalone 终局语义保留供探针战斗收尾)
func start_single(level: int) -> bool:
_level_mode = false
_shared_b4 = null
return _start_battle(level, -1)
# 连续两层(调试入口——共享 B4 + HP 携带;首层满血开局)
func start_debug_levels() -> bool:
_level_mode = true
_level_battles = [1, 2]
_level_idx = 0
_carry_hp = 0
rng.new_adventure(_seed_value.call())
_shared_b4 = RewardEconomy.new(db, rng)
return _start_battle(_level_battles[0], -1)
# 层间"进入下一层"(组合根过渡覆盖层确认后调用)
func next_layer() -> bool:
return _start_battle(_level_battles[_level_idx], _carry_hp)
# 终局处置(组合根 battle_finished 处理器调用)→ 决策载荷:
# {outcome: "next_layer"|"level_end"|"standalone_end", layer, carry_hp, items_left, remaining}
func on_battle_finished(payload: Dictionary) -> Dictionary:
var result: String = String(payload.get("result", ""))
if result == "victory" and _level_mode and _level_idx < _level_battles.size() - 1:
_carry_hp = maxi(1, int(payload.get("hero_hp", 0))) # despawn 前捕获值
_level_idx += 1
return {"outcome": "next_layer", "layer": _level_idx, "carry_hp": _carry_hp,
"items_left": b1.stock_count()}
var remaining := 0
var is_level_end := result == "victory" and _level_mode
if is_level_end:
remaining = int(b1.level_clear()["remaining"]) # 库存随关卡结束清空(关内延续裁决③)
elif _level_mode:
b1.level_clear() # 失败同样清库(关卡域生命周期闭合——调试模式与 runner 模式同规)
return {"outcome": "level_end" if is_level_end else "standalone_end",
"layer": _level_idx, "carry_hp": _carry_hp, "items_left": 0, "remaining": remaining}
# 会话复位(组合根终局出口"返回开始界面"调用)
func reset() -> void:
_level_mode = false
_shared_b4 = null
b1 = null
func _start_battle(level: int, carry_hp: int) -> bool:
if not _level_mode or _level_idx == 0:
rng.new_adventure(_seed_value.call())
var ov: Dictionary = _hero_override.call() # E4:生效值经 encounter 传入——零表写
b1 = BattleOrchestrator.new(db, rng, _shared_b4 if _level_mode else null)
battle_created.emit(b1, level, carry_hp)
var hp: int = carry_hp if carry_hp > 0 else int(ov.max_hp)
if not b1.begin_battle({"level_index": level, "node_depth": 0, "hero_hp": hp,
"hero_atk": int(ov.atk), "hero_max_hp": int(ov.max_hp)}):
push_error("[DebugSession] 战斗启动失败(level %d)" % level)
return false
battle_ready.emit(b1, level, carry_hp)
return true
@@ -0,0 +1 @@
uid://dsfxx2nhu487l
@@ -0,0 +1,208 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 实现 GDD:design/ux/battle.md(五区呈现装配器)——读核心只读面(B1 呈现快照 / runner)→
# 组装文案/投影 → 驱动各视图 setter。
# 高内聚边界:"核心状态 → 呈现文案"的全部翻译集中于此;视图保持零核心依赖;
# 静态无状态——呈现不持久化任何核心句柄。
# E1 封装收口(2026-09-13):核心状态读取统一经 B1.presentation_snapshot() 成文快照面——
# 禁再链式穿透 b1.b2.hero / b1.b2.enemies / b1.b3.query_active 一类路径(B1 协作对象已私有化,
# 敌人含死亡残位的投影由快照 enemies[] 承载,含 alive/beat/ring 字段)。
# 更新触发注记(battle.md Data Requirements):旗/拔/移动成功反馈零事件外发(A2 发射面封闭)——
# 经 ActionOutcome 返回值命令式驱动;AP/RAP 无推送信号——组合根每次调用返回后重查;
# B3 纯被动容器零信号——状态栏刷新绑 B1 结算编排完成时点;库存 B4 查询面——写路径回执后重查。
# Date: 2026-09-12(五区改造批;基础 = 2026-09-11 HUD 装配器;2026-09-13 快照面改造)
class_name HudPresenter
extends RefCounted
# 每次结算行/轮次/节点变化/动作返回后调用(组合根全量重查装配)
static func refresh(stage: BattleStageView, info: HeroInfoPanelView, ap_bar: ApBarView,
inv: InventoryPanelView, b1, runner, level_active: bool, db, refresh_remaining: int) -> void:
if b1 == null:
_refresh_out_of_battle(stage, info, inv, runner, level_active, db)
return
var s: Dictionary = b1.presentation_snapshot()
var hero: Dictionary = s.hero
if hero.is_empty() or not bool(hero.get("alive", false)):
_refresh_out_of_battle(stage, info, inv, runner, level_active, db)
return
_refresh_hero(stage, info, s)
_refresh_economy(info, ap_bar, s, db, refresh_remaining)
stage.set_enemies(s.enemies)
info.set_status_effects(s.hero_effects)
_fill_inventory(inv, s.inventory)
# ---- B 区英雄卡 + C 区数据框 ----
static func _refresh_hero(stage: BattleStageView, info: HeroInfoPanelView, s: Dictionary) -> void:
var hero: Dictionary = s.hero
var hp: int = int(hero.hp)
var max_hp: int = int(hero.max_hp)
var atk: int = int(hero.atk)
# 修正 = attack_up 池有效值(hero_offense);来源角标 = 效果 display_name(快照 hero_effects 投影)
var mod_total := 0
var sources: Array = []
for eff in s.hero_effects:
if String(eff.effect_id) == "attack_up":
mod_total += int(eff.effective_value)
sources.append("%s×%d" % [String(eff.display_name), int(eff.layers)])
var mod_text := ""
if mod_total != 0:
mod_text = "(%+d%% %s)" % [mod_total, "·".join(sources)]
stage.hero_card.set_data(hp, max_hp, atk, mod_text, _hero_status_summary(s.hero_effects))
info.set_hero(hp, max_hp)
info.set_attack(atk, mod_text)
static func _hero_status_summary(effects: Array) -> String:
var parts: Array = []
for eff in effects:
parts.append("%s×%d(剩 %d)" % [String(eff.display_name), int(eff.layers), int(eff.remaining_settlements)])
return " ".join(parts) if not parts.is_empty() else "状态:无"
# ---- 行动经济(AP 点阵 / RAP / 弃图刷新按钮态) ----
static func _refresh_economy(info: HeroInfoPanelView, ap_bar: ApBarView, s: Dictionary, db, refresh_remaining: int) -> void:
ap_bar.set_ap(int(s.ap), int(db.action_economy.ap_per_round))
# RAP 活值投影(C 区点阵)——清零反馈分支(A3 D1 义务)依赖本处持续投喂才会可达
info.set_rap(int(s.rap), int(db.action_economy.rap_refresh_cost))
var digging := String(s.phase) == ActionEconomy.PHASE_ACTIVE
ap_bar.set_refresh_state(refresh_remaining,
bool(s.can_request_refresh) and digging) # Waiting 代理已随 OQ12 退役——UI 仅挖掘期受理
ap_bar.set_stop_enabled(bool(s.hero.get("alive", false)) and digging)
# ---- E 区库存常读列表(枚举序 = stock 受理序 E21;来源不呈现——2026-09-12 拍板) ----
static func _fill_inventory(inv: InventoryPanelView, inventory: Array) -> void:
# 窗口态占屏(D2 原位窗口激活)——常读刷新不得覆盖窗口条目(窗口关闭后由下一轮刷新恢复常读面)
if inv.is_window_open():
return
var rows: Array = []
for inst in inventory:
if inst.status != "InStock":
continue
rows.append(_inventory_row(inst.display_name, String(inst.category), int(inst.power), "可用"))
if rows.is_empty():
inv.show_empty()
else:
inv.show_inventory(rows)
# 行值快照投影派生属性简述(伤害/回复/增益一句话)——来源 {cell, round} 不呈现
static func _inventory_row(name_: String, category: String, power: int, status: String) -> Dictionary:
var desc := ""
var tooltip := ""
match category:
"damage_item":
desc = "伤害 %d · 一次性" % power
tooltip = "对最前敌造成 %d 点伤害(恒定表值,不吃修正)。触发:轮末道具窗口选用,道具段生效。" % power
"heal_pack":
desc = "回复 %d · 可用" % power
tooltip = "回复 %d 点 HP(超量截断)。触发:轮末道具窗口选用;满血时禁选(不烧包)。" % power
"buff":
desc = "增益 · 结算期生效"
tooltip = "状态效果增益。触发:轮末结算增益/减益段。"
_:
desc = "威力 %d" % power
tooltip = "触发:轮末结算。"
return {"display_name": name_, "category": category, "desc": desc, "tooltip": tooltip, "status": status}
# 窗口条目投影(B1 window_opened 载荷 → E 区窗口态行;pos = 窗口条目位置——缺陷④契约)
static func window_rows(items: Array, hero_hp: int, hero_max_hp: int) -> Array:
var rows: Array = []
for pos in items.size():
var it: Dictionary = items[pos]
var row := _inventory_row(String(it.display_name), String(it.category), int(it.power), "")
row["pos"] = pos
row["usable_now"] = bool(it.usable_now)
row["reason"] = "HP 已满 · 禁选" if not bool(it.usable_now) else ""
rows.append(row)
return rows
# 战斗外(节点间隙/关卡结束)——HP 读 runner 携带值
static func _refresh_out_of_battle(stage: BattleStageView, info: HeroInfoPanelView, inv: InventoryPanelView,
runner, level_active: bool, db) -> void:
if not level_active:
return
if runner != null:
info.set_hero(runner.hero_hp, runner.effective_max_hp())
info.set_attack(runner.effective_atk(), "(关卡推进中)")
stage.hero_card.set_data(runner.hero_hp, runner.effective_max_hp(), runner.effective_atk(), "(关卡推进中)", "")
stage.clear_enemies() # 节点间隙清残位敌卡(死亡瞬间的灰化呈现已在结算行驱动完成)
info.set_rap(0, int(db.action_economy.rap_refresh_cost)) # battle_over RAP 清零反馈(A3 义务)
var rows: Array = []
for inst in runner.inventory():
if inst.status == "InStock":
rows.append(_inventory_row(inst.display_name, String(inst.category), int(inst.power), "可用"))
if rows.is_empty():
inv.show_empty()
else:
inv.show_inventory(rows)
# 三源拒绝可感知(A2 reject_reason/sub_reason → 玩家文案;pattern 1 双源拒绝反馈)
static func outcome_text(outcome: Dictionary) -> String:
if outcome.accepted:
return "执行完成。"
match String(outcome.get("reject_reason", "")):
"board_face":
var sub := String(outcome.get("sub_reason", ""))
var texts := {
"not_adjacent": "不能执行:目标与小人不相邻",
"target_revealed": "不能执行:目标已开",
"target_concealed": "不能移动:目标未开",
"flag_present": "不能挖:此格有旗——先右键拔旗",
"flag_absent": "不能拔旗:此格无旗",
"no_path": "不能移动:已探明区不连通",
"out_of_catalog": "无效目标",
}
return texts.get(sub, "棋盘面拒绝: " + sub)
"resource_face":
return "AP 不足。"
"phase":
return "结算中,非行动阶段。"
"board_invalid":
return "棋盘数据非法(fail-fast)。"
_:
return "动作被拒。"
# ---- 调整板批量模拟报告装配(文案归 presenter——视图只 set_text;code-review 2026-09-12 批) ----
static func tune_sim_report(n: int, atk_pct: int, hp_pct: int, wins: int, dmg_all: Array, hp_left_win: Array, hp_boss: Array, rounds_all: Array, death_nodes: Dictionary, base_hp: int) -> String:
var lines: Array = []
lines.append("[b]模拟 N=%d(atk+%d%% / hp+%d%%)[/b]" % [n, atk_pct, hp_pct])
lines.append("通关率 [color=#80d080]%.0f%%[/color](%d/%d)" % [100.0 * float(wins) / float(maxi(1, n)), wins, n])
lines.append("整关承伤:均 %.1f · p90 %.0f(基础 HP %d)" % [_mean(dmg_all), _p90(dmg_all), base_hp])
if not hp_left_win.is_empty():
lines.append("通关剩余 HP 均 %.1f · 进 Boss HP 均 %.1f" % [_mean(hp_left_win), _mean(hp_boss)])
lines.append("平均轮数 %.1f" % _mean(rounds_all))
if death_nodes.is_empty():
lines.append("败北节点:—")
else:
var parts: Array = []
for k in death_nodes.keys():
parts.append("%s×%d" % [k, death_nodes[k]])
lines.append("败北节点:%s(0-3=战斗序,4=Boss)" % " ".join(parts))
return "\n".join(lines)
static func _mean(a: Array) -> float:
if a.is_empty():
return 0.0
var t := 0.0
for v in a:
t += float(v)
return t / a.size()
static func _p90(a: Array) -> float:
if a.is_empty():
return 0.0
var b := a.duplicate()
b.sort()
return float(b[mini(b.size() - 1, int(0.9 * b.size()))])
@@ -0,0 +1 @@
uid://d15kn31qh3ae
@@ -0,0 +1,641 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 线性第一关(战×2→道具→战×2→恢复→Boss)能否承载"数值提升→通关期望改善"的元进度验证
# 实现 GDD:design/ux/battle.md(战斗屏五区 A/B/C/D/E + 全屏 States + 遥测)。
# Date: 2026-09-12(battle.md 五区改造批;基础 = 2026-09-11 D1-lite 组合根;code-review 修复批重构)
# 2026-09-13(封装审查修复批):核心状态读取收口至 B1/runner 成文只读面(presentation_snapshot /
# current_board / current_battle 等——禁链式穿透 b1.b2.hero 一类路径);调试模式编排抽离
# debug_session.gd(规范 1.1 审视线 + 调试语义单一实现)。
# 组合根(composition root):唯一的模式流转与 core↔view 接线处。
# 职责边界:① 构建五区视图并接线信号(节点构建归 ui_assembler.gd——规范 1.1,本类只接线)
# ② 模式管理(完整关卡 C1 runner / 调试两层——后者编排归 debug_session.gd;单场原语仅探针使用)
# ③ 核心写调用只在此处发生(a2.settle_batch / b1.submit_window_selection / 刷新编排入口 / runner 推进;
# E4 收口:加成值零表写——面板百分比经 runner.set_hero_override / debug_session 生效值 Callable
# 以 encounter 参数入战斗,db.hero 运行期只读)
# ④ 全屏 States 状态机(挖掘/结算/道具窗口/终局)与一次性提示去重。
# 渲染与文案归 views/,核心状态→文案装配归 hud_presenter.gd 与 battle_narrator.gd,节点构建归
# ui_assembler.gd,模态层级归 modal_stack.gd,批量模拟编排归 tune_sim_controller.gd,调试模式编排归
# debug_session.gd——本类不拼呈现字符串。
# ADR-0001 纪律:呈现层永不直接订阅 A2/A3——只订 B1/runner 转发信号;相位读取仅用于驱动视图状态。
extends Control
const UiAssembler := preload("res://presentation/ui_assembler.gd")
const Narrator := preload("res://presentation/battle_narrator.gd")
const ModalStack := preload("res://presentation/modal_stack.gd")
const TuneSimController := preload("res://presentation/tune_sim_controller.gd")
const DebugSessionScript := preload("res://presentation/debug_session.gd")
var db
var b1 = null # 当前战斗编排器(关卡与调试模式统一读它)
var runner = null # C1 线性关卡推进器(完整关卡模式)
var logger = null # 试玩日志器(JSONL 持久化 + 卡死诊断转储——呈现层组件)
var _level_active := false
var _seed_edit: LineEdit
var _start_overlay: Control
var _end_overlay: BattleEndOverlay
var _inter_overlay: Control
var _inter_label: Label
# 调试模式编排(单场/连续两层——状态机与战斗生命周期归 debug_session.gd,本类只接线与出 UI)
var _debug = null
# 视图(views/)——battle.md 五区
var strip_view: LevelStripView # A 区
var stage: BattleStageView # B 区(含 log_view 明细带)
var log_view: BattleLogView
var info_panel: HeroInfoPanelView # C 区
var board_zone: BoardZoneView # D 区(含 ap_bar/board_view/toast/fx)
var board_view: BoardView # 探针兼容别名(= board_zone.board_view)
var ap_bar: ApBarView # 探针兼容别名(= board_zone.ap_bar)
var inventory_panel: InventoryPanelView # E 区(原位窗口态)
# 模态 overlay(z 序 = 登记序;pattern 6 层级纪律)
var legend_overlay: LegendOverlay
var review_overlay: SettlementReviewOverlay
var node_overlay: NodeOverlay
var tune_panel: TunePanel
var _modals = null # 模态层级状态机(modal_stack.gd——缺陷⑤纪律载体)
var _tune_sim = null # 调整板批量模拟控制器(tune_sim_controller.gd)
var _hints_shown := {} # 一次性非模态提示去重(每 run 一次:fog_first/chord/no_adjacent)
var _refresh_used_round := 0 # 本轮已受理刷新次数(遥测 round_refresh_index——每轮 round_started 归零)
var _boss_active := false # Boss 遭遇变体(阶段指示附 Boss 名)
var _window_payload: Array = [] # 窗口投影缓存(ux_window_choice 遥测读 usable_now/item_id)
func _ready() -> void:
db = get_node("/root/ConfigDB")
if not db.is_ready:
var ok: bool = db.load_tables("res://data/tables", false)
if not ok:
_fatal_screen("数值表加载失败——见输出错误清单(fail-fast)")
return
_debug = DebugSessionScript.new(db, get_node("/root/RngService"),
Callable(self, "_debug_hero_override"), Callable(self, "_seed_value"))
_debug.battle_created.connect(_on_debug_battle_created)
_debug.battle_ready.connect(_on_debug_battle_ready)
_build_ui()
_start_overlay.visible = true
# ==================== 视图接线(battle.md 五区布局 1280×800;节点构建归 UiAssembler) ====================
func _build_ui() -> void:
var root := VBoxContainer.new()
add_child(root)
var five: Dictionary = UiAssembler.build_five_zone(root, db)
strip_view = five.strip_view
stage = five.stage
log_view = five.log_view
info_panel = five.info_panel
board_zone = five.board_zone
board_view = five.board_view # 探针兼容别名(= board_zone.board_view)
ap_bar = five.ap_bar # 探针兼容别名(= board_zone.ap_bar)
inventory_panel = five.inventory_panel
log_view.review_requested.connect(_open_review_from_band)
board_zone.action_requested.connect(_on_board_action)
board_zone.stop_pressed.connect(_on_stop_pressed)
board_zone.refresh_confirmed.connect(_on_refresh_confirmed)
board_zone.legend_toggled.connect(_on_legend_toggled)
board_zone.hover_forbidden.connect(_on_hover_forbidden)
board_zone.chord_attempted.connect(_on_chord_attempted)
board_zone.fog_region_completed.connect(_on_fog_region_completed)
inventory_panel.item_selected.connect(_on_window_item_selected)
inventory_panel.skipped.connect(_on_window_skipped)
inventory_panel.reject_reason.connect(_on_inventory_reject)
# 奖励徽章数据源:B4 身份解析只读回调(已开格身份 icon——挖掘期 Active 相位内有效)
board_view.set_identity_resolver(_identity_lookup)
logger = PlaytestLogger.new()
logger.ap_per_round = int(db.action_economy.ap_per_round)
# 模态覆盖层(登记序 = 树序 = z 遮挡序:图例 < 明细回看 < 节点 < 终局 < 调整板 < 开始)
var overlays: Dictionary = UiAssembler.build_overlays(self, db, int(db.hero.atk), int(db.hero.max_hp))
legend_overlay = overlays.legend_overlay
review_overlay = overlays.review_overlay
node_overlay = overlays.node_overlay
_end_overlay = overlays.end_overlay
tune_panel = overlays.tune_panel
legend_overlay.closed.connect(_on_modal_closed.bind(legend_overlay))
review_overlay.closed.connect(_on_modal_closed.bind(review_overlay))
node_overlay.continue_pressed.connect(_on_node_continue)
_end_overlay.review_pressed.connect(_on_end_review)
_end_overlay.continue_pressed.connect(_on_battle_end_continue)
tune_panel.closed.connect(_close_tune_panel)
tune_panel.apply_requested.connect(_on_tune_apply)
tune_panel.simulate_requested.connect(_on_tune_simulate)
_modals = ModalStack.new()
for m: Control in [legend_overlay, review_overlay, node_overlay, _end_overlay, tune_panel]:
_register_modal(m)
_tune_sim = TuneSimController.new(db, tune_panel, int(db.hero.max_hp))
var start: Dictionary = UiAssembler.build_start_overlay(self)
_start_overlay = start.overlay
_seed_edit = start.seed_edit
start.lvbtn.pressed.connect(_start_full_level)
start.tunebtn.pressed.connect(_show_tune_panel)
start.dbgbtn.pressed.connect(_start_debug_levels)
var inter: Dictionary = UiAssembler.build_inter_overlay(self)
_inter_overlay = inter.overlay
_inter_label = inter.label
inter.next_btn.pressed.connect(_on_inter_next_pressed)
var diag_btn := UiAssembler.build_diag_button(self)
diag_btn.pressed.connect(_on_diagnose_pressed)
func _fatal_screen(msg: String) -> void:
var l := UiKit.label(msg, 20, true)
l.add_theme_color_override("font_color", Color8(200, 60, 48))
l.set_anchors_preset(Control.PRESET_CENTER)
add_child(l)
# ==================== 模态 overlay 层级管理(pattern 6 / 缺陷⑤——状态机归 modal_stack.gd) ====================
func _register_modal(m: Control) -> void:
_modals.register(m)
func _open_modal(m: Control) -> void:
# 打开时显式隐藏树序在其上方的可见层(遮挡物隐藏),关闭恢复——单一模态栈
_modals.open(m)
func _close_modal(m: Control) -> void:
_modals.close(m)
func _on_modal_closed(m: Control) -> void:
_close_modal(m)
func _input_locked() -> bool:
# 模态/非战斗期棋盘输入锁(各 overlay 自身可见即锁定)
return legend_overlay.is_open() or review_overlay.is_open() or _end_overlay.is_open() \
or node_overlay.visible or tune_panel.visible or _start_overlay.visible \
or _inter_overlay.visible or b1 == null
# ==================== 完整关卡模式(C1 线性推进器)====================
func _start_full_level() -> void:
_start_overlay.visible = false
# E4:加成经 runner.set_hero_override → encounter 参数入战斗(零表写——无双写叠加隐患)
runner = LevelRunner.new(db, get_node("/root/RngService"))
runner.set_hero_override(tune_panel.atk_pct(), tune_panel.hp_pct())
runner.step_row.connect(_on_step_row)
runner.window_opened.connect(_on_window_opened)
runner.round_started.connect(_on_round_started)
runner.board_changed.connect(_on_board_changed)
runner.node_entered.connect(_on_runner_node)
runner.level_finished.connect(_on_level_finished)
_level_active = true
_reset_run_scope()
log_view.clear()
var seed_val := _seed_value()
logger.start_session(seed_val) # 各战斗的 attach 在 _on_runner_node 内(b1 逐战创建)
if not runner.start_level(1, seed_val):
_fatal_screen("关卡启动失败——见输出日志")
return
log_view.append_bb(Narrator.level_open_line(String(runner.level_row.get("display_name", "")),
seed_val, int(db.hero.atk), int(db.hero.max_hp),
tune_panel.atk_pct() > 0 or tune_panel.hp_pct() > 0))
func _reset_run_scope() -> void:
# 每 run 一次的提示去重与轮内计数(弃图刷新遥测轮内序号)
_hints_shown = {}
_refresh_used_round = 0
_boss_active = false
inventory_panel.close_window()
func _on_runner_node(node: Dictionary, info: Dictionary) -> void:
var t := String(node.get("type", ""))
if t == "battle" or t == "boss":
b1 = runner.current_battle() # 呈现层统一读 b1(与调试模式共用接线)
logger.attach_battle(b1)
_boss_active = t == "boss"
_show_board_fx_for_node(t)
log_view.append_bb(Narrator.node_banner(runner, node, t))
elif t == "reward":
var snap: Dictionary = info.get("snapshot", {})
_open_modal(node_overlay)
node_overlay.show_reward(snap)
log_view.append_bb(Narrator.reward_line(snap))
elif t == "rest":
var healed: int = int(info.get("healed", 0))
_open_modal(node_overlay)
node_overlay.show_rest(healed, int(info.get("hp", 0)), int(info.get("max_hp", 0)))
log_view.append_bb(Narrator.rest_line(healed, int(info.get("hp", 0)), int(info.get("max_hp", 0))))
logger.log_node(node, info, runner.node_index)
strip_view.refresh(runner)
_refresh_presentations()
func _on_level_finished(payload: Dictionary) -> void:
_level_active = false
var result: String = String(payload.get("result", ""))
b1 = null
_sync_settlement_visuals() # → 全关
info_panel.set_status_cleared_feedback() # 状态槽全清可见反馈(B3×4④)
stage.gray_all_enemies() # 死亡兜底:卡体灰化残位(禁瞬消语义的战斗收尾面)
if result == "victory":
# 关卡结算:库存清空可见反馈(B4 D1×⑤)+ 胜利终局 overlay(金蜡封落于棋盘中央)
var remaining: int = int(payload.get("remaining_items", 0))
inventory_panel.play_cleared_feedback(remaining)
_end_overlay.open_victory(Narrator.level_end_text("victory", payload, runner.effective_max_hp()),
"返回冒险", board_view.get_global_rect())
else:
_end_overlay.open_defeat(Narrator.level_end_text("defeat", payload, 0),
"返回", board_view.get_global_rect())
logger.log_level_end(payload) # 试玩时间线:关卡终局
runner.restore_hero_base() # 关卡结束——覆盖值归零(下次从面板重新应用)
strip_view.refresh(runner)
_open_modal(_end_overlay)
_refresh_presentations()
func _on_battle_end_continue() -> void:
# 终局出口:返回开始界面(切片无下一关数据面——NodeOverlay/关卡结算屏归相邻域 spec)
_close_modal(_end_overlay)
_end_overlay.close()
_debug.reset()
if runner != null:
runner.restore_hero_base()
runner = null
b1 = null
tune_panel.reset_sliders()
strip_view.refresh(null)
_start_overlay.visible = true
_refresh_presentations()
func _on_end_review() -> void:
review_overlay.open(log_view.parsed_text())
# ==================== 调试模式(连续两层——编排归 debug_session.gd;
# 单场遭遇用户入口已删(2026-09-13 用户裁决)——debug_session.start_single 原语仅探针使用)====================
func _start_debug_levels() -> void:
tune_panel.reset_sliders()
_start_overlay.visible = false
log_view.clear()
log_view.append_bb(Narrator.debug_open_line(_seed_value(), 1, true))
_reset_run_scope()
if not _debug.start_debug_levels():
_fatal_screen("战斗启动失败——见输出日志")
func _on_debug_battle_created(b1_ref, _level: int, _carry: int) -> void:
# begin_battle 前接手 + 接线(ADR-0001:signal.connect 唯一归组合根)。b1 必须先于 begin_battle
# 就位——begin 期间发出的 board_changed 要用新板重建棋盘并触发雾首遇提示(缺陷①同款时序义务)
b1 = b1_ref
_wire_battle(b1_ref)
func _on_debug_battle_ready(b1_ref, _level: int, _carry: int) -> void:
b1 = b1_ref
board_view.rebuild(b1.a2.current_board(), b1.a2)
_check_board_hints()
_refresh_presentations()
func _on_inter_next_pressed() -> void:
_inter_overlay.visible = false
if not _debug.next_layer():
_fatal_screen("战斗启动失败——见输出日志")
# E4:加成生效值供给(debug_session 注入 Callable——每次从面板百分比现算,经 encounter 传参零表写;
# 原表写路径 _apply_hero_override/_scaled_override 已废除,双写叠加 6→18→54 整类隐患随之移除)
func _debug_hero_override() -> Dictionary:
return {"atk": _scaled(int(db.hero.atk), tune_panel.atk_pct()),
"max_hp": _scaled(int(db.hero.max_hp), tune_panel.hp_pct())}
func _scaled(base: int, pct: int) -> int:
return maxi(1, int(round(float(base) * (100.0 + float(pct)) / 100.0)))
# 奖励徽章身份解析(组合根守卫:仅挖掘期 Active 相位内查询——B4 Ready 域外零读、零诊断噪音)
func _identity_lookup(c: Vector2i):
if b1 == null or b1.a2.current_board() == null or b1.a3.phase != b1.a3.PHASE_ACTIVE:
return null
return b1.query_reward_identity(c)
func _show_board_fx_for_node(node_type: String = "") -> void:
# Boss 遭遇变体:紫赤影子爬棋盘一次性占位演出(§2.6)
if node_type == "boss":
board_zone.fx.play_boss_shadow()
func _on_battle_finished(payload: Dictionary) -> void:
# 仅调试模式接线(完整关卡的结束归 C1 runner._on_level_finished)
var result: String = String(payload.get("result", ""))
_sync_settlement_visuals() # → 全关(battle_over 反馈面)
stage.gray_all_enemies() # 死亡兜底:卡体灰化残位(禁瞬消语义的战斗收尾面)
info_panel.set_rap(0, int(db.action_economy.rap_refresh_cost)) # RAP 清零滚动 + 微文案
info_panel.set_status_cleared_feedback() # 状态槽全清可见反馈(B3×4④)
var decision: Dictionary = _debug.on_battle_finished(payload)
match String(decision.outcome):
"next_layer":
_inter_label.text = "第 %d 层通过!\n库存道具 ×%d · HP %d/%d 带入下一层" % [
int(decision.layer), int(decision.items_left), int(decision.carry_hp), int(db.hero.max_hp)]
_inter_overlay.visible = true
_refresh_presentations()
return
"level_end":
inventory_panel.play_cleared_feedback(int(decision.remaining))
_end_overlay.open_victory(Narrator.battle_end_text("victory", true, int(decision.remaining)),
"返回", board_view.get_global_rect())
"standalone_end":
if result == "victory":
_end_overlay.open_victory(Narrator.battle_end_text("victory", false, 0),
"回到遭遇选择", board_view.get_global_rect())
else:
_end_overlay.open_defeat(Narrator.battle_end_text("defeat", false, 0),
"返回", board_view.get_global_rect())
_open_modal(_end_overlay)
_refresh_presentations()
# ==================== core↔view 接线(战斗信号统一走这里)====================
func _wire_battle(b1_ref) -> void:
b1_ref.step_row.connect(_on_step_row)
b1_ref.window_opened.connect(_on_window_opened)
b1_ref.battle_finished.connect(_on_battle_finished)
b1_ref.round_started.connect(_on_round_started)
b1_ref.board_changed.connect(_on_board_changed)
func _on_step_row(row: Dictionary) -> void:
log_view.append_row(row)
stage.on_step_row_fx(row)
# 高光演出(B1×3①② 占位):完美排雷横幅 / 标中旗加重弹道
if String(row.get("type", "")) == "highlight":
board_zone.fx.play_perfect_sweep()
elif String(row.get("highlight", "")) == "flag_hit":
board_zone.fx.play_flag_hit_projectile(true)
_refresh_presentations()
func _on_round_started(ri: int) -> void:
log_view.finish_settlement_round() # 上轮摘要行(挖掘期 1 行——明细带 pattern 3)
log_view.append_bb("[color=#e0b860]── 第 %d 轮 ──[/color]" % ri)
_refresh_used_round = 0
_refresh_presentations()
func _on_board_changed() -> void:
# 关卡模式下 board_changed 可能先于主场景接手 b1 到达(begin_battle 期间发)——先从 runner 同步
if b1 == null and _level_active and runner != null:
b1 = runner.current_battle()
if b1 == null:
return
board_view.rebuild(b1.a2.current_board(), b1.a2)
_check_board_hints()
_refresh_presentations()
# ---- 一次性非模态提示(每 run 一次——pattern 10)与诚实提示 ----
func _check_board_hints() -> void:
var board = b1.a2.current_board()
if board == null:
return
# 零合法板(A1 EC5 降级)诚实提示——非静默(toast,非模态)
if board.illegal_product:
board_zone.toast.show_toast("本板为零合法板降级产物——出图器已尽力,数据面诚实告知", "warn")
# 雾首遇(每 run 一次;必含三要点:无雷盲区不赌命 / 雾格不传播连锁 / 雾内插旗纯损——A4 D1×②)
if not board.fog_cells().is_empty() and not _hints_shown.has("fog_first"):
_hints_shown["fog_first"] = true
_show_hint("迷雾=无雷盲区,不赌命开雾;雾格不传播连锁;雾内插旗纯损。", "fog_first")
func _on_hover_forbidden(_cell: Vector2i) -> void:
if not _hints_shown.has("no_adjacent"):
_show_hint("需先走近(点击已开格移动,免费)", "no_adjacent")
func _on_chord_attempted(_cell: Vector2i) -> void:
if not _hints_shown.has("chord"):
_show_hint("本作无双击快开——插旗后轮末自动判定标中;点数字格可移动小人", "chord")
func _on_fog_region_completed(region_size: int) -> void:
# 雾区全开一次性完成信号(视觉合并域 = 连通分量,禁实例级双响——EC11)
board_zone.toast.show_toast("雾区已全开(%d 格)——迷雾域尽收眼底" % region_size, "gold")
func _show_hint(text: String, hint_id: String) -> void:
_hints_shown[hint_id] = true
logger.log_ux("ux_hint_shown", {"hint_id": hint_id})
board_zone.toast.show_toast(text, "info")
# ---- 道具窗口(E 区原位激活——布局决策 D2 / pattern 2) ----
func _on_window_opened(items: Array) -> void:
_window_payload = items
var hero: Dictionary = b1.presentation_snapshot().hero # E1 收口:经呈现快照面读 HP
var hp: int = int(hero.get("hp", 0)) if not hero.is_empty() else 0
var max_hp: int = int(hero.get("max_hp", 0)) if not hero.is_empty() else 1
inventory_panel.open_window(HudPresenter.window_rows(items, hp, max_hp))
_sync_settlement_visuals() # 阶段指示 → 道具窗口
func _on_window_item_selected(pos: int) -> void:
# 选择语义 = 窗口条目位置(缺陷④契约——非库存句柄)
if pos >= 0 and pos < _window_payload.size():
var it: Dictionary = _window_payload[pos]
logger.log_ux("ux_window_choice", {"selected_item_id": String(it.get("item_id", "")),
"usable_now": true, "skip": false})
inventory_panel.close_window()
if b1 != null:
b1.submit_window_selection([pos])
_refresh_presentations()
func _on_window_skipped() -> void:
logger.log_ux("ux_window_choice", {"selected_item_id": "", "usable_now": false, "skip": true})
inventory_panel.close_window()
if b1 != null:
b1.submit_window_selection([])
_refresh_presentations()
func _on_inventory_reject(text: String) -> void:
board_zone.toast.show_toast(text, "warn")
func _on_node_continue() -> void:
_close_modal(node_overlay)
if runner != null:
runner.continue_after_node()
# ==================== 玩家意图 → 核心写调用(唯一出口)====================
func _on_board_action(cell: Vector2i, kind: String) -> void:
# 玩家动作 → 核心写调用的唯一通道(模态/窗口期/无战斗拒绝在此收口)
if _input_locked() or inventory_panel.is_window_open():
return
var outcomes: Array = b1.a2.settle_batch([{"action": kind, "cell": cell}])
var accepted: bool = not outcomes.is_empty() and bool(outcomes[0].accepted)
logger.log_action(kind, cell, accepted)
_handle_outcome_feedback(outcomes[0] if not outcomes.is_empty() else {})
# settle_batch 内部同步走完 结算→战斗结束→关卡推进(事件同步发射)——关卡终局时 b1 已被
# runner 置空,清图演出判定须先判 b1(基线既有重入隐患,2026-09-13 加固)
if accepted and kind == "dig" and b1 != null and b1.a2.current_board() != null and b1.a2.clear_map_cleared():
board_zone.fx.play_clear_burst()
board_view.refresh()
_refresh_presentations()
func _on_stop_pressed() -> void:
if _input_locked() or inventory_panel.is_window_open():
return
# stop 经批次提交(A2 D1×6①——不直调 A3)
var outcomes: Array = b1.a2.settle_batch([{"action": "stop"}])
logger.log_action("stop", Vector2i(-1, -1), not outcomes.is_empty() and bool(outcomes[0].accepted))
if not outcomes.is_empty() and String(outcomes[0].get("outcome", "")) == "voided":
# voided ≠ 拒绝——独立文案(pattern 1:防静默改判读感欺骗)
board_zone.toast.show_toast("本轮已清图,停手未执行 · 剩余 AP 已作废", "warn")
logger.log_ux("ux_reject", {"source": "voided", "sub_reason": "stop_voided"})
board_view.refresh()
_refresh_presentations()
func _on_refresh_confirmed() -> void:
# 弃图刷新(挖掘期 Active 当场换图继续挖——D4;RAP−8 · 旧板旗作废,每轮上限 2 次)
if _input_locked() or inventory_panel.is_window_open():
return
var ok: bool = false
if _level_active and runner != null:
ok = runner.player_request_refresh()
elif b1 != null:
ok = b1.player_request_refresh()
logger.log_action("refresh", Vector2i(-1, -1), ok)
if ok:
_refresh_used_round += 1
logger.log_ux("ux_refresh_attempt", {"result": "accepted", "round_refresh_index": _refresh_used_round})
board_zone.toast.show_toast("RAP−8 · 旧板旗作废——新图已就位,AP 保留继续挖", "gold")
else:
# 资源面/上限拒绝(EC13 合法拒绝可见;A3 无载荷——客户端按上下文推断)
var rap: int = int(b1.a3.rap) if b1 != null else 0
var cost: int = int(db.action_economy.rap_refresh_cost)
var result := "rejected_rap" if rap < cost else "rejected_cap"
logger.log_ux("ux_refresh_attempt", {"result": result, "round_refresh_index": _refresh_used_round + 1})
if result == "rejected_rap":
board_zone.toast.show_toast("RAP 不足 · 还差 %d 点(弃图刷新需 8)" % (cost - rap), "warn")
info_panel.pulse_rap()
else:
board_zone.toast.show_toast("本轮刷新次数已用完(每轮 2 次)", "warn")
board_view.refresh()
_refresh_presentations()
func _handle_outcome_feedback(outcome: Dictionary) -> void:
# 双源拒绝反馈(pattern 1):每次拒绝玩家可感知 + 来源可区分 + 返回值命令式驱动(无拒绝事件)
if outcome.is_empty() or bool(outcome.get("accepted", false)):
return
logger.log_ux("ux_reject", {"source": String(outcome.get("reject_reason", "")),
"sub_reason": String(outcome.get("sub_reason", ""))})
board_zone.toast.show_toast(HudPresenter.outcome_text(outcome), "warn")
if String(outcome.get("reject_reason", "")) == "resource_face":
ap_bar.pulse_ap()
# ==================== 全屏 States 状态机(挖掘/结算/窗口/终局)====================
func _refresh_presentations() -> void:
# 每个核心信号/写调用返回后的全量重查装配(更新触发注记——无推送信号,命令式重查)
var refresh_remaining: int = maxi(0, int(db.action_economy.refresh_max_per_round) - _refresh_used_round)
HudPresenter.refresh(stage, info_panel, ap_bar, inventory_panel, b1, runner, _level_active, db, refresh_remaining)
_sync_settlement_visuals()
func _sync_settlement_visuals() -> void:
# 结算阶段(round_end → 下一轮):D 区去饱和+金橙偏移锁输入(明度不变)+ 明细带展开 84px
var settling := _is_settling()
board_view.set_desaturated(settling)
board_view.set_dig_glow_enabled(not settling)
stage.set_settle_focus(settling)
log_view.set_mode("settle" if settling else "dig")
stage.set_round_text(_phase_text())
func _is_settling() -> bool:
if b1 == null or not b1.is_battle_live():
return false
return b1.a3.phase == b1.a3.PHASE_WAITING
func _phase_text() -> String:
# 文案装配归 narrator(b1 缺位 = 非战斗态——组合根模式状态守卫)
if b1 == null:
return "第 — 轮 · —"
return Narrator.phase_text(int(b1.round_index), String(b1.battle_result),
inventory_panel.is_window_open(), b1.a3.phase == b1.a3.PHASE_WAITING,
_boss_active, Narrator.boss_name(runner, db))
# ==================== B 区明细带 / 图例(模态入口)====================
func _open_review_from_band() -> void:
review_overlay.open(log_view.parsed_text())
func _on_legend_toggled() -> void:
if legend_overlay.is_open():
_close_modal(legend_overlay)
else:
_open_modal(legend_overlay)
# ==================== 诊断转储(卡死时点击——全量核心状态 + 环形缓冲)====================
func _on_diagnose_pressed() -> void:
var ctx := {"mode": "level" if _level_active else "debug", "seed": _seed_value()}
if _level_active and runner != null:
ctx["runner"] = {"node_index": runner.node_index, "level_result": String(runner.level_result),
"hero_hp": runner.hero_hp, "b1_is_null": runner.current_battle() == null}
if b1 != null:
ctx["b1"] = b1.diagnostic_snapshot() # 诊断观测面(私有字段零直读——code-review 2026-09-12)
var path: String = logger.dump_diagnosis(ctx)
board_zone.toast.show_toast("已转储诊断: " + path, "info")
# ==================== 数值调整板(加成应用/批量模拟编排——组合根承接,视图纯 UI)====================
func _show_tune_panel() -> void:
_start_overlay.visible = false
tune_panel.visible = true
func _close_tune_panel() -> void:
tune_panel.visible = false
_start_overlay.visible = true
func _on_tune_apply(atk_pct: int, hp_pct: int) -> void:
# E4:加成无持久态可"落"——各战斗开局经 _debug_hero_override/runner 现算生效(面板仅关闭回开始层)
_close_tune_panel()
func _on_tune_simulate(n: int) -> void:
# 批量模拟编排 = 控制器承接(tune_sim_controller.gd);E4 后无表态需复位——模拟走 runner 覆盖参数
_tune_sim.run(n)
# ==================== 工具 ====================
func _seed_value() -> int:
if _seed_edit.text.is_valid_int():
return int(_seed_edit.text)
return 42
@@ -0,0 +1 @@
uid://dcbnvi7tvlkun
@@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3 uid="uid://slice0main0scene"]
[ext_resource type="Script" path="res://presentation/main.gd" id="1_main"]
[node name="Main" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_main")
@@ -0,0 +1,34 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 实现 GDD:design/ux/interaction-patterns.md(pattern 6 模态 overlay 层级纪律)。
# Date: 2026-09-12(code-review 修复批——自 main.gd 拆出,规范 1.1 状态装配独立成类)
# 模态层级状态机:登记序 = 树序 = z 遮挡序;打开时显式隐藏树序在其上方的可见层、关闭恢复
# (缺陷⑤纪律载体:功能存在 ≠ 功能可达——遮挡关系不得靠树序巧合)。
# 纯逻辑零节点构建——组合根持有本类并喂入 Control;不持久化核心状态。
extends RefCounted
var _list: Array = [] # 登记序 = z 遮挡序(低 → 高)
var _occluded := {} # modal → 被其隐藏的上方层(关闭恢复)
func register(m: Control) -> void:
_list.append(m)
func open(m: Control) -> void:
var hidden: Array = []
var idx := _list.find(m)
for i in range(idx + 1, _list.size()):
var higher: Control = _list[i]
if is_instance_valid(higher) and higher.visible:
higher.visible = false
hidden.append(higher)
_occluded[m] = hidden
m.visible = true
func close(m: Control) -> void:
m.visible = false
for h in _occluded.get(m, []):
if is_instance_valid(h):
h.visible = true
_occluded.erase(m)
@@ -0,0 +1 @@
uid://bk34o2kt2gfbq
@@ -0,0 +1,165 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 实现 GDD:design/ux/battle.md(§Events Fired 遥测义务——ux 行族)。
# Date: 2026-09-12(五区改造批增 ux 遥测;基础 = 2026-09-12 日志器首版)
# 试玩日志器(呈现层组件——订阅核心信号持久化 JSONL,供数值分析;卡死诊断转储)。
# 职责边界:核心层只发 settlement_snapshot 信号(B1)/既有信号(battle_finished/node_entered/
# level_finished/step_row);本组件负责订阅、缓冲与写文件——核心零日志感知(零 UI 依赖不变)。
# 文件 = user://logs/playtest_<seed>_<时间戳>.jsonl(每行一次结算/终局/节点的全量状态 JSON)。
# ux 行族(呈现层观测,不改 core):ux_round_input_ratio(原始输入动作数∶AP 消耗)、
# ux_round_exit{path_tag}(快照 round_facts.path——呈现层只订 B1 的 ADR 纪律下唯一合法来源)、
# ux_refresh_attempt / ux_window_choice / ux_reject / ux_hint_shown(组合根显式上报)。
# 诊断转储 = 全量核心状态 + 最近结算/操作环形缓冲 → user://logs/diag_<时间戳>.json(卡死时点按钮)。
class_name PlaytestLogger
extends RefCounted
const RING_SIZE := 200
var _file: FileAccess = null
var _path := ""
var _seed := 0
var _recent_steps: Array = [] # 环形缓冲:最近结算行
var _recent_actions: Array = [] # 环形缓冲:最近玩家动作
var _snap_count := 0
var ap_per_round := 8 # 组合根注入(AP 消耗口径分母——A3 无推送信号)
var _ux_inputs := 0 # 本结算轮原始输入动作数(log_action 计数,快照后归零)
# 开启会话(每次 start_level/start_battle 调用——按种子分文件)。返回日志路径。
func start_session(seed_val: int) -> String:
_seed = seed_val
_recent_steps.clear()
_recent_actions.clear()
_snap_count = 0
DirAccess.make_dir_recursive_absolute("user://logs")
var stamp: String = Time.get_datetime_string_from_system().replace(":", "").replace("-", "").replace(" ", "_")
_path = "user://logs/playtest_%d_%s.jsonl" % [seed_val, stamp]
if _file != null:
_file.close()
_file = FileAccess.open(_path, FileAccess.WRITE)
_append({"kind": "session", "seed": seed_val, "ts": Time.get_datetime_string_from_system()})
return _path
func close_session() -> void:
if _file != null:
_file.close()
_file = null
func log_path() -> String:
return _path
func attach_battle(b1) -> void:
b1.step_row.connect(_on_step_row)
b1.settlement_snapshot.connect(_on_settlement_snapshot)
b1.battle_finished.connect(func(p): _append({
"kind": "battle_end", "result": String(p.get("result", "")),
"hero_hp": int(p.get("hero_hp", 0)), "remaining_items": int(p.get("remaining_items", 0))}))
# ---- ux 遥测(battle.md §Events Fired——呈现层观测行族,不改 core) ----
func log_ux(ux_kind: String, payload: Dictionary) -> void:
var line := {"kind": "ux", "ux": ux_kind}
for k in payload.keys():
line[k] = payload[k]
_append(line)
func log_action(kind: String, cell: Vector2i, accepted: bool, extra: Dictionary = {}) -> void:
_ux_inputs += 1 # 原始输入动作数(含被拒——P4 ≤2 护栏观测口径 = 原始输入)
# 逐操作落盘(2026-09-12 用户要求:操作记录完整持久化——此前仅入环形缓冲、卡死诊断才可见)
var line := {"kind": "action", "op": kind, "cell": [cell.x, cell.y], "accepted": accepted}
for k in extra.keys():
line[k] = extra[k]
_append(line)
_ring(_recent_actions, {"kind": kind, "cell": [cell.x, cell.y], "accepted": accepted}.merged(extra))
func _on_settlement_snapshot(snap: Dictionary) -> void:
_append(snap)
# ux_round_exit{path_tag}:对齐上游封闭枚举 {stopped, ap_exhausted, cleared}
var facts: Dictionary = snap.get("round_facts", {})
log_ux("ux_round_exit", {"path_tag": String(facts.get("path", ""))})
# ux_round_input_ratio:原始输入动作数∶AP 消耗(本轮 AP 配额 − 快照时剩余 = 消耗)
var economy: Dictionary = snap.get("economy", {})
var ap_left: int = int(economy.get("ap", 0))
var consumed: int = clampi(ap_per_round - ap_left, 0, ap_per_round)
log_ux("ux_round_input_ratio", {"inputs": _ux_inputs, "ap_consumed": consumed,
"ratio": float(_ux_inputs) / float(maxi(consumed, 1))})
_ux_inputs = 0
func log_node(node: Dictionary, info: Dictionary, node_index: int) -> void:
var t := String(node.get("type", ""))
var line := {"kind": "node", "type": t, "index": node_index}
if t == "battle" or t == "boss":
line["depth"] = int(node.get("node_depth", 0))
elif t == "reward":
var snap: Dictionary = info.get("snapshot", {})
line["reward_id"] = String(snap.get("reward_id", ""))
line["power"] = int(snap.get("power", 0))
elif t == "rest":
line["healed"] = int(info.get("healed", 0))
_append(line)
# 批量模拟会话的局次标记(一文件多局——run_start 行携带该局种子/档位,供逐局切分)
func log_run(meta: Dictionary) -> void:
var line := {"kind": "run"}
for k in meta.keys():
line[k] = meta[k]
_append(line)
func log_level_end(payload: Dictionary) -> void:
_append({"kind": "level_end", "result": String(payload.get("result", "")),
"node_index": int(payload.get("node_index", -1)), "hp": int(payload.get("hp", 0)),
"remaining_items": int(payload.get("remaining_items", -1))})
func _on_step_row(row: Dictionary) -> void:
_ring(_recent_steps, {"t": row.type, "src": row.source_class, "v": int(row.value), "r": int(row.round_index)})
# 结算明细行逐行落盘(2026-09-12 用户要求——七段结算完整回放数据面)
_append({"kind": "step", "type": row.type, "source": row.source_class,
"cell": row.origin_cell, "round": int(row.round_index),
"text": String(row.display_name), "value": int(row.value),
"highlight": String(row.get("highlight", ""))})
# ---- 诊断转储(卡死时点击——全量状态 + 环形缓冲)----
func dump_diagnosis(context: Dictionary) -> String:
DirAccess.make_dir_recursive_absolute("user://logs")
var stamp: String = Time.get_datetime_string_from_system().replace(":", "").replace("-", "").replace(" ", "_")
var path := "user://logs/diag_%s.json" % stamp
var dump := {
"kind": "diagnosis", "ts": Time.get_datetime_string_from_system(), "seed": _seed,
"session_log": _path, "snap_count": _snap_count,
"recent_actions": _recent_actions.duplicate(),
"recent_steps": _recent_steps.duplicate(),
"core": context,
}
var f := FileAccess.open(path, FileAccess.WRITE)
if f != null:
f.store_string(JSON.stringify(dump, " "))
f.close()
print("[playtest_logger] 诊断转储已写入: %s(项目日志目录见输出)" % path)
print(" [diag] %s" % JSON.stringify(context))
return path
func _ring(buf: Array, item: Dictionary) -> void:
buf.append(item)
while buf.size() > RING_SIZE:
buf.pop_front()
func _append(dict: Dictionary) -> void:
if dict.get("kind", "") == "settlement":
_snap_count += 1
if _file == null:
return
_file.store_line(JSON.stringify(dict))
_file.flush()
@@ -0,0 +1 @@
uid://647qs6ujbugf
@@ -0,0 +1,54 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Date: 2026-09-12(code-review 修复批——自 main.gd 拆出,规范 1.1/1.2 模拟编排独立成类)
# 调整板批量模拟控制器:逐次驱动 LevelAutoplay(与 headless 扫描同口径)+ 进度/报告 setter 注入面板。
# 职责边界:零核心写——表基线复位由组合根在移交前完成(_apply_hero_override(0,0)),
# 加成施加 = LevelAutoplay 内部 runner.set_hero_override 单路径;报告文案装配归 HudPresenter。
# 组合根只转发 simulate_requested 信号。await 依赖面板所在场景树(进度逐帧刷新)。
extends RefCounted
const SEED_BASE := 4200 # 模拟种子基(与历史实现同源——同种子可回放)
const LevelAutoplay := preload("res://smoke/level_autoplay.gd") # 与 headless 扫描同口径的自动策略(P3:preload 编译期路径校验)
var db
var panel # TunePanel setter 面:atk_pct/hp_pct/set_sim_running/set_sim_progress/set_sim_report
var _base_hp := 0 # 报告中的"基础 HP"口径注记值
func _init(db_ref, panel_ref, base_hp: int) -> void:
db = db_ref
panel = panel_ref
_base_hp = base_hp
func run(n: int) -> void:
# 共享日志会话(2026-09-12 用户要求:模拟运行与真人试玩同日志合规——操作/轮次/战斗/通关全量 JSONL;
# 一文件多局,run 行携带种子切分局次)
var logger = load("res://presentation/playtest_logger.gd").new()
logger.ap_per_round = int(db.action_economy.ap_per_round)
var log_path: String = logger.start_session(SEED_BASE)
print("[tune_sim] 模拟日志: %s" % log_path)
panel.set_sim_running(true)
panel.set_sim_progress("模拟中:0/%d …" % n)
var wins := 0
var dmg_all: Array = []
var hp_left_win: Array = []
var hp_boss: Array = []
var rounds_all: Array = []
var death_nodes := {}
for i in range(n):
var st = LevelAutoplay.run_level(db, SEED_BASE + i, 1, panel.atk_pct(), panel.hp_pct(), false, logger)
dmg_all.append(st.hero_dmg)
rounds_all.append(st.rounds)
if st.result == "victory":
wins += 1
hp_left_win.append(st.hp_left)
hp_boss.append(st.hp_at_boss)
elif st.result == "defeat":
var key := "%d.%s" % [st.defeat_node_index, st.defeat_node_type]
death_nodes[key] = int(death_nodes.get(key, 0)) + 1
panel.set_sim_progress("模拟中:%d/%d …" % [i + 1, n])
await panel.get_tree().process_frame
logger.close_session()
panel.set_sim_report(HudPresenter.tune_sim_report(n, panel.atk_pct(), panel.hp_pct(),
wins, dmg_all, hp_left_win, hp_boss, rounds_all, death_nodes, _base_hp))
panel.set_sim_running(false)
@@ -0,0 +1 @@
uid://o0cee33g0yml
@@ -0,0 +1,164 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 实现 GDD:design/ux/battle.md(五区装配器——组合根的 UI 构建拆分载体)。
# Date: 2026-09-12(code-review 修复批:main.gd 回涨 761 行触规范 1.1 审视线——构建拆出)
# 高内聚边界(规范 1.1/1.3):视图构建装配器——只做 new / add_child / 布局,
# 零信号连接、零核心写调用;返回句柄供组合根接线(ADR-0001:接线拓扑唯一归组合根)。
# 静态无状态。z 序契约:调用方必须按 build 返回序 addChild(登记序 = 模态遮挡序——缺陷⑤纪律)。
extends RefCounted
# 战斗屏五区(A 进度条 / B 战斗画面 / C·D·E 三列)。返回句柄字典:
# {strip_view, stage, log_view, info_panel, board_zone, board_view, ap_bar, inventory_panel}
static func build_five_zone(root: Control, db) -> Dictionary:
# set_anchors_and_offsets_preset(非 set_anchors_preset):后者会把控件重置回最小内容尺寸
# (实测 927×613——五区缩顶、棋盘 45px 格 ×10 行超出 D 区 333px 高被裁,2026-09-13 用户报告回归)
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.add_theme_constant_override("separation", 0)
# A 区:关卡进度条(全宽 × 28px 顶栏)
var strip_panel := PanelContainer.new()
strip_panel.custom_minimum_size = Vector2(0, 28)
var sb := StyleBoxFlat.new()
sb.bg_color = Color8(30, 25, 20)
sb.set_corner_radius_all(0)
strip_panel.add_theme_stylebox_override("panel", sb)
root.add_child(strip_panel)
var strip_view := LevelStripView.new()
strip_panel.add_child(strip_view)
# B 区:战斗画面(全宽 × 252px——常驻双舞台,布局决策 D1)
var stage := BattleStageView.new(db)
root.add_child(stage)
# C/D/E 三列(520px)
var bottom := HBoxContainer.new()
bottom.size_flags_vertical = Control.SIZE_EXPAND_FILL
bottom.add_theme_constant_override("separation", 4)
root.add_child(bottom)
var info_panel := HeroInfoPanelView.new()
bottom.add_child(info_panel)
var board_zone := BoardZoneView.new()
bottom.add_child(board_zone)
var inventory_panel := InventoryPanelView.new()
bottom.add_child(inventory_panel)
return {
"strip_view": strip_view,
"stage": stage,
"log_view": stage.log_view,
"info_panel": info_panel,
"board_zone": board_zone,
"board_view": board_zone.board_view,
"ap_bar": board_zone.ap_bar,
"inventory_panel": inventory_panel,
}
# 模态覆盖层组(z 序 = 登记序:图例 < 明细回看 < 节点 < 终局 < 调整板)。
# 返回 {legend_overlay, review_overlay, node_overlay, end_overlay, tune_panel};调用方按本序 addChild。
static func build_overlays(host: Control, db, base_atk: int, base_hp: int) -> Dictionary:
var legend_overlay := LegendOverlay.new()
host.add_child(legend_overlay)
var review_overlay := SettlementReviewOverlay.new()
host.add_child(review_overlay)
var node_overlay := NodeOverlay.new()
host.add_child(node_overlay)
var end_overlay := BattleEndOverlay.new()
host.add_child(end_overlay)
var tune_panel := TunePanel.new(db, base_atk, base_hp)
tune_panel.visible = false # 显式初始化——原版依赖"被不透明开始层遮蔽"的树序巧合(缺陷⑤家族);模态锁输入以 visible 为准
host.add_child(tune_panel)
return {
"legend_overlay": legend_overlay,
"review_overlay": review_overlay,
"node_overlay": node_overlay,
"end_overlay": end_overlay,
"tune_panel": tune_panel,
}
# 开始覆盖层(返回 {overlay, box, seed_edit, lvbtn, tunebtn, dbgbtn}——按钮接线归组合根;
# 单场遭遇用户入口已删(2026-09-13 用户裁决——单场原语仅探针经 debug_session.start_single 使用))
static func build_start_overlay(host: Control) -> Dictionary:
var overlay := overlay_bg(host, Color8(28, 22, 18))
var box := overlay_box(overlay)
var title := UiKit.label("Dungeon Sweeper", 34, true)
title.add_theme_color_override("font_color", Color8(240, 226, 200))
box.add_child(title)
var sub := UiKit.label("战斗屏五区改造批 —— 扫雷的因,逐行兑现的果", 16, false)
sub.add_theme_color_override("font_color", Color8(200, 188, 166))
box.add_child(sub)
box.add_child(HSeparator.new())
var seed_row := HBoxContainer.new()
box.add_child(seed_row)
seed_row.add_child(UiKit.label("随机种子:", 15, false))
var seed_edit := LineEdit.new()
seed_edit.text = "42"
seed_edit.custom_minimum_size = Vector2(120, 0)
seed_row.add_child(seed_edit)
var lvbtn := UiKit.button("▶ 第一关 · 苔藓回廊(战×2 → 道具 → 战×2 → 恢复 → Boss)")
box.add_child(lvbtn)
var tunebtn := UiKit.button("测试用 · 角色数值调整板(模拟天赋树属性提升)")
box.add_child(tunebtn)
var dbgbtn := UiKit.button("调试 · 连续关卡两层(库存与 HP 关内延续)")
box.add_child(dbgbtn)
var help := UiKit.label("玩法:每轮 8 AP——挖开(1AP)/插旗(1AP)/移动(免费)/连锁(免费)。\n安全格=拾取战利品(轮末结算生效)· 雷=敌方获得伏击 · 标中旗=削弱敌人。\n停手:剩余 AP 转存 RAP;弃图刷新:RAP−8 当场换图继续挖(每轮剩 2 次)。\n敌人全灭胜、主角倒下负。悬停可看三态光标与可挖域微光。", 13, false)
help.add_theme_color_override("font_color", Color8(178, 166, 146))
box.add_child(help)
return {"overlay": overlay, "box": box, "seed_edit": seed_edit,
"lvbtn": lvbtn, "tunebtn": tunebtn, "dbgbtn": dbgbtn}
# 调试两层模式的层间过渡覆盖层(历史入口专用)。返回 {overlay, label, next_btn}——接线归组合根
static func build_inter_overlay(host: Control) -> Dictionary:
var overlay := overlay_bg(host, Color(0, 0, 0, 0.6))
var box := overlay_box(overlay)
var label := UiKit.label("", 24, true)
label.add_theme_color_override("font_color", Color8(240, 226, 200))
box.add_child(label)
var next_btn := UiKit.button("进入下一层")
box.add_child(next_btn)
overlay.visible = false
return {"overlay": overlay, "label": label, "next_btn": next_btn}
# Playtest 诊断按钮(调试变体——battle.md §States 允许;卡死转储)
static func build_diag_button(host: Control) -> Button:
var diag_btn := UiKit.button("诊断转储")
diag_btn.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
diag_btn.offset_left = 6.0
diag_btn.offset_top = -30.0
diag_btn.offset_right = 118.0
diag_btn.offset_bottom = -6.0
host.add_child(diag_btn)
return diag_btn
static func overlay_bg(host: Control, color: Color) -> ColorRect:
var dim := ColorRect.new()
dim.color = color
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
host.add_child(dim)
return dim
static func overlay_box(overlay: Control) -> VBoxContainer:
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
overlay.add_child(center)
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 12)
center.add_child(box)
return box
@@ -0,0 +1 @@
uid://c66hkrbcacd2s
Loaded 100 of 317 files, more files were not shown because too many files have changed in this diff. Show more