Commit
Theria
feat: make the Verdani's venom and web real lingering effects
modified CHANGELOG.md
@@ -35,6 +35,12 @@ protocol version.
### Added
- The Verdani's venom and web are now mechanics, not just names: a venom strike leaves a
damage-over-time that keeps biting for two seconds after it lands, and a web leaves a
movement slow on what it catches. Each venom ability trades part of its instant hit for
that lingering damage, so the Verdani lean on attrition where the Solane stay burst. A
struck unit carries one instance of each effect — a re-cast refreshes rather than stacks.
Sim-side only; the netcode protocol is unchanged.
- A practice match is now a tribe-versus-tribe choice: `--hero` accepts any hero of either
tribe, and the chosen hero's tribe fields the player's team while the opposing tribe fills
the bots — so the Verdani are now playable, not just an opponent. The default still
modified README.md
@@ -37,9 +37,11 @@ simulation. With that authority model proven, networked play over a
listen-server, the hero ability layer, and two full rosters of heroes — the
**Solane**, savanna big-cat shifters (lion, cheetah, hyena), and the opposing
**Verdani**, jungle venom-and-shadow shifters (snake, spider, chameleon) — now
run on top of it. A practice match fields the Solane squad against the Verdani —
the player drives one Solane hero, bots fill the rest — so both rosters are on
the field at once; multi-hero teams over the wire and the art direction come next.
run on top of it. A practice match fields one tribe against the other — `--hero`
picks any hero, the player drives it and bots fill out both squads — so both
rosters are on the field at once. The Verdani fight by attrition: their venom
lingers as damage over time and their webs slow what they catch, a foil to the
Solane's burst. Multi-hero teams over the wire and the art direction come next.
## Architecture
modified src/sim/ability_data.gd
@@ -30,9 +30,12 @@ extends RefCounted
## - **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 two
## tribes are still effect-mirrors (DAMAGE/HEAL/TRANSFORM); the venom/web flavor is carried
## by their targeting mix, tuning, and economy until a richer effect schema lands.
## 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.
## 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
@@ -359,7 +362,11 @@ const ABILITIES := {
"cost": 20,
"cooldown_ticks": 24,
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 70,
"power": 50, # trimmed from a pure poke: the rest of the bite is the venom below
"status": AbilitySpec.STATUS_DOT, # venom: lingering damage over two seconds
"status_power": 6,
"status_duration": 120,
"status_interval": 30,
},
41:
{
@@ -396,7 +403,11 @@ const ABILITIES := {
"cost": 15,
"cooldown_ticks": 18, # cheap and fast: harass on repeat
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 75,
"power": 55, # a lighter strike now that the fang leaves venom
"status": AbilitySpec.STATUS_DOT,
"status_power": 5,
"status_duration": 120,
"status_interval": 30,
},
44:
{
@@ -409,7 +420,11 @@ const ABILITIES := {
"cost": 35,
"cooldown_ticks": 50,
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 150,
"power": 105, # the heavy payoff, now split between the coil and its deep venom
"status": AbilitySpec.STATUS_DOT, # the tribe's strongest venom: a heavy lingering bleed
"status_power": 11,
"status_duration": 120,
"status_interval": 30,
},
45:
{
@@ -435,7 +450,10 @@ const ABILITIES := {
"cost": 30,
"cooldown_ticks": 38,
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 50, # the lowest per-hit power in either tribe: pure attrition
"power": 45, # the lowest per-hit power in either tribe: attrition, now with a snare
"status": AbilitySpec.STATUS_SLOW, # web: the strongest, longest slow — the trapper's lock
"status_power": 45, # a 45% slow
"status_duration": 150,
},
51:
{
@@ -472,7 +490,11 @@ const ABILITIES := {
"cost": 20,
"cooldown_ticks": 30,
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 85,
"power": 65, # a lighter bite, the rest delivered as venom
"status": AbilitySpec.STATUS_DOT,
"status_power": 5,
"status_duration": 120,
"status_interval": 30,
},
54:
{
@@ -486,7 +508,10 @@ const ABILITIES := {
"cost": 35,
"cooldown_ticks": 46,
"effect": AbilitySpec.EFFECT_DAMAGE,
"power": 55,
"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,
},
55:
{
modified src/sim/ability_executor.gd
@@ -39,10 +39,26 @@ static func execute(
AbilitySpec.EFFECT_DAMAGE:
for target in _targets(state, caster, spec, command):
target.hp -= spec.power
if spec.status != AbilitySpec.STATUS_NONE:
_apply_status(target, spec)
caster.ability_cooldowns[spec.id] = spec.cooldown_ticks
caster.resource -= spec.cost
## Lays the spec's lingering status on one struck target. One instance per kind: a
## 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.
static func _apply_status(target: SimEntity, spec: AbilitySpec) -> void:
target.statuses[spec.status] = {
"power": spec.status_power,
"remaining": spec.status_duration,
"interval": maxi(1, spec.status_interval),
"counter": 0,
}
## Swaps the caster to its other form and to that form's resource pool: max and
## regen switch, the current pool carries over clamped to the new max, and the regen
## counter restarts. Ability cooldowns are keyed by ability id, so they persist
modified src/sim/ability_spec.gd
@@ -35,6 +35,19 @@ const EFFECT_DAMAGE := 0
const EFFECT_HEAL := 1
const EFFECT_TRANSFORM := 2
## A lingering status an ability leaves on each enemy it strikes, on top of its
## immediate effect — the schema behind the Verdani's venom and web flavor.
## NONE — leaves nothing (the default; every Solane ability and every heal/transform).
## DOT — venom: deals `status_power` hp every `status_interval` ticks for
## `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.
## 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
## Catalog id (unique across the roster) and display name.
var id: int = 0
var name: String = ""
@@ -65,6 +78,16 @@ var effect: int = EFFECT_DAMAGE
## swaps to the caster's other form.
var power: int = 0
## The lingering status (above) laid on each enemy this ability strikes, and its
## tuning. `status` selects the kind (NONE leaves the ability instant-only).
## `status_power` is hp-per-interval for DOT, percent-slow for SLOW; `status_duration`
## is how long it lasts in ticks; `status_interval` is how often a DOT bites (clamped
## to >= 1 when applied, so it never ticks more than once a tick; ignored by SLOW).
var status: int = STATUS_NONE
var status_power: int = 0
var status_duration: int = 0
var status_interval: int = 0
## Builds a spec from one catalog row. Every field defaults, so a sparse row only
## states what it changes — keeping the catalog terse and the parse total.
@@ -81,4 +104,8 @@ static func from_dict(d: Dictionary) -> AbilitySpec:
spec.cooldown_ticks = d.get("cooldown_ticks", 0)
spec.effect = d.get("effect", EFFECT_DAMAGE)
spec.power = d.get("power", 0)
spec.status = d.get("status", STATUS_NONE)
spec.status_power = d.get("status_power", 0)
spec.status_duration = d.get("status_duration", 0)
spec.status_interval = d.get("status_interval", 0)
return spec
modified src/sim/sim_core.gd
@@ -167,6 +167,7 @@ func step(inputs: Dictionary) -> void:
_step_spawning()
_step_movement(inputs)
_step_creeps()
_step_statuses()
_step_abilities(inputs)
_step_combat()
_resolve_deaths()
@@ -198,7 +199,7 @@ static func apply_movement(entity: SimEntity, command: InputCommand) -> void:
move_dir = command.move_dir
if move_dir.length() > 1.0:
move_dir = move_dir.normalized()
entity.position += move_dir * entity.move_speed * TICK_DELTA
entity.position += move_dir * entity.current_move_speed() * TICK_DELTA
entity.position = MapData.clamp_to_bounds(entity.position)
@@ -254,6 +255,35 @@ func _step_creeps() -> void:
creep.position = MapData.clamp_to_bounds(creep.position)
## 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
## 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
## statuses, so it replays identically.
func _step_statuses() -> void:
for id in state.entities:
var entity: SimEntity = state.entities[id]
if entity.statuses.is_empty():
continue
var expired: Array[int] = []
for kind in entity.statuses:
var s: Dictionary = entity.statuses[kind]
if kind == AbilitySpec.STATUS_DOT:
s["counter"] += 1
if s["counter"] >= s["interval"]:
s["counter"] = 0
entity.hp -= s["power"]
s["remaining"] -= 1
if s["remaining"] <= 0:
expired.append(kind)
for kind in expired:
entity.statuses.erase(kind)
## Advances the ability layer one tick. First every hero's passive upkeep —
## resource regen and cooldown decay — which runs regardless of input so pools refill
## and cooldowns elapse while idle. Then any casts requested this tick: a cast is
modified src/sim/sim_entity.gd
@@ -70,6 +70,15 @@ var ability_cooldowns: Dictionary = {}
## 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.
var statuses: Dictionary = {}
func _init(
p_id: int = 0,
@@ -109,4 +118,18 @@ func clone() -> SimEntity:
copy.form_resource_regen = form_resource_regen.duplicate()
copy.ability_cooldowns = ability_cooldowns.duplicate()
copy.kit = kit.duplicate(true)
copy.statuses = statuses.duplicate(true)
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.
func current_move_speed() -> float:
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
added test/unit/test_status_effects.gd
@@ -0,0 +1,161 @@
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")
# --- 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")
modified test/unit/test_verdani.gd
@@ -102,7 +102,11 @@ func test_snake_fang_strike_locks_the_longest_single_target() -> void:
cast.ability_slot = 0 # animal Q = Fang Strike
cast.target_id = far
sim.step({id: cast})
assert_eq(sim.state.get_entity(far).hp, 525, "Fang Strike locks a target 360 away for its 75")
assert_eq(sim.state.get_entity(far).hp, 545, "Fang Strike locks a target 360 away for its 55")
assert_true(
sim.state.get_entity(far).statuses.has(AbilitySpec.STATUS_DOT),
"and leaves its venom on the locked target",
)
assert_eq(sim.state.get_entity(id).resource, 75, "and spends its cheap 15 from the 90 pool")
@@ -119,7 +123,11 @@ 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, 545, "an enemy in the wide nest takes Web Nest's 55")
assert_eq(sim.state.get_entity(inside).hp, 550, "an enemy in the wide nest takes Web Nest's 50")
assert_true(
sim.state.get_entity(inside).statuses.has(AbilitySpec.STATUS_SLOW),
"and is snared by the web's slow",
)
assert_eq(sim.state.get_entity(outside).hp, 600, "an enemy beyond its 220 radius is spared")