feat: melee combat #83

Merged
sickday merged 4 commits from feat/combat into main 2026-07-31 19:37:09 +00:00
Owner

The first consumer of the hits mask, the killer argument and the death
sequences: a player attacks an NPC from its own Attack option, the creature
retaliates or attacks first, and the drops at the end are addressed to
whoever dealt the most damage.

The design in one line: the attacker states numbers, the defender rolls.
A swing crosses a process boundary as four integers — attack roll, max hit,
style, speed — and the defender computes the defence roll, the shared
hit-chance curve and the damage from facts only it holds. Neither side ever
reads the other's stats, so combat needs no cross-process stat table and no
call can block a tick. NPC facts live in :persistent_term; the two live
cross-entity facts ride existing point-lookup snapshot rows (an NPC's
hitpoints for the XP clamp and the corpse-stop, a player's combat level for
the aggression scan). MovementAuthority rows stay position-only.

World.Combat is the pure arithmetic, era-2017 and integer-exact: effective
level + stance bonus + 8 (+9 for NPCs), rolls as effective * (bonus + 64) on
one shared scale, max hit as (e * (b + 64) + 320) / 640 — pinned by property
tests against the float floor form and against the one-scale rule. XP is
4.0x damage for a focused stance, 1.33x each for controlled, hitpoints
always 1.33x, clamped to what the target had left.

An attack is Follow's other half in Contract.Interaction: a pursuit that
fires and persists. The new repeats?/1 (no catch-all, like its siblings)
keeps the action across firings; the dispatcher translates a click to
:attack from the cache's own action text, since Attack sits wherever a
definition's list puts it. Melee reach is the existing :npc reach clause.

The NPC half runs in the world's NPC phase: retaliation (first swing seeded
a full attack period out, so both sides opening at zero cannot trade
simultaneous first hits forever), an aggression scan gated on the record's
level cap, a leash at wander radius plus eight flat tiles, and a chase that
aims at a cardinal-adjacent tile because the dumb pathfinder wedges
diagonally one step short when aimed at the target itself. An unmeasured
creature neither retaliates nor scans.

An incoming hit is a strong queue entry — being punched closes your bank,
which the queue rules gave for free — with a clock of two, because an op
applied before a tick's queue pass spends its first clock in that pass: a
clock of one lands the hit on the swing's own tick, where its flinch runs
ahead of the defender's swing and, against any faster attacker, steals it
every period. The flinch itself only starts an expired cooldown and never
extends a running one, which is the engine's own rule and the other half of
the same stall.

The combat tab is live: style buttons 593:3/7/11/15 write varp 43,
auto-retaliate 593:27 toggles varp 172 (zero means on), and the weapon-type
varbit 357 — six bits of varp 843 in this cache — is derived from the worn
weapon on every redress, never persisted. Varps 43 and 172 persist.

Data: npc_combat.exs widens from four fields to seventeen (combat levels,
per-type attack and defence bonuses, style, observed max hit, attack speed,
aggression cap, swing and flinch animations — the walk-family vote now fills
all three animation fields, and correctly stays silent where a rig swings
per weapon). weapon_categories.exs is new: 72 categories and 573 weapons,
because a weapon category is obj opcode block 200-212, stripped server-side
like tradability, so no client cache of any revision carries it. Every id
and animation is vetted against the operator's own cache.

Kill credit is damage dealt now: World.Npc tallies applied damage per name,
the pile goes to the top damager with the last hit as fallback, and overkill
buys no credit. Closes #60. Closes the extraction half of #69.

World.Player nests path, pending_action and ctrl_run? into route — one
concept, written together since the day interact/4 existed — paying for the
new combat field with room to spare under the 32-field cliff.

The suite runs against a pacified copy of the shipped bestiary: with real
aggression loaded, the creatures around every test tile attack low-level
test players mid-test, and a wandering giant rat's hitsplat reads as an
unexpected player update in whichever test was watching. Combat tests
install their own aggression.

2,729 tests; format, Credo, Dialyzer and the doc gate clean.

The first consumer of the hits mask, the killer argument and the death sequences: a player attacks an NPC from its own Attack option, the creature retaliates or attacks first, and the drops at the end are addressed to whoever dealt the most damage. The design in one line: the attacker states numbers, the defender rolls. A swing crosses a process boundary as four integers — attack roll, max hit, style, speed — and the defender computes the defence roll, the shared hit-chance curve and the damage from facts only it holds. Neither side ever reads the other's stats, so combat needs no cross-process stat table and no call can block a tick. NPC facts live in :persistent_term; the two live cross-entity facts ride existing point-lookup snapshot rows (an NPC's hitpoints for the XP clamp and the corpse-stop, a player's combat level for the aggression scan). MovementAuthority rows stay position-only. World.Combat is the pure arithmetic, era-2017 and integer-exact: effective level + stance bonus + 8 (+9 for NPCs), rolls as effective * (bonus + 64) on one shared scale, max hit as (e * (b + 64) + 320) / 640 — pinned by property tests against the float floor form and against the one-scale rule. XP is 4.0x damage for a focused stance, 1.33x each for controlled, hitpoints always 1.33x, clamped to what the target had left. An attack is Follow's other half in Contract.Interaction: a pursuit that fires and persists. The new repeats?/1 (no catch-all, like its siblings) keeps the action across firings; the dispatcher translates a click to :attack from the cache's own action text, since Attack sits wherever a definition's list puts it. Melee reach is the existing :npc reach clause. The NPC half runs in the world's NPC phase: retaliation (first swing seeded a full attack period out, so both sides opening at zero cannot trade simultaneous first hits forever), an aggression scan gated on the record's level cap, a leash at wander radius plus eight flat tiles, and a chase that aims at a cardinal-adjacent tile because the dumb pathfinder wedges diagonally one step short when aimed at the target itself. An unmeasured creature neither retaliates nor scans. An incoming hit is a strong queue entry — being punched closes your bank, which the queue rules gave for free — with a clock of two, because an op applied before a tick's queue pass spends its first clock in that pass: a clock of one lands the hit on the swing's own tick, where its flinch runs ahead of the defender's swing and, against any faster attacker, steals it every period. The flinch itself only starts an expired cooldown and never extends a running one, which is the engine's own rule and the other half of the same stall. The combat tab is live: style buttons 593:3/7/11/15 write varp 43, auto-retaliate 593:27 toggles varp 172 (zero means on), and the weapon-type varbit 357 — six bits of varp 843 in this cache — is derived from the worn weapon on every redress, never persisted. Varps 43 and 172 persist. Data: npc_combat.exs widens from four fields to seventeen (combat levels, per-type attack and defence bonuses, style, observed max hit, attack speed, aggression cap, swing and flinch animations — the walk-family vote now fills all three animation fields, and correctly stays silent where a rig swings per weapon). weapon_categories.exs is new: 72 categories and 573 weapons, because a weapon category is obj opcode block 200-212, stripped server-side like tradability, so no client cache of any revision carries it. Every id and animation is vetted against the operator's own cache. Kill credit is damage dealt now: World.Npc tallies applied damage per name, the pile goes to the top damager with the last hit as fallback, and overkill buys no credit. Closes #60. Closes the extraction half of #69. World.Player nests path, pending_action and ctrl_run? into route — one concept, written together since the day interact/4 existed — paying for the new combat field with room to spare under the 32-field cliff. The suite runs against a pacified copy of the shipped bestiary: with real aggression loaded, the creatures around every test tile attack low-level test players mid-test, and a wandering giant rat's hitsplat reads as an unexpected player update in whichever test was watching. Combat tests install their own aggression. 2,729 tests; format, Credo, Dialyzer and the doc gate clean.
feat: melee combat
All checks were successful
ci / gates (pull_request) Successful in 35s
dc4394087d
The first consumer of the hits mask, the killer argument and the death
sequences: a player attacks an NPC from its own Attack option, the creature
retaliates or attacks first, and the drops at the end are addressed to
whoever dealt the most damage.

The design in one line: the attacker states numbers, the defender rolls.
A swing crosses a process boundary as four integers — attack roll, max hit,
style, speed — and the defender computes the defence roll, the shared
hit-chance curve and the damage from facts only it holds. Neither side ever
reads the other's stats, so combat needs no cross-process stat table and no
call can block a tick. NPC facts live in :persistent_term; the two live
cross-entity facts ride existing point-lookup snapshot rows (an NPC's
hitpoints for the XP clamp and the corpse-stop, a player's combat level for
the aggression scan). MovementAuthority rows stay position-only.

World.Combat is the pure arithmetic, era-2017 and integer-exact: effective
level + stance bonus + 8 (+9 for NPCs), rolls as effective * (bonus + 64) on
one shared scale, max hit as (e * (b + 64) + 320) / 640 — pinned by property
tests against the float floor form and against the one-scale rule. XP is
4.0x damage for a focused stance, 1.33x each for controlled, hitpoints
always 1.33x, clamped to what the target had left.

An attack is Follow's other half in Contract.Interaction: a pursuit that
fires and persists. The new repeats?/1 (no catch-all, like its siblings)
keeps the action across firings; the dispatcher translates a click to
:attack from the cache's own action text, since Attack sits wherever a
definition's list puts it. Melee reach is the existing :npc reach clause.

The NPC half runs in the world's NPC phase: retaliation (first swing seeded
a full attack period out, so both sides opening at zero cannot trade
simultaneous first hits forever), an aggression scan gated on the record's
level cap, a leash at wander radius plus eight flat tiles, and a chase that
aims at a cardinal-adjacent tile because the dumb pathfinder wedges
diagonally one step short when aimed at the target itself. An unmeasured
creature neither retaliates nor scans.

An incoming hit is a strong queue entry — being punched closes your bank,
which the queue rules gave for free — with a clock of two, because an op
applied before a tick's queue pass spends its first clock in that pass: a
clock of one lands the hit on the swing's own tick, where its flinch runs
ahead of the defender's swing and, against any faster attacker, steals it
every period. The flinch itself only starts an expired cooldown and never
extends a running one, which is the engine's own rule and the other half of
the same stall.

The combat tab is live: style buttons 593:3/7/11/15 write varp 43,
auto-retaliate 593:27 toggles varp 172 (zero means on), and the weapon-type
varbit 357 — six bits of varp 843 in this cache — is derived from the worn
weapon on every redress, never persisted. Varps 43 and 172 persist.

Data: npc_combat.exs widens from four fields to seventeen (combat levels,
per-type attack and defence bonuses, style, observed max hit, attack speed,
aggression cap, swing and flinch animations — the walk-family vote now fills
all three animation fields, and correctly stays silent where a rig swings
per weapon). weapon_categories.exs is new: 72 categories and 573 weapons,
because a weapon category is obj opcode block 200-212, stripped server-side
like tradability, so no client cache of any revision carries it. Every id
and animation is vetted against the operator's own cache.

Kill credit is damage dealt now: World.Npc tallies applied damage per name,
the pile goes to the top damager with the last hit as fallback, and overkill
buys no credit. Closes #60. Closes the extraction half of #69.

World.Player nests path, pending_action and ctrl_run? into route — one
concept, written together since the day interact/4 existed — paying for the
new combat field with room to spare under the 32-field cliff.

The suite runs against a pacified copy of the shipped bestiary: with real
aggression loaded, the creatures around every test tile attack low-level
test players mid-test, and a wandering giant rat's hitsplat reads as an
unexpected player update in whichever test was watching. Combat tests
install their own aggression.

2,729 tests; format, Credo, Dialyzer and the doc gate clean.
Merge branch 'main' into feat/combat
All checks were successful
ci / gates (pull_request) Successful in 38s
baefd40740
Three finds from the first live sitting, all on the render side of things
the suite proved and the glass disproved.

A hit now lands as World.Hit.applied/2 says it does: capped at the target's
remaining hitpoints, splat included, with a roll capped to nothing drawn as
the block shield. The first fight showed a 26 on a twelve-hitpoint goblin,
which is not the live game: an ordinary monster can never show a player
their true max hit, and a killing blow draws exactly the hitpoints it
emptied. The old "subtract clamped, draw it all" doctrine was a misreading
of the modern reference's XP clamp. Both damage functions apply the rule,
and the player's swing clamps at the source so the wire, the kill-credit
tally and the experience all carry one number.

The combat tab's title and level line are server-sent strings — 593:1 (the
weapon's name, bare Unarmed for empty hands) and 593:2 (Combat Lvl: N) sit
in the cache with empty text and no listeners, so a server that never sends
them shows a nameless weapon and no level, which is exactly how it shipped.
They ride the same redress funnel as the weapon-type varbit, which covers
equipping and combat level-ups in one hook: a combat level cannot change
without a redress running for the appearance block. The era reference sends
the level line to component 3 in its revision; this cache's is 2, the usual
cross-revision shift.

The special-attack bar is varp 300, and the client holds zero until the
server says otherwise — so it drew empty forever. A login now seeds it to
the full thousand, deliberately outside the weapon sync: when special
attacks become real state, a refill must not hide inside every equipment
change.

Two tests pinned the old splat doctrine and now pin the correction.
fix: refuse animations the body cannot play, and standoff weapons combat cannot serve
All checks were successful
ci / gates (pull_request) Successful in 36s
ci / gates (push) Successful in 37s
0aad4ea93d
Two more finds from the live sitting, one of them the trap the decode gate
was never going to catch: a stale animation id from a later revision usually
decodes here — as somebody else's animation. The Falador guards died in a
shape no human dies in because their source file states the new guard rig's
death for every id, and in this revision only two ids are on that rig; the
rest still walk the human one, and a wrong-skeleton animation renders as
exactly the contortion it is.

The cache can name the rig, so the gate is mechanical now. Sequence opcode
1's frame ids carry their frames archive in the high halves, and an archive
is authored against one skeleton: Cache.Seq keeps the archives, Cache.Npc
keeps the standing sequence (opcode 13) as a second rig anchor, and the
converter refuses any stated animation whose archives its id's own walk or
stand has never heard of. Per id, not per file — the two guards genuinely on
the new rig keep their set, the human-rig ids fall to the family fill, and
the regenerated table corrects 213 stated animations over 87 ids in the same
silent condition. A refused swing falls to the family majority, so those
guards punch generically rather than swinging swords: rig-correct and
playable, with the per-weapon truth waiting on a better stats source.

A ranged- or magic-stanced weapon now refuses the swing — "You can't attack
with that yet." — instead of delivering a bow point-blank as pseudo-melee,
where the first landed hit asked the experience table about a stance it has
no clause for and took the session down with it. Staves are untouched: their
stances roll crush, which is the era's own staff-bashing, and it works.

Two test fixtures start settled now, because a login legitimately queues the
combat tab's strings and the special-energy seed, and "a fresh player has
said nothing" stopped being true.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Revenant/Server!83
No description provided.