Commit
Theria
feat: add a stun status; the Spider's Web Nest now locks
modified CHANGELOG.md
@@ -35,6 +35,13 @@ protocol version.
### Added
- Stun joins the lingering-status roster as a hard crowd-control effect: a stunned unit
cannot move, cast, or auto-attack until it wears off. The Verdani Spider's **Web Nest**
now lays this brief lock over its zone instead of a slow — its instant hit is trimmed in
trade — so the trapper opens a window rather than just chipping. The Spider keeps its
ranged **Web Snare** slow, so it now controls with both a snare and a lock. Like the
existing venom and web statuses, the effect is resolved entirely in the simulation; the
netcode protocol is unchanged.
- Practice bots now have a difficulty: **Easy**, **Normal**, or **Hard**, chosen from the
connect screen's new picker or with `--bot-difficulty`. Easy is the default, so a practice
match is winnable out of the box, while Hard is the previous full-strength bot. A lower
modified src/sim/ability_data.gd
@@ -26,16 +26,18 @@ extends RefCounted
## - **Snake** — a venom striker: a long single-target lock, a cheap low-cooldown
## Fang Strike, and a heavy Venom Coil payoff, on a mid-tier pool.
## - **Spider** — a trapper: the longest, widest, lowest-power ground webs in the
## game for pure attrition, on the deepest, slowest-regen pool.
## game — a long snaring slow and a brief hard lock — on the deepest,
## slowest-regen pool.
## - **Chameleon** — an ambusher: a short hard skillshot and the single heaviest hit
## in either tribe, on the leanest, fastest-refilling pool.
## In a practice match the player's squad fields the Solane and the bot squad the
## Verdani, so both rosters and all four targeting modes are exercised at once. The
## Verdani's venom and web are now mechanical, not just named: their striking abilities
## carry a lingering status (see AbilitySpec.STATUS_*) — venom is a damage-over-time, web
## a movement slow — so the bite keeps biting and the snare actually snares. Each venom
## ability trades part of its instant power for that lingering bite, so the Verdani lean
## attrition where the Solane stay burst.
## a movement slow, and the Spider's nest a brief hard stun — so the bite keeps biting,
## the snare actually snares, and the nest locks. Each such ability trades part of its
## instant power for that lingering effect, so the Verdani lean attrition and control
## where the Solane stay burst.
## Ability rows keyed by catalog id. Each row is parsed on demand into a typed
## AbilitySpec by `spec`; a sparse row leans on the spec defaults. The dictionary's
@@ -506,12 +508,11 @@ const ABILITIES := {
"range": 340.0,
"radius": 220.0, # the widest area in either tribe
"cost": 35,
"cooldown_ticks": 46,
"cooldown_ticks": 60, # a longer recharge: a hard lock is a deliberate engage, not spam
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 50, # a touch lighter, the nest now also snares the zone
"status": AbilitySpec.STATUS_SLOW, # web: a shorter, wider slow than the snare
"status_power": 30, # a 30% slow
"status_duration": 90,
"power": 35, # trimmed hard — the nest's payoff is now the lock, not the hit
"status": AbilitySpec.STATUS_STUN, # web: a brief hard lock (no move, cast, or attack)
"status_duration": 30, # half a second frozen
},
55:
{
modified src/sim/ability_executor.gd
@@ -11,10 +11,14 @@ extends RefCounted
## that reduce hp leave the kill to the core's death-resolution pass, so an ability
## and an auto-attack that both finish a unit this tick kill it once.
## Whether `caster` may cast `spec` this tick: the spec must belong to the caster's
## active form, the caster must hold enough resource, and the ability must be off
## cooldown. Reads only — the decision never mutates the world.
## Whether `caster` may cast `spec` this tick: the caster must not be stunned, the spec
## must belong to the caster's active form, the caster must hold enough resource, and the
## ability must be off cooldown. Reads only — the decision never mutates the world. Both
## the player's casts and the bot's gate through here, so a stunned hero of either kind is
## silenced by the same check.
static func can_cast(caster: SimEntity, spec: AbilitySpec) -> bool:
if caster.is_stunned():
return false
if spec.form != caster.form:
return false
if caster.resource < spec.cost:
@@ -49,7 +53,8 @@ static func execute(
## re-application overwrites any active status of the same kind, so it refreshes (the
## latest cast wins) rather than stacking — bounded and deterministic. A DOT clamps its
## interval to at least one tick and starts its damage counter fresh; a SLOW carries
## only its percent. The duration always starts over.
## only its percent; a STUN carries only its duration (its power and interval go unused).
## The duration always starts over.
static func _apply_status(target: SimEntity, spec: AbilitySpec) -> void:
target.statuses[spec.status] = {
"power": spec.status_power,
modified src/sim/ability_spec.gd
@@ -42,11 +42,15 @@ const EFFECT_TRANSFORM := 2
## `status_duration` ticks, so the bite keeps biting after it lands.
## SLOW — web: scales the target's move speed by (100 - `status_power`) percent for
## `status_duration` ticks, so a snared enemy crawls.
## STUN — a hard lock: for `status_duration` ticks the target cannot move, cast, or
## auto-attack. A stun has no magnitude — `status_power` and `status_interval`
## are unused; it either holds the unit or it does not.
## A status rides on the ability's target selection — it is laid by the DAMAGE/area
## path on every enemy struck, so a SELF heal or transform never carries one.
const STATUS_NONE := 0
const STATUS_DOT := 1
const STATUS_SLOW := 2
const STATUS_STUN := 3
## Catalog id (unique across the roster) and display name.
var id: int = 0
modified src/sim/sim_core.gd
@@ -240,6 +240,8 @@ func _step_creeps() -> void:
var creep: SimEntity = state.entities[id]
if not creep.is_creep:
continue
if creep.is_stunned():
continue # a stunned creep holds its ground — no march this tick
if _nearest_enemy_in_range(creep) != null:
continue
var path := MapData.lane_path(creep.lane, creep.team)
@@ -260,8 +262,9 @@ func _step_creeps() -> void:
## Ages every active status by one tick and applies a venom DOT's bite. For each
## entity carrying a status: a DOT advances its interval counter and, on each interval,
## subtracts its damage; every status counts its duration down and is dropped when it
## expires. A SLOW does nothing here — movement reads the live slow off the entity each
## tick — it only ages out. Runs before the cast step (upkeep first, like resource
## expires. A SLOW and a STUN do nothing here — the movement, cast, and combat steps read
## the live status off the entity each tick — they only age out. Runs before the cast step
## (upkeep first, like resource
## regen) so a status applied this tick begins aging next tick, and before
## `_resolve_deaths` so a lethal DOT, an auto-attack, and an ability all reconcile in
## the one death pass. Pure and insertion-ordered over entities and each entity's
@@ -351,6 +354,8 @@ func _step_combat() -> void:
var attacker: SimEntity = state.entities[id]
if attacker.attack_damage <= 0:
continue
if attacker.is_stunned():
continue # a locked unit neither strikes nor ticks its cooldown down
if attacker.cooldown > 0:
attacker.cooldown -= 1
if attacker.cooldown > 0:
modified src/sim/sim_entity.gd
@@ -83,13 +83,14 @@ var kit_id: String = ""
## the immutable specs the ids resolve to.
var kit: Dictionary = {}
## Active status effects (venom DOT, web SLOW) left on this entity by abilities that
## struck it, keyed by status kind (AbilitySpec.STATUS_*) so there is one instance per
## kind — a re-application refreshes it. Each value holds `power`, `remaining` ticks,
## the DOT `interval`, and its `counter`. Empty for every entity carrying no status
## (towers, creeps, an unharmed hero), so the status layer is inert until something is
## laid on. SimCore.`_step_statuses` ages and ticks these; insertion order keeps the
## pass deterministic.
## Active status effects (venom DOT, web SLOW, a hard STUN) left on this entity by
## abilities that struck it, keyed by status kind (AbilitySpec.STATUS_*) so there is one
## instance per kind — a re-application refreshes it. Each value holds `power`,
## `remaining` ticks, the DOT `interval`, and its `counter` (a SLOW reads only `power`, a
## STUN only `remaining`). Empty for every entity carrying no status (towers, creeps, an
## unharmed hero), so the status layer is inert until something is laid on.
## SimCore.`_step_statuses` ages and ticks these; insertion order keeps the pass
## deterministic.
var statuses: Dictionary = {}
@@ -137,14 +138,24 @@ func clone() -> SimEntity:
return copy
## This entity's move speed after any active slow. A SLOW status scales the base speed
## by (100 - its percent); with none, the base speed is returned unchanged — so a
## status-free entity (every entity on the wire, every Solane unit) moves by exactly
## the same math as before. The authoritative movement step and the client's local
## prediction both read it, so a slowed hero is predicted identically.
## This entity's move speed after any active lock or slow. A STUN freezes it outright
## (speed 0); otherwise a SLOW status scales the base speed by (100 - its percent), and
## with neither the base speed is returned unchanged — so a status-free entity (every
## entity on the wire, every Solane unit) moves by exactly the same math as before. The
## authoritative movement step and the client's local prediction both read it, so a
## stunned or slowed hero is predicted identically.
func current_move_speed() -> float:
if is_stunned():
return 0.0
var slow: Dictionary = statuses.get(AbilitySpec.STATUS_SLOW, {})
if slow.is_empty():
return move_speed
var pct := clampi(slow["power"], 0, 100)
return move_speed * float(100 - pct) / 100.0
## Whether a STUN status is currently locking this entity: while one is active it cannot
## move, cast, or auto-attack. The movement, ability, and combat steps each read this to
## freeze a locked unit; the stun ages out in `_step_statuses` like any other status.
func is_stunned() -> bool:
return statuses.has(AbilitySpec.STATUS_STUN)
modified test/unit/test_status_effects.gd
@@ -105,6 +105,69 @@ func test_slow_scales_move_speed_then_lifts() -> void:
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 -------------------------------------------------
@@ -159,3 +222,11 @@ func test_the_catalog_wires_venom_to_dot_and_web_to_slow() -> void:
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")
modified test/unit/test_verdani.gd
@@ -123,10 +123,10 @@ func test_spider_web_nest_zones_the_widest_area() -> void:
cast.ability_slot = 2 # animal E = Web Nest
cast.target_point = Vector2(300.0, 0.0)
sim.step({id: cast})
assert_eq(sim.state.get_entity(inside).hp, 550, "an enemy in the wide nest takes Web Nest's 50")
assert_eq(sim.state.get_entity(inside).hp, 565, "an enemy in the wide nest takes Web Nest's 35")
assert_true(
sim.state.get_entity(inside).statuses.has(AbilitySpec.STATUS_SLOW),
"and is snared by the web's slow",
sim.state.get_entity(inside).statuses.has(AbilitySpec.STATUS_STUN),
"and is locked by the web's hard stun",
)
assert_eq(sim.state.get_entity(outside).hp, 600, "an enemy beyond its 220 radius is spared")