Files
ElHombreAlex--FTL-Faster-Th…/tools/test_combat.gd
T

261 lines
18 KiB
GDScript

extends SceneTree
var failures := 0
func _initialize() -> void:
call_deferred("_run")
func _check(condition: bool, message: String) -> void:
if not condition:
failures += 1
push_error(message)
func _run() -> void:
var scene: Node3D = load("res://main.tscn").instantiate()
root.add_child(scene)
await process_frame
scene.set_process(false)
var player: Node3D = scene.player_ship
var enemy: Node3D = scene.enemy_ship
var effects: Node3D = scene.combat_effects
player.set_process(false)
enemy.set_process(false)
effects.set_process(false)
_check(player.global_basis.x.normalized().dot(Vector3.RIGHT) > 0.99, "Player bow must face enemy")
_check((-enemy.global_basis.z).normalized().dot(Vector3.LEFT) > 0.99, "Enemy bow must face player")
_check(scene.hud_surface.get_parent() == scene, "Gameplay HUD must support spatial placement without inheriting head motion")
for ship in [player, enemy]:
_check(ship.weapon_origin(0).is_equal_approx(ship.to_global(ship.mount_points[0])), "Mount must use the ship world transform")
_check(ship.room_target(0).is_equal_approx(ship.to_global(ship.room_points[0])), "Target room must use the ship world transform")
ship.set_shields(0)
_check(not ship.shield_shell.visible, "Empty shields must disappear")
ship.set_shields(0, 5)
_check(ship.shield_shell.visible, "Super shields must remain visible")
ship.set_shields(2)
var from: Vector3 = player.weapon_origin(0)
var target: Vector3 = enemy.room_target(0)
var hit: Vector3 = enemy.shield_intercept(from, target)
var normalized: Vector3 = (enemy.to_local(hit) - enemy.shield_center) / enemy.shield_radii
_check(absf(normalized.length() - 1.0) < 0.001, "Shield hit must lie on the rotated ellipsoid")
_check(from.distance_to(hit) < from.distance_to(target), "Shield must intercept before the target room")
for source in ["player", "enemy"]:
var sender: Node3D = player if source == "player" else enemy
var receiver: Node3D = enemy if source == "player" else player
scene._present_shot({"source": source, "kind": "laser", "target_room": 0, "outcome": "hull", "duration": 1.0})
var shot: Dictionary = effects.shots.back()
_check(Vector3(shot["start"]).is_equal_approx(sender.weapon_origin(0)), "Shot must originate at sender weapon")
_check(Vector3(shot["end"]).is_equal_approx(receiver.room_target(0)), "Shot must terminate at receiver room")
var node: MeshInstance3D = shot["node"]
effects._process(0.5)
_check(node.global_position.is_equal_approx(Vector3(shot["start"]).lerp(shot["end"], 0.5)), "Projectile must travel between ships")
effects.simulation_paused = true
var paused_position := node.global_position
effects._process(0.4)
_check(node.global_position.is_equal_approx(paused_position), "Pause must freeze projectiles")
effects.simulation_paused = false
effects._process(0.6)
_check(effects.shots.is_empty(), "Finished projectile must be removed")
_check(not effects.impacts.is_empty(), "Hull hit must produce an impact")
effects.simulation_paused = true
var flash: MeshInstance3D = effects.impacts.back()["node"]
var flash_scale := flash.scale
effects._process(0.4)
_check(flash.scale == flash_scale, "Pause must freeze impact flashes")
effects.simulation_paused = false
effects._process(0.3)
var impact_count: int = effects.impacts.size()
scene._present_shot({"source": "player", "kind": "laser", "outcome": "miss", "duration": 0.1})
effects._process(0.2)
_check(effects.impacts.size() == impact_count, "Miss must not produce a hull impact")
scene._present_shot({"source": "enemy", "kind": "bomb", "outcome": "hull", "duration": 1.0})
_check(not effects.shots.back()["node"].visible, "Bomb must teleport rather than fly through space")
effects._process(1.1)
scene._present_shot({"source": "player", "kind": "beam", "outcome": "shield", "target_room": 0, "end_room": 4, "duration": 1.0})
effects._process(0.5)
var beam: Dictionary = effects.shots.back()
var mesh: ImmediateMesh = beam["node"].mesh
var vertices: PackedVector3Array = mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX]
_check(vertices[0].is_equal_approx(effects.to_local(player.weapon_origin(0))), "Beam must connect to sender mount")
var core: MeshInstance3D = beam["node"].get_node("BeamCore")
var swept_endpoint: Vector3 = Vector3(beam["end"]).lerp(beam["beam_end"], 0.5)
_check(core.global_position.is_equal_approx((player.weapon_origin(0) + swept_endpoint) * 0.5), "Visible beam cylinder must follow its exact native sweep line")
_check(absf(core.global_basis.y.length() - player.weapon_origin(0).distance_to(swept_endpoint)) < 0.001, "Beam transform must preserve its emitter and target positions")
_check(core.mesh == beam["node"].get_node("BeamGlow").mesh and core.mesh.height == 1.0, "Beam layers must reuse immutable unit geometry")
var mesh_changes := {"count": 0}
core.mesh.changed.connect(func(): mesh_changes["count"] += 1)
effects._process(0.1)
_check(mesh_changes["count"] == 0, "A live sweep must not rebuild shared beam geometry")
var endpoint: Vector3 = beam["beam_end"]
_check(absf(((enemy.to_local(endpoint) - enemy.shield_center) / enemy.shield_radii).length() - 1.0) < 0.001, "Blocked beam must stay on shield")
effects._process(0.6)
_check(enemy.shield_flash > 0.0, "Shield impact must flash")
effects._process(0.3)
scene._present_shot({"source": "player", "kind": "laser", "outcome": "pending", "projectile_id": "live-miss", "target_room": 0})
effects.update_live_projectiles([{"id": "live-miss", "progress": 0.7, "missed": true}])
var miss_count: int = effects.misses.size()
_check(miss_count > 0 and effects.misses.back()["node"].text == "MISS", "A native evasion must visibly say MISS without an impact")
effects.update_live_projectiles([{"id": "live-miss", "progress": 0.7, "missed": true}])
effects.resolve_live({"projectile_id": "live-miss", "outcome": "miss", "target": {"x": 80, "y": 170}}, enemy)
_check(effects.misses.size() == miss_count and effects.shots.size() == 1, "Miss snapshots and native outcomes must produce one cue and preserve a still-live passing projectile")
effects._process(0.1)
_check(Vector3(effects.shots.back()["end"]).distance_to(enemy.room_target(0)) > 0.4, "Native miss trajectory must survive endpoint updates")
_check(effects.impacts.is_empty() and enemy.shield_flash > 0.0, "A miss must not add a hull impact or reset an existing shield state")
effects.simulation_paused = true
var miss_age: float = effects.misses.back()["time"]
effects._process(0.5)
_check(effects.misses.back()["time"] == miss_age, "Native pause must freeze evasion feedback")
effects.simulation_paused = false
effects.update_live_projectiles([])
effects._process(0.3)
effects.update_live_projectiles([])
effects._process(1.1)
_check(effects.misses.is_empty(), "Evasion feedback must clear after its short presentation lifetime")
effects.resolve_live({"projectile_id": "already-dead-miss", "outcome": "miss", "target": {"x": 80, "y": 170}}, enemy)
_check(effects.misses.size() == 1 and effects.impacts.is_empty(), "Native misses that vanish between snapshots must still display feedback without invented damage")
effects.resolve_live({"projectile_id": "actual-hull", "outcome": "hull", "target": {"x": 80, "y": 170}}, enemy)
effects.resolve_live({"projectile_id": "actual-shield", "outcome": "shield", "target": {"x": 80, "y": 170}}, enemy)
_check(effects.impacts[-2]["outcome"] == "hull" and effects.impacts[-1]["outcome"] == "shield", "Hull and shield feedback must retain distinct actual native outcomes")
_check(effects.impacts[-2]["node"].material_override.albedo_color != effects.impacts[-1]["node"].material_override.albedo_color, "Hull and shield flashes must use visibly different colors")
effects._process(1.1)
# A projectile can fire, miss, and die between native snapshots. Its real
# outcome must end the unseen flight immediately, even while paused.
effects.simulation_paused = true
effects.spawn_shot({"kind": "laser", "outcome": "pending", "projectile_id": "gap-miss"}, player, enemy)
effects.resolve_live({"projectile_id": "gap-miss", "outcome": "miss", "target": {"x": 80, "y": 170}}, enemy)
effects.update_live_projectiles([])
_check(effects.shots.is_empty() and effects.misses.size() == 1 and effects.impacts.is_empty(), "Native missed fire absent from its first snapshot must clear immediately with one MISS cue and no invented hit")
effects.spawn_shot({"kind": "laser", "outcome": "pending", "projectile_id": "fresh-live-miss"}, player, enemy)
effects.resolve_live({"projectile_id": "fresh-live-miss", "outcome": "miss", "target": {"x": 80, "y": 170}}, enemy)
effects.update_live_projectiles([{"id": "fresh-live-miss", "progress": 0.75, "missed": true}])
_check(effects.shots.size() == 1 and effects.shots[0]["projectile_id"] == "fresh-live-miss", "A native miss before its first snapshot must preserve the projectile when the same snapshot still reports it alive")
effects.update_live_projectiles([])
effects.simulation_paused = false
effects._process(1.1)
scene._present_shot({"source": "player", "kind": "beam", "outcome": "pending", "projectile_id": "live-beam", "target_room": 0, "end_point": {"x": 100, "y": 180}})
var beam_point := {"x": 80, "y": 170}
effects.update_live_projectiles([{"id": "live-beam", "progress": 0.65, "beam_point": beam_point}])
effects._process(0.1)
var live_beam: Dictionary = effects.shots.back()
var live_mesh: ImmediateMesh = live_beam["node"].mesh
var live_vertices: PackedVector3Array = live_mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX]
_check(live_vertices[1].is_equal_approx(effects.to_local(enemy.to_global(enemy.pixel_point(beam_point)))), "Live beam must follow FTL's current sweep position")
effects.resolve_live({"projectile_id": "live-beam", "outcome": "beam", "target": beam_point}, enemy)
_check(effects.shots.size() == 1, "First beam room hit must not delete a continuing sweep")
effects.update_live_projectiles([])
effects._process(0.4)
effects.update_live_projectiles([])
for kind in ["laser", "missile", "ion", "asteroid"]:
effects.spawn_shot({"source": "player", "kind": kind, "outcome": "hull", "duration": 1.0, "target_room": 0}, player, enemy)
var refined: Dictionary = effects.shots.back()
effects._process(0.5)
var refined_node: MeshInstance3D = refined["node"]
var direction: Vector3 = Vector3(refined["end"]) - Vector3(refined["start"])
_check((-refined_node.global_basis.z).normalized().dot(direction.normalized()) > 0.999, "Projectile model must point from sender toward receiver")
_check(refined_node.global_position.is_equal_approx(Vector3(refined["start"]).lerp(refined["end"], 0.5)), "Refined geometry must preserve authoritative travel timing")
effects._process(0.6)
# A combat drone can be owned by the player while physically orbiting the
# enemy. A defense drone can instead target a missile in its own space.
var drone_origin := {"x": 90, "y": 130}
var drone_target := {"x": 170, "y": 120}
effects.spawn_shot({"kind": "laser", "outcome": "pending", "projectile_id": "defense",
"origin_point": drone_origin, "origin_space": 1, "target": drone_target,
"target_space": 1}, player, enemy)
effects.update_live_projectiles([{"id": "defense", "progress": 0.4}])
effects._process(0.1)
var defense: Dictionary = effects.shots.back()
_check(Vector3(defense["start"]).is_equal_approx(enemy.to_global(enemy.pixel_point(drone_origin))), "Boarding drone fire must originate in its current native space")
_check(Vector3(defense["end"]).is_equal_approx(enemy.to_global(enemy.pixel_point(drone_target))), "Defense drone fire must target the actual native space and point")
# A paused native projectile remains frozen even beyond the presentation
# grace. A stale fire record must expire despite simulation time standing still.
effects.simulation_paused = true
var paused_native_node: MeshInstance3D = defense["node"]
var paused_native_position := paused_native_node.global_position
var paused_native_time: float = defense["time"]
var paused_impact_count: int = effects.impacts.size()
effects.spawn_shot({"kind": "laser", "outcome": "pending", "projectile_id": "paused-phantom"}, player, enemy)
effects.update_live_projectiles([{"id": "defense", "progress": 0.4}])
_check(effects.shots.size() == 2, "A newly received fire record must retain a short snapshot grace")
var grace_start_ms := Time.get_ticks_msec()
while Time.get_ticks_msec() - grace_start_ms < 360:
await process_frame
effects._process(1.0)
effects.update_live_projectiles([{"id": "defense", "progress": 0.4}])
_check(effects.shots.size() == 1 and effects.shots[0]["projectile_id"] == "defense", "Paused never-observed fire must expire using monotonic presentation time")
_check(paused_native_node.global_position == paused_native_position and defense["time"] == paused_native_time, "A genuinely active paused projectile must remain frozen beyond the grace")
effects.update_live_projectiles([])
_check(effects.shots.is_empty(), "A previously observed projectile must disappear immediately when native state removes it during pause")
_check(effects.impacts.size() == paused_impact_count, "Stale projectile cleanup must not invent a hit")
effects.spawn_shot({"kind": "beam", "outcome": "pending", "projectile_id": "paused-beam"}, player, enemy)
effects.update_live_projectiles([{"id": "paused-beam", "progress": 0.4}])
effects.resolve_live({"projectile_id": "paused-beam", "outcome": "beam", "target": beam_point}, enemy)
_check(effects.shots.size() == 1, "A paused beam tile hit must retain the native continuing sweep")
effects.update_live_projectiles([])
effects.simulation_paused = false
effects._process(0.3)
# A player-owned drone is physically modeled in the receiver's native space.
# Attach a fire record to that exact muzzle before testing orbital motion and
# independent pause versus encounter transforms.
enemy._apply_drones([{"id": "88001", "name": "COMBAT_2", "kind": "combat", "is_space": true,
"powered": true, "deployed": true, "x": 90, "y": 130, "angle": 35.0}])
var actual_drone: Node3D = enemy.drone_nodes["88001"]
var native_drone_event := {"kind": "laser", "outcome": "pending", "projectile_id": "drone-native",
"drone_id": "88001", "origin_space": 1, "origin_point": drone_origin,
"target_space": 1, "target": drone_target}
var muzzle: Vector3 = actual_drone.muzzle_position("drone-native")
effects.spawn_shot(native_drone_event, player, enemy)
effects.update_live_projectiles([{"id": "drone-native", "progress": 0.25}])
effects._process(0.1)
var drone_shot: Dictionary = effects.shots.back()
_check(Vector3(drone_shot["start"]).is_equal_approx(muzzle), "Native drone fire must originate at the visible muzzle in its current render space")
_check(drone_shot["node"].mesh.get_aabb().size.z < 0.1, "Drone bullets must remain sized for their small native emitters")
actual_drone.position += Vector3(0.2, 0, 0.1)
effects._process(0.1)
_check(Vector3(drone_shot["start"]).is_equal_approx(muzzle), "Later orbital drone movement must not move a bullet's original launch point")
var launch_local: Vector3 = enemy.to_local(muzzle)
var paused_progress: float = drone_shot["live_progress"]
effects.simulation_paused = true
enemy.position += Vector3(0.3, 0.1, -0.2)
enemy.rotation += Vector3(0.15, 0.2, 0.08)
enemy.scale = Vector3.ONE * 0.7
effects._process(0.5)
_check(Vector3(drone_shot["start"]).is_equal_approx(enemy.to_global(launch_local)), "Paused drone shots must follow encounter position, tilt and scale without advancing their native flight")
_check(is_equal_approx(drone_shot["live_progress"], paused_progress), "Repositioning a paused encounter must preserve native projectile progress")
_check(is_equal_approx(drone_shot["node"].global_basis.x.length(), enemy.global_basis.x.length()), "An orbiting drone's projectile size must follow its physical render space rather than its owner ship")
effects.update_live_projectiles([])
effects.simulation_paused = false
var beam_event := native_drone_event.duplicate()
beam_event.merge({"kind": "beam", "projectile_id": "drone-beam", "end_point": {"x": 200, "y": 130}}, true)
effects.spawn_shot(beam_event, player, enemy)
effects.update_live_projectiles([{"id": "drone-beam", "progress": 0.5, "beam_point": drone_target}])
actual_drone.position += Vector3(0.1, 0, -0.05)
effects._process(0.1)
var drone_beam: Dictionary = effects.shots.back()
_check(Vector3(drone_beam["start"]).is_equal_approx(actual_drone.muzzle_position("drone-beam")), "A continuing native drone beam must remain attached to its actual visible emitter")
_check(drone_beam["node"].get_node_or_null("BeamCore") != null and drone_beam["node"].mesh is ImmediateMesh, "A native beam drone must render a sweep, never an invented laser bullet")
var drone_beam_core: MeshInstance3D = drone_beam["node"].get_node("BeamCore")
_check(drone_beam_core.global_position.is_equal_approx((drone_beam["start"] + enemy.to_global(enemy.pixel_point(drone_target))) * 0.5), "The native beam's exact live endpoint and muzzle must bound its visible geometry")
effects.update_live_projectiles([{"id": "drone-beam", "progress": 1.0, "beam_point": drone_target}])
effects._process(0.1)
_check(effects.shots.size() == 1, "Native progress at one must not delete a still-present beam or invent its outcome")
effects.update_live_projectiles([])
var fallback_event := native_drone_event.duplicate()
fallback_event.merge({"drone_id": "-1", "projectile_id": "negative-native"}, true)
enemy.drone_nodes["-1"] = actual_drone
effects.spawn_shot(fallback_event, player, enemy)
_check(Vector3(effects.shots.back()["start"]).is_equal_approx(enemy.to_global(enemy.pixel_point(drone_origin, 0.3))), "An ambiguous negative native drone id must use its real native point at drone height")
enemy.drone_nodes.erase("-1")
effects.update_live_projectiles([{"id": "negative-native", "progress": 0.3}])
effects.update_live_projectiles([])
var first_bullet: MeshInstance3D = effects._projectile_visual("laser", Color.ORANGE, true)
var second_bullet: MeshInstance3D = effects._projectile_visual("laser", Color.ORANGE, true)
_check(first_bullet.mesh == second_bullet.mesh and first_bullet.get_child(0).mesh == second_bullet.get_child(0).mesh, "Repeated drone shots must reuse both core and trail geometry")
first_bullet.free()
second_bullet.free()
print("COMBAT_TESTS ", "PASS" if failures == 0 else "FAIL: %d" % failures)
scene.queue_free()
await process_frame
quit(0 if failures == 0 else 1)