GDScript 233 lines
extends GutTest
## Deterministic checks on the lingering-status layer — the venom damage-over-time and
## the web movement slow that a striking ability leaves on what it hits. These run
## headless against the same step function the client drives, with creep waves off so
## only the status under test changes the world. The status tuning is built by value
## here so a test never leans on the catalog's balance numbers.
func _sim() -> SimCore:
var sim := SimCore.new()
sim.spawn_creeps = false
return sim
## A caster whose auto-attack is silenced, so only the cast status touches the target.
func _silent_caster(sim: SimCore) -> int:
var id := sim.add_hero(0, Vector2.ZERO, 320.0)
sim.state.get_entity(id).attack_damage = 0
return id
## A bare damaging UNIT spec that deals no instant hit and carries only the given
## status — so a test observes the lingering effect in isolation.
func _status_spec(status: int, power: int, duration: int, interval: int) -> AbilitySpec:
return AbilitySpec.from_dict(
{
"id": 9001,
"target_kind": AbilitySpec.TARGET_UNIT,
"range": 1000.0,
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 0,
"status": status,
"status_power": power,
"status_duration": duration,
"status_interval": interval,
}
)
## Casts `spec` from caster onto a locked unit target, straight through the executor.
func _cast_at(sim: SimCore, caster_id: int, spec: AbilitySpec, target_id: int) -> void:
var cmd := InputCommand.new()
cmd.target_id = target_id
AbilityExecutor.execute(sim.state, sim.state.get_entity(caster_id), spec, cmd)
# --- Venom: damage over time -----------------------------------------------
func test_dot_bites_each_interval_then_expires() -> void:
var sim := _sim()
var caster := _silent_caster(sim)
var enemy := sim.add_entity(1, Vector2(100.0, 0.0), 0.0, 600)
# 10 hp every 5 ticks for 20 ticks: bites at ticks 5, 10, 15, 20 -> 40 total.
_cast_at(sim, caster, _status_spec(AbilitySpec.STATUS_DOT, 10, 20, 5), enemy)
assert_eq(sim.state.get_entity(enemy).hp, 600, "the zero-power strike itself deals nothing")
assert_true(
sim.state.get_entity(enemy).statuses.has(AbilitySpec.STATUS_DOT), "but it leaves venom"
)
for _i in 4:
sim.step({})
assert_eq(sim.state.get_entity(enemy).hp, 600, "no damage before the first interval elapses")
sim.step({})
assert_eq(sim.state.get_entity(enemy).hp, 590, "the first bite lands on the interval")
for _i in 15:
sim.step({})
assert_eq(sim.state.get_entity(enemy).hp, 560, "four bites over the duration: 40 total")
assert_false(
sim.state.get_entity(enemy).statuses.has(AbilitySpec.STATUS_DOT),
"and the venom expires when its duration runs out",
)
for _i in 10:
sim.step({})
assert_eq(sim.state.get_entity(enemy).hp, 560, "an expired venom deals nothing more")
func test_a_lethal_dot_kills_through_the_death_pass() -> void:
var sim := _sim()
var caster := _silent_caster(sim)
var enemy := sim.add_entity(1, Vector2(100.0, 0.0), 0.0, 5) # 5 hp
_cast_at(sim, caster, _status_spec(AbilitySpec.STATUS_DOT, 10, 20, 1), enemy)
sim.step({}) # a 10-hp bite against 5 hp
assert_null(sim.state.get_entity(enemy), "a lethal bite kills, resolved in the death pass")
# --- Web: movement slow -----------------------------------------------------
func test_slow_scales_move_speed_then_lifts() -> void:
var sim := _sim()
var caster := _silent_caster(sim)
var mover := sim.add_entity(1, Vector2.ZERO, 600.0, 600) # base speed 600
_cast_at(sim, caster, _status_spec(AbilitySpec.STATUS_SLOW, 50, 10, 0), mover)
assert_eq(sim.state.get_entity(mover).current_move_speed(), 300.0, "a 50% slow halves speed")
var go := InputCommand.new()
go.move_dir = Vector2(0.0, 1.0)
sim.step({mover: go})
# Slowed: 300 * (1/60) = 5 units, not the unslowed 10.
assert_almost_eq(sim.state.get_entity(mover).position.y, 5.0, 0.01, "it crawls half as far")
for _i in 10:
sim.step({})
assert_false(
sim.state.get_entity(mover).statuses.has(AbilitySpec.STATUS_SLOW), "the slow lifts"
)
assert_eq(sim.state.get_entity(mover).current_move_speed(), 600.0, "and full speed returns")
# --- Stun: a hard lock ------------------------------------------------------
func test_stun_freezes_movement_then_lifts() -> void:
var sim := _sim()
var caster := _silent_caster(sim)
var mover := sim.add_entity(1, Vector2(100.0, 0.0), 600.0, 600) # would move 10 a tick
_cast_at(sim, caster, _status_spec(AbilitySpec.STATUS_STUN, 0, 5, 0), mover)
assert_eq(sim.state.get_entity(mover).current_move_speed(), 0.0, "a stun zeroes move speed")
var go := InputCommand.new()
go.move_dir = Vector2(0.0, 1.0)
sim.step({mover: go})
assert_almost_eq(
sim.state.get_entity(mover).position.y, 0.0, 0.01, "a stunned unit holds its ground"
)
for _i in 5:
sim.step({mover: go})
var freed := sim.state.get_entity(mover)
assert_false(
freed.statuses.has(AbilitySpec.STATUS_STUN), "the stun lifts when its duration runs out"
)
assert_eq(freed.current_move_speed(), 600.0, "and full move speed returns")
assert_true(freed.position.y > 0.0, "so the unit moves again once freed")
func test_stun_blocks_casting() -> void:
var sim := _sim()
var caster := _silent_caster(sim)
var victim := sim.add_hero(1, Vector2(100.0, 0.0), 320.0)
var ready := AbilitySpec.from_dict({}) # human form, free, off cooldown: castable by default
assert_true(
AbilityExecutor.can_cast(sim.state.get_entity(victim), ready),
"an un-stunned hero can cast a ready, affordable ability",
)
_cast_at(sim, caster, _status_spec(AbilitySpec.STATUS_STUN, 0, 5, 0), victim)
assert_false(
AbilityExecutor.can_cast(sim.state.get_entity(victim), ready),
"but a stunned hero cannot cast at all",
)
for _i in 5:
sim.step({})
assert_true(
AbilityExecutor.can_cast(sim.state.get_entity(victim), ready),
"and casting returns once the stun lifts",
)
func test_stun_blocks_auto_attack() -> void:
var sim := _sim()
var attacker := sim.add_hero(0, Vector2.ZERO, 0.0) # 60 damage, range 250, cooldown 36
var dummy := sim.add_entity(1, Vector2(100.0, 0.0), 0.0, 600) # in range, takes the hit
# A team-1 source to lay the stun on the team-0 attacker; silenced so only the stun,
# not its own attack, touches the world.
var stun_src := sim.add_hero(1, Vector2(120.0, 0.0), 0.0)
sim.state.get_entity(stun_src).attack_damage = 0
_cast_at(sim, stun_src, _status_spec(AbilitySpec.STATUS_STUN, 0, 5, 0), attacker)
for _i in 4:
sim.step({})
assert_eq(sim.state.get_entity(dummy).hp, 600, "a stunned attacker lands no auto-attack")
sim.step({}) # the stun lifts this tick and the attacker, off cooldown, strikes
assert_eq(sim.state.get_entity(dummy).hp, 540, "and it attacks again once the stun lifts")
# --- Stacking + determinism -------------------------------------------------
func test_reapplying_a_status_refreshes_rather_than_stacks() -> void:
var sim := _sim()
var caster := _silent_caster(sim)
var enemy := sim.add_entity(1, Vector2(100.0, 0.0), 0.0, 600)
var spec := _status_spec(AbilitySpec.STATUS_DOT, 10, 20, 5)
_cast_at(sim, caster, spec, enemy)
sim.step({})
sim.step({}) # age it two ticks: counter 2, remaining 18
_cast_at(sim, caster, spec, enemy) # recast
var e := sim.state.get_entity(enemy)
assert_eq(e.statuses.size(), 1, "a re-applied status does not stack a second instance")
assert_eq(e.statuses[AbilitySpec.STATUS_DOT]["remaining"], 20, "it refreshes the duration")
assert_eq(e.statuses[AbilitySpec.STATUS_DOT]["counter"], 0, "and restarts the interval")
func test_a_status_run_replays_identically() -> void:
var a := _run()
var b := _run()
assert_eq(a, b, "the status layer must be a pure function of state + input")
## A scripted run: a venom DOT and a web slow laid on one enemy, then a fixed window
## stepped out. Returns a comparable digest so two runs check field-for-field.
func _run() -> Array:
var sim := _sim()
var caster := _silent_caster(sim)
var enemy := sim.add_entity(1, Vector2(100.0, 0.0), 0.0, 600)
_cast_at(sim, caster, _status_spec(AbilitySpec.STATUS_DOT, 7, 30, 6), enemy)
_cast_at(sim, caster, _status_spec(AbilitySpec.STATUS_SLOW, 40, 15, 0), enemy)
for _i in 40:
sim.step({})
var rows: Array = []
for id in sim.state.entities:
var en: SimEntity = sim.state.entities[id]
rows.append([id, en.hp, en.position.round()])
return rows
# --- Catalog wiring ---------------------------------------------------------
func test_the_catalog_wires_venom_to_dot_and_web_to_slow() -> void:
var venom := AbilityData.spec(44) # Snake: Venom Coil
assert_eq(venom.status, AbilitySpec.STATUS_DOT, "Venom Coil carries a venom DOT")
assert_true(venom.status_power > 0, "with real per-interval damage")
assert_true(venom.status_duration > 0 and venom.status_interval > 0, "and a real cadence")
var web := AbilityData.spec(50) # Spider: Web Snare
assert_eq(web.status, AbilitySpec.STATUS_SLOW, "Web Snare carries a web slow")
assert_true(web.status_power > 0 and web.status_duration > 0, "with a real slow")
var burst := AbilityData.spec(14) # Solane: Maul -- pure burst
assert_eq(burst.status, AbilitySpec.STATUS_NONE, "a Solane strike leaves no status")
func test_the_catalog_wires_web_nest_to_a_stun() -> void:
var nest := AbilityData.spec(54) # Spider: Web Nest
assert_eq(nest.status, AbilitySpec.STATUS_STUN, "Web Nest now carries a hard stun")
assert_true(nest.status_duration > 0, "with a real lock duration")
var snare := AbilityData.spec(50) # Spider: Web Snare -- still a slow
assert_eq(snare.status, AbilitySpec.STATUS_SLOW, "the Spider keeps its Web Snare slow")