feat(magic): the standard spellbook's teleports #149

Merged
sickday merged 2 commits from feat/teleports into main 2026-08-06 19:47:31 +00:00
Owner

The seven standard-spellbook teleports — Varrock, Lumbridge, Falador, Camelot, Ardougne, Watchtower and Trollheim — cast from the spellbook, spending runes, paying Magic experience, and moving the player three ticks later.

The first transport the world has had. Everything before this walked, and the world is not built for walking: content past early game assumes a player teleports close and covers the last stretch on foot, which is why a farming run works at all. Part of #140.

Where each column comes from, and why that matters here

level and runes are the cache's own, read from interface 218's on_load args at the offsets World.SpellbookTest already pins the combat rows against. A row that drifts from the operator's cache fails the suite rather than surprising a player.

destination is the one column no client cache has ever carried, and it is the reason this took sourcing rather than typing. It is ported from a 468-era table whose rune costs match this one exactly — two independently maintained tables, ten years apart, agreeing on every rune of every row, which does not leave the coordinates free to have moved. Our own priv/data/teleports.exs landmarks agree to within 1–2 tiles as a third witness.

Then every landing tile is asserted unblocked, floored, and outside the wilderness against the operator's own cache. That is the check that carries the weight: a date is a claim, a walkable tile is a fact, and a coordinate that survived a ten-year revision gap is proven by standing on it rather than by argument.

The ceiling is why this is a contract

A teleport refuses above wilderness level 20. That is not a nicety — it is what makes deep wilderness a commitment, and a server that forgets it has quietly deleted the risk from every encounter north of the ditch.

Contract.Teleport walks the boundary tile by tile in its tests, 0 through 56, because the whole rule is one level and > versus >= is the mistake it invites.

Nothing here re-derives where the wilderness is: it asks World.Wilderness.level/1 and lets the contract judge. A consumer restating an engine rule narrower is a mistake this codebase has already recorded twice — the chop verbs in Bot.Sites, and same_plane_bank/2.

Ordering

Runes are spent at the click and the player moves three ticks later, which is the rune altar's ordering and for the same reason: a player who logs out mid-flight has paid, and that is the reading that cannot be farmed by disconnecting. Player.delay/2 covers the same three ticks so the cast cannot repeat while its animation plays.

The animations were ported, so they were checked rather than trusted

714 and 715 share frame archive 207. That is the check that means something — an id which merely decodes may belong to another rig entirely, which is the trap the death-animation work paid for. Sharing an archive makes them genuinely one animation's pair; 715's frames are 714's in reverse.

714's last frame is a 20,000-cycle hold: [6, 6, 6, 6, 20, 4, 4, 4, 4, 4, 4, 20000]. The animation is the first eleven frames — 68 cycles, about 2.27 ticks — and the hold parks the caster in the vanish pose until something replaces it, which the arrival does. So the three-tick delay is not arbitrary, and a test asserts the playing frames still fit inside it: if that ever stops being true, the pose shows.

The graphic height was wrong and is corrected to 0. It had been copied from the rune altar's 100, which is what a graphic above the head wants; 111 is a full-body swirl that starts at the feet. The cache settles neither — Cache.SpotAnim carries no render height — so it is flagged for a live sitting rather than asserted.

What a player tries during the three ticks

Interrupting a teleport is one of the first things somebody will try, so it was measured live rather than reasoned about:

during the flight result verdict
click to walk still arrives correct — you cannot walk out of a teleport
die respawns, arrival does not fire correct — death wins
log out runes spent, arrival lost #148

The first two are now regression tests. The death case needed care to measure at all: cast from the Lumbridge spawn tile and "respawned" is indistinguishable from "never left", because they are the same coordinate — the first attempt fooled itself exactly that way, and the test now casts from {0, 3000, 3300} so origin, destination and respawn are three different places.

#148 also carries the latent trap: any future path that cancels a teleport mid-flight without animating the player leaves them frozen in the vanish pose. Teleblock is era content and is exactly that shape.

One test that passes for the wrong reason, and says so

"a combat spell's plain click never reaches the chain at all" does not exercise the fall-through. Wind Strike carries op 1 disabled on the client — it is cast through component_on_npc, not as a button — so Button.authorise/1 refuses it with {:op_disabled, 1} before the teleport handler is asked. The fall-through itself is asserted directly in World.TeleportSpellsTest, where spell/1 answers nil for component 2. A green test that proves something other than its name is worse than no test, so it names what it actually proves.

Scope

The standard spellbook only. Ancients, Lunar and Arceuus are 32 more rows on this same machinery — all 44 components are already read, with levels and rune costs, in .memory — and are deliberately absent until their destinations get the same three-source treatment.

Deliberately not shipped, each for a structural reason rather than a data one:

  • House (40) needs a POH; Bounty Target (85) needs a bounty system.
  • Ape Atoll (64) and Kourend (69) have no ported destination — Kourend is 2016 content the sister project predates entirely.
  • Home teleports need a cooldown timestamp that survives logout, and World.Player is at the 32-key cliff. World.Timers would reset at logout and a varp is client-visible; that is a design decision, not a missing row.

Verification

Gates match clean main exactly, measured by stashing every working change and re-running rather than inferred.

Worth flagging separately: #131's baseline is stale. It records 2 Dialyzer errors; there are now 12, and the extra ten are callback_arg_type_mismatch / callback_type_mismatch in test/support/scripts.ex that arrived with the PvP merge — confirmed present on clean main with this branch stashed. They look like a genuine World.Script callback type problem rather than an environmental quirk.

The seven standard-spellbook teleports — Varrock, Lumbridge, Falador, Camelot, Ardougne, Watchtower and Trollheim — cast from the spellbook, spending runes, paying Magic experience, and moving the player three ticks later. **The first transport the world has had.** Everything before this walked, and the world is not built for walking: content past early game assumes a player teleports close and covers the last stretch on foot, which is why a farming run works at all. Part of #140. ## Where each column comes from, and why that matters here `level` and `runes` are the **cache's own**, read from interface 218's `on_load` args at the offsets `World.SpellbookTest` already pins the combat rows against. A row that drifts from the operator's cache fails the suite rather than surprising a player. `destination` is the one column no client cache has ever carried, and it is the reason this took sourcing rather than typing. It is ported from a 468-era table whose rune costs match this one **exactly** — two independently maintained tables, ten years apart, agreeing on every rune of every row, which does not leave the coordinates free to have moved. Our own `priv/data/teleports.exs` landmarks agree to within 1–2 tiles as a third witness. Then every landing tile is asserted **unblocked, floored, and outside the wilderness** against the operator's own cache. That is the check that carries the weight: a date is a claim, a walkable tile is a fact, and a coordinate that survived a ten-year revision gap is proven by standing on it rather than by argument. ## The ceiling is why this is a contract A teleport refuses above wilderness level 20. That is not a nicety — it is what makes deep wilderness a commitment, and a server that forgets it has quietly deleted the risk from every encounter north of the ditch. `Contract.Teleport` walks the boundary **tile by tile** in its tests, 0 through 56, because the whole rule is one level and `>` versus `>=` is the mistake it invites. Nothing here re-derives where the wilderness is: it asks `World.Wilderness.level/1` and lets the contract judge. A consumer restating an engine rule narrower is a mistake this codebase has already recorded twice — the chop verbs in `Bot.Sites`, and `same_plane_bank/2`. ## Ordering Runes are spent at the click and the player moves three ticks later, which is the rune altar's ordering and for the same reason: a player who logs out mid-flight has paid, and that is the reading that cannot be farmed by disconnecting. `Player.delay/2` covers the same three ticks so the cast cannot repeat while its animation plays. ## The animations were ported, so they were checked rather than trusted `714` and `715` **share frame archive `207`**. That is the check that means something — an id which merely decodes may belong to another rig entirely, which is the trap the death-animation work paid for. Sharing an archive makes them genuinely one animation's pair; `715`'s frames are `714`'s in reverse. `714`'s last frame is a **20,000-cycle hold**: `[6, 6, 6, 6, 20, 4, 4, 4, 4, 4, 4, 20000]`. The animation is the first eleven frames — 68 cycles, about 2.27 ticks — and the hold parks the caster in the vanish pose until something replaces it, which the arrival does. So the three-tick delay is not arbitrary, and a test asserts the playing frames still fit inside it: if that ever stops being true, the pose shows. **The graphic height was wrong and is corrected to 0.** It had been copied from the rune altar's `100`, which is what a graphic *above the head* wants; `111` is a full-body swirl that starts at the feet. The cache settles neither — `Cache.SpotAnim` carries no render height — so it is flagged for a live sitting rather than asserted. ## What a player tries during the three ticks Interrupting a teleport is one of the first things somebody will try, so it was measured live rather than reasoned about: | during the flight | result | verdict | |---|---|---| | click to walk | still arrives | correct — you cannot walk out of a teleport | | die | respawns, arrival does not fire | correct — death wins | | log out | runes spent, arrival lost | **#148** | The first two are now regression tests. The death case needed care to measure at all: cast from the Lumbridge spawn tile and "respawned" is indistinguishable from "never left", because they are the same coordinate — the first attempt fooled itself exactly that way, and the test now casts from `{0, 3000, 3300}` so origin, destination and respawn are three different places. #148 also carries the latent trap: **any future path that cancels a teleport mid-flight without animating the player leaves them frozen in the vanish pose.** Teleblock is era content and is exactly that shape. ## One test that passes for the wrong reason, and says so `"a combat spell's plain click never reaches the chain at all"` does **not** exercise the fall-through. Wind Strike carries op 1 *disabled* on the client — it is cast through `component_on_npc`, not as a button — so `Button.authorise/1` refuses it with `{:op_disabled, 1}` before the teleport handler is asked. The fall-through itself is asserted directly in `World.TeleportSpellsTest`, where `spell/1` answers `nil` for component 2. A green test that proves something other than its name is worse than no test, so it names what it actually proves. ## Scope The standard spellbook only. Ancients, Lunar and Arceuus are 32 more rows on this same machinery — all 44 components are already read, with levels and rune costs, in `.memory` — and are deliberately absent until their destinations get the same three-source treatment. Deliberately not shipped, each for a structural reason rather than a data one: * **House (40)** needs a POH; **Bounty Target (85)** needs a bounty system. * **Ape Atoll (64)** and **Kourend (69)** have no ported destination — Kourend is 2016 content the sister project predates entirely. * **Home teleports** need a cooldown timestamp that survives logout, and `World.Player` is at the 32-key cliff. `World.Timers` would reset at logout and a varp is client-visible; that is a design decision, not a missing row. ## Verification Gates match clean `main` exactly, measured by stashing every working change and re-running rather than inferred. Worth flagging separately: **#131's baseline is stale.** It records 2 Dialyzer errors; there are now **12**, and the extra ten are `callback_arg_type_mismatch` / `callback_type_mismatch` in `test/support/scripts.ex` that arrived with the PvP merge — confirmed present on clean `main` with this branch stashed. They look like a genuine `World.Script` callback type problem rather than an environmental quirk.
Seven teleports — Varrock, Lumbridge, Falador, Camelot, Ardougne, Watchtower
and Trollheim — cast from the spellbook, spending runes, paying Magic
experience, and moving the player three ticks later.

The first transport the world has had. Everything before this walked, and the
world is not built for walking: content past early game assumes a player
teleports close and covers the last stretch on foot, which is why a farming
run works at all.

## Where each column comes from

`level` and `runes` are the **cache's own**, read from interface 218's
`on_load` args at the offsets `World.SpellbookTest` already pins the combat
rows against — so a row that drifts from the operator's cache fails the suite
rather than surprising a player.

`destination` is the one column no client cache has ever carried. It is ported
from a 468-era table whose rune costs match this one **exactly**, which is the
real evidence: two independently maintained tables ten years apart agreeing on
every rune of every row does not leave the coordinates free to differ. Then
every landing tile is asserted unblocked and floored against the operator's
own cache, because a date is a claim and a walkable tile is a fact.

## The ceiling is why this is a contract

A teleport refuses above wilderness level 20, and that is not a nicety — it is
what makes deep wilderness a commitment. `Contract.Teleport` walks the
boundary tile by tile in its tests, because the whole rule is one level and
`>` versus `>=` is the mistake it invites.

Nothing here re-derives where the wilderness is: it asks
`World.Wilderness.level/1` and lets `Contract.Teleport` judge. A consumer
restating an engine rule narrower is a mistake this codebase has already
recorded twice.

## Ordering

Runes are spent at the click and the player moves three ticks later, which is
the rune altar's ordering and for the same reason: a player who logs out
mid-flight has paid, and that is the reading that cannot be farmed by
disconnecting. `Player.delay/2` covers the same three ticks so the cast cannot
be repeated while its animation plays.

## Scope

The standard spellbook only. Ancients, Lunar and Arceuus are 32 more rows on
this same machinery — all 44 components are already read, with levels and rune
costs, in the teleport inventory — and are deliberately absent until their
destinations get the same three-source treatment. House needs a POH and Bounty
Target needs a bounty system; Ape Atoll and Kourend have no ported
destination; home teleports need a cooldown timestamp that survives logout,
which `World.Player` has no room for at the 32-key cliff.
test(magic): what a player tries during a teleport's three ticks
All checks were successful
ci / gates (pull_request) Successful in 1m0s
build / image (push) Successful in 32s
ci / gates (push) Successful in 1m2s
4f64b208c4
Interrupting a teleport is one of the first things somebody will try, so it
was measured rather than reasoned about. Two of the three cases were already
right and now have regression tests; the third is #148.

  walk during the flight   still arrives      correct
  die during the flight    death wins         correct
  log out during flight    paid, did not move #148

The death case needed care to measure at all: cast from the Lumbridge spawn
tile and "respawned" is indistinguishable from "never left", because they are
the same coordinate — the first attempt fooled itself exactly that way. It now
casts from {0, 3000, 3300} so origin, destination and respawn are three
different places.

Also pins the ported animation ids against this cache rather than trusting
them. `714` and `715` share **frame archive 207**, which is the check that
means something — an id that merely decodes may belong to another rig. And
`714`'s last frame is a 20,000-cycle **hold**, so its real length is the first
eleven frames, 68 cycles, about 2.27 ticks: the test asserts that fits inside
the three-tick delay, because if it ever stops fitting the vanish pose shows.

The graphic height was wrong and is corrected to 0. It had been copied from
the rune altar's 100, which is what a graphic ABOVE THE HEAD wants; `111` is a
full-body swirl that starts at the feet. The cache settles neither —
`Cache.SpotAnim` carries no render height — so this is flagged for a sitting.
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!149
No description provided.