ajhahn.de
← Theria
GDScript 283 lines
extends GutTest
## Deterministic checks on the hero ability layer — targeting, the per-form
## resource, cooldowns, and the shapeshift transform. These run headless against the
## exact step function the live client (later) drives, with creep waves off so only
## the ability under test changes the world.


func _hero(sim: SimCore, team: int, pos: Vector2) -> int:
	var id := sim.add_hero(team, pos, 320.0)
	sim.equip_kit(id, "wildkin")
	return id


## Equips a wildkin hero and transforms it to its animal form with a real Beast Form
## cast, so the animal-kit tests start from the form the transform actually produces.
func _animal_hero(sim: SimCore, team: int, pos: Vector2) -> int:
	var id := _hero(sim, team, pos)
	var beast := InputCommand.new()
	beast.ability_slot = 3  # human slot 3 = Beast Form
	sim.step({id: beast})
	return id


# --- Equip + form -----------------------------------------------------------


func test_equip_kit_makes_a_human_caster_with_a_full_pool() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var id := _hero(sim, 0, Vector2.ZERO)
	var h := sim.state.get_entity(id)
	assert_true(h.is_hero, "an equipped hero is an ability caster")
	assert_eq(h.form, AbilitySpec.FORM_HUMAN, "a freshly equipped hero starts human")
	assert_eq(h.resource, 100, "and with its human pool full")
	assert_eq(h.resource_max, 100)
	assert_eq(h.kit[AbilitySpec.FORM_HUMAN][0], 1, "Spirit Bolt sits in the human Q slot")


# --- Targeting: skillshot, ground, unit -------------------------------------


func test_skillshot_strikes_an_enemy_at_its_landing_point() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var id := _hero(sim, 0, Vector2.ZERO)
	# Range 600 along +x; the bolt flies the full range and clips an enemy there.
	var enemy := sim.add_entity(1, Vector2(600.0, 0.0), 0.0, 600)
	var cast := InputCommand.new()
	cast.ability_slot = 0
	cast.target_point = Vector2(100.0, 0.0)  # any point along +x: a skillshot flies through it
	sim.step({id: cast})
	assert_eq(sim.state.get_entity(enemy).hp, 520, "Spirit Bolt deals its power at the landing point")
	assert_eq(sim.state.get_entity(id).resource, 80, "and the cast spends its resource")


func test_skillshot_misses_when_aimed_away() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var id := _hero(sim, 0, Vector2.ZERO)
	var enemy := sim.add_entity(1, Vector2(600.0, 0.0), 0.0, 600)
	var cast := InputCommand.new()
	cast.ability_slot = 0
	cast.target_point = Vector2(0.0, 100.0)  # aimed up +y: lands at (0,600), nowhere near the enemy
	sim.step({id: cast})
	assert_eq(sim.state.get_entity(enemy).hp, 600, "a bolt aimed away does not hit")


func test_ground_area_strikes_every_enemy_in_its_radius() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var id := _animal_hero(sim, 0, Vector2.ZERO)
	# Pounce: GROUND, range 400, radius 150. Land it at (400,0).
	var inside := sim.add_entity(1, Vector2(400.0, 100.0), 0.0, 600)  # 100 from centre -> hit
	var outside := sim.add_entity(1, Vector2(400.0, 300.0), 0.0, 600)  # 300 from centre -> spared
	var cast := InputCommand.new()
	cast.ability_slot = 0  # animal Q = Pounce
	cast.target_point = Vector2(400.0, 0.0)
	sim.step({id: cast})
	assert_eq(sim.state.get_entity(inside).hp, 540, "an enemy inside the area is struck")
	assert_eq(sim.state.get_entity(outside).hp, 600, "an enemy outside the radius is spared")


func test_unit_ability_strikes_its_locked_target() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var id := _animal_hero(sim, 0, Vector2.ZERO)
	sim.state.get_entity(id).attack_damage = 0  # isolate Rend from the auto-attack (same range band)
	var enemy := sim.add_entity(1, Vector2(150.0, 0.0), 0.0, 600)  # inside Rend's 200 range
	var cast := InputCommand.new()
	cast.ability_slot = 2  # animal E = Rend
	cast.target_id = enemy
	sim.step({id: cast})
	assert_eq(sim.state.get_entity(enemy).hp, 480, "Rend deals its power to the locked target")


func test_unit_ability_whiffs_on_an_out_of_range_target() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var id := _animal_hero(sim, 0, Vector2.ZERO)
	sim.state.get_entity(id).attack_damage = 0
	var enemy := sim.add_entity(1, Vector2(300.0, 0.0), 0.0, 600)  # beyond Rend's 200 range
	var cast := InputCommand.new()
	cast.ability_slot = 2
	cast.target_id = enemy
	sim.step({id: cast})
	assert_eq(sim.state.get_entity(enemy).hp, 600, "an out-of-range unit cast lands no damage")
	assert_eq(sim.state.get_entity(id).resource, 70, "but the whiffed cast still books its cost")


# --- Unit target acquisition (the driver's cursor pick) ---------------------


func test_pick_unit_target_returns_the_nearest_enemy() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var near := sim.add_entity(1, Vector2(100.0, 0.0), 0.0, 600)
	sim.add_entity(1, Vector2(500.0, 0.0), 0.0, 600)
	var picked := AbilityExecutor.pick_unit_target(sim.state, 0, Vector2(120.0, 0.0))
	assert_eq(picked, near, "the enemy nearest the point is acquired")


func test_pick_unit_target_ignores_allies_and_empties() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	sim.add_entity(0, Vector2(100.0, 0.0), 0.0, 600)  # an ally near the point
	assert_eq(
		AbilityExecutor.pick_unit_target(sim.state, 0, Vector2(100.0, 0.0)),
		0,
		"no enemy in the world acquires nothing, never an ally",
	)


# --- Effects: heal, transform -----------------------------------------------


func test_self_heal_restores_hp_clamped_to_max() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var id := _hero(sim, 0, Vector2.ZERO)
	var h := sim.state.get_entity(id)
	h.hp = 550  # Mend heals 100; from 550 the clamp caps the result at max_hp, not 650
	var cast := InputCommand.new()
	cast.ability_slot = 1  # human W = Mend
	sim.step({id: cast})
	assert_eq(sim.state.get_entity(id).hp, 600, "a self-heal never overfills past max_hp")
	assert_eq(sim.state.get_entity(id).resource, 70, "Mend spends its cost")


func test_transform_swaps_form_and_keeps_cooldowns_running() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var id := _hero(sim, 0, Vector2.ZERO)
	# Put Spirit Bolt on cooldown, then transform.
	var bolt := InputCommand.new()
	bolt.ability_slot = 0
	bolt.target_point = Vector2(100.0, 0.0)
	sim.step({id: bolt})
	var beast := InputCommand.new()
	beast.ability_slot = 3  # human R = Beast Form
	sim.step({id: beast})
	var h := sim.state.get_entity(id)
	assert_eq(h.form, AbilitySpec.FORM_ANIMAL, "Beast Form swaps the hero to its animal form")
	assert_true(h.ability_cooldowns[1] > 0, "the human bolt cooldown keeps running across the swap")
	assert_eq(h.resource_max, 100, "the animal pool is active after the swap")


# --- Gates: form, resource, cooldown ----------------------------------------


func test_can_cast_gates_on_form_resource_and_cooldown() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var id := _hero(sim, 0, Vector2.ZERO)
	var h := sim.state.get_entity(id)
	var bolt := AbilityData.spec(1)  # human Spirit Bolt, cost 20
	var pounce := AbilityData.spec(4)  # animal Pounce
	assert_true(AbilityExecutor.can_cast(h, bolt), "a human, full, ready hero can cast in form")
	assert_false(AbilityExecutor.can_cast(h, pounce), "an animal ability is not castable while human")
	h.resource = 10
	assert_false(AbilityExecutor.can_cast(h, bolt), "a cast is refused without enough resource")
	h.resource = 100
	h.ability_cooldowns[1] = 5
	assert_false(AbilityExecutor.can_cast(h, bolt), "a cast is refused while on cooldown")


func test_cooldown_blocks_recast_until_it_elapses() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var id := _hero(sim, 0, Vector2.ZERO)
	var cast := InputCommand.new()
	cast.ability_slot = 0
	cast.target_point = Vector2(100.0, 0.0)
	sim.step({id: cast})  # cast 1: Spirit Bolt -> cooldown 30
	var h := sim.state.get_entity(id)
	assert_eq(h.ability_cooldowns[1], 30, "the ability enters its full cooldown when cast")
	sim.step({id: cast})  # a recast one tick later is refused
	assert_eq(h.ability_cooldowns[1], 29, "the cooldown ticks down and the recast is dropped")
	for _i in 29:
		sim.step({})
	assert_eq(h.ability_cooldowns[1], 0, "the cooldown reaches zero")
	sim.step({id: cast})  # now the recast lands
	assert_eq(h.ability_cooldowns[1], 30, "off cooldown, the recast lands and re-enters cooldown")


func test_resource_regenerates_on_its_interval() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var id := _hero(sim, 0, Vector2.ZERO)
	var cast := InputCommand.new()
	cast.ability_slot = 0
	cast.target_point = Vector2(100.0, 0.0)
	sim.step({id: cast})  # spend 20 -> 80
	var h := sim.state.get_entity(id)
	assert_eq(h.resource, 80, "the cast leaves the pool down by its cost")
	for _i in 12:  # regen is one point every 12 ticks
		sim.step({})
	assert_eq(h.resource, 81, "one point regenerates after the interval")
	for _i in 12:
		sim.step({})
	assert_eq(h.resource, 82, "and another after the next interval")


# --- Inert without a kit + determinism --------------------------------------


func test_an_unequipped_hero_ignores_ability_intent() -> void:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var id := sim.add_hero(0, Vector2.ZERO, 320.0)  # no equip_kit
	var enemy := sim.add_entity(1, Vector2(600.0, 0.0), 0.0, 600)
	var cast := InputCommand.new()
	cast.ability_slot = 0
	cast.target_point = Vector2(100.0, 0.0)
	sim.step({id: cast})  # must not cast or crash
	assert_true(sim.state.get_entity(id).kit.is_empty(), "a bare hero carries no kit to cast from")
	assert_eq(sim.state.get_entity(enemy).hp, 600, "and its ability intent does nothing")


func test_an_ability_run_replays_identically() -> void:
	var a := _run_abilities()
	var b := _run_abilities()
	assert_eq(a, b, "the ability layer must be a pure function of state + input")


## A scripted ability sequence over a fixed window: a hero casts a bolt, transforms,
## pounces, and idles, against two enemies. Returns a deterministic digest of the
## survivors so two runs can be compared field-for-field.
func _run_abilities() -> Array:
	var sim := SimCore.new()
	sim.spawn_creeps = false
	var id := _hero(sim, 0, Vector2.ZERO)
	sim.add_entity(1, Vector2(600.0, 0.0), 0.0, 600)
	sim.add_entity(1, Vector2(400.0, 80.0), 0.0, 600)
	var script: Array[InputCommand] = []
	for i in 90:
		var cmd := InputCommand.new()
		if i == 0:
			cmd.ability_slot = 0  # Spirit Bolt
			cmd.target_point = Vector2(100.0, 0.0)
		elif i == 5:
			cmd.ability_slot = 3  # Beast Form
		elif i == 10:
			cmd.ability_slot = 0  # Pounce (animal)
			cmd.target_point = Vector2(400.0, 0.0)
		else:
			cmd.ability_slot = -1
		script.append(cmd)
	for cmd in script:
		sim.step({id: cmd})
	return _digest(sim.state)


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