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>
168 lines
8.5 KiB
GDScript
168 lines
8.5 KiB
GDScript
extends Node
|
|
## Acceptance against production rules, also runnable from the exported program.
|
|
const AI = preload("res://scripts/ai/simple_ai.gd")
|
|
const Unit = preload("res://scripts/combat/unit.gd")
|
|
var checks: int = 0
|
|
var failures: Array[String] = []
|
|
var matches: Array[Dictionary] = []
|
|
var main: Node3D
|
|
|
|
func check(value: bool, message: String) -> void:
|
|
checks += 1
|
|
if not value:
|
|
failures.append(message)
|
|
printerr("EXPEDITION_FAIL: ", message)
|
|
|
|
func capture(label: String) -> void:
|
|
if DisplayServer.get_name() == "headless":
|
|
return
|
|
await get_tree().process_frame
|
|
await get_tree().create_timer(.25).timeout
|
|
await RenderingServer.frame_post_draw
|
|
DirAccess.make_dir_recursive_absolute("user://phase8-captures")
|
|
check(get_viewport().get_texture().get_image().save_png("user://phase8-captures/"+label+".png") == OK, "Capture " + label)
|
|
|
|
func run(scene: Node3D) -> void:
|
|
main = scene
|
|
WaveManager.set_physics_process(false)
|
|
Soundscape.enabled = false
|
|
main.battle.render_enabled = DisplayServer.get_name() != "headless"
|
|
GameState.reset_match()
|
|
GameState.factions = ["glutpakt", "glutpakt"]
|
|
GameState.timed_build = true
|
|
main.hud._mode_picker.item_selected.emit(1)
|
|
check(DataCatalog.wave_count() == 8 and DataCatalog.get_wave(9).is_empty(), "Short match remains eight waves")
|
|
check(DataCatalog.get_wave(8).groups.has("e08"), "Short finale remains Riegelkoloss")
|
|
main.hud._mode_picker.item_selected.emit(0)
|
|
check(DataCatalog.wave_count() == 20 and "Zwanzig" in main.hud._overlay_title.text, "Menu selects the twenty-wave expedition")
|
|
check(DataCatalog.get_wave(0).is_empty() and DataCatalog.get_wave(21).is_empty(), "Wave bounds reject out-of-range requests")
|
|
for number: int in range(1,21):
|
|
var wave: Dictionary = DataCatalog.get_wave(number)
|
|
check(wave.count == DataCatalog.get_attackers(number).size() and not wave.hint.is_empty(), "Wave %d has composition and counter hint" % number)
|
|
if number % 5 == 0:
|
|
check(wave.groups.has("e%02d" % (6+number/5)), "Boss appears at wave %d" % number)
|
|
if number > 1:
|
|
var previous: Dictionary = DataCatalog.get_wave(number-1)
|
|
check(wave.hp_factor > previous.hp_factor and wave.damage_factor > previous.damage_factor, "Wave %d scales upward" % number)
|
|
for id: String in ["e05","e06","e09","e10"]:
|
|
var model: Node3D = AssetLibrary.instantiate_fighter(id)
|
|
check(model != null and model.has_method("pose"), id + " has its own articulated model")
|
|
if model != null:
|
|
check(AssetLibrary._models[id].triangles > 15000, id + " has modeled detail")
|
|
var baked: ArrayMesh = AssetLibrary.bake_mesh(model)
|
|
check(baked.get_aabb().size.y > 1.5, id + " has correct world scale")
|
|
model.pose(.2,true,.14,.3,0)
|
|
model.free()
|
|
_test_abilities()
|
|
await capture("menu")
|
|
await _play("Standard gegen Standard",GameState.AI_STANDARD,GameState.AI_STANDARD,GameState.DRAW,true)
|
|
await _play("Standard gegen Lernmodus",GameState.AI_STANDARD,GameState.AI_LEARNING,0)
|
|
await _play("Leeres Baufeld gegen Standard",-1,GameState.AI_STANDARD,1)
|
|
main.hud._mode_picker.item_selected.emit(1)
|
|
main.hud._overlay_action.pressed.emit()
|
|
check(GameState.wave == 1 and DataCatalog.wave_count() == 8 and GameState.core_hp == [3000,3000], "Restart after expedition can select a fresh short match")
|
|
check(EconomyManager.get_team(0).gold == 220 and EconomyManager.get_team(0).roster.is_empty(), "Restart clears all expedition purchases")
|
|
check(not GameState.select_mode(GameState.MODE_EXPEDITION) and DataCatalog.wave_count() == 8, "Match length cannot change during an active match")
|
|
var file := FileAccess.open("user://phase8-verification.json",FileAccess.WRITE)
|
|
file.store_string(JSON.stringify({"checks":checks,"failures":failures,"matches":matches,"engine":Engine.get_version_info().string},"\t"))
|
|
file.close()
|
|
print("EXPEDITION_PASS: %d checks; %d failures; report %s" % [checks,failures.size(),ProjectSettings.globalize_path("user://phase8-verification.json")])
|
|
await Soundscape.quit_game(0 if failures.is_empty() else 1)
|
|
|
|
func _play(title: String, player_level: int, rival_level: int, expected: int, pictures: bool = false) -> void:
|
|
GameState.reset_match()
|
|
GameState.select_mode(GameState.MODE_EXPEDITION)
|
|
GameState.ai_level = rival_level
|
|
WaveManager.start_match()
|
|
var ai := AI.new()
|
|
var waves: Array[Dictionary] = []
|
|
for number: int in range(1,21):
|
|
check(GameState.wave == number and GameState.phase == GameState.Phase.BUILD,title + " build %d" % number)
|
|
if player_level >= 0:
|
|
ai.plan(0,number,WaveManager.rival_snapshot(),GameState.last_reports[0],player_level)
|
|
var plan: Dictionary = EconomyManager.get_team(0)
|
|
check(plan.gold >= 0 and EconomyManager.reserved_sparks(0) <= mini(plan.sparks,EconomyManager.send_budget(number)),title + " paid purchases and sends %d" % number)
|
|
if number == 20:
|
|
var result: Dictionary = EconomyManager.apply_command(0,{"type":"worker"})
|
|
check(not result.ok and "letzten Welle" in result.message,"No worker investment in the final wave")
|
|
WaveManager.ready_for_battle()
|
|
for step: int in range(5401):
|
|
if GameState.phase != GameState.Phase.COMBAT:
|
|
break
|
|
WaveManager.advance(1.0/60.0)
|
|
if pictures and step == 280 and number in [9,15,20]:
|
|
await capture("wave-%02d" % number)
|
|
check(GameState.phase in [GameState.Phase.RESULTS,GameState.Phase.GAME_OVER],title + " settled %d" % number)
|
|
waves.append({"wave":number,"seconds":main.battle.elapsed,"core_hp":GameState.core_hp.duplicate(),"leaks":main.battle.leaks.duplicate(),"gold":plan.gold,"workers":plan.workers,"pending_workers":plan.pending_workers,"sends":plan.send_queue,"formation":_roster(plan.roster)})
|
|
if GameState.phase == GameState.Phase.GAME_OVER:
|
|
break
|
|
WaveManager.ready_for_battle()
|
|
check(GameState.outcome == expected,title + " expected outcome")
|
|
if player_level == rival_level:
|
|
check(GameState.wave == 20 and GameState.core_hp[0] > 0,"Symmetric match reaches and resolves the real final boss")
|
|
if pictures:
|
|
await capture("result")
|
|
matches.append({"title":title,"outcome":GameState.outcome,"waves":waves})
|
|
print("EXPEDITION_MATCH: ",title,"; wave ",GameState.wave,"; cores ",GameState.core_hp,"; outcome ",GameState.outcome)
|
|
|
|
func _roster(entries: Array) -> Array:
|
|
var result: Array = []
|
|
for entry: Dictionary in entries:
|
|
result.append({"id":entry.type_id,"uid":entry.uid,"cell":[entry.cell.x,entry.cell.y],"invested":entry.invested})
|
|
return result
|
|
|
|
func _unit(id: String, uid: int, lane: int = 0, attacker: bool = true) -> Node3D:
|
|
var unit := Unit.new()
|
|
add_child(unit)
|
|
var data: Dictionary = DataCatalog.ENEMIES[id].duplicate(true) if attacker else DataCatalog.get_unit(id)
|
|
unit.setup(data,uid,lane,attacker,Vector2i(-1,-1),0,false)
|
|
return unit
|
|
|
|
func _test_abilities() -> void:
|
|
var healer := _unit("e06",1)
|
|
var ally := _unit("e05",2)
|
|
var wrong_lane := _unit("e05",3,1)
|
|
ally.hp = 100
|
|
wrong_lane.hp = 10
|
|
healer.combat_time = 6
|
|
healer.data.hp_factor = 2.0
|
|
healer.ability_component.step(healer,[healer,ally,wrong_lane],0)
|
|
check(is_equal_approx(ally.hp,160) and is_equal_approx(wrong_lane.hp,10),"Knotenrufer heals 30 times wave factor in its own lane")
|
|
healer.ability_component.step(healer,[healer,ally],0)
|
|
check(is_equal_approx(ally.hp,160),"Healing observes its six-second cooldown")
|
|
for unit: Node3D in [healer,ally,wrong_lane]: unit.free()
|
|
var judge := _unit("e09",1)
|
|
var victims: Array = []
|
|
for i: int in range(5):
|
|
var unit := _unit("g03",10+i,0,false)
|
|
unit.position.x = .2*i
|
|
victims.append(unit)
|
|
judge.combat_time = 6
|
|
judge.ability_component.step(judge,[judge]+victims,0)
|
|
for i: int in range(5):
|
|
check(victims[i].effects.has("dissonance") == (i<3),"Maskenrichter slow target limit %d" % i)
|
|
victims[i].advance_status(3.0)
|
|
check(not victims[i].effects.has("dissonance"),"Maskenrichter slow expires %d" % i)
|
|
victims[i].free()
|
|
judge.free()
|
|
for id: String in ["e05","e10"]:
|
|
var source := _unit(id,1)
|
|
var primary := _unit("g03",10,0,false)
|
|
var extras: Array = []
|
|
for i: int in range(6):
|
|
var unit := _unit("g03",20+i,0,false)
|
|
unit.data.ability = ""
|
|
unit.data.armor_type = source.data.attack_type
|
|
unit.position.x = .1*(i+1)
|
|
extras.append(unit)
|
|
var hp: float = extras[0].hp
|
|
source.ability_component.on_hit(source,primary,[source,primary]+extras,3)
|
|
check(is_equal_approx(extras[0].hp,hp),id + " no splash before fourth hit")
|
|
source.ability_component.on_hit(source,primary,[source,primary]+extras,4)
|
|
for i: int in range(6):
|
|
var loss: float = source.data.damage*(.40 if id=="e05" else .60)*1.3 if i<(2 if id=="e05" else 4) else 0.0
|
|
check(is_equal_approx(extras[i].hp,hp-loss),id+" exact fourth-hit splash target %d"%i)
|
|
extras[i].free()
|
|
check(primary.hp == primary.max_hp,id+" splash excludes primary")
|
|
primary.free()
|
|
source.free()
|