Unveränderter Ausgangsstand inklusive Masterprompt und Konzeptbildern unter assets/. .godot/ und docs/screenshots/ bleiben laut .gitignore unversioniert; letztere sind unter ../backups gesichert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
317 lines
16 KiB
GDScript
317 lines
16 KiB
GDScript
extends Node
|
|
## Phase 9 acceptance: ordinary paid commands, real input, both factions and modes.
|
|
const Unit = preload("res://scripts/combat/unit.gd")
|
|
const AI = preload("res://scripts/ai/simple_ai.gd")
|
|
var checks: int = 0
|
|
var failures: Array[String] = []
|
|
var matches: Array = []
|
|
var fixtures: Array[Node] = []
|
|
var main: Node3D
|
|
var pictures: bool = false
|
|
|
|
func check(value: bool, label: String) -> void:
|
|
checks += 1
|
|
if not value:
|
|
failures.append(label)
|
|
printerr("FACTION_FAIL: ", label)
|
|
|
|
func run(scene: Node3D) -> void:
|
|
main = scene
|
|
pictures = DisplayServer.get_name() != "headless"
|
|
WaveManager.set_physics_process(false)
|
|
check(main.world.has_node("VerdantRift/LivingRiver"),"New landscape and animated river instantiated")
|
|
for map_path: String in ["oak-albedo-4k", "oak-height-4k"]:
|
|
var texture: Texture2D = load("res://assets/materials/phase10/"+map_path+".png")
|
|
check(texture != null and texture.get_size() == Vector2(4096,4096),"Native 4K map included: "+map_path)
|
|
Soundscape.enabled = false
|
|
main.battle.render_enabled = pictures
|
|
_test_abilities()
|
|
for id: String in DataCatalog.UNITS:
|
|
if not id.begins_with("w"):
|
|
continue
|
|
var model: Node3D = AssetLibrary.instantiate_fighter(id)
|
|
check(model != null and model.has_method("pose"), id+" articulated model")
|
|
# Skeletal remakes are judged by form and motion; rigid HD models keep the 20k floor.
|
|
var poly_min: int = 14000 if model.has_method("update_combat") else 20000
|
|
check(AssetLibrary._models[id].triangles > poly_min and AssetLibrary._models[id].triangles <= 180000,id+" authored geometry within budget")
|
|
if model.has_method("update_combat"):
|
|
check(model.skeleton.find_bone("Thigh.L") >= 0 and model.skeleton.find_bone("Thigh.R") >= 0,id+" weighted leg bones")
|
|
else:
|
|
check(model.parts.has("LegL") and model.parts.has("LegR"),id+" independent roots")
|
|
model.pose(.4,true,.2,.2,0)
|
|
model.free()
|
|
await _test_interface()
|
|
if "--ui-only" not in OS.get_cmdline_user_args():
|
|
await _play("Wurzelbund gegen Glutpakt", "wurzelbund", "glutpakt", GameState.MODE_EXPEDITION, true)
|
|
await _play("Glutpakt gegen Wurzelbund", "glutpakt", "wurzelbund", GameState.MODE_EXPEDITION)
|
|
await _play("Wurzelbund Spiegel", "wurzelbund", "wurzelbund", GameState.MODE_EXPEDITION)
|
|
await _play("Wurzelbund Kurzpartie", "wurzelbund", "wurzelbund", GameState.MODE_SHORT)
|
|
var path: String = "user://phase10-verification.json"
|
|
var file := FileAccess.open(path, FileAccess.WRITE)
|
|
file.store_string(JSON.stringify({"checks":checks,"failures":failures,"matches":matches,"renderer":RenderingServer.get_current_rendering_method(),"engine":Engine.get_version_info().string},"\t"))
|
|
file.close()
|
|
print("FACTION_PASS: %d checks; %d failures; %s" % [checks,failures.size(),ProjectSettings.globalize_path(path)])
|
|
await Soundscape.quit_game(0 if failures.is_empty() else 1)
|
|
|
|
func fixture(id: String, uid: int, team: int = 0, attacker: bool = false) -> CombatUnit:
|
|
var unit := Unit.new()
|
|
var data: Dictionary = DataCatalog.get_unit(id) if DataCatalog.UNITS.has(id) else DataCatalog.ENEMIES[id].duplicate(true)
|
|
unit.setup(data,uid,team,attacker,Vector2i.ZERO,0,false)
|
|
fixtures.append(unit)
|
|
return unit
|
|
|
|
func _test_abilities() -> void:
|
|
for id: String in ["w03","w04"]:
|
|
var tank := fixture(id,1)
|
|
tank.combat_time = 5
|
|
tank.ability_component.step(tank,[tank],0)
|
|
check(tank.ability_component.ready_time == 5,id+" does not spend heal on full HP")
|
|
tank.hp -= 150
|
|
var before: float = tank.hp
|
|
tank.ability_component.step(tank,[tank],0)
|
|
check(is_equal_approx(tank.hp-before,100 if id == "w04" else 35),id+" self heal")
|
|
check(tank.ability_component.ready_time == 10,id+" five-second cooldown")
|
|
tank.ability_component.step(tank,[tank],0)
|
|
check(is_equal_approx(tank.hp-before,100 if id == "w04" else 35),id+" cannot heal twice in same instant")
|
|
for id: String in ["w09","w10"]:
|
|
var healer := fixture(id,10)
|
|
var near := fixture("w03",11)
|
|
var urgent := fixture("w03",12)
|
|
var third := fixture("w03",13)
|
|
var far := fixture("w03",14)
|
|
var dead := fixture("w03",15)
|
|
var rival := fixture("w03",16,1)
|
|
var foe := fixture("e02",17,0,true)
|
|
var army: Array = [healer,near,urgent,third,far,dead,rival,foe]
|
|
for ally in army:
|
|
ally.hp = ally.max_hp*.5
|
|
healer.hp = healer.max_hp
|
|
near.position.x = .5
|
|
urgent.position.x = 3.5
|
|
urgent.hp = 1
|
|
third.position.x = 2
|
|
far.position.x = 5
|
|
dead.alive = false
|
|
healer.combat_time = 4
|
|
var before: float = near.hp
|
|
healer.ability_component.step(healer,army,0)
|
|
check(urgent.hp == (81 if id == "w10" else 66),id+" prioritizes lowest HP ratio")
|
|
check(near.hp == before+(80 if id == "w10" else 0),id+" target limit")
|
|
check(far.hp == far.max_hp*.5 and rival.hp == rival.max_hp*.5 and foe.hp == foe.max_hp*.5 and not dead.alive,id+" respects range, lane, allegiance and death")
|
|
check(healer.ability_component.ready_time == 8,id+" four-second cooldown")
|
|
healer.combat_time = 8
|
|
urgent.apply_effect("rift_wound","healing_reduction",.30,4)
|
|
var hp: float = urgent.hp
|
|
healer.ability_component.step(healer,army,0)
|
|
check(is_equal_approx(urgent.hp-hp,(80 if id == "w10" else 65)*.70),id+" healing reduction applies")
|
|
for id: String in ["w01","w02"]:
|
|
var root := fixture(id,20)
|
|
var victim := fixture("e08",21,0,true)
|
|
root.ability_component.on_hit(root,victim,[root,victim],2)
|
|
check(victim.effects.is_empty(),id+" no early proc")
|
|
root.ability_component.on_hit(root,victim,[root,victim],3)
|
|
check(is_equal_approx(victim.movement_factor(),.85 if id == "w02" else .90),id+" boss movement slow is halved")
|
|
check(is_equal_approx(victim.attack_speed_factor(),.95 if id == "w02" else 1.0),id+" attack slow")
|
|
victim.advance_status(3)
|
|
check(victim.effects.is_empty(),id+" slow expires")
|
|
for id: String in ["w05","w06"]:
|
|
var archer := fixture(id,30)
|
|
var victim := fixture("e08",31,0,true)
|
|
victim.shield = 0
|
|
archer.ability_component.on_hit(archer,victim,[archer,victim],3)
|
|
check(victim.dots.is_empty(),id+" waits until fourth hit")
|
|
archer.ability_component.on_hit(archer,victim,[archer,victim],4)
|
|
victim.apply_dot("burn",4,3,0)
|
|
check(victim.dots.has("burn") and victim.dots.has("seed_burn"),id+" independent burn families")
|
|
victim.dots.erase("burn")
|
|
var hp: float = victim.hp
|
|
for i in 4:
|
|
victim.advance_status(1)
|
|
check(is_equal_approx(hp-victim.hp,(10 if id == "w06" else 3)*4*1.30),id+" four scaled damage ticks")
|
|
check(victim.dots.is_empty(),id+" DOT expires")
|
|
for id: String in ["w07","w08"]:
|
|
var spore := fixture(id,40)
|
|
var primary := fixture("e02",41,0,true)
|
|
var army: Array = [spore,primary]
|
|
var targets: Array = []
|
|
for i in 6:
|
|
var victim := fixture("e02",42+i,0,true)
|
|
victim.position.x = .1*(i+1)
|
|
targets.append(victim)
|
|
army.append(victim)
|
|
spore.apply_effect("warmth","damage",.3,4)
|
|
spore.ability_component.on_hit(spore,primary,army,3)
|
|
var maximum: int = 4 if id == "w08" else 3
|
|
for i in 6:
|
|
var damage: float = float(spore.data.damage)*(.8 if id == "w08" else .6) if i < maximum else 0
|
|
check(is_equal_approx(targets[i].max_hp-targets[i].hp,damage),id+" splash priority and unbuffed damage %d" % i)
|
|
check(primary.hp == primary.max_hp,id+" primary excluded from extra splash")
|
|
var archer := fixture("w05",80)
|
|
var a := fixture("e02",81,0,true)
|
|
var b := fixture("e02",82,0,true)
|
|
archer.target = a
|
|
for i in 3: archer.attack_component.step(archer,[archer,a,b],1)
|
|
archer.target = b
|
|
archer.attack_component.step(archer,[archer,a,b],1)
|
|
check(archer.attack_component.hit_count == 4 and b.dots.has("seed_burn"),"Target changes retain hit counters")
|
|
for unit: Node in fixtures: unit.free()
|
|
fixtures.clear()
|
|
|
|
func _test_interface() -> void:
|
|
GameState.reset_match()
|
|
GameState.factions = ["wurzelbund","glutpakt"]
|
|
GameState.timed_build = false
|
|
GameState.select_mode(GameState.MODE_EXPEDITION)
|
|
main.hud._faction_pickers[0].select(1)
|
|
main.hud._faction_pickers[1].select(0)
|
|
main.hud._refresh()
|
|
await capture("menu")
|
|
main.hud._overlay_action.pressed.emit()
|
|
check(GameState.phase == GameState.Phase.BUILD,"Menu starts selected match")
|
|
WaveManager.advance(120)
|
|
check(GameState.phase == GameState.Phase.BUILD,"Untimed building does not start unexpectedly")
|
|
main.hud.request_wave_start()
|
|
check(GameState.phase == GameState.Phase.BUILD,"Empty formation gets build guidance")
|
|
check(not GameState.select_faction(0,"glutpakt"),"Faction locked during active match")
|
|
var before: Dictionary = EconomyManager.get_team(0)
|
|
check(not EconomyManager.apply_command(0,{"type":"build","type_id":"g01","cell":Vector2i.ZERO}).ok,"Foreign faction purchase rejected")
|
|
check(EconomyManager.get_team(0) == before,"Rejected order preserves complete economy")
|
|
main.hud._worker_button.pressed.emit()
|
|
check(EconomyManager.get_team(0).pending_workers == 1 and EconomyManager.get_team(0).workers == 1,"Worker waits until next build phase")
|
|
check(EconomyManager.get_team(0).sparks == 10,"Worker cannot produce on purchase")
|
|
main.hud._undo.pressed.emit()
|
|
check(EconomyManager.get_team(0) == before,"Undo cancels worker and refunds full price")
|
|
main.hud._starter_button.pressed.emit()
|
|
check(EconomyManager.get_team(0).gold == 35 and EconomyManager.get_team(0).roster.size() == 3,"Wurzelbund starter costs exactly 185")
|
|
check(main.hud._shop_buttons.has("w01") and not main.hud._shop_buttons.has("g01"),"Shop follows selected faction")
|
|
await capture("build")
|
|
main.hud.base_requested.emit()
|
|
check(main._base_view and main.camera.size == 14,"Base button focuses workers and crystal")
|
|
await capture("base")
|
|
main.hud.base_requested.emit()
|
|
check(not main._base_view,"Base button returns to formation")
|
|
main.hud.request_wave_start()
|
|
check(GameState.phase == GameState.Phase.COMBAT,"Ready starts combat")
|
|
check(not main.hud._cards.visible and main.hud._shop_panel.offset_top == -84,"Combat collapses shop and frees board space")
|
|
for step: int in range(5401):
|
|
if GameState.phase != GameState.Phase.COMBAT: break
|
|
WaveManager.advance(1.0/60.0)
|
|
if step == 420: await capture("combat")
|
|
check(GameState.phase == GameState.Phase.RESULTS,"Real starter resolves first wave")
|
|
WaveManager.advance(60)
|
|
check(GameState.phase == GameState.Phase.RESULTS,"Untimed result waits for the player")
|
|
main.hud.request_wave_start()
|
|
check(GameState.wave == 2,"Next-wave button opens next build")
|
|
check(main.hud._cards.visible and main.hud._shop_panel.offset_top == -212,"Next build restores full shop")
|
|
var gold: int = EconomyManager.get_team(0).gold
|
|
main.hud._shop_buttons["w01"].pressed.emit()
|
|
check(is_instance_valid(main._ghost),"Build selection creates model preview")
|
|
var point: Vector2 = main.camera.unproject_position(Vector3(-14,0,3))
|
|
# Real input route: this cell must be in the visible play area, not beneath HUD.
|
|
await get_tree().process_frame
|
|
var move := InputEventMouseMotion.new()
|
|
move.position = point
|
|
get_viewport().push_input(move,true)
|
|
await get_tree().process_frame
|
|
var click := InputEventMouseButton.new()
|
|
click.button_index = MOUSE_BUTTON_LEFT
|
|
click.position = point
|
|
click.pressed = true
|
|
get_viewport().push_input(click,true)
|
|
await get_tree().process_frame
|
|
click.pressed = false
|
|
get_viewport().push_input(click,true)
|
|
check(EconomyManager.get_team(0).gold == gold-50,"Visible-cell mouse input builds with real gold")
|
|
check(main.build_type.is_empty() and main.selected_uid > 0,"Single placement selects newly built unit")
|
|
check(not is_instance_valid(main._ghost),"Placement clears ghost")
|
|
await capture("selection")
|
|
if pictures:
|
|
var clip: ScrollContainer = main.hud._upgrade.get_parent().get_parent()
|
|
check(clip.get_global_rect().encloses(main.hud._upgrade.get_global_rect()), "Upgrade action visible without scrolling")
|
|
main.hud._undo.pressed.emit()
|
|
check(EconomyManager.get_team(0).gold == gold,"Undo refunds placed unit")
|
|
main.hud._worker_button.pressed.emit()
|
|
var pending: int = EconomyManager.get_team(0).pending_workers
|
|
main.hud.request_wave_start()
|
|
for step: int in range(5401):
|
|
if GameState.phase != GameState.Phase.COMBAT: break
|
|
WaveManager.advance(1.0/60.0)
|
|
main.hud.request_wave_start()
|
|
check(pending == 1 and EconomyManager.get_team(0).workers == 2 and EconomyManager.get_team(0).pending_workers == 0,"Ordered worker activates once next wave")
|
|
check(EconomyManager.get_team(0).sparks == 40,"Two workers produce exactly twenty new sparks")
|
|
main.hud._send_picker.select(0)
|
|
main.hud._send_button.pressed.emit()
|
|
main.hud._refresh()
|
|
check(EconomyManager.reserved_sparks(0) == 20 and main.hud._sparks.text == "20","Sends and free-resource readout agree")
|
|
main.hud._cancel_send.pressed.emit()
|
|
check(EconomyManager.reserved_sparks(0) == 0,"Cancel releases reserved resources")
|
|
await capture("economy")
|
|
if pictures:
|
|
# Presentation-only stress fixture; the real economic state is untouched.
|
|
main.hud._refresh_send_queue({"send_queue":["s01","s02","s03","s04","s05","s01","s02","s03"]})
|
|
await get_tree().process_frame
|
|
await get_tree().process_frame
|
|
check(main.hud._queue_scroll.get_global_rect().end.y <= get_viewport().get_visible_rect().end.y, "Eight send chips stay inside the screen")
|
|
check(main.hud._queue_scroll.size.y < 50, "Send queue scrolls horizontally instead of growing over controls")
|
|
main.hud._refresh_send_queue(EconomyManager.get_team(0))
|
|
if pictures:
|
|
var original: Vector2i = get_window().size
|
|
get_window().size = Vector2i(1152,720)
|
|
await capture("minimum-window")
|
|
var rect := Rect2(Vector2.ZERO,get_viewport().get_visible_rect().size)
|
|
for control: Control in [main.hud._worker_button,main.hud._ready_button,main.hud._starter_button,main.hud._send_button,main.hud._shop_buttons.w09]:
|
|
check(rect.encloses(control.get_global_rect()),"Minimum window contains "+control.name)
|
|
get_window().size = original
|
|
GameState.reset_match()
|
|
GameState.factions = ["glutpakt","wurzelbund"]
|
|
GameState.timed_build = true
|
|
main._start_match()
|
|
main._starter()
|
|
check(EconomyManager.get_team(0).gold == 40,"Glutpakt starter keeps 180 gold price")
|
|
WaveManager.advance(45)
|
|
check(GameState.phase == GameState.Phase.COMBAT,"Optional timed mode still auto-starts")
|
|
main.hud.menu_requested.emit()
|
|
check(GameState.phase == GameState.Phase.MENU and EconomyManager.get_team(0).roster.is_empty(),"New game returns to faction choice and clears old match")
|
|
|
|
func _play(title: String, own: String, rival: String, mode: String, screenshots: bool = false) -> void:
|
|
GameState.reset_match()
|
|
GameState.factions = [own,rival]
|
|
GameState.select_mode(mode)
|
|
GameState.timed_build = false
|
|
GameState.ai_level = GameState.AI_STANDARD
|
|
main.battle.render_enabled = pictures and screenshots
|
|
WaveManager.start_match()
|
|
var ai := AI.new()
|
|
var waves: Array = []
|
|
for number: int in range(1,DataCatalog.wave_count()+1):
|
|
check(GameState.wave == number and GameState.phase == GameState.Phase.BUILD,title+" build %d" % number)
|
|
ai.plan(0,number,WaveManager.rival_snapshot(),GameState.last_reports[0],GameState.AI_STANDARD)
|
|
for team: int in range(2):
|
|
var state: Dictionary = EconomyManager.get_team(team)
|
|
check(state.gold >= 0 and EconomyManager.reserved_sparks(team) <= mini(state.sparks,EconomyManager.send_budget(number)),title+" paid commands %d/%d" % [number,team])
|
|
for entry: Dictionary in state.roster:
|
|
check(str(entry.type_id).begins_with("w" if GameState.factions[team] == "wurzelbund" else "g"),title+" roster ownership")
|
|
WaveManager.ready_for_battle()
|
|
for step: int in range(5401):
|
|
if GameState.phase != GameState.Phase.COMBAT: break
|
|
WaveManager.advance(1.0/60.0)
|
|
if screenshots and step == 360 and number in [5,10,15,20]: await capture("wave-%02d" % number)
|
|
check(GameState.phase in [GameState.Phase.RESULTS,GameState.Phase.GAME_OVER],title+" resolves %d" % number)
|
|
waves.append({"wave":number,"core_hp":GameState.core_hp.duplicate(),"seconds":main.battle.elapsed,"leaks":main.battle.leaks.duplicate(),"player_gold":EconomyManager.get_team(0).gold,"roster":EconomyManager.get_team(0).roster})
|
|
if GameState.phase == GameState.Phase.GAME_OVER: break
|
|
WaveManager.ready_for_battle()
|
|
check(GameState.phase == GameState.Phase.GAME_OVER,title+" concludes")
|
|
if own == rival:
|
|
check(GameState.outcome == GameState.DRAW,title+" symmetric outcome")
|
|
check(GameState.wave == DataCatalog.wave_count(),title+" reaches finale with ordinary resources")
|
|
matches.append({"title":title,"outcome":GameState.outcome,"waves":waves})
|
|
print("FACTION_MATCH: ",title," wave ",GameState.wave," cores ",GameState.core_hp)
|
|
|
|
func capture(label: String) -> void:
|
|
if not pictures: return
|
|
await get_tree().process_frame
|
|
main.hud._refresh()
|
|
await get_tree().create_timer(.22).timeout
|
|
await RenderingServer.frame_post_draw
|
|
DirAccess.make_dir_recursive_absolute("user://phase10-captures")
|
|
check(get_viewport().get_texture().get_image().save_png("user://phase10-captures/"+label+".png") == OK,"capture "+label)
|