Author SHA1 Message Date
Eric WagonerandClaude Fable 5 64a6041a28 The first-person pane becomes an invitation, not a default
Through-your-eyes ships opt-in: the preferences toggle turns it on,
and only an explicit yes is remembered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:57:32 -04:00
Eric WagonerandClaude Fable 5 37d3265c67 Rules divide the checkbox stanza only
Between checkboxed rows and nowhere else — no frame above or below
the stanza, none around the control rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:52:58 -04:00
Eric WagonerandClaude Fable 5 95bfecb206 The preferences read as a ruled ledger
Rows rule apart with the games ledger's own brown line, fill the
panel's width so the checkbox column aligns, and control rows
right-justify their controls opposite their labels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:51:36 -04:00
Eric WagonerandClaude Fable 5 054b06c631 Preferences wear the table's ink; hand-drawn becomes alternate
The native checkboxes clashed with the parchment: they are now
stamp-bordered boxes checked solid with a parchment tick, and their
labels rag left with the box instead of centering. The token-art
toggle's second option reads "alternate".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:46:58 -04:00
Eric WagonerandClaude Fable 5 2206982c05 Forward rides above the glass, back below it
The strides map to the space they move you through: step-forward
centered in the top bezel, step-back centered in the bottom, matching
the hand's instinct from the old edge chevrons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:45:26 -04:00
Eric WagonerandClaude Fable 5 3bd7e3c81d Credibility pass: the duels leave the code, staying in the ledgers
Third pass, scoped from 7a32370. Three blind reviewers (engine, web,
server/tools) each concluded the work is coherent engineering with
seam-level tells; every finding was verified before touching a line.

Session biography left the comments: the bot brain's heuristics no
longer cite the opponent who taught them, the RNRX coma parenthetical
and the thief-chase citation are gone, the seat-wallet comments state
their invariants without the war stories, and process-named test
groups now name the behaviors they pin. The incident record lives
where history belongs — commit messages and the ledgers.

Structural dedup: one VISIONSTONE one-edge-sight loop serves both
LOS paths; one creature-arrival touch handler serves walking and
warp-stepping (error text aligned); one facingWedge helper draws both
keymap ribbons; one spriteVisibleInCol rule serves the draw pass and
the hover test (which also stops re-sorting per pointermove); and
deepestFacing joins the director, replacing four copied scans.

Test hardening exposed real rot the tells were hiding: the tight
CreatureState cast caught two literals with a bogus field masking
three missing ones; the number-hoarding rig had NEVER run (its
column didn't exist on seed 42 — it now carves its own geometry);
the bank-guard rig now drives the whole table to an arrival
assertion; the bent-trace test walls off straight sight so the bend
must answer. Silent `return`-on-rig-failure became loud throws, and
can-never-fail assertions were removed.

Sweep-up: the eyeTurn ghost comment, the stacked leave() doc
comments (leave now delegates to leaveLocal), the dead ternary in
the seat client, the kick handler's name-coercion drift, kick ledger
lines gain timestamps, archiveRoomFile reuses fileFor, the RULES_REV
alias retires in favor of the engine constant, hitTest un-exports,
the NUL-sentinel hover shape becomes an honest "none" variant, and
the steering holds get named constants. The bezel's stride cluster
also centers per the table's note.

288 tests, 24 ledgers verified, all workspaces typecheck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:43:24 -04:00
Eric WagonerandClaude Fable 5 7bd8eff11c The instruments move into the bezel; the windshield goes bare
Per the table's suggestion: chevrons and trays no longer overlay the
canvas — a frame holds them. The top bezel carries the ride controls,
the sharing-your-square portraits, and the hide button; tall turn
paddles flank the glass; the bottom bezel holds the strides, the
at-your-feet tray, hazard chips, the warp-step, and the key hint.
Every canvas pixel is now permanently hover-and-click target glass,
and the yield-while-targeting hack retires unneeded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:23:14 -04:00
Eric WagonerandClaude Fable 5 0530ecb039 The helm yields the glass while a card is aimed
The drive chevrons are buttons over the canvas edges, and the
back-stride strip owns the bottom band — exactly where an adjacent
square's floor renders. With a card selected, hovering there showed
no target and a click would have STEPPED backward instead of casting
(the tacks report: the pane refused what the map allowed). While a
card is aimed the strips now fade and stop catching the pointer, so
every pixel of the pane hovers and clicks as a target; the arrow keys
still steer throughout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:21:05 -04:00
Eric WagonerandClaude Fable 5 728f9fc28a The feet tray learns hazards and the warp step
Two blind spots close. The active body — wizard or ridden mount —
standing on a dimensional-warp token gets a "step through the warp"
button in the feet tray, dispatching warpStep for the wizard and
creatureWarpStep for the mount (the command the RNRX troll report
earned). And whatever you stand IN shows as a warning chip: tacks,
slime, dust, a pit, the ooze — the near plane's last secrets told.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:19:22 -04:00
Eric WagonerandClaude Fable 5 043a8b94a2 Bodies sharing your square get a tray of their own
A wizard or creature standing IN your square lives inside the
renderer's near plane — no sprite, nothing to click, though
same-square is exactly where punches, thieves, and a mount's claws
operate. A "sharing your square" tray now joins the cockpit above the
feet tray: co-located bodies as clickable portraits, routed through
the same target handler a sprite click uses — so a possessed skeleton
claws its square-mate and a wizard punches theirs, all from inside
the pane.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:13:46 -04:00
Eric WagonerandClaude Fable 5 e499df22d7 The facing ribbon rides the mount
While the pane possesses a creature, the keymap's golden ribbon moves
to the mount's token — same shallow triangle, sized to the smaller
body, turning with the borrowed eyes — and leaves the wizard's brow
until the rider returns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:02:28 -04:00
Eric WagonerandClaude Fable 5 714c5bef80 Dismounting hands back the exact view you left
Stepping out of a creature's skull treated the return as a fresh cut,
and fresh cuts face the deepest corridor — spinning the rider 90
degrees from where they had been looking. The saddle now remembers:
your position and facing are saved at mount and restored verbatim on
every dismount path (the button, the turn passing, the mount dying),
with no cut and no re-aim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:01:18 -04:00
Eric WagonerandClaude Fable 5 1595118410 Possession: ride your creature's eyes; the cockpit stops flinching
The pane learns the menagerie. On your turn, a "ride" button appears
for each creature you command with moves or claws unspent; mounting
seats the camera in its skull facing its longest corridor, hides its
own hide from its eyes, and hands it the helm — chevrons and arrows
stride IT, an adjacent floor click steps it, and clicking an enemy
sharing its square lands its blow. A banner shows the mount's
remaining legs; one tap (or the turn passing, or the mount dying)
returns you to your own eyes, where the director reseats you fresh.

Two cockpit-dignity fixes from live play ride along: a cutaway to
your own body now plays only for things done TO you — dropping a
dagger no longer flings your eyes out of your skull — and a standing
camera is never "rescued" off a wall you deliberately faced; the
deepest-corridor default applies only to fresh cuts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 14:56:29 -04:00
Eric WagonerandClaude Fable 5 5f91e30eb4 Warps preserve your heading, not your bearing
Stepping through a rim warp spun the live camera 180: the director
aimed along the displacement vector, and a top-to-bottom wrap's
vector points exactly opposite the walk. The exit facing now comes
from the warp's own geometry — the far mouth's inward direction —
so travel carries straight through plain wraps and rotates correctly
where boards were laid rotated; a dimensional token step keeps the
facing it entered with.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 14:51:56 -04:00
Eric WagonerandClaude Fable 5 770e79d063 The ribbon brightens and grows a hair
Full-strength marigold instead of muted brass, and the rise goes to a
quarter of the token's width.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 14:48:18 -04:00
Eric WagonerandClaude Fable 5 a254e46c5e The facing mark becomes a ribbon on the token's brow
Per the table's taste: not a needle but a short wide golden triangle
hugging the token's leading edge — base as wide as the token itself,
rising a fifth of that width outward, turning with the pane's
quarter-turns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 14:46:56 -04:00
Eric WagonerandClaude Fable 5 19752e55e1 Forfeit legs earn no scolding; the pane sees your boots
Picking up an object ends the turn's actions AND movement — yet the
end-turn caution still tallied the unspendable points as regret. The
unspent-movement reason now stays quiet once actions have ended:
those points cannot be spent, only mourned.

And the raycaster has no downward pitch, so whatever lay in your own
square — a treasure, a dropped blade — was invisible from inside the
cockpit. An "at your feet" tray now rides the pane's bottom edge on
your turn: your square's floor treasures and objects as clickable
icons, each a one-tap pickup through the same commands the board's
buttons send.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 13:51:27 -04:00
Eric WagonerandClaude Fable 5 3d2bf17585 Absence is not evidence: the seat wallet trusts only verdicts
The games-list reply pruned any wallet seat the server did not vouch
for — so one poll against a restarting (or wrong) server silently
deleted live credentials, the second seat-burning path this session's
server-squatting exposed. myGames now returns explicit verdicts:
a seat is voided only when its room EXISTS and refused that exact
token. Absent rooms earn no verdict and absent seats survive; the
wallet caps by age at fifty instead of by trust.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-26 14:03:29 -04:00
Eric WagonerandClaude Fable 5 72148dd7c2 A missing room must not burn the seat
The client deleted its saved seat credentials on "no such room" — but
that error proves nothing about the seat: a restarting server, a
stale restore, or a wrong backend all say it, and burning the token
on a transient locked a live player out of a live game (room 22EC,
after this session's server-squatting incident). Now only "name is
taken" — this exact token tried and refused — clears the credential;
a not-found shows its error and keeps the key for the reconnect that
finds the room home again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-26 14:00:30 -04:00
Eric WagonerandClaude Fable 5 b396469bea The facing needle surfaces from under the token
The keymap's facing wedge rendered BENEATH the wizard's own art, its
tips a pixel past the portrait's edge — present in the DOM, invisible
at the table. It now draws above the token as a proper compass
needle, tip half a cell out, dark-stroked for contrast. Verified in
rendered pixels this time, not element existence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-26 13:53:56 -04:00
Eric WagonerandClaude Fable 5 22c443d2c5 The crosshair answers before the click commits
Hovering the pane now shows what a click would do: a wizard or
creature outlines in gold with its name at the crosshair, a wall or
door face lights across every column it owns, and a floor square
draws its perspective-true quad on the ground. Warp-bent ground still
targets truly but labels itself "through the warp" rather than
drawing a quad in the wrong geometry. And when a cell-target card is
selected, the ineligible ground dims in the 3D view itself — the same
litCells the board's shadow uses, rasterized into the floor pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-26 12:35:12 -04:00
20 changed files with 802 additions and 298 deletions
+3 -2
View File
@@ -95,8 +95,9 @@ probing legality is free, so when unsure, try it and read the error.
## Game-three scars (lost in four rounds)
- **CHECK `dimWarps` EVERY SINGLE VIEW** — Kestrel has won two games
with quietly-placed wormholes ending beside his home. The client now
- **CHECK `dimWarps` EVERY SINGLE VIEW** — Kestrel (Eric's wizard) has
won two games
with quietly-placed wormholes ending beside his home. The client
prints a !! line for them; treat any new token pair as a five-alarm
fire and recompute both players' delivery distances through it.
- **Never lead the attack against Kestrel.** Three games, three saved
+12 -11
View File
@@ -296,13 +296,13 @@ function pathDenial(view: GameView): Command | null {
}
// Enemy gold banked at MY home: my score, snatchable by anyone. A
// raider closing on the bank gets the same road-lengthening treatment.
const me2 = me(view);
const self = me(view);
const banked = view.treasures.find(
(t) => t.owner !== view.you && t.position && cellKey(t.position) === cellKey(me2.home));
(t) => t.owner !== view.you && t.position && cellKey(t.position) === cellKey(self.home));
if (banked) {
for (const e of livingEnemies(view)) {
const d = Math.abs(e.position.x - me2.home.x) + Math.abs(e.position.y - me2.home.y);
if (d <= 6) threats.push({ enemy: e.position, goal: me2.home });
const d = Math.abs(e.position.x - self.home.x) + Math.abs(e.position.y - self.home.y);
if (d <= 6) threats.push({ enemy: e.position, goal: self.home });
}
}
if (threats.length === 0) return null;
@@ -758,9 +758,8 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
}
// The LAST counter in hand is a treasure of its own: at a healthy life
// total it waits for something big rather than answering every jab
// patience the clockwork's best opponent taught it, three counters at
// a time. (Lethal blows override this below.)
// total it waits for something big rather than answering every jab.
// (Lethal blows override this below.)
const counterCount = view.yourHand.filter((c) => {
const t = cardDef(c.cardId).cardType;
return t === "counteraction" || t === "neutral/counteraction";
@@ -797,6 +796,8 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
// when the incoming points reach the clockwork's life, any counter
// that can save it is cheap at the price.
const lethal = !affliction && incoming >= me(view).life;
// 9 clears every thrift threshold below: a killing blow is answered
// with whatever can answer it, price be damned.
const weight = lethal ? Math.max(incoming, 9) : incoming;
if (stack.kind === "spell") {
// REVERSE eats points; a pure duration offers it nothing.
@@ -943,6 +944,7 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
if (sd && biggest) {
const shown = target.displayed.filter((c) => STONES.has(c.cardId)).length;
const dmg = biggestValue * shown;
// `best` is assigned only inside offer(), so TS narrows it to null here.
const cur = best as BestPick | null;
if (dmg >= 4 && (!cur || (dmg >= target.life && !cur.kill) || dmg > cur.damage)) {
best = {
@@ -1132,9 +1134,8 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
}
}
// INFRASTRUCTURE: a wormhole anchored beside home turns every future
// delivery into a three-move stroll — the pattern that won its best
// opponent two games. Placed early, from home's doorstep, far mouth
// by the richest distant gold.
// delivery into a three-move stroll. Placed early, from home's
// doorstep, far mouth by the richest distant gold.
{
const dwarp = inHand(view, "dimensional-warp");
const nearHome = Math.abs(self.position.x - self.home.x) + Math.abs(self.position.y - self.home.y) <= 1;
@@ -1547,7 +1548,7 @@ export function automatonCommand(
const bankedCount = view.treasures.filter(
(t) => t.owner !== you && t.position && cellKey(t.position) === cellKey(self.home)).length;
// The predicate reads only ENEMY positions — my own steps must not
// flip it mid-march or the walker shuttles (the thief-chase lesson).
// flip it mid-march or the walker shuttles between goals.
const bankThreatened = bankedCount > 0 && !self.carriedTreasureId &&
livingEnemies(view).some((p) =>
!p.carriedTreasureId &&
+46 -54
View File
@@ -389,13 +389,11 @@ function doorsAjar(state: GameState, viewerId: PlayerId | undefined, board: Asse
}
/** LOS including square-filling blockers. */
function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean {
const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state)));
const blockers = losBlockers(state);
if (sightBetween(board, from, to, blockers)) return true;
if (!stone) return false;
const viewer = viewerId ? state.players.find((p) => p.id === viewerId) : undefined;
if (!viewer || !viewer.alive || !displays(viewer, "visionstone")) return false;
/** Sight granted by removing any ONE closed edge the VISIONSTONE's
* whole power, shared by every path that honors it. */
function sightThroughOneEdge(
board: AssembledBoard, from: Cell, to: Cell, blockers: Record<string, true>,
): boolean {
for (const key of Object.keys(board.edges)) {
if ((board.edges[key] ?? "open") === "open") continue;
const edges = { ...board.edges };
@@ -405,11 +403,21 @@ function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | un
return false;
}
function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean {
const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state)));
const blockers = losBlockers(state);
if (sightBetween(board, from, to, blockers)) return true;
if (!stone) return false;
const viewer = viewerId ? state.players.find((p) => p.id === viewerId) : undefined;
if (!viewer || !viewer.alive || !displays(viewer, "visionstone")) return false;
return sightThroughOneEdge(board, from, to, blockers);
}
/** LOS including square-filling blockers. VISIONSTONE is the bearer's
* sight wherever sight is asked of them creations, dispels, utility
* spells not just attacks: one wall or door, any type, falls away.
* Safe at every revision: widening what a cast may target never changes
* how a recorded command replays. */
* Ungated by design: validation may widen without a rev bump, because
* refused commands never entered any ledger. */
export function gameLos(state: GameState, from: Cell, to: Cell, viewerId?: PlayerId): boolean {
return losWith(state, from, to, viewerId, true);
}
@@ -472,13 +480,7 @@ function casterLos(
const blockers = losBlockers(state);
if (sightBetween(board, from, to, blockers)) return true;
if (!displays(caster, "visionstone")) return false;
for (const key of Object.keys(board.edges)) {
if ((board.edges[key] ?? "open") === "open") continue;
const edges = { ...board.edges };
delete edges[key];
if (sightBetween({ ...board, edges }, from, to, blockers)) return true;
}
return false;
return sightThroughOneEdge(board, from, to, blockers);
}
/** Bent LOS for AROUND THE CORNER: caster sees a middle cell, which sees the target. */
@@ -2941,6 +2943,31 @@ function impCheck(state: GameState, events: GameEvent[], onlyPlayer?: PlayerId):
checkVictory(state, events);
}
/** A creature arriving in a player's square touches: the wraith's chill
* and the democratic monster's claw open a counteraction window ("REFLECTIONs
* used on the wraith's touch will damage the wraith" the card assumes
* exactly this window). True = a stack opened and the turn should return.
* "May attack only one player per round of turns" spends attackUsed. */
function creatureTouchOnArrival(state: GameState, events: GameEvent[], creature: CreatureState): boolean {
for (const p of state.players) {
if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue;
if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator
if (creature.kind === "wraith" && !creature.attackUsed) {
creature.attackUsed = true;
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
openCreatureStack(state, creature, p, 2, "wraith");
return true;
}
if (creature.kind === "democratic-monster" && !creature.attackUsed && !creature.justCreated) {
creature.attackUsed = true;
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
openCreatureStack(state, creature, p, 2, "claw");
return true;
}
}
return false;
}
/** A creature standing on a DIMENSIONAL WARP token steps through it like
* any walker: its commander spends one of its moves, solid stone on the
* far side refuses it, and a monster is no braver than a wizard about
@@ -2964,7 +2991,7 @@ function doCreatureWarpStep(prev: GameState, creatureId: string): CommandResult
if (state.squareContents[cellKey(dest)]?.kind === "stone") return err("the far side is solid stone");
if (state.players.some((o) => o.alive && cellKey(o.position) === cellKey(dest) &&
sustainedOn(state, o.id, "big-man").length > 0)) {
return err("a giant fills that square");
return err("a giant fills that corridor");
}
const from = creature.position;
// FEAR holds monsters off too: "no player or monster".
@@ -2972,23 +2999,7 @@ function doCreatureWarpStep(prev: GameState, creatureId: string): CommandResult
creature.position = { ...dest };
creature.movementUsed++;
const events: GameEvent[] = [{ type: "creatureWarpStepped", creatureId, from, to: creature.position, by: active.id }];
// Touch effects on arriving in a player's square, as any step has.
for (const p of state.players) {
if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue;
if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue;
if (creature.kind === "wraith" && !creature.attackUsed) {
creature.attackUsed = true;
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
openCreatureStack(state, creature, p, 2, "wraith");
return { ok: true, state, events };
}
if (creature.kind === "democratic-monster" && !creature.attackUsed && !creature.justCreated) {
creature.attackUsed = true;
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
openCreatureStack(state, creature, p, 2, "claw");
return { ok: true, state, events };
}
}
creatureTouchOnArrival(state, events, creature);
return { ok: true, state, events };
}
@@ -3046,26 +3057,7 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C
}
creature.movementUsed++;
events.push({ type: "creatureMoved", creatureId: creature.id, from, to: creature.position, direction, by: active.id });
// Touch effects on entering a player's square.
for (const p of state.players) {
if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue;
if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator
if (creature.kind === "wraith" && !creature.attackUsed) {
creature.attackUsed = true;
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
// The victim may counteract ("REFLECTIONs used on the wraith's touch
// will damage the wraith" — the card assumes exactly this window).
openCreatureStack(state, creature, p, 2, "wraith");
return { ok: true, state, events };
}
if (creature.kind === "democratic-monster" && !creature.attackUsed && !creature.justCreated) {
creature.attackUsed = true; // "may attack only one player per round of turns"
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
openCreatureStack(state, creature, p, 2, "claw");
return { ok: true, state, events };
}
}
if (creatureTouchOnArrival(state, events, creature)) return { ok: true, state, events };
checkVictory(state, events);
return { ok: true, state, events };
}
+56 -31
View File
@@ -757,7 +757,7 @@ describe("the thief-chase holds one goal per turn", () => {
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "open";
// A number card and no attacks: the blow the chase serves cannot land.
bot.hand = [];
bot.hand.push({ cardId: "number-2", instanceId: "N2" } as never);
bot.hand.push({ cardId: "number-2", instanceId: "N2" });
const visited = [cellKey(bot.position)];
for (let guard = 0; guard < 40; guard++) {
const view = viewFor(state, "bot");
@@ -776,8 +776,9 @@ describe("the thief-chase holds one goal per turn", () => {
});
});
describe("the archmage's sharpened instincts", () => {
function botTurn(seed = 42) {
describe("the archmage's key, shield, and race discipline", () => {
function botTurn() {
const seed = 42;
let { state } = createGame({ playerIds: ["foe", "bot"], seed, sets: ["basic", "expansion1"] });
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
@@ -790,7 +791,7 @@ describe("the archmage's sharpened instincts", () => {
it("a MASTER KEY drawn goes straight on display", () => {
const state = botTurn();
const bot = state.players.find((p) => p.id === "bot")!;
bot.hand.push({ cardId: "master-key", instanceId: "MK" } as never);
bot.hand.push({ cardId: "master-key", instanceId: "MK" });
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toEqual({ type: "cast", instanceId: "MK" });
});
@@ -806,8 +807,8 @@ describe("the archmage's sharpened instincts", () => {
const bot = state.players.find((p) => p.id === "bot")!;
bot.position = { ...foe.position };
bot.life = 2;
bot.hand = [{ cardId: "full-shield", instanceId: "FS" } as never];
foe.hand.push({ cardId: "fireball", instanceId: "FB" } as never);
bot.hand = [{ cardId: "full-shield", instanceId: "FS" }];
foe.hand.push({ cardId: "fireball", instanceId: "FB" });
const r = applyCommand(state, "foe", {
type: "cast", instanceId: "FB", target: { kind: "player", playerId: "bot" },
});
@@ -833,15 +834,20 @@ describe("the archmage's sharpened instincts", () => {
gold[1]!.position = null;
bot.carriedTreasureId = gold[1]!.id;
foe.position = { ...bot.home }; // a foe in sight: the war chest would hoard
bot.hand = [
{ cardId: "fireball", instanceId: "FB" } as never,
{ cardId: "number-3", instanceId: "N3" } as never,
];
// Stand the bot a straight, open four squares from home.
// Legs only: an attack in hand would fire at the foe in sight and
// open a stack this single-seat loop cannot answer.
bot.hand = [{ cardId: "number-3", instanceId: "N3" }];
// Carve a straight, open four-square run to home; the rig owns its
// geometry rather than praying the seed provides it.
const home = bot.home;
const column = [0, 1, 2, 3, 4].map((d) => ({ x: home.x, y: home.y + d }));
if (!column.every((c) => viewFor(state, "bot").board.cells[cellKey(c)])) return;
bot.position = { ...column[4]! };
const column = [4, 3, 2, 1, 0].map((d) => ({ x: home.x, y: home.y - d }));
for (const c of column) {
if (!viewFor(state, "bot").board.cells[cellKey(c)]) {
throw new Error("setup: the column north of home runs off the board");
}
delete state.squareContents[cellKey(c)];
}
bot.position = { ...column[0]! };
for (const c of column.slice(0, 4)) state.edgeOverrides[edgeKey(c, "S")] = "open";
for (let guard = 0; guard < 12; guard++) {
const view = viewFor(state, "bot");
@@ -857,8 +863,9 @@ describe("the archmage's sharpened instincts", () => {
});
});
describe("lessons from the human duels", () => {
function rig(players = ["foe", "bot"]) {
describe("bank guarding, teleport delivery, and turn theft", () => {
function rig() {
const players = ["foe", "bot"];
let { state } = createGame({ playerIds: players, seed: 42, sets: ["basic", "expansion1"] });
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
@@ -878,9 +885,28 @@ describe("lessons from the human duels", () => {
foe.position = { x: bot.home.x, y: bot.home.y + 1 };
bot.position = { ...foe.home };
bot.hand = [];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
// Whatever step it picks, the march must be TOWARD home, not the gold map.
expect(cmd?.type).toBe("move");
// The march may wind through the maze; what matters is that it ENDS
// at the threatened bank, not out on the gold map.
let cur = state;
for (let guard = 0; guard < 60; guard++) {
const seat = actingSeat(cur);
if (seat !== "bot") {
// The foe answers stacks with a pass and burns its turns.
let r2 = applyCommand(cur, seat, { type: "pass" });
if (!r2.ok) r2 = applyCommand(cur, seat, { type: "endTurn", draw: 0 });
if (!r2.ok) throw new Error(`foe stuck: ${r2.error}`);
cur = r2.state;
continue;
}
const view = viewFor(cur, "bot");
const cmd = automatonCommand(view, "hunter", "archmage") ?? automatonFallback(view, "archmage");
const r = applyCommand(cur, "bot", cmd);
if (!r.ok) throw new Error(`refused: ${JSON.stringify(cmd)}${r.error}`);
cur = r.state;
const at = cur.players.find((p) => p.id === "bot")!;
if (cellKey(at.position) === cellKey(bot.home)) return;
}
throw new Error("the clockwork never came home to its threatened bank");
});
it("carrying with home a blink away, it teleports the delivery", () => {
@@ -895,7 +921,7 @@ describe("lessons from the human duels", () => {
for (const side of ["N", "S", "E", "W"] as const) {
state.edgeOverrides[edgeKey(bot.position, side)] = "wall";
}
bot.hand = [{ cardId: "teleport", instanceId: "TP" } as never];
bot.hand = [{ cardId: "teleport", instanceId: "TP" }];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toEqual({ type: "cast", instanceId: "TP", target: { kind: "cell", cell: { ...bot.home } } });
});
@@ -908,15 +934,15 @@ describe("lessons from the human duels", () => {
const banked = state.treasures.find((t) => t.owner === "bot")!;
banked.position = { ...foe.home };
const carried = state.treasures.filter((t) => t.owner === "bot")[1];
if (!carried) return;
if (!carried) throw new Error("setup: the bot owns fewer than two treasures");
carried.carriedBy = "foe";
carried.position = null;
foe.carriedTreasureId = carried.id;
foe.position = { ...bot.position };
bot.hand = [
{ cardId: "fireball", instanceId: "FB" } as never,
{ cardId: "lightning-blast", instanceId: "LB" } as never,
{ cardId: "number-4", instanceId: "N4" } as never,
{ cardId: "fireball", instanceId: "FB" },
{ cardId: "lightning-blast", instanceId: "LB" },
{ cardId: "number-4", instanceId: "N4" },
];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "LB" });
@@ -924,10 +950,10 @@ describe("lessons from the human duels", () => {
});
describe("the denial planner offers only castable blocks", () => {
it("tacks at range are never proposed — the RNRX coma", () => {
// Room RNRX froze Automaton II for three turns: pathDenial proposed
// scattering tacks four squares away, the engine refused ("you must
// be adjacent"), and the refusal-fallback loop read as a coma.
it("tacks are offered only at the caster's feet", () => {
// TACKS demand adjacency; a planner that proposes them at range has
// its cast refused every turn, and the refusal-fallback loop reads
// as an idle bot.
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
@@ -941,14 +967,13 @@ describe("the denial planner offers only castable blocks", () => {
const gold = state.treasures.find((t) => t.owner === "bot" && t.position)!;
foe.position = { ...gold.position! };
bot.position = { ...bot.home };
bot.hand = [{ cardId: "handful-of-tacks", instanceId: "HT" } as never];
bot.hand = [{ cardId: "handful-of-tacks", instanceId: "HT" }];
for (let guard = 0; guard < 6; guard++) {
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
if (!cmd) break;
const r = applyCommand(state, "bot", cmd);
// Whatever the brain proposes, the engine must accept it.
expect(r.ok, `refused: ${JSON.stringify(cmd)}${!r.ok ? r.error : ""}`).toBe(true);
if (!r.ok) break;
if (!r.ok) throw new Error(`brain proposed a refused command: ${JSON.stringify(cmd)}${r.error}`);
state = r.state;
if (cmd.type === "endTurn") break;
}
+6 -5
View File
@@ -1186,12 +1186,13 @@ describe("the bent sight-trace shows AROUND THE CORNER's legs", () => {
powerAttackPoints: 0, params: null, kind: "spell",
counters: [], waitingOn: d.id, bentCorner: true,
};
// Wall the straight diagonal shut so only the bend can answer.
state.edgeOverrides[edgeKey({ x: 2, y: 2 }, "S")] = "wall";
state.edgeOverrides[edgeKey({ x: 2, y: 3 }, "E")] = "wall";
const traced = stackSightTrace(viewFor(state, d.id));
expect(traced).not.toBeNull();
if (traced) {
// Whether sight ran straight (free-angle found a gap) or bent, the
// overlay has something to draw; a bend names its middle square.
if (traced.bend) expect(traced.bend.mid).toBeDefined();
}
// The engine promises A workable corner, not which one.
expect(traced?.bend).toBeDefined();
expect(traced?.bend?.mid).toBeDefined();
});
});
+9 -9
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
import {
type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers";
@@ -709,7 +710,7 @@ describe("fear holds off monsters and unwilling feet alike", () => {
damage: 0, maxDamage: 6, movesPerTurn: 3, movementUsed: 0,
wallPassesPerTurn: 0, wallPassUsed: 0, attackUsed: false, justCreated: false,
scorchedThisTurn: [],
} as never);
});
for (const y of [5, 6, 7, 8]) state.edgeOverrides[edgeKey({ x: 2, y }, "S")] = "open";
while (state.players[state.turn.activeIndex]!.id !== "a") {
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
@@ -749,7 +750,6 @@ describe("fear holds off monsters and unwilling feet alike", () => {
// The closer step is the only way out: permitted.
const r = applyCommand(state, "a", { type: "move", direction: "S" });
if (!r.ok) throw new Error("escape step refused: " + r.error);
expect(r.ok).toBe(true);
});
});
@@ -760,9 +760,9 @@ describe("creatures and the dimensional warp", () => {
state.dimWarps.push({ a: { x: 1, y: 1 }, b: { x: 3, y: 8 } });
state.creatures.push({
id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 },
life: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0,
} as never);
damage: 0, maxDamage: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
});
const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" });
expect(r.ok).toBe(true);
if (r.ok) {
@@ -779,9 +779,9 @@ describe("creatures and the dimensional warp", () => {
state.squareContents[cellKey({ x: 3, y: 8 })] = { kind: "stone", damage: 0, createdBy: "b" };
state.creatures.push({
id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 },
life: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0,
} as never);
damage: 0, maxDamage: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
});
const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("stone");
@@ -488,7 +488,9 @@ describe("a held door is an open doorway to the eye", () => {
// no longer on their threshold; clear their path to the doorway.
const near = neighbor(cell, side);
const far = neighbor(near, side);
if (!boardView(state).cells[cellKey(far)]) return; // the maze ends here; geometry unavailable
if (!boardView(state).cells[cellKey(far)]) {
throw new Error("setup: seed 42 lost the hallway this rig stands in");
}
state.players.find((p) => p.id === pursuer)!.position = far;
state.edgeOverrides[edgeKey(near, side)] = "open";
const rm = giveCard(state, holder, "remove-lock");
+15 -5
View File
@@ -396,6 +396,7 @@ function botRemark(room: Room, actor: string, events: { type: string; [k: string
}
}
}
/** Spoken when a clockwork's chosen command was refused by the engine —
* the table sees a stumble instead of an unexplained idle turn. */
const HESITATION_LINES = [
@@ -549,11 +550,12 @@ wss.on("connection", (socket) => {
case "kickSeat": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const problem = kickSeat(room, session.playerId, String(msg.name ?? ""));
const kickName = String(msg.name ?? "");
const problem = kickSeat(room, session.playerId, kickName);
if (problem) return send(socket, { type: "error", message: problem });
// A kicked live socket is set adrift so it cannot act on a seat it lost.
for (const other of sessions) {
if (other.roomId === room.id && other.playerId === msg.name) {
if (other.roomId === room.id && other.playerId === kickName) {
other.playerId = null;
other.roomId = null;
other.token = null;
@@ -732,18 +734,26 @@ wss.on("connection", (socket) => {
break;
}
case "myGames": {
// {seats: [{roomId, name, token}]} -> summaries for valid seats.
// {seats: [{roomId, name, token}]} -> summaries for valid seats,
// plus explicit verdicts on seats PROVEN dead: the room exists
// and refused this exact token. A room this server simply does
// not know earns no verdict — absence is not evidence (a
// restarting or wrong server knows nothing about anything).
const seats = Array.isArray(msg.seats) ? msg.seats.slice(0, MAX_MYGAMES_SEATS) : [];
const games = [];
const voided: string[] = [];
for (const seat of seats) {
if (typeof seat !== "object" || seat === null) continue;
const room = getRoom(String(seat.roomId ?? ""));
if (!room) continue;
const name = String(seat.name ?? "");
if (!seatTokenValid(room, name, typeof seat.token === "string" ? seat.token : null)) continue;
if (!seatTokenValid(room, name, typeof seat.token === "string" ? seat.token : null)) {
voided.push(`${room.id}:${name}`);
continue;
}
games.push(summarize(room, name));
}
send(socket, { type: "games", games });
send(socket, { type: "games", games, voided });
break;
}
default:
+7 -12
View File
@@ -54,11 +54,6 @@ export interface Room {
const rooms = new Map<string, Room>();
/** Rules revision new games are dealt under (stored games keep their own).
* A rules change while games are live must bump this and gate the engine;
* local hotseat games ride the engine's default and follow in lockstep. */
const RULES_REV = CURRENT_RULES_REV;
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
/** Tokens live hashed at rest (memory and disk); clients hold the raw form. */
@@ -164,7 +159,7 @@ export function kickSeat(room: Room, byId: PlayerId, name: PlayerId): string | n
room.tokens.delete(name);
room.bots.delete(name);
room.colorChoices.delete(name);
appendLine(room.id, { kind: "kick", name });
appendLine(room.id, { kind: "kick", name, at: new Date().toISOString() });
recordRoom(room);
return null;
}
@@ -228,9 +223,9 @@ function startInMemory(room: Room, expansion: boolean, colors?: number[], deckRe
export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } {
if (room.state) return { error: "already started" };
const colors = resolveColors(room);
const result = startInMemory(room, expansion, colors, RULES_REV);
const result = startInMemory(room, expansion, colors, CURRENT_RULES_REV);
if ("error" in result) return result;
appendLine(room.id, { kind: "start", expansion, colors, deckRev: RULES_REV });
appendLine(room.id, { kind: "start", expansion, colors, deckRev: CURRENT_RULES_REV });
recordRoom(room);
return result;
}
@@ -320,22 +315,22 @@ function actingSeat(room: Room): PlayerId | null {
*/
export function driveOneAutomaton(
room: Room,
): { seat: PlayerId; events: GameEvent[]; hesitated?: { refused: Command; error: string } } | null {
): { seat: PlayerId; events: GameEvent[]; hesitated?: true } | null {
const seat = actingSeat(room);
if (!seat || !room.bots.has(seat)) return null;
const view = viewFor(room.state!, seat);
const bot = room.bots.get(seat);
const chosen = automatonCommand(view, bot?.style, bot?.tier);
let hesitated: { refused: Command; error: string } | undefined;
let hesitated: true | undefined;
let r = runCommand(room, seat, chosen ?? automatonFallback(view, bot?.tier));
if ("error" in r) {
// A refused choice retries with the fallback — unless the fallback IS
// what just failed — then burns down the ladder to endTurn and pass.
// The refusal is remembered: a brain whose choice the engine rejects
// is a bug in the brain, and the table deserves to see the stumble
// rather than an unexplained idle turn (the RNRX coma lesson).
// rather than an unexplained idle turn.
if (chosen) {
hesitated = { refused: chosen, error: r.error };
hesitated = true;
console.warn(
`automaton ${seat} hesitated in ${room.id}: ${JSON.stringify(chosen)} refused (${r.error})`);
r = runCommand(room, seat, automatonFallback(view, bot?.tier));
+2 -1
View File
@@ -62,6 +62,7 @@ export interface KickLine {
kind: "kick";
/** The seat the host removed from an unstarted room. */
name: string;
at: string;
}
export interface AbandonLine {
@@ -119,7 +120,7 @@ export function readAllRooms(): Map<string, RoomLine[]> {
/** Retire an abandoned room's ledger to the graveyard — never deleted,
* only moved out of the living rooms directory. */
export function archiveRoomFile(roomId: string): void {
const src = join(DATA_DIR, `${roomId}.jsonl`);
const src = fileFor(roomId);
if (!existsSync(src)) return;
const graveyard = join(DATA_DIR, "..", "rooms-abandoned");
mkdirSync(graveyard, { recursive: true });
+66 -11
View File
@@ -56,6 +56,8 @@
let fpBatch = $state<{ n: number; events: GameEvent[]; view: GameView } | null>(null);
/** The pane camera's compass quadrant, worn by the keymap token. */
let fpvFacing = $state<Side | null>(null);
/** The creature the pane is riding, if any: the ribbon moves to it. */
let fpvBody = $state<string | null>(null);
let fxCancels: (() => void)[] = [];
/** A trap drawn by YOU earns a modal; buried log lines get missed. */
let trapNotice = $state<string | null>(null);
@@ -971,8 +973,10 @@
if (view && me) {
const left = view.turn.movementAllowance - view.turn.movementUsed;
// Unspent legs are no regret when the walk ended ON someone's gold —
// stopping there was the point.
if (left > 0 && !treasuresHere.some((t) => t.owner !== view.you)) {
// stopping there was the point — nor when a pickup or drop already
// forfeited them: those points cannot be spent, only mourned.
if (left > 0 && !view.turn.actionsEnded &&
!treasuresHere.some((t) => t.owner !== view.you)) {
reasons.push(`${left} movement point${left === 1 ? "" : "s"} unspent`);
}
const limit = me.displayed.some((c) => c.cardId === "brainstone") ? 9 : 7;
@@ -1397,29 +1401,29 @@
<button class="stamp tiny" class:lit={prefs.art === "photo"}
onclick={() => setPref("art", "photo")}>cardboard</button>
<button class="stamp tiny" class:lit={prefs.art === "drawn"}
onclick={() => setPref("art", "drawn")}>hand-drawn</button>
onclick={() => setPref("art", "drawn")}>alternate</button>
</div>
<label class="pref-row">
<label class="pref-row pref-check">
<input type="checkbox" checked={prefs.autoGrab}
onchange={(e) => setPref("autoGrab", e.currentTarget.checked)} />
<span>Ending my turn on a lone treasure picks it up (never on my own home)</span>
</label>
<label class="pref-row">
<label class="pref-row pref-check">
<input type="checkbox" checked={prefs.flourishes}
onchange={(e) => setPref("flourishes", e.currentTarget.checked)} />
<span>Spell flourishes (the animated effects)</span>
</label>
<label class="pref-row">
<label class="pref-row pref-check">
<input type="checkbox" checked={prefs.liveFp}
onchange={(e) => setPref("liveFp", e.currentTarget.checked)} />
<span>Through your eyes: a first-person pane above the board during play</span>
</label>
<label class="pref-row">
<label class="pref-row pref-check">
<input type="checkbox" checked={prefs.instantReplay}
onchange={(e) => setPref("instantReplay", e.currentTarget.checked)} />
<span>Instant replay: notable chronicle lines offer a 👁 that relives the turn</span>
</label>
<label class="pref-row">
<label class="pref-row pref-check">
<input type="checkbox" checked={prefs.cautions}
onchange={(e) => setPref("cautions", e.currentTarget.checked)} />
<span>Ask "are you sure?" before easy-to-regret plays (unspent movement, capped draws, undropped deliveries)</span>
@@ -1901,7 +1905,16 @@
{#if prefs.liveFp && view.you && !net.spectating}
<LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)}
onstride={(side) => tryMove(side)} canStride={yourMoment}
ontarget={fpvTarget} onfacing={(s) => (fpvFacing = s)} />
ontarget={fpvTarget} onfacing={(s) => (fpvFacing = s)} {litCells}
onpickup={(w) => dispatch(w.kind === "treasure"
? { type: "pickUpTreasure", treasureId: w.id }
: { type: "pickUpObject", instanceId: w.id })}
onCreatureMove={(creatureId, direction) => dispatch({ type: "moveCreature", creatureId, direction })}
onCreatureAttack={(creatureId, targetId) => dispatch({ type: "creatureAttack", creatureId, targetId })}
onbody={(id) => (fpvBody = id)}
onWarpStep={(creatureId) => dispatch(creatureId
? { type: "creatureWarpStep", creatureId }
: { type: "warpStep" })} />
{/if}
<Board
{view}
@@ -1924,6 +1937,7 @@
{litCells}
{sightTrace}
povFacing={prefs.liveFp && view.you && !net.spectating ? fpvFacing : null}
povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null}
/>
</div>
</section>
@@ -2822,11 +2836,52 @@
.pref-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.55rem 0;
gap: 0.6rem;
width: 100%;
box-sizing: border-box;
margin: 0;
padding: 0.55rem 0.1rem;
font-size: 0.85rem;
color: #43331f;
text-align: left;
cursor: pointer;
}
/* Rules divide the checkbox stanza only — never framing it, never
around the control rows. */
.pref-check + .pref-check { border-top: 1.5px solid #6b5a41; }
/* Checkboxes wear the table's own ink: a stamp-bordered box, checked
solid with a parchment tick — the native widget never matches the
parchment. */
.pref-row input[type="checkbox"] {
appearance: none;
flex: none;
width: 1.05rem;
height: 1.05rem;
border: 1.5px solid #43331f;
border-radius: 3px;
background: #f6efdd;
cursor: pointer;
display: grid;
place-content: center;
margin: 0;
}
.pref-row input[type="checkbox"]:checked {
background: #43331f;
}
.pref-row input[type="checkbox"]:checked::before {
content: "✓";
color: #f6efdd;
font-size: 0.8rem;
line-height: 1;
font-weight: 700;
}
.pref-row span { flex: 1; }
/* Control rows read as a two-column table: the label holds the left
column, its controls right-align into the second. */
.pref-row > span:first-child { flex: 0 1 auto; }
.pref-row > span:first-child + .stamp,
.pref-row > span:first-child + input,
.pref-row > span:first-child + .pref-swatch { margin-left: auto; }
.pref-note { font-size: 0.72rem; color: #8a7a5e; margin-top: 0.6rem; }
.pref-name { max-width: 11rem; }
.pref-swatch {
+25 -10
View File
@@ -24,6 +24,7 @@
onEdgeClick,
onPlayerClick,
povFacing = null,
povCreatureId = null,
onCreatureClick,
onWarpClick,
onCellPeek,
@@ -47,6 +48,8 @@
/** Which way YOUR first-person camera faces: a wedge on your token
* keeps the keymap and the pane pointing the same way. */
povFacing?: "N" | "E" | "S" | "W" | null;
/** While the pane rides a creature, the ribbon moves to ITS token. */
povCreatureId?: string | null;
onCreatureClick?: (creatureId: string) => void;
onWarpClick?: (cell: { x: number; y: number }, side: Side) => void;
/** Long-press on a square: read the card behind whatever occupies it. */
@@ -117,6 +120,18 @@
}
// BIG MAN towers; SHRINK dwindles. The token says so at a glance.
/** The facing ribbon: a shallow golden triangle hugging a token's
* leading edge — base the token's width, rising a quarter of it. */
function facingWedge(side: "N" | "E" | "S" | "W", half: number): string {
const a = side === "E" ? 0 : side === "S" ? Math.PI / 2 : side === "W" ? Math.PI : -Math.PI / 2;
const fx = Math.cos(a), fy = Math.sin(a);
const px = -fy, py = fx;
const rise = half * 0.5;
return `${fx * half + px * half},${fy * half + py * half} ` +
`${fx * half - px * half},${fy * half - py * half} ` +
`${fx * (half + rise)},${fy * (half + rise)}`;
}
function wizardScale(id: string): number {
if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === id)) return 1.5;
if (view.sustained.some((e) => e.cardId === "shrink" && e.targetId === id)) return 0.62;
@@ -543,14 +558,6 @@
{@const cx = p.position.x * CELL + CELL / 2 + (group.length > 1 ? (gi - (group.length - 1) / 2) * 14 : 0)}
{@const cy = p.position.y * CELL + CELL * 0.36}
<g class="mover" class:snap={snapsTo(`w:${p.id}`, cx, cy)} style={`transform: translate(${cx}px, ${cy}px)`}>
{#if povFacing && p.id === view.you}
{@const fr = CELL * 0.34}
{@const fa = povFacing === "E" ? 0 : povFacing === "S" ? Math.PI / 2 : povFacing === "W" ? Math.PI : -Math.PI / 2}
<polygon
points={`${Math.cos(fa) * fr},${Math.sin(fa) * fr} ${Math.cos(fa + 2.6) * fr * 0.55},${Math.sin(fa + 2.6) * fr * 0.55} ${Math.cos(fa - 2.6) * fr * 0.55},${Math.sin(fa - 2.6) * fr * 0.55}`}
class="pov-wedge"
/>
{/if}
<g
role="button" tabindex="-1"
onclick={(ev) => { ev.stopPropagation(); if (peekFired) { peekFired = false; return; } onPlayerClick?.(p.id); }}
@@ -600,6 +607,9 @@
{/if}
{/if}
</g>
{#if povFacing && !povCreatureId && p.id === view.you}
<polygon points={facingWedge(povFacing, CELL * 0.3 * wizardScale(p.id))} class="pov-wedge" />
{/if}
</g>
{/each}
@@ -646,6 +656,10 @@
<text x="0" y="4" class="creature-label">{c.kind === "fire-imp" ? "I" : c.kind === "democratic-monster" ? "D" : c.kind[0]?.toUpperCase()}</text>
{/if}
</g>
{#if povFacing && povCreatureId === c.id}
<!-- The rider's facing ribbon, worn by the mount. -->
<polygon points={facingWedge(povFacing, CELL * 0.26)} class="pov-wedge" />
{/if}
</g>
{/each}
@@ -778,8 +792,9 @@
pointer-events: none;
}
.pov-wedge {
fill: #c9a72a;
opacity: 0.85;
fill: #ffd23e;
stroke: #43331f;
stroke-width: 1.2;
pointer-events: none;
}
.peekable { cursor: pointer; pointer-events: all; }
+340 -81
View File
@@ -9,13 +9,15 @@
// here first.
import FirstPerson from "./fpv/FirstPerson.svelte";
import type { FpvTarget } from "./fpv/raycast";
import { objectArt, tokenArt } from "./art";
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
import { castRay } from "./fpv/raycast";
import { castRay, SIDE_ANGLE, OPPOSITE } from "./fpv/raycast";
import { deepestFacing } from "./fpv/director";
import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
import { untrack } from "svelte";
import type { GameEvent, GameView, Side } from "@wizwar/engine";
let { view, batch, onhide, onstride = null, canStride = false, ontarget = null, onfacing = null }: {
let { view, batch, onhide, onstride = null, canStride = false, ontarget = null, onfacing = null, litCells = null, onpickup = null, onCreatureMove = null, onCreatureAttack = null, onbody = null, onWarpStep = null }: {
view: GameView;
/** The latest live event batch, numbered so each plays once, with
* the view the server sent alongside it. */
@@ -30,11 +32,125 @@
/** Reports the camera's compass quadrant whenever it settles on a new
* one — the keymap wears it as a wedge on your token. */
onfacing?: ((side: Side) => void) | null;
/** Cell-card eligibility, shared with the board's dimming aid. */
litCells?: Set<string> | null;
/** The at-your-feet tray's grab: the pane cannot look down, so what
* lies in your own square shows as clickable icons instead. */
onpickup?: ((w: { kind: "treasure" | "object"; id: string }) => void) | null;
/** Possession: ride a creature's eyes and drive it from the pane. */
onCreatureMove?: ((creatureId: string, side: Side) => void) | null;
onCreatureAttack?: ((creatureId: string, targetId: string) => void) | null;
/** Reports which creature (if any) the pane is riding, so the keymap
* can move the facing ribbon to the mount's token. */
onbody?: ((creatureId: string | null) => void) | null;
/** A dimensional-warp step for whichever body the pane is: the
* wizard (null) or the ridden creature (its id). */
onWarpStep?: ((creatureId: string | null) => void) | null;
} = $props();
const povId = $derived(view.you);
const me = $derived(view.players.find((p) => p.id === povId && p.alive) ?? null);
/** What lies in YOUR square, grabbable: floor treasures and dropped
* objects. The raycaster has no downward pitch — the tray is the eye's
* substitute for looking at your own boots. */
const atFeet = $derived.by(() => {
if (!me || me.carriedTreasureId) return [];
const k = `${me.position.x},${me.position.y}`;
const items: { kind: "treasure" | "object"; id: string; art: string | null; label: string }[] = [];
for (const t of view.treasures) {
if (!t.position || t.carriedBy) continue;
if (`${t.position.x},${t.position.y}` !== k) continue;
if (t.owner === view.you && k === `${me.home.x},${me.home.y}`) continue; // safe at home
const ci = view.players.find((p) => p.id === t.owner)?.colorIndex ?? 0;
items.push({ kind: "treasure", id: t.id, art: tokenArt(`treasure-${ci % 6}`, "objects"), label: `${t.owner}'s treasure` });
}
for (const o of view.groundObjects[k] ?? []) {
const file = objectArt(o.cardId);
items.push({ kind: "object", id: o.instanceId, art: file ? tokenArt(file, "objects") : null, label: o.cardId.replace(/-/g, " ") });
}
return items;
});
/** Riding a creature: the pane looks through ITS eyes and the helm
* drives ITS legs, until you step back into your own skull. */
let possessedId = $state<string | null>(null);
const possessed = $derived(
possessedId ? view.creatures.find((c) => c.id === possessedId) ?? null : null);
/** Creatures you may ride right now: yours (and the democratic
* monster), with movement or an attack still unspent. */
const rideable = $derived(canStride
? view.creatures.filter((c) =>
(c.controllerId === view.you || c.kind === "democratic-monster") &&
!c.justCreated &&
(c.movementUsed < c.movesPerTurn || !c.attackUsed))
: []);
/** The rider's own stance, held while riding: dismounting must hand
* back the exact view you left — same feet, same facing, no cut. */
let preMount: { x: number; y: number; facing: number } | null = null;
function dismount() {
possessedId = null;
if (preMount) {
cam.x = preMount.x; cam.y = preMount.y; cam.facing = preMount.facing;
preMount = null;
}
}
// A dead or vanished mount — or the turn passing — dismounts you.
$effect(() => {
if (possessedId && (!possessed || !canStride)) dismount();
});
// Mounting seats the eyes in the creature's skull facing its longest
// corridor; the rider steers from there.
let seatedMount: string | null = null;
$effect(() => {
if (!possessedId || !possessed) { seatedMount = null; return; }
if (seatedMount === possessedId) return;
seatedMount = possessedId;
preMount = untrack(() => ({ x: cam.x, y: cam.y, facing: cam.facing }));
const mx = possessed.position.x + 0.5, my = possessed.position.y + 0.5;
cam.x = mx; cam.y = my; cam.facing = deepestFacing(view, mx, my);
});
/** What the ACTIVE body stands in or on: hazards it cannot see below
* itself, and the dimensional token it could step through. */
const underfoot = $derived.by(() => {
const body = possessed ?? me;
if (!body || !canStride) return { hazard: null as string | null, warpHere: false };
const k = `${body.position.x},${body.position.y}`;
const HAZARD_NAMES: Record<string, string> = {
tacks: "tacks underfoot", slime: "green slime", dust: "a dust cloud",
pit: "an open pit", ooze: "the killer ooze",
};
const kind = view.squareContents[k]?.kind;
return {
hazard: kind ? HAZARD_NAMES[kind] ?? null : null,
warpHere: view.dimWarps.some((w) =>
(w.a.x === body.position.x && w.a.y === body.position.y) ||
(w.b.x === body.position.x && w.b.y === body.position.y)),
};
});
/** Bodies sharing the active eyes' square: inside the near plane, so
* the renderer cannot show them — the tray stands in for the sprite,
* and clicking routes exactly as clicking the sprite would. */
const withYou = $derived.by(() => {
const body = possessed ?? me;
if (!body || !canStride) return [];
const bx = body.position.x, by = body.position.y;
const items: { kind: "player" | "creature"; id: string; art: string; label: string }[] = [];
for (const p of view.players) {
if (!p.alive || p.id === view.you) continue;
if (p.position.x !== bx || p.position.y !== by) continue;
items.push({ kind: "player", id: p.id, art: tokenArt(`wizard-${p.colorIndex}`, "players"), label: p.id });
}
for (const c of view.creatures) {
if (c.id === possessedId) continue;
if (c.position.x !== bx || c.position.y !== by) continue;
items.push({ kind: "creature", id: c.id, art: tokenArt(c.kind, "creatures"), label: c.kind.replace(/-/g, " ") });
}
return items;
});
const cam = $state({ x: 0, y: 0, facing: 0 });
let camReady = false;
/** While the player steers, the director keeps its hands off the
@@ -43,11 +159,15 @@
let manualUntil = 0;
let turning = false;
/** Manual steering holds the director's idle aims off this long. */
const STEER_HOLD_MS = 4000;
/** Mounting holds them off for the whole ride. */
const RIDE_HOLD_MS = 60000;
const SIDES4 = ["E", "S", "W", "N"] as const;
function manualTurn(dir: -1 | 1) {
if (turning) return;
turning = true;
manualUntil = performance.now() + 4000;
manualUntil = performance.now() + STEER_HOLD_MS;
const from = cam.facing;
const to = from + dir * (Math.PI / 2);
const t0 = performance.now();
@@ -59,12 +179,34 @@
};
requestAnimationFrame(tick);
}
/** Pane clicks: a possessed mount claims them first — adjacent floor
* steps it, a same-square enemy takes its blow — and only the rider's
* own eyes pass clicks through to the board's handlers. */
function handleTarget(t: FpvTarget) {
if (!possessed) { ontarget?.(t); return; }
const c = possessed;
if (t.kind === "player" && onCreatureAttack) {
const p = view.players.find((q) => q.id === t.id);
if (p && p.position.x === c.position.x && p.position.y === c.position.y) {
onCreatureAttack(c.id, t.id);
return;
}
}
if (t.kind === "cell" && onCreatureMove) {
const dx = t.cell.x - c.position.x, dy = t.cell.y - c.position.y;
if (Math.abs(dx) + Math.abs(dy) === 1) {
onCreatureMove(c.id, dx === 1 ? "E" : dx === -1 ? "W" : dy === 1 ? "S" : "N");
}
}
}
function manualStride(back: boolean) {
if (!canStride || !onstride) return;
manualUntil = performance.now() + 4000;
if (!canStride) return;
manualUntil = performance.now() + STEER_HOLD_MS;
const q = Math.round(cam.facing / (Math.PI / 2));
const side = SIDES4[((q % 4) + 4 + (back ? 2 : 0)) % 4]!;
onstride(side);
if (possessed) onCreatureMove?.(possessed.id, side);
else onstride?.(side);
}
function onKey(e: KeyboardEvent) {
const t = e.target as HTMLElement | null;
@@ -133,6 +275,18 @@
const b = batch;
const m = me;
if (!m) { camReady = false; return; }
// Possessed: the director stands aside. The camera sits in the
// mount's skull, keeps whatever facing the rider has chosen, and
// hard-follows the mount's steps; dismounting reseats the wizard.
const mount = possessed;
if (mount) {
if (b) directedBatch = b.n; // consume batches so dismounting doesn't replay them
const mx = mount.position.x + 0.5, my = mount.position.y + 0.5;
if (Math.hypot(untrack(() => cam.x) - mx, untrack(() => cam.y) - my) > 0.01) {
cam.x = mx; cam.y = my;
}
return; // dismount() restores the rider's own stance, uncut
}
const v = view;
const tx = m.position.x + 0.5;
const ty = m.position.y + 0.5;
@@ -147,18 +301,31 @@
const events = b && b.n !== directedBatch ? b.events : [];
if (b) directedBatch = b.n;
const hurled = hurledIn(events, povId);
// Stepping through a warp keeps your HEADING, not your bearing to a
// point: you emerge walking out of the far mouth, so the exit facing
// is the far mouth's inward direction — travel preserved through a
// plain wrap, properly rotated where the boards were laid rotated.
// (A dimensional token has no mouths: facing simply carries over.)
let warpFacing: number | null = null;
for (const e of events) {
if (e.type === "moved" && e.player === povId && e.via === "warp") {
const w = v.board.warps.find((wp) =>
wp.from.cell.x === e.from.x && wp.from.cell.y === e.from.y &&
wp.to.cell.x === e.to.x && wp.to.cell.y === e.to.y);
if (w) warpFacing = SIDE_ANGLE[OPPOSITE[w.to.side]];
} else if (e.type === "warpStepped" && e.player === povId) {
warpFacing = camF;
}
}
const willCut = !camReady || (dist > 1.6 && !hurled);
cutawayShot = false;
const takeCutaway = (ax: number, ay: number) => {
let best = { d: -1, a: 0 };
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
const h = castRay(v, ax, ay, a);
if (h.dist > best.d) best = { d: h.dist, a };
}
const back = Math.min(1.6, Math.max(0.35, best.d - 0.4));
cam.x = ax + Math.cos(best.a) * back;
cam.y = ay + Math.sin(best.a) * back;
cam.facing = best.a + Math.PI;
const a = deepestFacing(v, ax, ay);
const d = castRay(v, ax, ay, a).dist;
const back = Math.min(1.6, Math.max(0.35, d - 0.4));
cam.x = ax + Math.cos(a) * back;
cam.y = ay + Math.sin(a) * back;
cam.facing = a + Math.PI;
camReady = true;
cutawayShot = true;
};
@@ -166,12 +333,18 @@
const aim = aimOfEvents(events, v, povId, m.position, actor);
let targetFacing = camF;
let aimed = false;
if (camReady && !willCut && dist > 0.05 && !hurled) {
if (warpFacing !== null) {
targetFacing = warpFacing;
aimed = true;
} else if (camReady && !willCut && dist > 0.05 && !hurled) {
targetFacing = Math.atan2(dy, dx);
aimed = true;
} else if (aim?.self) {
} else if (aim?.self && (actor !== povId || hurled)) {
// A cutaway to your own body is replay theater: in the cockpit it
// plays only for things done TO you, never for your own hands —
// dropping a dagger must not fling your eyes out of your skull.
return takeCutaway(m.position.x + 0.5, m.position.y + 0.5);
} else if (aim) {
} else if (aim && !aim.self) {
const ax = aim.x + 0.5, ay = aim.y + 0.5;
const toAim = Math.hypot(ax - tx, ay - ty);
const sight = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
@@ -191,15 +364,11 @@
}
}
}
if (!aimed && performance.now() >= manualUntil &&
(willCut || castRay(v, tx, ty, targetFacing).dist < 0.8)) {
// Nothing asks to be watched and the eyes would idle nose-to-brick:
// face the deepest corridor from where the wizard stands.
let deepest = -1;
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
const h = castRay(v, tx, ty, a);
if (h.dist > deepest) { deepest = h.dist; targetFacing = a; }
}
if (!aimed && willCut && performance.now() >= manualUntil) {
// A fresh CUT with nothing asking to be watched faces the deepest
// corridor — but a STANDING camera is never "rescued": where you
// pointed your own head is where it stays, brick or no brick.
targetFacing = deepestFacing(v, tx, ty);
}
if (willCut) {
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
@@ -232,6 +401,7 @@
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
});
$effect(() => { onbody?.(possessedId); });
let lastQuad = -99;
$effect(() => {
const q = ((Math.round(cam.facing / (Math.PI / 2)) % 4) + 4) % 4;
@@ -246,74 +416,163 @@
{#if me}
<div class="live-fp">
<FirstPerson {view} povId={cutawayShot ? "" : povId}
x={cam.x} y={cam.y} facing={cam.facing} width={960} height={400}
fx={fpFx} posOverride={actorPos}
ontarget={ontarget ?? undefined} />
<button class="live-fp-hide" onclick={onhide} title="hide (re-enable in preferences)"></button>
<!-- The helm, tappable: edge strips turn and stride. -->
<button class="drive drive-left" onclick={() => manualTurn(-1)} aria-label="turn left"></button>
<button class="drive drive-right" onclick={() => manualTurn(1)} aria-label="turn right"></button>
{#if canStride}
<button class="drive drive-fwd" onclick={() => manualStride(false)} aria-label="step forward">︿</button>
<button class="drive drive-back" onclick={() => manualStride(true)} aria-label="step back"></button>
<div class="live-fp-hint">← → turn · ↑ ↓ walk</div>
{/if}
<!-- The bezel: instruments live in the frame, never on the glass —
every canvas pixel is target. -->
<div class="bezel-top">
<span class="bezel-zone">
{#if possessed}
<span class="ride-label">riding the {possessed.kind.replace(/-/g, " ")}</span>
<span class="ride-stats">{Math.max(0, possessed.movesPerTurn - possessed.movementUsed)} moves{possessed.attackUsed ? "" : " · claws ready"}</span>
<button class="stamp tiny" onclick={dismount}>back to your eyes</button>
{:else if rideable.length > 0 && (onCreatureMove || onCreatureAttack)}
{#each rideable as c (c.id)}
<button class="stamp tiny" onclick={() => { possessedId = c.id; manualUntil = performance.now() + RIDE_HOLD_MS; }}>
👁 ride the {c.kind.replace(/-/g, " ")}
</button>
{/each}
{/if}
</span>
<span class="bezel-zone bezel-center">
{#if canStride}
<button class="drive stride" onclick={() => manualStride(false)} aria-label="step forward">︿ forward</button>
{/if}
</span>
<span class="bezel-zone bezel-right">
{#if withYou.length > 0}
<span class="feet-label">sharing your square</span>
{#each withYou as body (body.id)}
<button class="feet-item" title={body.label}
onclick={() => handleTarget(body.kind === "player" ? { kind: "player", id: body.id } : { kind: "creature", id: body.id })}>
<img src={body.art} alt={body.label} />
</button>
{/each}
{/if}
<button class="live-fp-hide" onclick={onhide} title="hide (re-enable in preferences)"></button>
</span>
</div>
<div class="bezel-mid">
<button class="drive" onclick={() => manualTurn(-1)} aria-label="turn left"></button>
<FirstPerson {view} povId={cutawayShot ? "" : (possessedId ?? povId)}
x={cam.x} y={cam.y} facing={cam.facing} width={960} height={400}
fx={fpFx} posOverride={actorPos}
ontarget={handleTarget} {litCells} />
<button class="drive" onclick={() => manualTurn(1)} aria-label="turn right"></button>
</div>
<div class="bezel-bottom">
<span class="bezel-zone">
{#if canStride && ((!possessed && atFeet.length > 0) || underfoot.hazard || underfoot.warpHere)}
<span class="feet-label">at your feet</span>
{#if !possessed && onpickup}
{#each atFeet as item (item.id)}
<button class="feet-item" title={item.label}
onclick={() => onpickup?.({ kind: item.kind, id: item.id })}>
{#if item.art}
<img src={item.art} alt={item.label} />
{:else}
<span class="feet-fallback">{item.label}</span>
{/if}
</button>
{/each}
{/if}
{#if underfoot.hazard}
<span class="feet-hazard">{underfoot.hazard}</span>
{/if}
{#if underfoot.warpHere && onWarpStep}
<button class="stamp tiny" onclick={() => onWarpStep?.(possessedId)}> step through the warp</button>
{/if}
{/if}
</span>
<span class="bezel-zone bezel-center">
{#if canStride}
<button class="drive stride" onclick={() => manualStride(true)} aria-label="step back">﹀ back</button>
{/if}
</span>
<span class="bezel-zone bezel-right">
<span class="live-fp-hint">← → turn · ↑ ↓ walk</span>
</span>
</div>
</div>
{/if}
<style>
.live-fp {
position: relative;
margin-bottom: 0.6rem;
border: 1px solid #3a3428;
border-radius: 6px;
background: #14121a;
overflow: hidden;
}
.live-fp-hide {
position: absolute;
top: 0.4rem;
right: 0.4rem;
background: rgba(13, 12, 18, 0.65);
border: 1px solid #5a5342;
border-radius: 3px;
color: #a49c86;
.bezel-top, .bezel-bottom {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
min-height: 34px;
}
.bezel-top { border-bottom: 1px solid #2a2620; }
.bezel-bottom { border-top: 1px solid #2a2620; }
.bezel-spacer { flex: 1; }
.bezel-zone { flex: 1; display: flex; align-items: center; gap: 8px; }
.bezel-center { justify-content: center; }
.bezel-right { justify-content: flex-end; }
.bezel-mid {
display: flex;
align-items: stretch;
}
.bezel-mid :global(.fpv-canvas) {
border: none;
border-radius: 0;
flex: 1;
min-width: 0;
}
.drive {
background: none;
border: none;
color: #8d8672;
font-size: 1.3rem;
cursor: pointer;
font-size: 0.75rem;
padding: 0.1rem 0.4rem;
padding: 0 10px;
}
.drive:hover { color: #e9e1cb; background: rgba(201, 167, 42, 0.08); }
.stride { font-size: 0.8rem; letter-spacing: 0.06em; }
.live-fp-hide {
background: none;
border: none;
color: #8d8672;
cursor: pointer;
font-size: 0.9rem;
}
.live-fp-hide:hover { color: #e9e1cb; }
.live-fp-hint {
position: absolute;
bottom: 0.45rem;
left: 0.6rem;
color: rgba(233, 225, 203, 0.55);
font-family: "Courier Prime", monospace;
font-size: 0.7rem;
pointer-events: none;
font-size: 0.65rem;
color: #5d5748;
}
/* The helm: faint chevron strips along the pane's edges — always
* there for a thumb, louder under a pointer. */
.drive {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
.ride-label {
font-family: "Oswald", sans-serif;
font-size: 0.62rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #c9a72a;
}
.ride-stats { font-size: 0.7rem; color: #8d8672; }
.feet-label {
font-family: "Oswald", sans-serif;
font-size: 0.6rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #8d8672;
}
.feet-item {
background: none;
border: none;
border: 1px solid #4a4536;
border-radius: 4px;
padding: 2px;
cursor: pointer;
color: rgba(233, 225, 203, 0.35);
font-size: 2.2rem;
line-height: 1;
padding: 0;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
line-height: 0;
}
.drive:hover {
color: rgba(233, 225, 203, 0.95);
background: linear-gradient(to right, rgba(0, 0, 0, 0.22), transparent);
}
.drive-left { left: 0; top: 15%; bottom: 15%; width: 13%; }
.drive-right { right: 0; top: 15%; bottom: 15%; width: 13%; }
.drive-right:hover { background: linear-gradient(to left, rgba(0, 0, 0, 0.22), transparent); }
.drive-fwd { top: 0; left: 20%; right: 20%; height: 16%; }
.drive-fwd:hover { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.22), transparent); }
.drive-back { bottom: 0; left: 20%; right: 20%; height: 15%; }
.drive-back:hover { background: linear-gradient(to top, rgba(0, 0, 0, 0.22), transparent); }
.feet-item:hover { border-color: #c9a72a; }
.feet-item img { width: 26px; height: 26px; object-fit: contain; }
.feet-fallback { font-size: 0.65rem; color: #d8d2c0; padding: 0 4px; }
.feet-hazard { font-size: 0.7rem; color: #d98a4a; white-space: nowrap; }
</style>
+7 -10
View File
@@ -7,7 +7,7 @@
import { scheduleFx, type BoardFx } from "./fx";
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
import { castRay, edgeMid } from "./fpv/raycast";
import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
import { deepestFacing, aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
import { prefs } from "./prefs.svelte";
import { stackSightTrace } from "@wizwar/engine";
import type { GameEvent, GameView } from "@wizwar/engine";
@@ -249,15 +249,12 @@
* corridor, facing it, holding a beat of the world-before so the
* deed happens ON screen — with the reel's own wizard visible. */
const takeCutaway = (ax: number, ay: number) => {
let best = { d: -1, a: 0 };
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
const h = castRay(v, ax, ay, a);
if (h.dist > best.d) best = { d: h.dist, a };
}
const back = Math.min(1.6, Math.max(0.35, best.d - 0.4));
cam.x = ax + Math.cos(best.a) * back;
cam.y = ay + Math.sin(best.a) * back;
cam.facing = best.a + Math.PI;
const a = deepestFacing(v, ax, ay);
const d = castRay(v, ax, ay, a).dist;
const back = Math.min(1.6, Math.max(0.35, d - 0.4));
cam.x = ax + Math.cos(a) * back;
cam.y = ay + Math.sin(a) * back;
cam.facing = a + Math.PI;
camReady = true;
cutawayShot = true;
const i2 = Math.min(idx, steps.length - 1);
+164 -26
View File
@@ -3,9 +3,7 @@
// GameView. Columns of wall shaded by distance and facing; token art
// billboarded for whatever stands in the corridors, occluded per column
// by the same depth buffer the walls wrote.
import {
warpMotion,
type FpvTarget, castRay, billboards } from "./raycast";
import { billboards, castRay, warpMotion, type FpvTarget } from "./raycast";
import { materialTextures } from "./textures";
import { doorOpenness, fxFallback, growProgress, type FpFx } from "./fx3d";
import { terrainFallback, TERRAIN3D } from "./terrain3d";
@@ -25,6 +23,7 @@
posOverride,
rubble = [],
ontarget,
litCells = null,
}: {
view: GameView;
povId: string;
@@ -43,6 +42,9 @@
rubble?: { x: number; y: number }[];
/** Present = the pane is an instrument: clicks resolve to targets. */
ontarget?: (t: FpvTarget) => void;
/** Squares a selected cell-target card may aim at: the pane dims the
* ineligible ground exactly as the board dims its squares. */
litCells?: Set<string> | null;
} = $props();
const FOV = Math.PI / 2.9;
@@ -55,9 +57,27 @@
W: number; H: number; ex: number; ey: number; facing: number;
zbuf: Float64Array; warpIdCol: Int32Array; warpDistCol: Float64Array;
cols: ({ edge?: string; kind: string; top: number; h: number } | null)[];
/** Depth-sorted nearest-first, ready for hover hit-testing. */
sprites: Projected[];
} | null = null;
/** The one visibility rule a sprite obeys in a column — shared by the
* draw pass and the hover hit test so they can never disagree. */
function spriteVisibleInCol(
sp: Projected, col: number,
zbuf: Float64Array, warpIdCol: Int32Array, warpDistCol: Float64Array,
): boolean {
if (sp.depth >= zbuf[col]!) return false;
if (sp.warped) {
if (warpIdCol[col] !== sp.warpId || sp.depth <= warpDistCol[col]!) return false;
} else if (sp.depth >= warpDistCol[col]!) return false;
if (sp.clampL !== undefined && (col < sp.clampL || col > sp.clampR!)) return false;
return true;
}
/** Crosshair position in canvas pixels, while the pointer is over the
* pane and the pane is an instrument. */
let mouse: { x: number; y: number } | null = null;
// Token art loads lazily; a sprite draws once its image has arrived.
const images = new Map<string, HTMLImageElement>();
function imageFor(src: string): HTMLImageElement | null {
@@ -183,6 +203,17 @@
const fl = pixelsOf(textures.floor!);
const ce = pixelsOf(textures.ceiling!);
const FOG = 13;
// A selected cell-card's eligibility, rasterized once for the pixel
// loop: 1 = castable ground, 0 = dimmed. Absent card = null = all lit.
let litGrid: Uint8Array | null = null;
const litBw = view.board.width, litBh = view.board.height;
if (litCells) {
litGrid = new Uint8Array(litBw * litBh);
for (const k of litCells) {
const [lx, ly] = k.split(",").map(Number) as [number, number];
if (lx >= 0 && ly >= 0 && lx < litBw && ly < litBh) litGrid[ly * litBw + lx] = 1;
}
}
for (let row = 0; row < H; row++) {
const below = row > half;
const dz = below ? row - half : half - row;
@@ -216,6 +247,11 @@
let v = wy % 1; if (v < 0) v += 1;
const ti = (((v * th) | 0) * tw + ((u * tw) | 0)) * 4;
let r = tp[ti]!, g = tp[ti + 1]!, b = tp[ti + 2]!;
let sh = shade;
if (litGrid && below) {
const gx = (wx - u) | 0, gy = (wy - v) | 0;
if (gx < 0 || gy < 0 || gx >= litBw || gy >= litBh || !litGrid[gy * litBw + gx]) sh *= 0.3;
}
if (dec) {
const hx = (wx - u) | 0, hy = (wy - v) | 0;
if (hx >= 0 && hy >= 0 && hx < dec.bw && hy < dec.bh) {
@@ -236,9 +272,9 @@
}
}
}
buf[o] = r * shade;
buf[o + 1] = g * shade;
buf[o + 2] = b * shade;
buf[o] = r * sh;
buf[o + 1] = g * sh;
buf[o + 2] = b * sh;
}
buf[o + 3] = 255;
wx += stepX;
@@ -380,7 +416,10 @@
if (s) sprites.push({ ...s, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art });
}
sprites.sort((a, b) => b.sort - a.sort);
hitFrame = { W, H, ex, ey, facing, zbuf, warpIdCol, warpDistCol, cols: hitCols, sprites };
hitFrame = {
W, H, ex, ey, facing, zbuf, warpIdCol, warpDistCol, cols: hitCols,
sprites: [...sprites].sort((a, b) => a.depth - b.depth),
};
// The nearest sprite each column carries — depth AND vertical span —
// so the overlay passes (veils, ghosts, lintels, risings) can paint
// around a body standing in front of them instead of over it.
@@ -410,11 +449,7 @@
if (s.glow) ctx.globalCompositeOperation = "lighter";
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = s.alpha ?? 1;
for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) {
if (s.depth >= zbuf[col]!) continue;
if (s.warped) {
if (warpIdCol[col] !== s.warpId || s.depth <= warpDistCol[col]!) continue;
} else if (s.depth >= warpDistCol[col]!) continue;
if (s.clampL !== undefined && (col < s.clampL || col > s.clampR!)) continue;
if (!spriteVisibleInCol(s, col, zbuf, warpIdCol, warpDistCol)) continue;
if (s.depth < spriteZ[col]!) {
spriteZ[col] = s.depth;
sprTop[col] = s.top;
@@ -537,6 +572,71 @@
ctx.fillRect(0, 0, W, H);
ctx.globalAlpha = 1;
}
// The crosshair's answer, before the click commits: what the pane
// would target here, outlined in the table's gold with its name.
if (ontarget && mouse) drawHover(ctx, W, H, half);
}
/** Paint the hover cue for whatever stands under the crosshair. */
function drawHover(ctx: CanvasRenderingContext2D, W: number, H: number, half: number) {
const f = hitFrame;
if (!f || !mouse) return;
const found = resolveHover(mouse.x, mouse.y);
if (!found) return;
ctx.save();
ctx.strokeStyle = "#c9a72a";
ctx.fillStyle = "rgba(201,167,42,0.14)";
ctx.lineWidth = 1.5;
let label = "";
if (found.shape.kind === "rect") {
const r = found.shape;
ctx.strokeRect(r.left, r.top, r.right - r.left, r.bottom - r.top);
ctx.fillRect(r.left, r.top, r.right - r.left, r.bottom - r.top);
label = found.label;
} else if (found.shape.kind === "face") {
// Tint every column that shows THIS edge: the whole face answers.
for (let col = 0; col < f.W; col++) {
const c = f.cols[col];
if (!c || c.edge !== found.shape.edge) continue;
ctx.fillRect(col, c.top, 1, c.h);
}
label = found.label;
} else if (found.shape.kind === "none") {
label = found.label;
} else {
// A ground square: its four corners projected onto the floor plane.
const flen = (f.W / 2) / Math.tan(FOV / 2);
const cosF = Math.cos(f.facing), sinF = Math.sin(f.facing);
const { x: cx0, y: cy0 } = found.shape.cell;
const pts: [number, number][] = [];
for (const [ox, oy] of [[0, 0], [1, 0], [1, 1], [0, 1]] as const) {
const rx = cx0 + ox - f.ex, ry = cy0 + oy - f.ey;
const depth = rx * cosF + ry * sinF;
if (depth < 0.12) { pts.length = 0; break; }
const side = -rx * sinF + ry * cosF;
pts.push([f.W / 2 + (side / depth) * flen, half + (f.H / 2) / depth]);
}
if (pts.length === 4) {
ctx.beginPath();
ctx.moveTo(pts[0]![0], pts[0]![1]);
for (const [px, py] of pts.slice(1)) ctx.lineTo(px, py);
ctx.closePath();
ctx.fill();
ctx.stroke();
}
}
if (label) {
ctx.font = "12px 'Courier Prime', monospace";
const wTxt = ctx.measureText(label).width;
const lx = Math.min(f.W - wTxt - 10, Math.max(4, mouse.x + 10));
const ly = Math.max(16, mouse.y - 8);
ctx.fillStyle = "rgba(13,12,18,0.85)";
ctx.fillRect(lx - 4, ly - 12, wTxt + 8, 16);
ctx.fillStyle = "#e9e1cb";
ctx.fillText(label, lx, ly);
}
ctx.restore();
}
interface Projected {
@@ -630,7 +730,21 @@
* (or vault) square the pixel lies on — warp-bent columns mapping
* their virtual ground back to real cells through the warp's own
* rigid motion. */
export function hitTest(px: number, py: number): FpvTarget | null {
function hitTest(px: number, py: number): FpvTarget | null {
return resolveHover(px, py)?.target ?? null;
}
/** The full answer for a canvas pixel: the target, the screen shape to
* highlight, and the name to whisper beside the crosshair. */
function resolveHover(px: number, py: number): {
target: FpvTarget;
label: string;
shape:
| { kind: "rect"; left: number; right: number; top: number; bottom: number }
| { kind: "face"; edge: string }
| { kind: "ground"; cell: { x: number; y: number } }
| { kind: "none" };
} | null {
const f = hitFrame;
if (!f) return null;
const col = Math.max(0, Math.min(f.W - 1, px | 0));
@@ -638,17 +752,14 @@
// Sprites first, nearest first, honoring the draw pass's own
// visibility rules for this column.
const byDepth = [...f.sprites].sort((a, b) => a.depth - b.depth);
for (const sp of byDepth) {
for (const sp of f.sprites) {
if (!sp.hit && !sp.cell) continue; // pure spectacle (projectiles, rubble)
if (px < sp.left || px >= sp.right || py < sp.top || py > sp.bottom) continue;
if (sp.clampL !== undefined && (col < sp.clampL || col > sp.clampR!)) continue;
if (sp.depth >= f.zbuf[col]!) continue;
if (sp.warped) {
if (f.warpIdCol[col] !== sp.warpId || sp.depth <= f.warpDistCol[col]!) continue;
} else if (sp.depth >= f.warpDistCol[col]!) continue;
if (sp.hit) return sp.hit;
return { kind: "cell", cell: { x: sp.cell!.x, y: sp.cell!.y } };
if (!spriteVisibleInCol(sp, col, f.zbuf, f.warpIdCol, f.warpDistCol)) continue;
const shape = { kind: "rect" as const, left: sp.left, right: sp.right, top: sp.top, bottom: sp.bottom };
const label = sp.hit ? (sp.hit.kind === "player" ? sp.hit.id : labelOf(sp)) : labelOf(sp);
if (sp.hit) return { target: sp.hit, label, shape };
return { target: { kind: "cell", cell: { x: sp.cell!.x, y: sp.cell!.y } }, label, shape };
}
// The wall span: doors and walls answer as their EDGE.
@@ -657,14 +768,18 @@
if ((c.kind === "wall" || c.kind === "door" || c.kind === "firewall") && c.edge) {
const [kind, coords] = c.edge.split(":") as [string, string];
const [x, y] = coords.split(",").map(Number) as [number, number];
return { kind: "edge", cell: { x, y }, side: kind === "V" ? "E" : "S" };
return {
target: { kind: "edge", cell: { x, y }, side: kind === "V" ? "E" : "S" },
label: c.kind === "firewall" ? "wall of fire" : c.kind,
shape: { kind: "face", edge: c.edge },
};
}
if (c.kind === "stone") {
// A stone fill is a square, not an edge: the cell just past the
// struck face along this column's ray.
const t = f.zbuf[col]! + 0.05;
const pt = groundPoint(f, col, t);
return pt ? { kind: "cell", cell: pt } : null;
return pt ? { target: { kind: "cell", cell: pt }, label: "solid stone", shape: { kind: "ground", cell: pt } } : null;
}
return null; // rims and frame posts are nobody's target
}
@@ -674,8 +789,25 @@
if (dz < 1) return null;
const d = (f.H / 2) / dz;
if (d >= f.zbuf[col]!) return null; // past the wall: nothing to click
if (f.warpIdCol[col]! >= 0 && d > f.warpDistCol[col]!) {
// Warp-bent ground still TARGETS truly, but the highlight quad
// cannot be drawn in this frame's geometry: label it instead.
const pt = groundPoint(f, col, d);
return pt ? { target: { kind: "cell", cell: pt }, label: `through the warp (${pt.x},${pt.y})`, shape: { kind: "none" } } : null;
}
const pt = groundPoint(f, col, d);
return pt ? { kind: "cell", cell: pt } : null;
return pt ? { target: { kind: "cell", cell: pt }, label: "", shape: { kind: "ground", cell: pt } } : null;
}
/** A sprite's spoken name: the creature or thing under the crosshair. */
function labelOf(sp: Projected): string {
if (sp.hit?.kind === "creature") {
const c = view.creatures.find((k) => k.id === sp.hit!.id);
return c ? c.kind.replace(/-/g, " ") : "creature";
}
if (sp.fallback) return sp.fallback.replace(/-/g, " ");
const m = sp.src.match(/\/([a-z0-9-]+)\.png/i);
return m ? m[1]!.replace(/-/g, " ") : "";
}
/** The real-world square at perpendicular depth d down column col —
@@ -718,7 +850,13 @@
</script>
<canvas bind:this={canvas} {width} {height} class="fpv-canvas" class:targeting={!!ontarget}
onclick={onCanvasClick}></canvas>
onclick={onCanvasClick}
onpointermove={(e) => {
if (!ontarget || !canvas) return;
const rect = canvas.getBoundingClientRect();
mouse = { x: (e.clientX - rect.left) * (width / rect.width), y: (e.clientY - rect.top) * (height / rect.height) };
}}
onpointerleave={() => (mouse = null)}></canvas>
<style>
.targeting { cursor: crosshair; }
+12
View File
@@ -6,6 +6,7 @@
import { edgeMid } from "./raycast";
import { fpFxForEvents } from "./fx3d";
import type { GameEvent, GameView } from "@wizwar/engine";
import { castRay } from "./raycast";
export function shortestArc(from: number, to: number): number {
let d = (to - from) % (2 * Math.PI);
@@ -131,3 +132,14 @@ export function gatherGlides(before: GameView, after: GameView, povId: string):
}
return moves;
}
/** The facing that stares down the longest corridor from a square the
* default view wherever nothing asks to be watched. */
export function deepestFacing(view: GameView, x: number, y: number): number {
let deepest = -1, face = 0;
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
const h = castRay(view, x, y, a);
if (h.dist > deepest) { deepest = h.dist; face = a; }
}
return face;
}
+1
View File
@@ -403,6 +403,7 @@ export function billboards(
});
}
for (const c of view.creatures) {
if (c.id === povId) continue; // possessed: you do not see your own hide
const o = posOverride?.[c.id];
out.push({
x: o?.x ?? c.position.x + 0.5, y: o?.y ?? c.position.y + 0.5,
+25 -26
View File
@@ -225,7 +225,6 @@ export interface LogLine {
* momentSteps, so a chronicle turn number addresses the same commands. */
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
const SEAT_KEY = "wizwar-seat";
const SEATS_KEY = "wizwar-seats";
const CHAT_SEEN_KEY = "wizwar-chat-seen";
@@ -318,7 +317,6 @@ class Net {
/** Turns witnessed so far: counts the same boundary events the server
* counts, so a chronicle line can name the turn it belongs to. */
private turnCounter = -1;
/** The turn that already carries an instant-replay eye (one per turn). */
/** The turn whose moment reel is open (share links point at it). */
private momentTurn: number | null = null;
private shareResolve: ((url: string) => void) | null = null;
@@ -491,24 +489,31 @@ class Net {
if (g.yourTurn && !was) this.notifyTurn(g);
this.lastYourTurn.set(key, g.yourTurn);
}
// A seat the server answered for but did not list is gone — the
// room was deleted (or the seat's token voided). Quietly drop it
// from the ledger rather than showing "unreachable" forever.
// (The server checks at most MAX_MYGAMES_SEATS = 50 per ask;
// never prune blind past that cap.)
if (this.seats.length <= 50) {
const listed = new Set((msg.games as GameSummary[]).map((g) => `${g.roomId}:${g.name}`));
const kept = this.seats.filter((s) => listed.has(`${s.roomId}:${s.name}`));
if (kept.length !== this.seats.length) {
this.seats = kept;
saveSeats(this.seats);
}
// Prune ONLY seats the server proved dead: the room exists and
// refused this exact token. A seat merely ABSENT from the reply
// stays — a restarting server, a stale restore, or the wrong
// backend all answer with ignorance, and ignorance is not
// deletion. The wallet is capped by age instead of by trust.
const voided = new Set((msg.voided as string[] | undefined) ?? []);
let kept = this.seats.filter((s) => !voided.has(`${s.roomId}:${s.name}`));
if (kept.length > 50) kept = kept.slice(kept.length - 50);
if (kept.length !== this.seats.length) {
this.seats = kept;
saveSeats(this.seats);
}
break;
}
case "error":
if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) {
// "Name is taken" means THIS token was tried and refused: the
// credential itself is dead, and holding it helps nobody. But
// "no such room" proves nothing about the seat — a restarting
// server, a stale restore, or the wrong backend all say it.
// The seat stays; the error shows; a later reconnect against
// the right server walks back in.
if (this.roomIdPending && /name is taken/.test(msg.message)) {
localStorage.removeItem(SEAT_KEY);
}
if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) {
this.roomIdPending = null;
}
this.error = msg.message;
@@ -659,8 +664,9 @@ class Net {
} catch { /* blocked at the OS level; the tab title still shows it */ }
}
/** Forget the remembered seat and return to the lobby. */
/** Client-side teardown when the SERVER detached us (kick, abandon). */
/** Client-side teardown when the SERVER detached us (kick, abandon).
* The chronicle survives: the detachment appended its own explanatory
* line, and wiping it would erase the only notice of why. */
leaveLocal(): void {
localStorage.removeItem(SEAT_KEY);
this.roomId = null;
@@ -673,18 +679,11 @@ class Net {
this.audience = 0;
}
/** Walk away from the table (or gallery) by choice. */
leave(): void {
this.send({ type: "leave" }); // detach server-side too (frees a gallery seat)
localStorage.removeItem(SEAT_KEY);
this.roomId = null;
this.roomIdPending = null;
this.view = null;
this.started = false;
this.players = [];
this.leaveLocal();
this.resetChronicle();
this.token = null;
this.spectating = false;
this.audience = 0;
}
start(expansion: boolean): void {
+2 -2
View File
@@ -29,7 +29,7 @@ const KEY = "wizwar-prefs";
function load(): Prefs {
const fallback: Prefs = {
art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null,
cautions: true, botTier: "adept", instantReplay: true, liveFp: true,
cautions: true, botTier: "adept", instantReplay: true, liveFp: false,
};
try {
const raw = localStorage.getItem(KEY);
@@ -44,7 +44,7 @@ function load(): Prefs {
cautions: p.cautions !== false,
botTier: p.botTier === "apprentice" || p.botTier === "archmage" ? p.botTier : "adept",
instantReplay: p.instantReplay !== false,
liveFp: p.liveFp !== false,
liveFp: p.liveFp === true,
};
} catch {
return fallback;
+1 -1
View File
@@ -42,7 +42,7 @@ function renderView(v) {
for (let x = 0; x < B.width; x++) {
const k = `${x},${y}`;
if (!B.cells[k]) { top += " "; mid += " "; continue; }
const nEdge = y === 0 ? (B.edges[`H:${x},${y - 1}`] ?? "open") : (B.edges[`H:${x},${y - 1}`] ?? "open");
const nEdge = B.edges[`H:${x},${y - 1}`] ?? "open";
const north = y === 0 ? "wall" : nEdge; // rim renders solid; warps noted separately
top += "+" + (north === "wall" ? "————" : north === "door" ? "—DD—" : north === "firewall" ? "~FF~" : " ");
const wEdge = x === 0 ? "wall" : (B.edges[`V:${x - 1},${y}`] ?? "open");