ajhahn.de
← Theria
GDScript 376 lines
extends GutTest
## Deterministic checks on the authoritative simulation core. These run headless
## and stay free of any engine/render coupling — they exercise the exact step
## function the live client and (later) the netcode drive.


func test_constant_input_advances_position_deterministically() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false  # isolate the movement assertion from the wave schedule
	var id := sim.add_entity(0, Vector2.ZERO, 300.0)
	var command := InputCommand.new()
	command.move_dir = Vector2.RIGHT
	var inputs := {id: command}
	for _i in SimCore.TICK_RATE:
		sim.step(inputs)
	var entity := sim.state.get_entity(id)
	# 60 ticks * (1/60 s) * 300 u/s = 300 units along +x.
	assert_almost_eq(entity.position.x, 300.0, 0.0001)
	assert_almost_eq(entity.position.y, 0.0, 0.0001)
	assert_eq(sim.state.tick, 60)


func test_identical_input_replays_identically() -> void:
	var a := _run_scripted(120)
	var b := _run_scripted(120)
	assert_eq(a, b, "the simulation must be a pure function of state + input")


func test_diagonal_input_is_not_faster() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var speed := 300.0
	var id := sim.add_entity(0, Vector2.ZERO, speed)
	var command := InputCommand.new()
	command.move_dir = Vector2.ONE  # length sqrt(2) -> must clamp to 1
	sim.step({id: command})
	var moved := sim.state.get_entity(id).position.length()
	assert_almost_eq(moved, speed * SimCore.TICK_DELTA, 0.0001)


func test_entity_without_command_holds_still() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var id := sim.add_entity(0, Vector2(10.0, -5.0), 300.0)
	sim.step({})
	assert_eq(sim.state.get_entity(id).position, Vector2(10.0, -5.0))


func _run_scripted(ticks: int) -> Vector2:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var id := sim.add_entity(0, Vector2.ZERO, 250.0)
	var command := InputCommand.new()
	for i in ticks:
		command.move_dir = Vector2(sin(float(i)), cos(float(i)))
		sim.step({id: command})
	return sim.state.get_entity(id).position


# --- Combat: towers, structures, and the win condition ----------------------


func test_structure_strikes_an_enemy_in_range() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	sim.add_structure(0, Vector2.ZERO, 1000, 50, 200.0, 60)
	var enemy := sim.add_entity(1, Vector2(100.0, 0.0), 0.0, 600)
	sim.step({})
	assert_eq(sim.state.get_entity(enemy).hp, 550, "an in-range enemy takes attack_damage")


func test_structure_ignores_an_enemy_out_of_range() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	sim.add_structure(0, Vector2.ZERO, 1000, 50, 200.0, 60)
	var enemy := sim.add_entity(1, Vector2(300.0, 0.0), 0.0, 600)
	sim.step({})
	assert_eq(sim.state.get_entity(enemy).hp, 600, "an out-of-range enemy is untouched")


func test_structure_does_not_strike_an_ally() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	sim.add_structure(0, Vector2.ZERO, 1000, 50, 200.0, 60)
	var ally := sim.add_entity(0, Vector2(100.0, 0.0), 0.0, 600)
	sim.step({})
	assert_eq(sim.state.get_entity(ally).hp, 600, "an attacker never hits its own team")


func test_attack_respects_its_cooldown() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	sim.add_structure(0, Vector2.ZERO, 1000, 50, 200.0, 60)
	var enemy := sim.add_entity(1, Vector2(100.0, 0.0), 0.0, 600)
	for _i in 60:
		sim.step({})
	assert_eq(sim.state.get_entity(enemy).hp, 550, "one hit lands across a full cooldown window")
	sim.step({})
	assert_eq(sim.state.get_entity(enemy).hp, 500, "the cooldown elapses next tick, second hit lands")


func test_an_entity_dies_when_its_hp_reaches_zero() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	sim.add_structure(0, Vector2.ZERO, 1000, 100, 200.0, 60)
	var enemy := sim.add_entity(1, Vector2(100.0, 0.0), 0.0, 100)
	sim.step({})
	assert_null(sim.state.get_entity(enemy), "an entity at 0 hp is removed from the world")


# --- Hero death & respawn ---------------------------------------------------


func test_a_slain_hero_is_downed_not_erased() -> void:
	# Unlike a creep, a dead hero is kept in the world and put on the respawn clock, so its id and
	# countdown persist for the client's death screen and the revive step.
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var hero := sim.add_hero(0, MapData.spawn_for_team(0), 320.0)
	sim.state.get_entity(hero).hp = 0
	sim.step({})
	var downed := sim.state.get_entity(hero)
	assert_not_null(downed, "a slain hero stays in the world rather than being erased")
	assert_true(downed.is_dead(), "the slain hero is marked dead")
	assert_eq(downed.respawn_ticks, SimCore.HERO_RESPAWN_TICKS, "its respawn clock is started")
	assert_eq(downed.hp, 0, "a downed hero sits at 0 hp")


func test_a_downed_hero_respawns_full_at_its_spawn_point() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var spawn := MapData.spawn_for_team(0)
	var hero := sim.add_hero(0, spawn, 320.0)
	# Walk the hero off its spawn so the respawn-in-place is observable, then kill it.
	sim.state.get_entity(hero).position = spawn + Vector2(500.0, 0.0)
	sim.state.get_entity(hero).hp = 0
	sim.step({})  # downs the hero, starting the HERO_RESPAWN_TICKS countdown
	for _i in SimCore.HERO_RESPAWN_TICKS - 1:
		sim.step({})
		assert_true(sim.state.get_entity(hero).is_dead(), "the hero stays down until the timer elapses")
	sim.step({})  # the tick the timer reaches 0
	var revived := sim.state.get_entity(hero)
	assert_false(revived.is_dead(), "the hero is alive once the timer elapses")
	assert_eq(revived.hp, SimCore.HERO_HP, "it returns at full health")
	assert_eq(revived.position, spawn, "it returns at its spawn point")


func test_a_downed_hero_is_inert_and_untargetable() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var tower := sim.add_structure(1, Vector2.ZERO, 1000, 100, 300.0, 60)
	var hero := sim.add_hero(0, Vector2(100.0, 0.0), 320.0)
	sim.state.get_entity(hero).hp = 0
	sim.step({})  # downs the hero
	assert_true(sim.state.get_entity(hero).is_dead())
	var down_pos := sim.state.get_entity(hero).position
	# Untargetable: the only enemy in the tower's range is the corpse, so it finds nothing to hit.
	assert_null(
		sim._nearest_enemy_in_range(sim.state.get_entity(tower)),
		"a downed hero is not a valid attack target",
	)
	# Inert: a move command on a downed hero is ignored — it holds where it fell.
	var command := InputCommand.new()
	command.move_dir = Vector2.RIGHT
	sim.step({hero: command})
	assert_eq(sim.state.get_entity(hero).position, down_pos, "a downed hero does not move")


func test_nexus_destruction_sets_the_winner_and_freezes_the_match() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	sim.add_structure(0, Vector2.ZERO, 100, 0, 0.0, 0, true)  # team 0 nexus
	# A team 1 attacker in range (a stand-in for the creeps that arrive next).
	sim.add_structure(1, Vector2(100.0, 0.0), 1000, 100, 200.0, 60)
	sim.step({})
	assert_true(sim.state.is_match_over(), "a destroyed nexus ends the match")
	assert_eq(sim.state.winner, 1, "the other team wins")
	var frozen_tick := sim.state.tick
	sim.step({})
	assert_eq(sim.state.tick, frozen_tick, "the simulation no-ops once the match is over")


func test_spawn_structures_is_mirror_fair() -> void:
	var sim := SimCore.new()
	sim.spawn_structures()
	# Every team 0 structure must have a team 1 structure at the axially mirrored position
	# with the same role and health, so neither side starts ahead.
	for id in sim.state.entities:
		var s: SimEntity = sim.state.entities[id]
		if s.team != 0:
			continue
		var mirror := _structure_at(sim.state, 1, MapData.mirror(s.position))
		assert_not_null(mirror, "team 0's structure must have a mirrored team 1 counterpart")
		if mirror != null:
			assert_eq(mirror.is_nexus, s.is_nexus, "the mirrored structure must share its role")
			assert_eq(mirror.max_hp, s.max_hp, "the mirrored structure must share its health")


func test_spawn_structures_gives_each_team_a_nexus_and_four_towers() -> void:
	# A team's defences: one destructible nexus plus four towers — two ringing the nexus and
	# two forward down the lanes.
	var sim := SimCore.new()
	sim.spawn_structures()
	for team in MapData.NEXUS_POSITIONS.size():
		var nexuses := 0
		var towers := 0
		for id in sim.state.entities:
			var s: SimEntity = sim.state.entities[id]
			if not s.is_structure or s.team != team:
				continue
			if s.is_nexus:
				nexuses += 1
			else:
				towers += 1
		assert_eq(nexuses, 1, "a team has exactly one nexus")
		assert_eq(towers, 4, "a team fields four towers — two guarding the nexus, two forward")


func _structure_at(state: SimState, team: int, position: Vector2) -> SimEntity:
	for id in state.entities:
		var s: SimEntity = state.entities[id]
		if s.team == team and s.is_structure and s.position.is_equal_approx(position):
			return s
	return null


func test_a_combat_run_replays_identically() -> void:
	var a := _run_combat()
	var b := _run_combat()
	assert_eq(a, b, "combat must be a pure function of state + input")


func _run_combat() -> Array:
	var sim := SimCore.new()
	sim.spawn_structures()
	var hero := sim.add_hero(0, MapData.spawn_for_team(0), 320.0)
	var bot := sim.add_hero(1, MapData.spawn_for_team(1), 300.0)
	var march := InputCommand.new()
	march.move_dir = Vector2(1.0, -1.0)  # walk both units toward the enemy base
	for _i in 600:
		sim.step({hero: march, bot: march})
	return _snapshot(sim.state)


## A deterministic, comparable digest of the world: every surviving entity's id,
## hp, and rounded position, ordered by id.
func _snapshot(state: SimState) -> Array:
	var ids := state.entities.keys()
	ids.sort()
	var rows: Array = []
	for id in ids:
		var entity: SimEntity = state.entities[id]
		rows.append([id, entity.hp, entity.position.round()])
	return rows


# --- Creeps: lane marching, contact combat, and the wave schedule -----------


func test_a_creep_marches_its_lane_toward_the_enemy_nexus() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var path := MapData.lane_path(0, 0)
	var creep := sim.add_creep(0, 0, path[0])
	var start := sim.state.get_entity(creep).position
	for _i in SimCore.TICK_RATE:
		sim.step({})
	var here := sim.state.get_entity(creep).position
	assert_true(here.distance_to(start) > 0.0, "a creep with a clear lane keeps moving")
	assert_true(
		here.distance_to(path[1]) < start.distance_to(path[1]),
		"it advances toward its next waypoint",
	)


func test_a_creep_holds_position_to_fight_an_enemy_in_range() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var spawn := MapData.lane_path(0, 0)[0]
	var creep := sim.add_creep(0, 0, spawn)
	# An enemy parked just inside the creep's reach: the creep must stop to fight.
	var enemy := sim.add_entity(1, spawn + Vector2(SimCore.CREEP_RANGE - 10.0, 0.0), 0.0, 600)
	sim.step({})
	assert_eq(
		sim.state.get_entity(creep).position,
		spawn,
		"a creep with an enemy in range holds to fight",
	)
	assert_eq(
		sim.state.get_entity(enemy).hp,
		600 - SimCore.CREEP_DAMAGE,
		"and strikes it through the shared combat primitive",
	)


func test_an_unopposed_creep_destroys_the_enemy_nexus_and_wins() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	# A team-1 nexus weak enough to fall to two creep hits, and a lone team-0 creep
	# already in range — the win condition driven entirely by a creep.
	var nexus := sim.add_structure(1, Vector2.ZERO, SimCore.CREEP_DAMAGE * 2, 0, 0.0, 0, true)
	sim.add_creep(0, 0, Vector2(SimCore.CREEP_RANGE - 10.0, 0.0))
	for _i in SimCore.CREEP_COOLDOWN_TICKS + 2:
		sim.step({})
	assert_null(sim.state.get_entity(nexus), "the creep's strikes destroy the enemy nexus")
	assert_true(sim.state.is_match_over(), "felling the nexus ends the match")
	assert_eq(sim.state.winner, 0, "the creep's team wins")


func test_creep_waves_spawn_on_the_wave_schedule() -> void:
	var sim := SimCore.new()  # spawn_creeps defaults on
	var per_wave := SimCore.CREEP_PER_WAVE * MapData.lane_count() * MapData.NEXUS_POSITIONS.size()
	sim.step({})  # tick 0 -> the opening wave
	assert_eq(
		_count_creeps(sim.state),
		per_wave,
		"a full wave spawns for both teams on every lane at tick 0",
	)
	# Clear the wave so the two teams' creeps can't clash and confound the count,
	# leaving the schedule the only thing that adds creeps.
	for id in sim.state.entities.keys():
		if sim.state.entities[id].is_creep:
			sim.state.entities.erase(id)
	for _i in SimCore.CREEP_WAVE_INTERVAL_TICKS - 1:
		sim.step({})
	assert_eq(_count_creeps(sim.state), 0, "no wave spawns between intervals")
	sim.step({})  # the next interval boundary
	assert_eq(_count_creeps(sim.state), per_wave, "the next wave spawns on the interval")


func test_creep_waves_are_mirror_fair() -> void:
	var sim := SimCore.new()
	sim.step({})  # spawn and advance the opening waves one tick
	for id in sim.state.entities:
		var creep: SimEntity = sim.state.entities[id]
		if not creep.is_creep or creep.team != 0:
			continue
		assert_not_null(
			_creep_at(sim.state, 1, MapData.mirror(creep.position)),
			"every team-0 creep has a team-1 creep mirrored across the y = x axis",
		)


# --- Heroes: the player/bot combat unit -------------------------------------


func test_a_hero_strikes_an_enemy_in_range() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var hero := sim.add_hero(0, Vector2.ZERO, 320.0)
	var enemy := sim.add_entity(1, Vector2(SimCore.HERO_RANGE - 10.0, 0.0), 0.0, 600)
	sim.step({})
	assert_eq(
		sim.state.get_entity(enemy).hp,
		600 - SimCore.HERO_DAMAGE,
		"a hero auto-attacks an enemy in range through the shared combat primitive",
	)
	# A hero out-hits a creep: its damage exceeds a creep's, so it clears waves.
	assert_true(SimCore.HERO_DAMAGE > SimCore.CREEP_DAMAGE, "a hero out-damages a creep")


func _count_creeps(state: SimState) -> int:
	var n := 0
	for id in state.entities:
		if state.entities[id].is_creep:
			n += 1
	return n


func _creep_at(state: SimState, team: int, position: Vector2) -> SimEntity:
	for id in state.entities:
		var creep: SimEntity = state.entities[id]
		if creep.is_creep and creep.team == team and creep.position.is_equal_approx(position):
			return creep
	return null