Compare commits
43
Commits
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: wizwar-duel
|
||||
description: Play Wiz-War live against Eric — join his room as a real seat, reason about every move yourself, and duel turn by turn over the websocket. Use when Eric wants a game, gives a room code, or says "let's play".
|
||||
---
|
||||
|
||||
# Playing Wiz-War against Eric
|
||||
|
||||
You are a PLAYER, not the automaton. Read the board, reason, and choose
|
||||
every command yourself. Eric plays in his browser; you play through the
|
||||
CLI seat at `tools/claude-seat.mjs`. He prompts "go" after his moves —
|
||||
each of his messages is your cue to look and act.
|
||||
|
||||
## The seat client
|
||||
|
||||
Run from the repo root (needs the workspace's `ws` package):
|
||||
|
||||
```bash
|
||||
node tools/claude-seat.mjs join <ROOM> Claude # take a seat (token saved)
|
||||
node tools/claude-seat.mjs view # board + hand + last events
|
||||
node tools/claude-seat.mjs do '<command json>' # one engine command
|
||||
node tools/claude-seat.mjs chat "text" # table talk
|
||||
```
|
||||
|
||||
Seat state persists in `/tmp/wizwar-claude-seat.json` (override:
|
||||
`WIZWAR_SEAT`); server defaults to production (`WIZWAR_SERVER` to point
|
||||
elsewhere). Eric creates the room, gives you the code, and starts the
|
||||
game after you join.
|
||||
|
||||
Commands are the engine's `Command` union (packages/engine/src/game.ts):
|
||||
`{"type":"move","direction":"N|S|E|W"}`, `cast` (with `target`, optional
|
||||
`numberInstanceIds`/`params`), `playNumberForMovement`, `pickUpTreasure`,
|
||||
`dropTreasure`, `warpStep`, `counteract`, `pass`, `punch`, `setAmbush`,
|
||||
`wardChoice`, `endTurn` (with `draw`). A refused command costs nothing —
|
||||
probing legality is free, so when unsure, try it and read the error.
|
||||
|
||||
## Reading the map
|
||||
|
||||
- The renderer prints y=0 at top; **y grows SOUTH**. N = y-1, S = y+1.
|
||||
- `C2` is you, `P1` etc. are opponents, `$c`/`$1` treasures, `h?` homes,
|
||||
two-letter codes are square contents (SA safe, RO rosebush, ST stone,
|
||||
DU dust). `D` on a line is a door, `—` a wall, blank is open.
|
||||
- WARPS line lists rim passages as `2,0N→2,9`: standing at (2,0) and
|
||||
moving N carries you to (2,9). **The warp rides one specific side of
|
||||
its square** — check which before you walk (a wrong guess burns moves;
|
||||
ask me how I know).
|
||||
- Dimensional-warp TOKENS (cast by players) are separate: stand on one
|
||||
and `warpStep`. Creatures use `creatureWarpStep`.
|
||||
|
||||
## Hard-won rules knowledge (each cost me something in game one)
|
||||
|
||||
- `pickUpTreasure` ENDS your turn's actions AND movement — arrive with
|
||||
the grab as your last act, never mid-plan. `dropTreasure` at home too.
|
||||
- You can pick up ANY floor treasure, including your own stolen-and-
|
||||
delivered one sitting on the enemy's home square. Repossession is real.
|
||||
- THIEF and punches need same-square; THIEF steals a *named* card and
|
||||
fizzles (card spent) on a wrong guess.
|
||||
- MENTAL FORCE moves the victim ≤3 *walked* spaces — walls and relocked
|
||||
doors shrink its reach; rev 5+ refuses impossible destinations.
|
||||
- Doors relock behind you when you pass through (rev 4+). Your own
|
||||
escape route can seal itself — and seal pursuers out.
|
||||
- LOS threads through rim warps: you can be seen (and shot) through a
|
||||
warp mouth from the far side of the board. Camping one square off the
|
||||
mouth keeps you hidden.
|
||||
- lock-in-place freezes movement AND warpStep for a full turn; REUSE
|
||||
SPELL retrieves only your LAST cast spell, enabling one re-lock.
|
||||
- An ambush (`setAmbush` interrupt/opportunity-fire + committed attack)
|
||||
triggers on LOS *entry* transitions only — someone already in sight
|
||||
never springs it. It survives forever and fires out of turn.
|
||||
- Walking-dead bleeds ½ life per space moved, permanently. Once cursed,
|
||||
every plan must be priced in steps.
|
||||
|
||||
## Game-two scars (each cost me the game or nearly)
|
||||
|
||||
- **RECOUNT THE SCOREBOARD EVERY TURN**: how many treasures does each
|
||||
player have banked AT HOME right now? I sprinted a "winning" delivery
|
||||
in game two that was actually my FIRST of two — Eric had un-banked me
|
||||
five rounds earlier and let me monologue. Banked counts, not vibes.
|
||||
- A banked treasure is NOT safe: anyone can pick it up off your home
|
||||
square and carry it away. Guard the bank or count it as contested.
|
||||
- LOS is FREE-ANGLE center-to-center (the rulebook's rule), not rows
|
||||
and columns: razor diagonals threading open gaps are legal for
|
||||
everyone — including at you. The board draws bent/straight traces.
|
||||
- ILLUSIONARY ATTACK deals its imitated spell's damage if NOT
|
||||
countered; ANY counteraction dispels it entirely. Counter it with
|
||||
the cheapest thing you hold; never "call the bluff" by passing.
|
||||
- Never trust your ASCII parse of the map for multi-step plans: run a
|
||||
BFS over `view.board.edges` (see tools/claude-seat.mjs view JSON, or
|
||||
write a quick router) before spending a number card on a sprint. I
|
||||
burned a 7-move turn bouncing off six misread walls.
|
||||
- Eric counter-plays YOUR bot upgrades: he summoned a troll bodyguard
|
||||
specifically against the archmage's exile-the-winner instinct, and
|
||||
relocated MY home sector mid-carry. Expect meta-warfare.
|
||||
- In 4p with automatons: the bots are chaos agents — they stun, exile,
|
||||
disease, and con. Herd them at Eric; never assume they're on script.
|
||||
|
||||
## Game-three scars (lost in four rounds)
|
||||
|
||||
- **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
|
||||
counters sprung at the perfect moment (anti-anti, full-shield bait,
|
||||
full-reflection held three rounds for my lightning). Force his
|
||||
counters out with throwaways or positional threats before committing
|
||||
a real spell; his patience is perfect and he telegraphs nothing.
|
||||
- DESTROY WALL showers the ADJACENT caster with rubble (4 damage) —
|
||||
cast it from range, never on your own square's edge.
|
||||
- Wading a rosebush costs 3. A reflected LIGHTNING BLAST still stuns
|
||||
its caster even at 1 reflected damage — the stun is the payload, and
|
||||
a lost turn against Kestrel is a lost game.
|
||||
- Grab-turn-one openings feel great and prove nothing: deliveries are
|
||||
the only score. He banked quietly both times while I showboated.
|
||||
|
||||
## Playing well
|
||||
|
||||
- Kestrel (Eric) is EXCELLENT: he baited my thief, saved anti-anti for
|
||||
my full shield, and won game one with a home-to-home dimensional-warp
|
||||
superhighway. Infrastructure beats sprinting — watch what he builds,
|
||||
and consider your own wormhole early.
|
||||
- Win = 2 enemy treasures delivered to your home, or last wizard alive.
|
||||
Track BOTH players' step-counted delivery timelines every turn; the
|
||||
race is decided in tempo, not damage.
|
||||
- Hold counters (full-shield, absorb) for permanent curses and lethal
|
||||
damage. Absorb soaks 3 points; it cannot touch durations.
|
||||
- OPSEC: your prose is visible to Eric. NEVER name the cards you hold or
|
||||
draw, and never announce plans. (Game one: I narrated my hand like a
|
||||
debug log until he asked why I was making it easy.) Trash talk freely
|
||||
— about the board, never your hand.
|
||||
- Send `chat` for table talk at dramatic moments; it's half the fun.
|
||||
|
||||
## Cadence
|
||||
|
||||
1. On "go": `view`, read HIS events since your last turn (the client
|
||||
prints only NEW events; `view` shows a short history tail).
|
||||
2. Think about the whole board — his timeline, yours, threats, LOS.
|
||||
3. Execute your commands one at a time, checking errors.
|
||||
4. `endTurn` with a draw that respects the 7-card hand limit.
|
||||
5. Tell Eric it's his turn — reasoning aloud is fine, hand contents are
|
||||
not. When a stack waits on him, say so and wait for "go".
|
||||
@@ -9,6 +9,7 @@
|
||||
import { cardDef, type CardInstance } from "./cards";
|
||||
import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side } from "./board";
|
||||
import { bentSightFor, sightedCellsFor, type GameView } from "./view";
|
||||
import { wallIgnoringDistance } from "./game";
|
||||
import type { AmbushTrigger, Command, PlayerId } from "./game";
|
||||
|
||||
export type AutomatonStyle = "hunter" | "berserker" | "worrier";
|
||||
@@ -293,6 +294,17 @@ function pathDenial(view: GameView): Command | null {
|
||||
if (d <= 6) threats.push({ enemy: e.position, goal: floorGold.position! });
|
||||
}
|
||||
}
|
||||
// Enemy gold banked at MY home: my score, snatchable by anyone. A
|
||||
// raider closing on the bank gets the same road-lengthening treatment.
|
||||
const self = me(view);
|
||||
const banked = view.treasures.find(
|
||||
(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 - 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;
|
||||
|
||||
const sighted = sightedCellsFor(view);
|
||||
@@ -358,7 +370,12 @@ function pathDenial(view: GameView): Command | null {
|
||||
);
|
||||
}
|
||||
// Never brick the square the treasure needs to stay reachable on.
|
||||
if (filler && cellKey(b) !== cellKey(goal) && cellCastable(cellKey(b))) {
|
||||
// TACKS are scattered at the caster's feet ("you must be adjacent"),
|
||||
// not thrown — offering a distant square wedges the whole brain on
|
||||
// an eternally refused cast.
|
||||
const fillerReaches = !filler || filler.cardId !== "handful-of-tacks" ||
|
||||
Math.abs(b.x - me(view).position.x) + Math.abs(b.y - me(view).position.y) <= 1;
|
||||
if (filler && fillerReaches && cellKey(b) !== cellKey(goal) && cellCastable(cellKey(b))) {
|
||||
consider(
|
||||
{ type: "cast", instanceId: filler.instanceId, target: { kind: "cell", cell: b } },
|
||||
{ avoidCell: cellKey(b) },
|
||||
@@ -597,7 +614,7 @@ function pathToward(
|
||||
const hazard = view.squareContents[k]?.kind;
|
||||
if (!opts.throughHazards &&
|
||||
(hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
|
||||
hazard === "rosebush" || hazard === "slime")) continue;
|
||||
hazard === "rosebush" || hazard === "slime" || hazard === "tacks")) continue;
|
||||
if (opts.avoidNearEnemies && nearEnemy(to)) continue;
|
||||
next.push(to);
|
||||
}
|
||||
@@ -630,7 +647,16 @@ function treasureGoals(view: GameView): Set<string> {
|
||||
}
|
||||
for (const t of view.treasures) {
|
||||
if (!t.position || t.carriedBy) continue;
|
||||
if (t.owner === view.you) continue;
|
||||
if (t.owner === view.you) {
|
||||
// REPOSSESSION: my own gold banked at an enemy's home is a point on
|
||||
// THEIR scoreboard. Marching to take it back is worth the detour
|
||||
// only when that enemy is one delivery from winning — constant
|
||||
// re-stealing farms nothing and stalls the whole table.
|
||||
const banker = view.players.find(
|
||||
(p) => p.id !== view.you && cellKey(p.home) === cellKey(t.position!));
|
||||
if (banker && deliveryWins(view, banker)) goals.add(cellKey(t.position));
|
||||
continue;
|
||||
}
|
||||
if (cellKey(t.position) === cellKey(self.home)) continue;
|
||||
goals.add(cellKey(t.position));
|
||||
}
|
||||
@@ -645,6 +671,15 @@ function bushOrMist(view: GameView, id: PlayerId): boolean {
|
||||
view.sustained.some((s) => s.cardId === "mist-body" && s.targetId === id);
|
||||
}
|
||||
|
||||
/** Would this wizard's delivery, landed, win them the game right now? */
|
||||
function deliveryWins(view: GameView, p: { id: PlayerId; home: Cell; carriedTreasureId: string | null }): boolean {
|
||||
if (!p.carriedTreasureId) return false;
|
||||
const carried = view.treasures.find((t) => t.id === p.carriedTreasureId);
|
||||
if (!carried || carried.owner === p.id) return false;
|
||||
return view.treasures.some(
|
||||
(t) => t.owner !== p.id && t.position && cellKey(t.position) === cellKey(p.home));
|
||||
}
|
||||
|
||||
/** The wizard making off with MY gold, if any. */
|
||||
function thiefOfMine(view: GameView) {
|
||||
const carriers = new Set(
|
||||
@@ -722,7 +757,15 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
||||
return { type: "pass" };
|
||||
}
|
||||
|
||||
const flinch = (style === "worrier" ? 1 : 0) - tier.counterThrift;
|
||||
// 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.
|
||||
// (Lethal blows override this below.)
|
||||
const counterCount = view.yourHand.filter((c) => {
|
||||
const t = cardDef(c.cardId).cardType;
|
||||
return t === "counteraction" || t === "neutral/counteraction";
|
||||
}).length;
|
||||
const lastCounterHold = counterCount <= 1 && me(view).life >= 10 ? 2 : 0;
|
||||
const flinch = (style === "worrier" ? 1 : 0) - tier.counterThrift - lastCounterHold;
|
||||
const attackId = stack.attackCard?.cardId ?? null;
|
||||
const atk = attackId ? ATTACKS[attackId] : null;
|
||||
const affliction = attackId ? AFFLICTIONS[attackId] != null : false;
|
||||
@@ -749,19 +792,26 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
||||
? Math.max(2, stack.numberValue ?? 2)
|
||||
: (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
|
||||
: 1;
|
||||
// A blow that would be the last one outranks every thrift threshold:
|
||||
// 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.
|
||||
const reverse = find("reverse");
|
||||
if (reverse && !affliction && incoming >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId };
|
||||
if (reverse && !affliction && weight >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId };
|
||||
// ABSORB SPELL steals the good ones for later.
|
||||
const absorbSpell = find("absorb-spell");
|
||||
if (absorbSpell && incoming >= 3) return { type: "counteract", instanceId: absorbSpell.instanceId };
|
||||
if (absorbSpell && weight >= 3) return { type: "counteract", instanceId: absorbSpell.instanceId };
|
||||
const shield = find("full-shield");
|
||||
if (shield && (incoming >= 3 - flinch || (affliction && style === "worrier"))) {
|
||||
if (shield && (weight >= 3 - flinch || (affliction && style === "worrier"))) {
|
||||
return { type: "counteract", instanceId: shield.instanceId };
|
||||
}
|
||||
const reflect = find("full-reflection");
|
||||
if (reflect && incoming >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId };
|
||||
if (reflect && weight >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId };
|
||||
// A curse in flight is best refused at the door.
|
||||
if (affliction) {
|
||||
const cleanse = find("remove-curse");
|
||||
@@ -777,9 +827,11 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
||||
const permanentCurse = affliction &&
|
||||
(attackId === "slow-death" || attackId === "walking-dead" || attackId === "idiot");
|
||||
const absorb = find("absorb");
|
||||
if (absorb && !affliction && incoming >= 2 - flinch && incoming <= 3) return { type: "counteract", instanceId: absorb.instanceId };
|
||||
if (absorb && !affliction && weight >= 2 - flinch && (incoming <= 3 || (lethal && incoming - 3 < me(view).life))) {
|
||||
return { type: "counteract", instanceId: absorb.instanceId };
|
||||
}
|
||||
const blunt = find("blunt");
|
||||
if (blunt && !permanentCurse && incoming >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId };
|
||||
if (blunt && !permanentCurse && weight >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId };
|
||||
// The berserker shares its pain out of spite — but only pain: a
|
||||
// damage-less curse gives EMPATHY nothing to mirror.
|
||||
const empathy = find("empathy");
|
||||
@@ -788,13 +840,13 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
||||
}
|
||||
// Nothing to block with: teleport clear of the big ones.
|
||||
const tp = find("teleport");
|
||||
if (tp && incoming >= 4 - flinch) {
|
||||
if (tp && weight >= 4 - flinch) {
|
||||
const out = escapeCell(view, me(view).position);
|
||||
if (out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } };
|
||||
}
|
||||
// Or vanish: INVISIBLE gives the attacker a 1-in-4 hit and lingers after.
|
||||
const vanish = find("invisible");
|
||||
if (vanish && incoming >= 3 - flinch) {
|
||||
if (vanish && weight >= 3 - flinch) {
|
||||
const num = numbersInHand(view)[0];
|
||||
return {
|
||||
type: "counteract", instanceId: vanish.instanceId,
|
||||
@@ -814,12 +866,44 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
||||
/** The best attack available against a visible target, numbers and amplify included. */
|
||||
function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd: Command; damage: number } | null {
|
||||
const numbers = numbersInHand(view);
|
||||
// TURN THEFT: against a carrier one delivery from winning, a
|
||||
// LIGHTNING BLAST's stolen turn outranks any point total — the number
|
||||
// rides along so soaks cannot zero the damage and void the stun.
|
||||
{
|
||||
const carrier = view.players.find((p) => p.id === targetId);
|
||||
const bolt = inHand(view, "lightning-blast");
|
||||
const biggestNum = numbers[numbers.length - 1];
|
||||
if (carrier && bolt && biggestNum && deliveryWins(view, carrier)) {
|
||||
return {
|
||||
damage: cardDef(biggestNum.cardId).value ?? 1,
|
||||
cmd: {
|
||||
type: "cast", instanceId: bolt.instanceId,
|
||||
target: { kind: "player", playerId: targetId },
|
||||
numberInstanceIds: [biggestNum.instanceId],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
const biggest = numbers[numbers.length - 1];
|
||||
const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0;
|
||||
const target = view.players.find((p) => p.id === targetId)!;
|
||||
const together = cellKey(target.position) === cellKey(me(view).position);
|
||||
const amplify = tier.amplify ? inHand(view, "amplify") : undefined;
|
||||
let best: { cmd: Command; damage: number } | null = null;
|
||||
// A killing blow at minimal spend outranks maximum splash — but only
|
||||
// when it clears the target's life with soak to spare: an exact-lethal
|
||||
// blow dies to one ABSORB, so thrift begins three points past the kill.
|
||||
type BestPick = { cmd: Command; damage: number; kill: boolean; cost: number };
|
||||
let best: BestPick | null = null;
|
||||
const offer = (damage: number, cost: number, cmd: Command) => {
|
||||
const rank = (d: number) => (d >= target.life + 3 ? 2 : d >= target.life ? 1 : 0);
|
||||
const r = rank(damage);
|
||||
const better = !best ? true
|
||||
: r !== rank(best.damage) ? r > rank(best.damage)
|
||||
: r === 2 ? cost < best.cost
|
||||
: damage !== best.damage ? damage > best.damage
|
||||
: cost < best.cost;
|
||||
if (better) best = { cmd, damage, kill: damage >= target.life, cost };
|
||||
};
|
||||
for (const c of view.yourHand) {
|
||||
const atk = ATTACKS[c.cardId];
|
||||
if (!atk) continue;
|
||||
@@ -831,25 +915,27 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
|
||||
const isWand = c.cardId === "blaster-wand";
|
||||
const uncharged = isWand && view.wandCharges[c.instanceId] == null;
|
||||
if ((uncharged || atk.needsNumber) && !biggest) continue;
|
||||
const withNumber = (atk.perNumber || uncharged) && biggest;
|
||||
let damage = atk.base + (atk.perNumber && withNumber ? biggestValue : 0);
|
||||
if (damage <= 0) continue;
|
||||
// AMPLIFY doubles the heavy hitters (spells only, not thrown things).
|
||||
const amplified = amplify && damage >= 4 && !isWand &&
|
||||
c.cardId !== "dagger" && c.cardId !== "large-rock";
|
||||
if (amplified) damage *= 2;
|
||||
if (!best || damage > best.damage) {
|
||||
best = {
|
||||
damage,
|
||||
cmd: {
|
||||
const mustNumber = uncharged || atk.needsNumber === true;
|
||||
const canAmplify = amplify && !isWand && c.cardId !== "dagger" && c.cardId !== "large-rock";
|
||||
const numberChoices: (CardInstance | undefined)[] = atk.perNumber
|
||||
? [undefined, ...numbers]
|
||||
: [mustNumber ? biggest : undefined];
|
||||
for (const num of numberChoices) {
|
||||
if (mustNumber && !num) continue;
|
||||
const value = num ? (cardDef(num.cardId).value ?? 0) : 0;
|
||||
const base = atk.base + (atk.perNumber && num ? value : 0);
|
||||
if (base <= 0) continue;
|
||||
for (const amp of canAmplify && base >= 4 ? [false, true] : [false]) {
|
||||
const damage = amp ? base * 2 : base;
|
||||
offer(damage, value + (amp ? 5 : 0), {
|
||||
type: "cast", instanceId: c.instanceId,
|
||||
target: { kind: "player", playerId: targetId },
|
||||
// An illusion needs a spell to imitate; a fireball sells best.
|
||||
...(c.cardId === "illusionary-attack" ? { params: { cardId: "fireball" } } : {}),
|
||||
...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}),
|
||||
...(amplified ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
|
||||
},
|
||||
};
|
||||
...(num ? { numberInstanceIds: [num.instanceId] } : {}),
|
||||
...(amp && amplify ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// STONE DEAD: number times the stones the victim carries — the displayed
|
||||
@@ -858,9 +944,11 @@ 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;
|
||||
if (dmg >= 4 && dmg > (best?.damage ?? 0)) {
|
||||
// `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 = {
|
||||
damage: dmg,
|
||||
damage: dmg, kill: dmg >= target.life, cost: biggestValue,
|
||||
cmd: {
|
||||
type: "cast", instanceId: sd.instanceId,
|
||||
target: { kind: "player", playerId: targetId },
|
||||
@@ -869,7 +957,8 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
|
||||
};
|
||||
}
|
||||
}
|
||||
return best;
|
||||
const picked = best as BestPick | null;
|
||||
return picked ? { cmd: picked.cmd, damage: picked.damage } : null;
|
||||
}
|
||||
|
||||
/** An affliction worth casting when no damage lands, mid number attached.
|
||||
@@ -957,6 +1046,14 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
|
||||
return { type: "cast", instanceId: c.instanceId };
|
||||
}
|
||||
}
|
||||
// So does the MASTER KEY: displayed, every workable lock in the maze
|
||||
// opens under its bearer's hand without another cast.
|
||||
{
|
||||
const mk = inHand(view, "master-key");
|
||||
if (mk && !self.displayed.some((d) => d.instanceId === mk.instanceId)) {
|
||||
return { type: "cast", instanceId: mk.instanceId };
|
||||
}
|
||||
}
|
||||
if (!tier.buffs) return null; // the apprentice's book ends at the stones
|
||||
// A curse on the clockwork gets scrubbed off.
|
||||
const cursed = view.sustained.some(
|
||||
@@ -1036,6 +1133,42 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
|
||||
}
|
||||
}
|
||||
}
|
||||
// INFRASTRUCTURE: a wormhole anchored beside home turns every future
|
||||
// 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;
|
||||
if (dwarp && nearHome && view.dimWarps.length === 0 && view.turn.round <= 6) {
|
||||
const legal = (c: Cell) =>
|
||||
view.board.cells[cellKey(c)] !== undefined &&
|
||||
!view.board.homes.some((h) => h.x === c.x && h.y === c.y) &&
|
||||
!view.squareContents[cellKey(c)] &&
|
||||
!view.treasures.some((t) => t.position && cellKey(t.position) === cellKey(c)) &&
|
||||
(view.groundObjects[cellKey(c)] ?? []).length === 0;
|
||||
const goals = treasureGoals(view);
|
||||
if (legal(self.position) && goals.size > 0) {
|
||||
const dHome = distancesFrom(view, [self.home], false);
|
||||
const sighted = sightedCellsFor(view);
|
||||
let far: { cell: Cell; worth: number } | null = null;
|
||||
for (const g of goals) {
|
||||
const [gx, gy] = g.split(",").map(Number) as [number, number];
|
||||
for (const side of SIDES) {
|
||||
const c = neighbor({ x: gx, y: gy }, side);
|
||||
if (!legal(c) || !sighted.has(cellKey(c))) continue;
|
||||
const worth = dHome.get(cellKey(c)) ?? 0;
|
||||
if (worth >= 8 && (far === null || worth > far.worth)) far = { cell: c, worth };
|
||||
}
|
||||
}
|
||||
if (far) {
|
||||
return {
|
||||
type: "cast", instanceId: dwarp.instanceId,
|
||||
params: { cell: self.position }, target: { kind: "cell", cell: far.cell },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Guard the gold on the floor: a SAFE locks it, GLUE sticks it down.
|
||||
const myFloorTreasure = tier.guardGold
|
||||
? view.treasures.find((t) => t.owner === view.you && t.position && !t.carriedBy)
|
||||
@@ -1128,10 +1261,13 @@ export function automatonCommand(
|
||||
if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" };
|
||||
if (!self.carriedTreasureId) {
|
||||
const prize = view.treasures.find(
|
||||
(t) => t.position && !t.carriedBy && t.owner !== you && cellKey(t.position) === here &&
|
||||
here !== cellKey(self.home),
|
||||
(t) => t.position && !t.carriedBy && cellKey(t.position) === here &&
|
||||
here !== cellKey(self.home) &&
|
||||
(t.owner !== you ||
|
||||
// Repossessing my own gold off an enemy's home square.
|
||||
view.players.some((p) => p.id !== you && cellKey(p.home) === here)),
|
||||
);
|
||||
if (prize) return { type: "pickUpTreasure" };
|
||||
if (prize) return { type: "pickUpTreasure", treasureId: prize.id };
|
||||
}
|
||||
|
||||
// Wounded clockwork teleports clear of visible hunters.
|
||||
@@ -1200,13 +1336,16 @@ export function automatonCommand(
|
||||
const target = thief && visible.some((p) => p.id === thief.id)
|
||||
? thief
|
||||
: visible.sort((a, b) => a.life - b.life)[0]!;
|
||||
// DROP OBJECT shakes my treasure out of the thief's hands.
|
||||
if (thief && target.id === thief.id) {
|
||||
const dob = inHand(view, "drop-object");
|
||||
if (dob) {
|
||||
// DROP OBJECT shakes the treasure out of the hands that matter:
|
||||
// the thief of my gold, or any carrier whose delivery wins.
|
||||
{
|
||||
const mark = (thief && visible.some((p) => p.id === thief.id)) ? thief
|
||||
: visible.find((p) => deliveryWins(view, p));
|
||||
const dob = mark ? inHand(view, "drop-object") : undefined;
|
||||
if (mark && dob) {
|
||||
return {
|
||||
type: "cast", instanceId: dob.instanceId,
|
||||
target: { kind: "player", playerId: thief.id }, params: { cardId: "treasure" },
|
||||
target: { kind: "player", playerId: mark.id }, params: { cardId: "treasure" },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1216,6 +1355,7 @@ export function automatonCommand(
|
||||
if (exile) {
|
||||
const carrier = visible.find((p) => {
|
||||
if (thief && p.id === thief.id) return true;
|
||||
if (deliveryWins(view, p)) return true;
|
||||
if (!p.carriedTreasureId) return false;
|
||||
return Math.abs(p.position.x - p.home.x) + Math.abs(p.position.y - p.home.y) <= 6;
|
||||
});
|
||||
@@ -1365,6 +1505,24 @@ export function automatonCommand(
|
||||
}
|
||||
}
|
||||
|
||||
// TELEPORT DELIVERY: carrying with home four wall-ignoring spaces away
|
||||
// but farther by boot — blink in and drop next command. The jump beats
|
||||
// any ambush camped on the walking road.
|
||||
if (self.carriedTreasureId && !view.turn.actionsEnded) {
|
||||
const tpHome = inHand(view, "teleport");
|
||||
if (tpHome) {
|
||||
const blink = wallIgnoringDistance(view.board, self.position, self.home);
|
||||
const movesLeft = view.turn.movementAllowance - view.turn.movementUsed;
|
||||
const dWalk = blink >= 1 && blink <= 4
|
||||
? distancesFrom(view, [self.position], UNLOCKS.some((id) => inHand(view, id)))
|
||||
.get(cellKey(self.home)) ?? Infinity
|
||||
: 0;
|
||||
if (blink <= 4 && blink >= 1 && dWalk > movesLeft) {
|
||||
return { type: "cast", instanceId: tpHome.instanceId, target: { kind: "cell", cell: { ...self.home } } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// March. The thief-chase outranks everything; the berserker hunts wizards
|
||||
// over gold; the worrier keeps its distance; the hunter goes to the gold.
|
||||
if (view.turn.movementUsed < view.turn.movementAllowance) {
|
||||
@@ -1374,19 +1532,38 @@ export function automatonCommand(
|
||||
const ownGoldCells = new Set(view.treasures
|
||||
.filter((t) => t.owner === view.you && t.position && !t.carriedBy)
|
||||
.map((t) => cellKey(t.position!)));
|
||||
// A thief already underfoot is not a destination — with the chase
|
||||
// moot, march on the real goals (deliver, grab) instead of standing
|
||||
// on them forever.
|
||||
const thiefAfar = thief && cellKey(thief.position) !== here ? thief : null;
|
||||
// The chase exists to deliver a blow, so it claims the march only
|
||||
// while that blow is still live: an attack in hand, this turn's
|
||||
// attack unspent, casting not yet closed. Those gates hold still as
|
||||
// the clockwork walks — a goal keyed to the thief's nearness flips
|
||||
// underfoot and shuttles the walker on and off their square. Caught
|
||||
// with the strike still live, it stands its ground (a goal underfoot
|
||||
// ends the march); otherwise the gold has its legs for the turn.
|
||||
const strikeLive = !view.turn.attackUsed && !view.turn.attackForbidden &&
|
||||
!view.turn.actionsEnded && view.yourHand.some((c) => ATTACKS[c.cardId] != null);
|
||||
const chasing = thief !== null && strikeLive;
|
||||
// BANK GUARD: enemy gold delivered to my home is my scoreboard, and
|
||||
// anyone may snatch it off the floor. A raider nearer my bank than I
|
||||
// am, with the bank stocked, outranks the next grab — run home.
|
||||
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 between goals.
|
||||
const bankThreatened = bankedCount > 0 && !self.carriedTreasureId &&
|
||||
livingEnemies(view).some((p) =>
|
||||
!p.carriedTreasureId &&
|
||||
Math.abs(p.position.x - self.home.x) + Math.abs(p.position.y - self.home.y) <= 2);
|
||||
const objectives = cursedIdiot && ownGoldCells.size > 0
|
||||
? ownGoldCells
|
||||
: thiefAfar
|
||||
? new Set([cellKey(thiefAfar.position)])
|
||||
: style === "berserker" && !self.carriedTreasureId
|
||||
? (enemyCells.size > 0 ? enemyCells : gold)
|
||||
: gold;
|
||||
: chasing
|
||||
? new Set([cellKey(thief.position)])
|
||||
: bankThreatened
|
||||
? new Set([cellKey(self.home)])
|
||||
: style === "berserker" && !self.carriedTreasureId
|
||||
? (enemyCells.size > 0 ? enemyCells : gold)
|
||||
: gold;
|
||||
const path =
|
||||
pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !thiefAfar, canUnlock }) ??
|
||||
pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !chasing, canUnlock }) ??
|
||||
pathToward(view, self.position, objectives, { canUnlock }) ??
|
||||
pathToward(view, self.position, enemyCells, { canUnlock }) ??
|
||||
// No clean road to anything: grit the teeth and wade the hazards,
|
||||
@@ -1469,12 +1646,17 @@ export function automatonCommand(
|
||||
// The war chest: when an attack in hand wants a number, the
|
||||
// biggest one is reserved — a waterbolt unfired outbids a longer
|
||||
// march, and walking is free next turn.
|
||||
const wantsNumber = view.yourHand.some((c) => {
|
||||
const atk = ATTACKS[c.cardId];
|
||||
if (!atk) return false;
|
||||
return atk.perNumber || atk.needsNumber === true ||
|
||||
(c.cardId === "blaster-wand" && view.wandCharges[c.instanceId] == null);
|
||||
});
|
||||
// — but a chest hoarded with no enemy in reach just slows the
|
||||
// march, and no reserve outbids the delivery that wins the game.
|
||||
const foesClose = livingEnemies(view).some((p) =>
|
||||
Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 6);
|
||||
const wantsNumber = foesClose && !deliveryWins(view, self) &&
|
||||
view.yourHand.some((c) => {
|
||||
const atk = ATTACKS[c.cardId];
|
||||
if (!atk) return false;
|
||||
return atk.perNumber || atk.needsNumber === true ||
|
||||
(c.cardId === "blaster-wand" && view.wandCharges[c.instanceId] == null);
|
||||
});
|
||||
const spendable = wantsNumber ? numbers.slice(0, -1) : numbers;
|
||||
// A treasure within free walking ends this turn at the grab (the
|
||||
// gold-underfoot rung outranks everything) — a number played first
|
||||
|
||||
+162
-44
@@ -195,6 +195,9 @@ export interface CastStack {
|
||||
/** FULL REFLECTION vs SWAP MEET: the reflector's chosen trade. */
|
||||
cardId?: string }[];
|
||||
waitingOn: PlayerId;
|
||||
/** AROUND THE CORNER carried this attack: sight bent through a middle
|
||||
* square, so the straight line the table would look for does not exist. */
|
||||
bentCorner?: true;
|
||||
/** CHAOS only: the defender's FULL SHIELD sat them out rather than stopping it. */
|
||||
defenderShielded?: boolean;
|
||||
/** Set when a creature, not a wizard, delivers the attack. */
|
||||
@@ -226,7 +229,7 @@ export interface CastParams {
|
||||
}
|
||||
|
||||
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
|
||||
export const CURRENT_RULES_REV = 3;
|
||||
export const CURRENT_RULES_REV = 5;
|
||||
|
||||
export interface GameConfig {
|
||||
playerIds: PlayerId[];
|
||||
@@ -245,6 +248,11 @@ export interface GameConfig {
|
||||
* Rev 3: VISIONSTONE pierces its one wall for every sight the game
|
||||
* asks of its bearer — creations and utility spells included — not
|
||||
* only direct attacks.
|
||||
* Rev 4: "the door will relock behind you" means it — passing through
|
||||
* an unlocked door shuts it at the walker's back unless a hand holds
|
||||
* it; unpassed, it relocks at turn's end as before.
|
||||
* Rev 5: MENTAL FORCE refuses a destination the victim cannot walk to
|
||||
* in three spaces, instead of eating the card silently at resolution.
|
||||
*/
|
||||
deckRev?: number;
|
||||
}
|
||||
@@ -381,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 };
|
||||
@@ -397,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);
|
||||
}
|
||||
@@ -464,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. */
|
||||
@@ -576,6 +586,8 @@ export type GameEvent =
|
||||
| { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } }
|
||||
| { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" }
|
||||
| { type: "sectorRotated"; caster: PlayerId; sectorIndex: number; clockwise: boolean }
|
||||
| { type: "creatureWarpStepped"; creatureId: string; from: Cell; to: Cell; by: PlayerId }
|
||||
| { type: "mentalForceFizzled"; attacker: PlayerId; defender: PlayerId; cell: Cell }
|
||||
| { type: "sectorRelocated"; caster: PlayerId; sectorIndex: number; from: Cell; to: Cell;
|
||||
/** Origins in FINAL coordinates (the maze may renormalize after the
|
||||
* landing); from/to record the pre-shift request. */
|
||||
@@ -692,6 +704,7 @@ export type Command =
|
||||
| { type: "wardChoice"; play: boolean }
|
||||
| { type: "warpStep" }
|
||||
| { type: "moveCreature"; creatureId: string; direction: Side }
|
||||
| { type: "creatureWarpStep"; creatureId: string }
|
||||
| { type: "creatureAttack"; creatureId: string; targetId: string }
|
||||
| {
|
||||
type: "cast";
|
||||
@@ -744,7 +757,7 @@ type AttackEffect = {
|
||||
sustains?: boolean;
|
||||
/** Card stays in hand and is displayed rather than discarded (WIZARDBLADE). */
|
||||
keepInHand?: boolean;
|
||||
validate?: (state: GameState, cmd: Extract<Command, { type: "cast" }>) => string | null;
|
||||
validate?: (state: GameState, cmd: Extract<Command, { type: "cast" }>, caster?: PlayerState, target?: PlayerState) => string | null;
|
||||
onResolved?: (ctx: ResolutionContext) => void;
|
||||
};
|
||||
|
||||
@@ -1233,8 +1246,14 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
keepInHand: true,
|
||||
// "Unlocks any door (door relocks behind you). Do not discard when used.
|
||||
// Display Immediately. Must be adjacent. Does not work on a JAMmed LOCK."
|
||||
// Cast bare, it simply goes on display, and a DISPLAYED key turns in
|
||||
// every lock its bearer walks through (see doMove) — no further casts.
|
||||
// Cast at a door, it works that one lock like PICK LOCK and may hold
|
||||
// the door open for others.
|
||||
resolve: (state, events, caster, cmd) =>
|
||||
unlockDoor(state, events, caster, cmd, "master-key", { requireAdjacent: true }),
|
||||
cmd.target
|
||||
? unlockDoor(state, events, caster, cmd, "master-key", { requireAdjacent: true })
|
||||
: null,
|
||||
},
|
||||
"remove-lock": {
|
||||
kind: "neutral",
|
||||
@@ -2154,11 +2173,19 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
"mental-force": {
|
||||
kind: "attack",
|
||||
baseDamage: () => 0, // no LOS printed
|
||||
validate: (state, cmd) => {
|
||||
validate: (state, cmd, caster, target) => {
|
||||
const cell = cmd.params?.cell;
|
||||
if (!cell) return "say where they go (within three moved spaces)";
|
||||
if (!boardView(state).cells[cellKey(cell)]) return "off the board";
|
||||
if (state.squareContents[cellKey(cell)]?.kind === "stone") return "that square is solid stone";
|
||||
// The victim WALKS those three spaces — a destination the maze's
|
||||
// walls put out of reach is refused up front, not swallowed at
|
||||
// resolution with the card already spent. (Rev 5; the victim may
|
||||
// still slip out of range before it resolves — see onResolved.)
|
||||
if ((state.config.deckRev ?? 1) >= 5 && target &&
|
||||
walkingDistance(state, target.position, cell) > 3) {
|
||||
return "the walls put that square beyond three moved spaces";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onResolved: (ctx) => {
|
||||
@@ -2166,7 +2193,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
|
||||
const to = ctx.stack.params?.cell;
|
||||
if (!to) return; // an ambush armed without a destination fizzles
|
||||
if (walkingDistance(ctx.state, ctx.defender.position, to) > 3) return;
|
||||
if (walkingDistance(ctx.state, ctx.defender.position, to) > 3) {
|
||||
// The victim slipped beyond reach: the force strains and fails,
|
||||
// and the table sees it fail rather than wondering.
|
||||
ctx.events.push({ type: "mentalForceFizzled", attacker: ctx.attacker.id, defender: ctx.defender.id, cell: to });
|
||||
return;
|
||||
}
|
||||
const from = ctx.defender.position;
|
||||
ctx.defender.position = to;
|
||||
ctx.events.push({ type: "teleported", player: ctx.defender.id, from, to, by: ctx.attacker.id, cardId: "mental-force" });
|
||||
@@ -2911,6 +2943,66 @@ 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
|
||||
* FEAR. (Monsters obey the same maze the wizards do.) */
|
||||
function doCreatureWarpStep(prev: GameState, creatureId: string): CommandResult {
|
||||
const blocked = requireActionsAvailable(prev);
|
||||
if (blocked) return err(blocked);
|
||||
const state = clone(prev);
|
||||
const active = activePlayer(state);
|
||||
const creature = creatureById(state, creatureId);
|
||||
if (!creature) return err("no such creature");
|
||||
if (creature.kind !== "democratic-monster" && creature.controllerId !== active.id) {
|
||||
return err("that creature does not obey you");
|
||||
}
|
||||
if (creature.movesPerTurn === 0) return err("that creature cannot move");
|
||||
if (creature.movementUsed >= creature.movesPerTurn) return err("no creature movement left");
|
||||
const here = cellKey(creature.position);
|
||||
const pair = state.dimWarps.find((w) => cellKey(w.a) === here || cellKey(w.b) === here);
|
||||
if (!pair) return err("it is not standing on a warp token");
|
||||
const dest = cellKey(pair.a) === here ? pair.b : pair.a;
|
||||
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 corridor");
|
||||
}
|
||||
const from = creature.position;
|
||||
// FEAR holds monsters off too: "no player or monster".
|
||||
if (fearRepels(state, null, from, dest)) return err("an unnatural dread stops the beast");
|
||||
creature.position = { ...dest };
|
||||
creature.movementUsed++;
|
||||
const events: GameEvent[] = [{ type: "creatureWarpStepped", creatureId, from, to: creature.position, by: active.id }];
|
||||
creatureTouchOnArrival(state, events, creature);
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
function doMoveCreature(prev: GameState, creatureId: string, direction: Side): CommandResult {
|
||||
const blocked = requireActionsAvailable(prev);
|
||||
if (blocked) return err(blocked);
|
||||
@@ -2965,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 };
|
||||
}
|
||||
@@ -3412,7 +3485,7 @@ function walkingDistance(state: GameState, from: Cell, to: Cell): number {
|
||||
}
|
||||
|
||||
/** BFS steps between cells ignoring walls (teleport distance). */
|
||||
function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): number {
|
||||
export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): number {
|
||||
if (cellKey(from) === cellKey(to)) return 0;
|
||||
const seen = new Map<string, number>([[cellKey(from), 0]]);
|
||||
const queue: Cell[] = [from];
|
||||
@@ -3730,6 +3803,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
|
||||
case "wardChoice": return err("no grab is hanging on your Ward");
|
||||
case "warpStep": return doWarpStep(state);
|
||||
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
|
||||
case "creatureWarpStep": return doCreatureWarpStep(state, command.creatureId);
|
||||
case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId);
|
||||
case "cast": return doCast(state, command);
|
||||
case "setAmbush": return doSetAmbush(state, command);
|
||||
@@ -3941,9 +4015,27 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
||||
p.position = { ...warp.to.cell };
|
||||
via = "warp";
|
||||
crossedFirewall = true;
|
||||
} else if (edge === "door" && doorIsOpen(state, key)) {
|
||||
} else if (edge === "door" &&
|
||||
(doorIsOpen(state, key) || (displays(p, "master-key") && state.doorStates[key] !== "jammed"))) {
|
||||
// The displayed MASTER KEY turns in every lock it meets: the walker
|
||||
// passes without another cast, and the door RELOCKS BEHIND THEM at
|
||||
// once — "door relocks behind you" grants no lingering opening, so
|
||||
// bystanders see only a shut door (the bearer's own threshold peek
|
||||
// rides the key itself). A JAMmed LOCK still refuses it. To hold a
|
||||
// door open, cast the key at it as ever.
|
||||
if (!doorIsOpen(state, key)) {
|
||||
events.push({ type: "doorUnlocked", player: p.id, edge: parseEdgeKey(key), withCardId: "master-key" });
|
||||
}
|
||||
p.position = dest;
|
||||
via = "step";
|
||||
// "The door will relock behind you": stepping through an unlocked
|
||||
// door shuts it at the walker's back unless a hand holds it open.
|
||||
// (Rev 4; earlier games kept their turn-long openings.)
|
||||
if ((state.config.deckRev ?? 1) >= 4 &&
|
||||
state.openDoorEdges.includes(key) && !state.heldDoors.some((h) => h.key === key)) {
|
||||
state.openDoorEdges = state.openDoorEdges.filter((k) => k !== key);
|
||||
events.push({ type: "doorsRelocked", count: 1 });
|
||||
}
|
||||
} else if (edge === "firewall") {
|
||||
// "Passing through it does four points of magical damage." Firewalls
|
||||
// burn even a MIST-BODY.
|
||||
@@ -4737,6 +4829,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (effect.kind === "neutral" && effect.displayOnce && caster.displayed.includes(inHand.instanceId)) {
|
||||
return err(`${def.name} is already displayed`);
|
||||
}
|
||||
if (inHand.cardId === "master-key" && !cmd.target && caster.displayed.includes(inHand.instanceId)) {
|
||||
return err("the key is already on display");
|
||||
}
|
||||
|
||||
// Magic wands: charged on first use by the number card(s) played; one
|
||||
// charge per use, one use per turn; discarded when the last charge goes.
|
||||
@@ -4933,6 +5028,28 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
}
|
||||
return { ok: true, state, events: events2 };
|
||||
}
|
||||
// CHAOS aims at no one: cast bare, it sweeps every other living
|
||||
// wizard into the pile, each offered their FULL SHIELD sit-out in
|
||||
// turn order. (Full shields only exclude; nothing in the set cancels
|
||||
// a zero-point storm, so the queue is the whole defense.) The older
|
||||
// player-targeted form is still accepted below, so stored games
|
||||
// replay unchanged.
|
||||
if (inHand.cardId === "chaos" && (!cmd.target || cmd.target.kind !== "player")) {
|
||||
consumeCast(state, caster, inHand, mods, false);
|
||||
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
|
||||
state.turn.attackUsed = true;
|
||||
state.lastSpellUsed[caster.id] = inHand.cardId;
|
||||
const events: GameEvent[] = [{
|
||||
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
|
||||
from: caster.position, target: null, targetCell: null,
|
||||
}];
|
||||
const queue = turnOrderFrom(state, caster.id).filter((id) =>
|
||||
id !== caster.id && state.players.find((p) => p.id === id)!.alive);
|
||||
state.chaosPending = { casterId: caster.id, excluded: [], queue };
|
||||
finishChaosIfReady(state, events);
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
if (!cmd.target || cmd.target.kind !== "player") return err("attack spells target a player");
|
||||
if (cmd.target.playerId === caster.id) return err("you cannot attack yourself");
|
||||
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
||||
@@ -4954,7 +5071,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
(s) => !(s.cardId === "buddy" && s.casterId === caster.id && s.targetId === target.id),
|
||||
);
|
||||
if (effect.validate) {
|
||||
const problem = effect.validate(state, cmd);
|
||||
const problem = effect.validate(state, cmd, caster, target);
|
||||
if (problem) return err(problem);
|
||||
}
|
||||
if (inHand.cardId === "waterbolt") {
|
||||
@@ -5042,6 +5159,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
kind: effect.physical ? "physical" : "spell",
|
||||
counters: [],
|
||||
waitingOn: target.id,
|
||||
...(mods.aroundCorner ? { bentCorner: true as const } : {}),
|
||||
};
|
||||
state.lastSpellUsed[caster.id] = inHand.cardId;
|
||||
const events: GameEvent[] = [...wandEvents, ...preEvents];
|
||||
|
||||
@@ -296,7 +296,7 @@ export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean {
|
||||
*/
|
||||
export function stackSightTrace(
|
||||
view: GameView,
|
||||
): { from: Cell; to: Cell; trace: SightTrace } | null {
|
||||
): { from: Cell; to: Cell; trace: SightTrace; bend?: { mid: Cell; trace: SightTrace } } | null {
|
||||
const stack = view.stack;
|
||||
if (!stack || stack.creatureId) return null;
|
||||
if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null;
|
||||
@@ -305,7 +305,23 @@ export function stackSightTrace(
|
||||
if (!a || !d) return null;
|
||||
if (a.position.x === d.position.x && a.position.y === d.position.y) return null;
|
||||
const trace = traceSightFor(view, a.position, d.position);
|
||||
return trace ? { from: a.position, to: d.position, trace } : null;
|
||||
if (trace) return { from: a.position, to: d.position, trace };
|
||||
// AROUND THE CORNER: no straight line exists — find a middle square both
|
||||
// ends can see and draw the sight leg by leg, so the table can audit the
|
||||
// needle instead of doubting it.
|
||||
if (stack.bentCorner) {
|
||||
for (const key of Object.keys(view.board.cells)) {
|
||||
const [mx, my] = key.split(",").map(Number) as [number, number];
|
||||
const mid = { x: mx, y: my };
|
||||
if ((mid.x === a.position.x && mid.y === a.position.y) ||
|
||||
(mid.x === d.position.x && mid.y === d.position.y)) continue;
|
||||
const leg1 = traceSightFor(view, a.position, mid);
|
||||
if (!leg1) continue;
|
||||
const leg2 = traceSightFor(view, mid, d.position);
|
||||
if (leg2) return { from: a.position, to: d.position, trace: leg1, bend: { mid, trace: leg2 } };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const CREATION_CARD_IDS = new Set([
|
||||
|
||||
@@ -14,6 +14,7 @@ import { pushSustained } from "./helpers";
|
||||
/** Whose input does the maze want right now? */
|
||||
function actingSeat(state: GameState): PlayerId {
|
||||
return (
|
||||
state.wardPending?.ownerId ??
|
||||
state.stack?.waitingOn ??
|
||||
state.pendingDiscard ??
|
||||
state.chaosPending?.queue[0] ??
|
||||
@@ -735,3 +736,246 @@ describe("the clockwork flees the dread", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the thief-chase holds one goal per turn", () => {
|
||||
it("a clockwork beside its thief never shuttles on and off their square", () => {
|
||||
let { state } = createGame({ playerIds: ["thief", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
// Round 2, thief's turn burned; the bot acts with a full allowance.
|
||||
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const thief = state.players.find((p) => p.id === "thief")!;
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
// The thief carries the bot's gold and stands one step away.
|
||||
const mine = state.treasures.find((t) => t.owner === "bot")!;
|
||||
mine.carriedBy = "thief";
|
||||
mine.position = null;
|
||||
thief.position = { x: 2, y: 4 };
|
||||
bot.position = { x: 2, y: 5 };
|
||||
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" });
|
||||
const visited = [cellKey(bot.position)];
|
||||
for (let guard = 0; guard < 40; guard++) {
|
||||
const view = viewFor(state, "bot");
|
||||
const cmd = automatonCommand(view, "hunter", "archmage") ?? automatonFallback(view, "archmage");
|
||||
if (cmd.type === "endTurn") break;
|
||||
const r = applyCommand(state, "bot", cmd);
|
||||
if (!r.ok) break;
|
||||
state = r.state;
|
||||
const at = cellKey(state.players.find((p) => p.id === "bot")!.position);
|
||||
if (cmd.type === "move" && at !== visited[visited.length - 1]) visited.push(at);
|
||||
}
|
||||
// No step may return to the square just departed: A-B-A is the shuttle.
|
||||
for (let i = 2; i < visited.length; i++) {
|
||||
expect(visited[i]).not.toBe(visited[i - 2]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
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" });
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toEqual({ type: "cast", instanceId: "MK" });
|
||||
});
|
||||
|
||||
it("a lethal small blow is countered, thrift be damned", () => {
|
||||
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "foe") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const foe = state.players.find((p) => p.id === "foe")!;
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
bot.position = { ...foe.position };
|
||||
bot.life = 2;
|
||||
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" },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
// Base fireball, 2 points: below every thrift threshold, but fatal at
|
||||
// 2 life — the shield comes out.
|
||||
const cmd = automatonCommand(viewFor(r.state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toEqual({ type: "counteract", instanceId: "FS" });
|
||||
});
|
||||
|
||||
it("no number is hoarded against the delivery that wins the game", () => {
|
||||
let state = botTurn();
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const foe = state.players.find((p) => p.id === "foe")!;
|
||||
// One of the foe's treasures already rests at the bot's home; the bot
|
||||
// carries the second, four squares out with three legs — only the
|
||||
// number bridges it home this turn.
|
||||
const gold = state.treasures.filter((t) => t.owner === "foe");
|
||||
expect(gold.length).toBeGreaterThanOrEqual(2);
|
||||
gold[0]!.position = { ...bot.home };
|
||||
gold[0]!.carriedBy = null;
|
||||
gold[1]!.carriedBy = "bot";
|
||||
gold[1]!.position = null;
|
||||
bot.carriedTreasureId = gold[1]!.id;
|
||||
foe.position = { ...bot.home }; // a foe in sight: the war chest would hoard
|
||||
// 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 = [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");
|
||||
const cmd = automatonCommand(view, "hunter", "archmage") ?? automatonFallback(view, "archmage");
|
||||
if (cmd.type === "playNumberForMovement") return; // the chest opened for the win
|
||||
if (cmd.type === "endTurn") break;
|
||||
const r = applyCommand(state, "bot", cmd);
|
||||
if (!r.ok) break;
|
||||
state = r.state;
|
||||
if (state.phase !== "playing") return; // delivered and won outright
|
||||
}
|
||||
throw new Error("the clockwork hoarded its number instead of winning");
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
it("a raider at the stocked bank's gates pulls the clockwork home", () => {
|
||||
const state = rig();
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const foe = state.players.find((p) => p.id === "foe")!;
|
||||
const gold = state.treasures.find((t) => t.owner === "foe")!;
|
||||
gold.position = { ...bot.home };
|
||||
gold.carriedBy = null;
|
||||
foe.position = { x: bot.home.x, y: bot.home.y + 1 };
|
||||
bot.position = { ...foe.home };
|
||||
bot.hand = [];
|
||||
// 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", () => {
|
||||
const state = rig();
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const prize = state.treasures.find((t) => t.owner === "foe")!;
|
||||
prize.carriedBy = "bot";
|
||||
prize.position = null;
|
||||
bot.carriedTreasureId = prize.id;
|
||||
// Two squares from home as the spell flies, but walled off on foot.
|
||||
bot.position = { x: bot.home.x, y: bot.home.y + 2 };
|
||||
for (const side of ["N", "S", "E", "W"] as const) {
|
||||
state.edgeOverrides[edgeKey(bot.position, side)] = "wall";
|
||||
}
|
||||
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 } } });
|
||||
});
|
||||
|
||||
it("a would-win carrier eats the lightning, not the fireball", () => {
|
||||
let state = rig();
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const foe = state.players.find((p) => p.id === "foe")!;
|
||||
// Foe has one banked and carries the second: one delivery from winning.
|
||||
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) 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" },
|
||||
{ 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" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the denial planner offers only castable blocks", () => {
|
||||
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 });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
const foe = state.players.find((p) => p.id === "foe")!;
|
||||
// The bot's floor gold with a raider closing on it, and only TACKS
|
||||
// in hand to deny the road — from a stand-off distance.
|
||||
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" }];
|
||||
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.
|
||||
if (!r.ok) throw new Error(`brain proposed a refused command: ${JSON.stringify(cmd)} — ${r.error}`);
|
||||
state = r.state;
|
||||
if (cmd.type === "endTurn") break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, createGame, type GameState } from "../src/game";
|
||||
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Cell, type Side, SIDES, stepTarget } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { sightedCellsFor, viewFor } from "../src/view";
|
||||
import { sightedCellsFor, stackSightTrace, viewFor } from "../src/view";
|
||||
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
|
||||
|
||||
/** Test surgery: put a specific card into a player's hand (swapping one out). */
|
||||
@@ -402,6 +402,35 @@ describe("the ward window and chaos shields", () => {
|
||||
expect(state.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
|
||||
});
|
||||
|
||||
it("chaos casts bare — no target, every other wizard queued for a shield", () => {
|
||||
let { state } = threeGame();
|
||||
state = toRound2(state);
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
const caster = activePlayer(state);
|
||||
const others = state.players.filter((p) => p.id !== caster.id).map((p) => p.id);
|
||||
const chaos = giveCard(state, caster.id, "chaos", "C", 0);
|
||||
const shielder = others[0]!;
|
||||
giveCard(state, shielder, "full-shield", "S", 0);
|
||||
const shielderHand = () => state.players.find((p) => p.id === shielder)!.hand.map((c) => c.instanceId).sort();
|
||||
const kept = shielderHand().filter((id) => id !== "full-shield#S").sort();
|
||||
|
||||
state = must(state, caster.id, { type: "cast", instanceId: chaos.instanceId });
|
||||
// No stack: the queue opens at once, in turn order from the caster.
|
||||
expect(state.stack).toBeNull();
|
||||
expect(state.chaosPending?.queue.length).toBe(2);
|
||||
const first = state.chaosPending!.queue[0]!;
|
||||
state = first === shielder
|
||||
? must(state, first, { type: "counteract", instanceId: "full-shield#S" })
|
||||
: must(state, first, { type: "pass" });
|
||||
const second = state.chaosPending!.queue[0]!;
|
||||
state = second === shielder
|
||||
? must(state, second, { type: "counteract", instanceId: "full-shield#S" })
|
||||
: must(state, second, { type: "pass" });
|
||||
expect(state.chaosPending).toBeNull();
|
||||
// The shielded hand rode out the storm untouched.
|
||||
expect(shielderHand()).toEqual(kept);
|
||||
});
|
||||
|
||||
it("chaos: bystanders may shield out, reflections are refused", () => {
|
||||
let { state } = threeGame();
|
||||
state = toRound2(state);
|
||||
@@ -1111,3 +1140,59 @@ describe("stone dead counts only the stones in play", () => {
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MENTAL FORCE respects the victim's three walked spaces", () => {
|
||||
it("a destination beyond the walls is refused up front, card kept", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const caster = activePlayer(state);
|
||||
const victim = state.players.find((p) => p.id !== caster.id)!;
|
||||
caster.position = { x: 0, y: 0 };
|
||||
victim.position = { x: 2, y: 4 };
|
||||
// Seal the victim into their square: every walked space is out of
|
||||
// reach, so ANY other destination is beyond three moved spaces.
|
||||
for (const side of SIDES) {
|
||||
state.edgeOverrides[edgeKey(victim.position, side)] = "wall";
|
||||
}
|
||||
const mf = giveCard(state, caster.id, "mental-force", "MF");
|
||||
const far = { x: 4, y: 9 };
|
||||
const refused = applyCommand(state, caster.id, {
|
||||
type: "cast", instanceId: mf.instanceId,
|
||||
target: { kind: "player", playerId: victim.id }, params: { cell: far },
|
||||
});
|
||||
expect(refused.ok).toBe(false);
|
||||
if (!refused.ok) expect(refused.error).toContain("three moved spaces");
|
||||
// The card is still in hand — nothing was spent on the refusal.
|
||||
expect(state.players.find((p) => p.id === caster.id)!.hand.some((c) => c.instanceId === mf.instanceId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the bent sight-trace shows AROUND THE CORNER's legs", () => {
|
||||
it("a bentCorner stack yields two auditable legs through a midpoint", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const a = activePlayer(state);
|
||||
const d = state.players.find((p) => p.id !== a.id)!;
|
||||
// Diagonal neighbors: center-to-center sight grazes the shared corner
|
||||
// (blocked, strict reading), but the legs through (3,2) are opened.
|
||||
a.position = { x: 2, y: 2 };
|
||||
d.position = { x: 3, y: 3 };
|
||||
state.edgeOverrides[edgeKey({ x: 2, y: 2 }, "E")] = "open";
|
||||
state.edgeOverrides[edgeKey({ x: 3, y: 2 }, "S")] = "open";
|
||||
state.stack = {
|
||||
attackerId: a.id, defenderId: d.id,
|
||||
attackCard: { instanceId: "fireball#T", cardId: "fireball" },
|
||||
numberValue: null, amplifyFactor: 1, extendFactor: 1,
|
||||
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();
|
||||
// The engine promises A workable corner, not which one.
|
||||
expect(traced?.bend).toBeDefined();
|
||||
expect(traced?.bend?.mid).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,6 +750,40 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("creatures and the dimensional warp", () => {
|
||||
it("a commanded troll steps through the warp tokens", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
const you = state.players[state.turn.activeIndex]!.id;
|
||||
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 },
|
||||
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) {
|
||||
const troll = r.state.creatures.find((c) => c.id === "troll-w")!;
|
||||
expect(cellKey(troll.position)).toBe(cellKey({ x: 3, y: 8 }));
|
||||
expect(troll.movementUsed).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("solid stone on the far side refuses the beast", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
const you = state.players[state.turn.activeIndex]!.id;
|
||||
state.dimWarps.push({ a: { x: 1, y: 1 }, b: { x: 3, y: 8 } });
|
||||
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 },
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, createGame, sustainedOn, type GameState } from "../src/game";
|
||||
import { cellKey, edgeKey, neighbor, type Side } from "../src/board";
|
||||
import { cellKey, edgeKey, neighbor, opposite, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
|
||||
|
||||
@@ -111,7 +111,7 @@ describe("doors", () => {
|
||||
throw new Error("no door on this board");
|
||||
}
|
||||
|
||||
it("pick lock opens an adjacent door until end of turn", () => {
|
||||
it("pick lock opens an adjacent door — and it relocks behind the walker", () => {
|
||||
let { state } = newGame();
|
||||
const me = activePlayer(state);
|
||||
const door = findDoor(state);
|
||||
@@ -130,9 +130,8 @@ describe("doors", () => {
|
||||
state = must(state, me.id, { type: "move", direction: dir });
|
||||
expect(cellKey(activePlayer(state).position)).toBe(cellKey(other));
|
||||
|
||||
// Relocks when the turn ends.
|
||||
state = must(state, me.id, { type: "endTurn", draw: 0 });
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
// "It will relock behind you": shut at the walker's back at once —
|
||||
// the way back is barred without another pick.
|
||||
expect(state.openDoorEdges.length).toBe(0);
|
||||
expect(applyCommand(state, me.id, { type: "move", direction: dir === "E" ? "W" : "N" }).ok).toBe(false);
|
||||
});
|
||||
@@ -489,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");
|
||||
@@ -506,3 +507,63 @@ describe("a held door is an open doorway to the eye", () => {
|
||||
if (!refused.ok) expect(refused.error).toContain("line of sight");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the displayed MASTER KEY", () => {
|
||||
function keyRig() {
|
||||
let { state } = createGame({ playerIds: ["keeper", "watcher"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const view = boardView(state);
|
||||
for (const [key, edge] of Object.entries(view.edges)) {
|
||||
if (edge !== "door") continue;
|
||||
const [kind, coords] = key.split(":") as [string, string];
|
||||
const [x, y] = coords.split(",").map(Number) as [number, number];
|
||||
const cell = { x, y };
|
||||
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
|
||||
const keeper = activePlayer(state);
|
||||
keeper.position = { ...cell };
|
||||
return { state, cell, side, key, keeper };
|
||||
}
|
||||
throw new Error("setup: seed 42 grew a maze with no doors");
|
||||
}
|
||||
|
||||
it("cast bare, the key goes on display — once", () => {
|
||||
const { state, keeper } = keyRig();
|
||||
const mk = giveCard(state, keeper.id, "master-key", "MK");
|
||||
const cast = applyCommand(state, keeper.id, { type: "cast", instanceId: mk.instanceId });
|
||||
expect(cast.ok).toBe(true);
|
||||
if (cast.ok) {
|
||||
const after = cast.state.players.find((p) => p.id === keeper.id)!;
|
||||
expect(after.displayed).toContain(mk.instanceId);
|
||||
expect(after.hand.some((c) => c.instanceId === mk.instanceId)).toBe(true);
|
||||
const again = applyCommand(cast.state, keeper.id, { type: "cast", instanceId: mk.instanceId });
|
||||
expect(again.ok).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("its bearer walks through locked doors, which relock at their back", () => {
|
||||
const { state, cell, side, key, keeper } = keyRig();
|
||||
const mk = giveCard(state, keeper.id, "master-key", "MK");
|
||||
keeper.displayed.push(mk.instanceId);
|
||||
const r = applyCommand(state, keeper.id, { type: "move", direction: side });
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const after = r.state.players.find((p) => p.id === keeper.id)!;
|
||||
expect(cellKey(after.position)).toBe(cellKey(neighbor(cell, side)));
|
||||
// "Door relocks behind you": no lingering opening for bystanders.
|
||||
expect(r.state.openDoorEdges).not.toContain(key);
|
||||
expect(r.events.some((e) => e.type === "doorUnlocked")).toBe(true);
|
||||
// The key turns again on the way back.
|
||||
const back = applyCommand(r.state, keeper.id, { type: "move", direction: opposite(side) });
|
||||
expect(back.ok).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("a JAMmed LOCK refuses even the master key", () => {
|
||||
const { state, side, key, keeper } = keyRig();
|
||||
const mk = giveCard(state, keeper.id, "master-key", "MK");
|
||||
keeper.displayed.push(mk.instanceId);
|
||||
state.doorStates[key] = "jammed";
|
||||
const r = applyCommand(state, keeper.id, { type: "move", direction: side });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,8 @@ import {
|
||||
summarize,
|
||||
viewForPlayer,
|
||||
type Room,
|
||||
kickSeat,
|
||||
abandonRoom,
|
||||
} from "./rooms";
|
||||
import { engagementStats, recordHotseat } from "./stats";
|
||||
import { getShare, loadShares, mintShare } from "./shares";
|
||||
@@ -394,6 +396,17 @@ 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 = [
|
||||
"RECALCULATING.",
|
||||
"TACTICAL PAUSE. INTENTIONAL. PROBABLY.",
|
||||
"THE MAZE REFUSES MY GENIUS.",
|
||||
"ERROR LOGGED. DIGNITY INTACT.",
|
||||
"I MEANT TO DO THAT.",
|
||||
];
|
||||
|
||||
const pumping = new Set<string>();
|
||||
function runBots(room: Room): void {
|
||||
if (pumping.has(room.id)) return;
|
||||
@@ -408,6 +421,14 @@ function runBots(room: Room): void {
|
||||
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
|
||||
// The game's end unmasks the mystery machines in the roster.
|
||||
if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room));
|
||||
// A refused brain-choice becomes a visible stumble, in character —
|
||||
// an idle bot turn should read as hesitation, never as nothing.
|
||||
if (step.hesitated) {
|
||||
const said = addChat(room, step.seat, HESITATION_LINES[Math.floor(Math.random() * HESITATION_LINES.length)]!);
|
||||
if (!("error" in said)) {
|
||||
broadcast(room, () => ({ type: "chat", player: step.seat, text: said.text, at: said.at }));
|
||||
}
|
||||
}
|
||||
botRemark(room, step.seat, step.events as { type: string }[]);
|
||||
setTimeout(tick, BOT_STEP_MS);
|
||||
};
|
||||
@@ -526,6 +547,39 @@ wss.on("connection", (socket) => {
|
||||
session.token = null;
|
||||
break;
|
||||
}
|
||||
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 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 === kickName) {
|
||||
other.playerId = null;
|
||||
other.roomId = null;
|
||||
other.token = null;
|
||||
send(other.socket, { type: "kicked", roomId: room.id });
|
||||
}
|
||||
}
|
||||
broadcastRoomState(room);
|
||||
break;
|
||||
}
|
||||
case "abandonRoom": {
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
const problem = abandonRoom(room, session.playerId);
|
||||
if (problem) return send(socket, { type: "error", message: problem });
|
||||
for (const other of sessions) {
|
||||
if (other.roomId === room.id) {
|
||||
other.playerId = null;
|
||||
other.roomId = null;
|
||||
other.token = null;
|
||||
send(other.socket, { type: "roomAbandoned", roomId: room.id });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "addBot": {
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
@@ -680,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:
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type PlayerId,
|
||||
CURRENT_RULES_REV,
|
||||
} from "@wizwar/engine";
|
||||
import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
|
||||
import { appendLine, archiveRoomFile, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
|
||||
import { recordRoom } from "./stats";
|
||||
|
||||
export interface LoggedCommand {
|
||||
@@ -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. */
|
||||
@@ -153,6 +148,33 @@ export function joinRoom(
|
||||
return { token: fresh };
|
||||
}
|
||||
|
||||
/** The host shows an unclaimed (or unwanted) seat the door — lobby only:
|
||||
* once the deal happens, every seat is a player in the game's record. */
|
||||
export function kickSeat(room: Room, byId: PlayerId, name: PlayerId): string | null {
|
||||
if (byId !== room.hostId) return "only the host may clear a seat";
|
||||
if (room.state) return "the game has started — seats are settled";
|
||||
if (name === room.hostId) return "the host cannot kick themselves — abandon the room instead";
|
||||
if (!room.players.includes(name)) return "no such seat";
|
||||
room.players = room.players.filter((p) => p !== name);
|
||||
room.tokens.delete(name);
|
||||
room.bots.delete(name);
|
||||
room.colorChoices.delete(name);
|
||||
appendLine(room.id, { kind: "kick", name, at: new Date().toISOString() });
|
||||
recordRoom(room);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The host dissolves the room: a lobby that never dealt, or a finished
|
||||
* game done being remembered. The ledger is archived, never deleted. */
|
||||
export function abandonRoom(room: Room, byId: PlayerId): string | null {
|
||||
if (byId !== room.hostId) return "only the host may abandon the room";
|
||||
if (room.state && room.state.phase === "playing") return "the game is still being played";
|
||||
appendLine(room.id, { kind: "abandon", by: byId, at: new Date().toISOString() });
|
||||
archiveRoomFile(room.id);
|
||||
rooms.delete(room.id);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Everyone gets their chosen standee; the undecided get the first free one. */
|
||||
export function resolveColors(room: Room): number[] {
|
||||
const taken = new Set<number>();
|
||||
@@ -201,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;
|
||||
}
|
||||
@@ -291,17 +313,28 @@ function actingSeat(room: Room): PlayerId | null {
|
||||
* One automaton command, if the maze is waiting on clockwork. Null when a
|
||||
* human holds the floor (or the game is over, or the clockwork is wedged).
|
||||
*/
|
||||
export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEvent[] } | null {
|
||||
export function driveOneAutomaton(
|
||||
room: Room,
|
||||
): { 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: 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.
|
||||
if (chosen) r = runCommand(room, seat, automatonFallback(view, bot?.tier));
|
||||
// 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.
|
||||
if (chosen) {
|
||||
hesitated = true;
|
||||
console.warn(
|
||||
`automaton ${seat} hesitated in ${room.id}: ${JSON.stringify(chosen)} refused (${r.error})`);
|
||||
r = runCommand(room, seat, automatonFallback(view, bot?.tier));
|
||||
}
|
||||
if ("error" in r) r = runCommand(room, seat, { type: "endTurn", draw: 0 });
|
||||
if ("error" in r) r = runCommand(room, seat, { type: "pass" });
|
||||
if ("error" in r) {
|
||||
@@ -309,7 +342,7 @@ export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEve
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return { seat, events: r.events };
|
||||
return { seat, events: r.events, ...(hesitated ? { hesitated } : {}) };
|
||||
}
|
||||
|
||||
export interface GameSummary {
|
||||
@@ -601,8 +634,14 @@ export function loadPersistedRooms(): void {
|
||||
chat: [],
|
||||
bots: new Map(),
|
||||
};
|
||||
if (lines.some((l) => l.kind === "abandon")) continue;
|
||||
for (const line of lines.slice(1)) {
|
||||
if (line.kind === "join") {
|
||||
if (line.kind === "kick") {
|
||||
room.players = room.players.filter((p) => p !== line.name);
|
||||
room.tokens.delete(line.name);
|
||||
room.bots.delete(line.name);
|
||||
room.colorChoices.delete(line.name);
|
||||
} else if (line.kind === "join") {
|
||||
if (line.bot) {
|
||||
room.players.push(line.name);
|
||||
room.bots.set(line.name, {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// command. Because the engine is deterministic, replaying a file rebuilds
|
||||
// the exact game state — server restarts lose nothing.
|
||||
|
||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export interface RoomMetaLine {
|
||||
@@ -58,7 +58,20 @@ export interface ChatLine {
|
||||
at: string;
|
||||
}
|
||||
|
||||
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine;
|
||||
export interface KickLine {
|
||||
kind: "kick";
|
||||
/** The seat the host removed from an unstarted room. */
|
||||
name: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface AbandonLine {
|
||||
kind: "abandon";
|
||||
by: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine;
|
||||
|
||||
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
|
||||
|
||||
@@ -103,3 +116,13 @@ export function readAllRooms(): Map<string, RoomLine[]> {
|
||||
}
|
||||
return rooms;
|
||||
}
|
||||
|
||||
/** 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 = fileFor(roomId);
|
||||
if (!existsSync(src)) return;
|
||||
const graveyard = join(DATA_DIR, "..", "rooms-abandoned");
|
||||
mkdirSync(graveyard, { recursive: true });
|
||||
renameSync(src, join(graveyard, `${roomId}.${Date.now()}.jsonl`));
|
||||
}
|
||||
|
||||
+140
-11
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { attentionLabel, net, spellName } from "./net.svelte";
|
||||
import Board from "./Board.svelte";
|
||||
import LiveFirstPerson from "./LiveFirstPerson.svelte";
|
||||
import { colorIndexOf, PLAYER_COLORS, wizardColor } from "./colors";
|
||||
import Faq from "./Faq.svelte";
|
||||
import Card from "./Card.svelte";
|
||||
@@ -14,7 +15,7 @@
|
||||
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
||||
import { local } from "./local.svelte";
|
||||
import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
|
||||
import type { CardInstance, Side } from "@wizwar/engine";
|
||||
import type { CardInstance, GameEvent, Side } from "@wizwar/engine";
|
||||
|
||||
net.connect();
|
||||
net.startGamePolling();
|
||||
@@ -51,6 +52,12 @@
|
||||
});
|
||||
/** Live spell flourishes on the board (cosmetic, self-expiring). */
|
||||
let boardFx = $state<BoardFx[]>([]);
|
||||
/** The live pane's feed: each event batch, numbered, with its view. */
|
||||
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);
|
||||
@@ -126,6 +133,7 @@
|
||||
wardBite = { owner: e.owner, amount: dmg?.type === "damaged" ? dmg.amount : 0 };
|
||||
}
|
||||
}
|
||||
if (view) fpBatch = { n: (fpBatch?.n ?? 0) + 1, events, view };
|
||||
if (!prefs.flourishes) return;
|
||||
fxCancels.push(scheduleFx(
|
||||
events, view,
|
||||
@@ -338,7 +346,7 @@
|
||||
"wall-of-fire": "click a corridor line for the fire",
|
||||
"waterwall": "click a corridor line — the wave collapses at once",
|
||||
"pick-lock": "click a locked door",
|
||||
"master-key": "click a locked door",
|
||||
"master-key": "click a locked door — or Display it once and walk through every lock",
|
||||
"jam-lock": "click a door to jam its lock solid",
|
||||
"remove-lock": "click a door to strip its lock for good",
|
||||
"create-door": "click a wall for the new door",
|
||||
@@ -716,6 +724,15 @@
|
||||
dispatch({ type: "moveCreature", creatureId: selectedCreature, direction: w.from.side });
|
||||
return;
|
||||
}
|
||||
// Standing on a DIMENSIONAL WARP token: clicking the paired token
|
||||
// steps it through.
|
||||
const dw = view.dimWarps.find((d) =>
|
||||
(cellKey(d.a) === cellKey(creature.position) && cellKey(d.b) === cellKey(cell)) ||
|
||||
(cellKey(d.b) === cellKey(creature.position) && cellKey(d.a) === cellKey(cell)));
|
||||
if (dw) {
|
||||
dispatch({ type: "creatureWarpStep", creatureId: selectedCreature });
|
||||
return;
|
||||
}
|
||||
}
|
||||
selectedCreature = null;
|
||||
return;
|
||||
@@ -890,6 +907,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** A click in the first-person pane, resolved by the renderer to the
|
||||
* board's own vocabulary — then handled by the very same functions the
|
||||
* top-down board uses, so the pane and the map can never disagree. */
|
||||
function fpvTarget(t: import("./fpv/raycast").FpvTarget) {
|
||||
if (t.kind === "player") clickPlayer(t.id);
|
||||
else if (t.kind === "creature") clickCreature(t.id);
|
||||
else if (t.kind === "edge") clickEdge(t.cell, t.side);
|
||||
else clickCell(t.cell);
|
||||
}
|
||||
|
||||
function clickPlayer(playerId: string) {
|
||||
if (!view || !yourMoment) return;
|
||||
if (selectedCreature) {
|
||||
@@ -946,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;
|
||||
@@ -1372,24 +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 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>
|
||||
@@ -1803,6 +1837,10 @@
|
||||
<span class="dot" style:background="#b3a687"></span>
|
||||
{/if}
|
||||
{p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ⚙ ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""}
|
||||
{#if net.you === net.hostId && p !== net.hostId}
|
||||
<button class="stamp tiny kick" title="clear this seat"
|
||||
onclick={() => net.kickSeat(p)}>✕</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -1826,6 +1864,8 @@
|
||||
{/each}
|
||||
</div>
|
||||
{#if net.you === net.hostId}
|
||||
<button class="stamp tiny abandon" title="dissolve this room for everyone"
|
||||
onclick={() => net.abandonRoom()}>abandon room</button>
|
||||
{#if net.players.length < 6}
|
||||
<span class="bot-row">
|
||||
<span class="bot-label">⚙ seat a</span>
|
||||
@@ -1861,6 +1901,21 @@
|
||||
{:else if view}
|
||||
<div class="game">
|
||||
<section class="board-zone">
|
||||
<div class="table-stack" class:fpv-primary={prefs.liveFp && !!view.you && !net.spectating}>
|
||||
{#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)} {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}
|
||||
edgeSelectMode={edgeSelectMode && yourMoment}
|
||||
@@ -1881,7 +1936,10 @@
|
||||
onEdgePeek={(tip) => (peekEdge = tip)}
|
||||
{litCells}
|
||||
{sightTrace}
|
||||
povFacing={prefs.liveFp && view.you && !net.spectating ? fpvFacing : null}
|
||||
povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="paper-rail">
|
||||
@@ -2205,6 +2263,10 @@
|
||||
{#if selectedCard?.cardId === "pick-lock" || selectedCard?.cardId === "master-key"}
|
||||
<label class="inline"><input type="checkbox" bind:checked={holdDoor} /> hold the door open</label>
|
||||
{/if}
|
||||
{#if selectedCard?.cardId === "master-key" && isYourTurn &&
|
||||
!me?.displayed.some((c) => c.instanceId === selectedCard!.instanceId)}
|
||||
<button class="stamp tiny" onclick={castSelfWithNumber}>Display the key</button>
|
||||
{/if}
|
||||
{#if selectedCard?.cardId === "rotate-sector"}
|
||||
<label class="inline"><input type="checkbox" bind:checked={rotateCW} /> clockwise</label>
|
||||
<span>— click the sector</span>
|
||||
@@ -2449,6 +2511,8 @@
|
||||
text-transform: uppercase;
|
||||
color: #8d8672;
|
||||
}
|
||||
.kick { margin-left: 0.4rem; color: #a04545; }
|
||||
.abandon { margin: 0.4rem 0; color: #a04545; border-color: #a04545; }
|
||||
.mast-version {
|
||||
font-family: "Courier Prime", monospace;
|
||||
font-size: 0.65rem;
|
||||
@@ -2772,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 {
|
||||
@@ -2809,6 +2914,30 @@
|
||||
min-height: 0;
|
||||
}
|
||||
.board-zone { display: flex; align-items: flex-start; justify-content: center; min-width: 0; }
|
||||
/* The first-person pane wears exactly the board's width, right above
|
||||
* it: the stack shrink-wraps to the board, and width:0 + min-width
|
||||
* keeps the pane out of the intrinsic sizing so the BOARD alone
|
||||
* decides how wide the column is. */
|
||||
.table-stack {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.table-stack :global(.live-fp) { width: 0; min-width: 100%; }
|
||||
.table-stack :global(.live-fp .fpv-canvas) { width: 100%; height: auto; }
|
||||
/* FPV play: the 3D view is the primary window into the game; the
|
||||
* top-down board shrinks to a keymap beneath it. */
|
||||
.table-stack.fpv-primary {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 64rem;
|
||||
}
|
||||
.board-zone .table-stack.fpv-primary :global(svg.board) {
|
||||
max-height: 30dvh;
|
||||
align-self: center;
|
||||
}
|
||||
.board-zone :global(svg.board) {
|
||||
max-height: calc(100dvh - 21.5rem);
|
||||
width: auto;
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
onCellClick,
|
||||
onEdgeClick,
|
||||
onPlayerClick,
|
||||
povFacing = null,
|
||||
povCreatureId = null,
|
||||
onCreatureClick,
|
||||
onWarpClick,
|
||||
onCellPeek,
|
||||
@@ -43,6 +45,11 @@
|
||||
onCellClick?: (cell: { x: number; y: number }) => void;
|
||||
onEdgeClick?: (cell: { x: number; y: number }, side: Side) => void;
|
||||
onPlayerClick?: (playerId: string) => void;
|
||||
/** 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. */
|
||||
@@ -68,7 +75,7 @@
|
||||
effects?: BoardFx[] | null;
|
||||
/** The sight line an attack in progress traveled — proof against "how
|
||||
* can he even see me?", drawn leg by leg through any warp mouth. */
|
||||
sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace } | null;
|
||||
sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace; bend?: { mid: { x: number; y: number }; trace: SightTrace } } | null;
|
||||
} = $props();
|
||||
|
||||
|
||||
@@ -113,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;
|
||||
@@ -390,14 +409,20 @@
|
||||
x={gx * CELL + 4 + i * 9} y={gy * CELL + CELL - CELL * 0.42 - 3}
|
||||
width={CELL * 0.4} height={CELL * 0.4}
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
class="token-art small"
|
||||
class="token-art small peekable"
|
||||
role="button" tabindex="-1"
|
||||
onclick={(ev) => { ev.stopPropagation(); onCellPeek?.({ x: gx, y: gy }); }}
|
||||
onkeydown={() => {}}
|
||||
>
|
||||
<title>{o.cardId}</title>
|
||||
</image>
|
||||
{:else}
|
||||
<rect
|
||||
x={gx * CELL + 6 + i * 8} y={gy * CELL + CELL - 16}
|
||||
width={12} height={10} rx="2" class="ground-object"
|
||||
width={12} height={10} rx="2" class="ground-object peekable"
|
||||
role="button" tabindex="-1"
|
||||
onclick={(ev) => { ev.stopPropagation(); onCellPeek?.({ x: gx, y: gy }); }}
|
||||
onkeydown={() => {}}
|
||||
>
|
||||
<title>{o.cardId}</title>
|
||||
</rect>
|
||||
@@ -582,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}
|
||||
|
||||
@@ -628,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}
|
||||
|
||||
@@ -661,7 +693,14 @@
|
||||
|
||||
<!-- the sight line an attack traveled, leg by leg through any warp mouth -->
|
||||
{#if sightTrace}
|
||||
<SightTraceOverlay from={sightTrace.from} to={sightTrace.to} trace={sightTrace.trace} />
|
||||
{#if sightTrace.bend}
|
||||
<SightTraceOverlay from={sightTrace.from} to={sightTrace.bend.mid} trace={sightTrace.trace} />
|
||||
<SightTraceOverlay from={sightTrace.bend.mid} to={sightTrace.to} trace={sightTrace.bend.trace} />
|
||||
<rect x={sightTrace.bend.mid.x * CELL + CELL / 2 - 6} y={sightTrace.bend.mid.y * CELL + CELL * 0.36 - 6}
|
||||
width={12} height={12} class="sight-corner" transform="rotate(45 {sightTrace.bend.mid.x * CELL + CELL / 2} {sightTrace.bend.mid.y * CELL + CELL * 0.36})" />
|
||||
{:else}
|
||||
<SightTraceOverlay from={sightTrace.from} to={sightTrace.to} trace={sightTrace.trace} />
|
||||
{/if}
|
||||
{/if}
|
||||
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
|
||||
<g class="fx-layer" aria-hidden="true">
|
||||
@@ -752,6 +791,23 @@
|
||||
fill: #6a5c44;
|
||||
pointer-events: none;
|
||||
}
|
||||
.pov-wedge {
|
||||
fill: #ffd23e;
|
||||
stroke: #43331f;
|
||||
stroke-width: 1.2;
|
||||
pointer-events: none;
|
||||
}
|
||||
.peekable { cursor: pointer; pointer-events: all; }
|
||||
.sight-corner {
|
||||
fill: none;
|
||||
stroke: #c9a72a;
|
||||
stroke-width: 2;
|
||||
animation: sight-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes sight-pulse {
|
||||
0%, 100% { opacity: 0.9; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
.dim-cell {
|
||||
fill: rgba(12, 9, 5, 0.55);
|
||||
pointer-events: none;
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
<script lang="ts">
|
||||
// The living maze through your own eyes: a pane that rides above the
|
||||
// board during play. The board below stays the tactical truth — this
|
||||
// is the experience. The camera carries the reel's instincts: it
|
||||
// walks your strides, turns to your aims and to actors you can see,
|
||||
// cuts away to deeds beyond your sight, and never idles nose-to-brick.
|
||||
// Every live event batch plays through the same fx pipeline the
|
||||
// instant replay uses, so spells, doors, and conjurations perform
|
||||
// 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, 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, 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. */
|
||||
batch: { n: number; events: GameEvent[]; view: GameView } | null;
|
||||
onhide: () => void;
|
||||
/** Issue a real move command: the pane is the cockpit on your turn. */
|
||||
onstride?: ((side: Side) => void) | null;
|
||||
canStride?: boolean;
|
||||
/** Clicks in the pane resolve to board targets: the pane is not just
|
||||
* the window but the wand hand. */
|
||||
ontarget?: ((t: FpvTarget) => void) | null;
|
||||
/** 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
|
||||
* idle facings (actor-watching, corridor rescue); aims and cutaways
|
||||
* still fire — your own magic always turns your head. */
|
||||
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() + STEER_HOLD_MS;
|
||||
const from = cam.facing;
|
||||
const to = from + dir * (Math.PI / 2);
|
||||
const t0 = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const w = Math.min(1, (now - t0) / 180);
|
||||
cam.facing = from + (to - from) * (w * w * (3 - 2 * w));
|
||||
if (w < 1) requestAnimationFrame(tick);
|
||||
else turning = false;
|
||||
};
|
||||
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) 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]!;
|
||||
if (possessed) onCreatureMove?.(possessed.id, side);
|
||||
else onstride?.(side);
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
|
||||
const k = e.key.toLowerCase();
|
||||
if (k === "arrowleft" || k === "a") { e.preventDefault(); manualTurn(-1); }
|
||||
else if (k === "arrowright" || k === "d") { e.preventDefault(); manualTurn(1); }
|
||||
else if (k === "arrowup" || k === "w") { e.preventDefault(); manualStride(false); }
|
||||
else if (k === "arrowdown" || k === "s") { e.preventDefault(); manualStride(true); }
|
||||
}
|
||||
let cutawayShot = $state(false);
|
||||
let fpFx = $state<FpFx[]>([]);
|
||||
let actorPos = $state<Record<string, { x: number; y: number }>>({});
|
||||
|
||||
// --- The fx: each batch plays once, on its own beat. -----------------
|
||||
let playedBatch = 0;
|
||||
$effect(() => {
|
||||
const b = batch;
|
||||
if (!b || b.n === playedBatch || !me) return;
|
||||
playedBatch = b.n;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
const started: number[] = [];
|
||||
for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) {
|
||||
timers.push(setTimeout(() => {
|
||||
started.push(fx.id);
|
||||
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
|
||||
timers.push(setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80));
|
||||
}, delay));
|
||||
}
|
||||
return () => {
|
||||
timers.forEach(clearTimeout);
|
||||
started.forEach((id) => (fpFx = fpFx.filter((f) => f.id !== id)));
|
||||
};
|
||||
});
|
||||
|
||||
// --- Other bodies glide between views. -------------------------------
|
||||
let prevView: GameView | null = null;
|
||||
$effect(() => {
|
||||
const v = view;
|
||||
const before = prevView;
|
||||
prevView = v;
|
||||
if (!before || before === v || !me) return;
|
||||
const moves = gatherGlides(before, v, povId);
|
||||
if (moves.length === 0) { actorPos = {}; return; }
|
||||
const t0 = performance.now();
|
||||
const dur = 400;
|
||||
let raf = 0;
|
||||
const tick = (now: number) => {
|
||||
const w = Math.min(1, Math.max(0, (now - t0) / dur));
|
||||
const ease = w * w * (3 - 2 * w);
|
||||
const next: Record<string, { x: number; y: number }> = {};
|
||||
for (const m of moves) {
|
||||
next[m.id] = { x: m.fx + (m.tx - m.fx) * ease, y: m.fy + (m.ty - m.fy) * ease };
|
||||
}
|
||||
actorPos = next;
|
||||
if (w < 1) raf = requestAnimationFrame(tick);
|
||||
else actorPos = {};
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
});
|
||||
|
||||
// --- The camera: the director's ladder, live. ------------------------
|
||||
let directedBatch = 0;
|
||||
$effect(() => {
|
||||
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;
|
||||
// Untracked camera reads: this effect's own tween writes cam every
|
||||
// frame, and a tracked read would restart the ease per frame.
|
||||
const camX = untrack(() => cam.x);
|
||||
const camY = untrack(() => cam.y);
|
||||
const camF = untrack(() => cam.facing);
|
||||
const dx = tx - camX;
|
||||
const dy = ty - camY;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
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) => {
|
||||
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 actor = v.activePlayerId;
|
||||
const aim = aimOfEvents(events, v, povId, m.position, actor);
|
||||
let targetFacing = camF;
|
||||
let aimed = false;
|
||||
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 && (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 && !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));
|
||||
if (sight.warped || sight.dist < toAim - 0.4) return takeCutaway(ax, ay);
|
||||
targetFacing = Math.atan2(ay - ty, ax - tx);
|
||||
aimed = true;
|
||||
} else if (performance.now() >= manualUntil) {
|
||||
// Another wizard acting in your sight line turns your head.
|
||||
const other = actor !== povId ? v.players.find((p) => p.id === actor && p.alive) : null;
|
||||
if (other && (other.position.x !== m.position.x || other.position.y !== m.position.y)) {
|
||||
const ax = other.position.x + 0.5, ay = other.position.y + 0.5;
|
||||
const toActor = Math.hypot(ax - tx, ay - ty);
|
||||
const ray = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
|
||||
if (!ray.warped && ray.dist > toActor - 0.2) {
|
||||
targetFacing = Math.atan2(ay - ty, ax - tx);
|
||||
aimed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
camReady = true;
|
||||
return;
|
||||
}
|
||||
const fromX = camX, fromY = camY, fromF = camF;
|
||||
const arc = shortestArc(fromF, targetFacing);
|
||||
const turnMs = hurled ? 0 : Math.min(260, Math.abs(arc) * 180);
|
||||
const walkMs = dist > 0.05 ? (hurled ? 260 : 380) : 0;
|
||||
const t0 = performance.now();
|
||||
let raf = 0;
|
||||
const tick = (now: number) => {
|
||||
const t = Math.max(0, now - t0);
|
||||
if (turnMs > 0 && t < turnMs) {
|
||||
cam.facing = fromF + arc * (t / turnMs);
|
||||
} else if (walkMs > 0 && t < turnMs + walkMs) {
|
||||
cam.facing = fromF + arc;
|
||||
const w = (t - turnMs) / walkMs;
|
||||
const ease = w * w * (3 - 2 * w);
|
||||
cam.x = fromX + (tx - fromX) * ease;
|
||||
cam.y = fromY + (ty - fromY) * ease;
|
||||
} else {
|
||||
cam.facing = fromF + arc;
|
||||
cam.x = tx; cam.y = ty;
|
||||
return;
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
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;
|
||||
if (q !== lastQuad) {
|
||||
lastQuad = q;
|
||||
onfacing?.(SIDES4[q]!);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
{#if me}
|
||||
<div class="live-fp">
|
||||
<!-- 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 {
|
||||
margin-bottom: 0.6rem;
|
||||
border: 1px solid #3a3428;
|
||||
border-radius: 6px;
|
||||
background: #14121a;
|
||||
overflow: hidden;
|
||||
}
|
||||
.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;
|
||||
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 {
|
||||
font-family: "Courier Prime", monospace;
|
||||
font-size: 0.65rem;
|
||||
color: #5d5748;
|
||||
}
|
||||
.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: 1px solid #4a4536;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
line-height: 0;
|
||||
}
|
||||
.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>
|
||||
+10
-100
@@ -7,6 +7,7 @@
|
||||
import { scheduleFx, type BoardFx } from "./fx";
|
||||
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
||||
import { castRay, edgeMid } from "./fpv/raycast";
|
||||
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";
|
||||
@@ -187,28 +188,7 @@
|
||||
const before = prevView;
|
||||
prevView = v;
|
||||
if (!before || before === v) return;
|
||||
const moves: { id: string; fx: number; fy: number; tx: number; ty: number }[] = [];
|
||||
const gather = (
|
||||
id: string,
|
||||
now: { x: number; y: number },
|
||||
was: { x: number; y: number } | undefined,
|
||||
) => {
|
||||
if (!was) return;
|
||||
const d = Math.hypot(now.x - was.x, now.y - was.y);
|
||||
// A single stride or shove glides; a leap across the maze is a
|
||||
// teleport and should simply be there.
|
||||
if (d > 0.05 && d <= 3.5) {
|
||||
moves.push({ id, fx: was.x + 0.5, fy: was.y + 0.5, tx: now.x + 0.5, ty: now.y + 0.5 });
|
||||
}
|
||||
};
|
||||
for (const p of v.players) {
|
||||
if (!p.alive || p.id === povId) continue;
|
||||
const was = before.players.find((q) => q.id === p.id && q.alive);
|
||||
gather(p.id, p.position, was?.position);
|
||||
}
|
||||
for (const c of v.creatures) {
|
||||
gather(c.id, c.position, before.creatures.find((q) => q.id === c.id)?.position);
|
||||
}
|
||||
const moves = gatherGlides(before, v, povId);
|
||||
if (moves.length === 0) { actorPos = {}; return; }
|
||||
const t0 = performance.now();
|
||||
const dur = 400 / speed;
|
||||
@@ -234,12 +214,6 @@
|
||||
// still, the eye turns toward whoever acted.
|
||||
const cam = $state({ x: 0, y: 0, facing: 0 });
|
||||
let camReady = false;
|
||||
function shortestArc(from: number, to: number): number {
|
||||
let d = (to - from) % (2 * Math.PI);
|
||||
if (d > Math.PI) d -= 2 * Math.PI;
|
||||
if (d < -Math.PI) d += 2 * Math.PI;
|
||||
return d;
|
||||
}
|
||||
$effect(() => {
|
||||
if (!fp) { camReady = false; return; }
|
||||
const v = step.view;
|
||||
@@ -261,72 +235,11 @@
|
||||
// actor, when someone else moved the world; wherever it was, otherwise.
|
||||
// A blow that hurls the body glides fast and straight, however far,
|
||||
// eyes still where they were; only unexplained leaps (teleports) cut.
|
||||
const hurled = step.events.some((e) =>
|
||||
(e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" ||
|
||||
e.type === "retreatedInHorror") && e.player === povId);
|
||||
const hurled = hurledIn(step.events, povId);
|
||||
const actor = v.players.find((p) => p.id === step.actor);
|
||||
// Where the eyes' own magic went this step: a wizard looks where
|
||||
// they aim, so the camera turns to watch its own spells land.
|
||||
const aim = (() => {
|
||||
for (const e of step.events) {
|
||||
let cell: { x: number; y: number } | null = null;
|
||||
if (e.type === "creatureCreated" && "controller" in e && e.controller === povId) {
|
||||
cell = (e as { at: { x: number; y: number } }).at;
|
||||
} else if (e.type === "spellCast" && e.caster === povId) {
|
||||
cell = e.targetCell ?? v.players.find((p) => p.id === e.target)?.position ?? null;
|
||||
} else if (e.type === "attackResolved" && e.attacker === povId) {
|
||||
cell = v.players.find((p) => p.id === e.defender)?.position ?? null;
|
||||
} else if (e.type === "objectThrown" && e.attacker === povId) {
|
||||
cell = e.landedAt;
|
||||
} else if (
|
||||
// Deeds done to an EDGE — a lock picked or jammed, a door
|
||||
// unlocked, a wall raised, razed, or drowned — turn the eyes
|
||||
// to the edge's midpoint just the same.
|
||||
"edge" in e && typeof e.edge === "object" && e.edge !== null && "cell" in e.edge &&
|
||||
(("caster" in e && e.caster === povId) || ("player" in e && e.player === povId))
|
||||
) {
|
||||
const edge = e.edge as { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" };
|
||||
const m = edgeMid(edge.cell, edge.side);
|
||||
// edgeMid sits ON the boundary; step half a texel toward the
|
||||
// cell so the same-cell check below behaves.
|
||||
if (Math.abs(m.x - (me.position.x + 0.5)) > 0.3 || Math.abs(m.y - (me.position.y + 0.5)) > 0.3) {
|
||||
return { x: m.x - 0.5, y: m.y - 0.5 };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (cell && (cell.x !== me.position.x || cell.y !== me.position.y)) return cell;
|
||||
// The deed lands on the wizard's OWN square — a summon at their
|
||||
// feet, invisible from inside their own eyes. Mark it for the
|
||||
// cutaway: step outside yourself and watch.
|
||||
if (cell) return { ...cell, self: true };
|
||||
}
|
||||
// Last resort, only for the reel's own wizard's step: wherever the
|
||||
// fx layer is about to play something visible — an impact, a
|
||||
// projectile's landing — the eyes should already be.
|
||||
if (step.actor === povId) {
|
||||
for (const e of step.events) {
|
||||
if (e.type === "creatureMoved" && "by" in e && e.by === povId) {
|
||||
const to = (e as { to: { x: number; y: number } }).to;
|
||||
if (to.x !== me.position.x || to.y !== me.position.y) return to;
|
||||
}
|
||||
if (
|
||||
(e.type === "treasurePickedUp" || e.type === "treasureDropped" || e.type === "objectDropped") &&
|
||||
"player" in e && (e as { player: string }).player === povId
|
||||
) {
|
||||
const at = (e as { at: { x: number; y: number } }).at;
|
||||
return { ...at, self: at.x === me.position.x && at.y === me.position.y };
|
||||
}
|
||||
}
|
||||
for (const { fx } of fpFxForEvents(step.events, v, povId)) {
|
||||
const spot = fx.kind === "impact" ? fx.at : fx.kind === "projectile" ? fx.to : null;
|
||||
if (!spot) continue;
|
||||
const cx = Math.floor(spot.x), cy = Math.floor(spot.y);
|
||||
if (cx === me.position.x && cy === me.position.y) return { x: cx, y: cy, self: true };
|
||||
return { x: cx, y: cy };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})() as ({ x: number; y: number; self?: boolean } | null);
|
||||
const aim = aimOfEvents(step.events, v, povId, me.position, step.actor);
|
||||
// A leap the legs cannot explain (first frame, teleport, the return
|
||||
// from a cutaway) is a CUT, and a cut is not a stride: the direction
|
||||
// of travel means nothing at the far end.
|
||||
@@ -336,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);
|
||||
|
||||
@@ -3,7 +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 { 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";
|
||||
@@ -22,6 +22,8 @@
|
||||
fx = [],
|
||||
posOverride,
|
||||
rubble = [],
|
||||
ontarget,
|
||||
litCells = null,
|
||||
}: {
|
||||
view: GameView;
|
||||
povId: string;
|
||||
@@ -38,11 +40,44 @@
|
||||
posOverride?: Record<string, { x: number; y: number }>;
|
||||
/** Where walls have died: a mound of stone marks each fallen edge. */
|
||||
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;
|
||||
let canvas: HTMLCanvasElement;
|
||||
|
||||
/** Everything the LAST drawn frame knew, kept for click resolution:
|
||||
* the pane is an instrument only because the renderer remembers what
|
||||
* stood under every pixel. */
|
||||
let hitFrame: {
|
||||
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 {
|
||||
@@ -168,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;
|
||||
@@ -201,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) {
|
||||
@@ -221,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;
|
||||
@@ -237,6 +288,7 @@
|
||||
// side must MATCH its column's, or bodies near a far mouth would
|
||||
// ghost into real corridors the unrolled space happens to overlap.
|
||||
const zbuf = new Float64Array(W);
|
||||
const hitCols: ({ edge?: string; kind: string; top: number; h: number } | null)[] = new Array(W).fill(null);
|
||||
// Per column: which warp (if any) the ray bent through, and how far
|
||||
// away that mouth stood. A REAL body paints a column only if it is
|
||||
// nearer than the mouth (it stands in front of the window); a
|
||||
@@ -281,6 +333,7 @@
|
||||
}
|
||||
const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05));
|
||||
const top = half - wallH / 2;
|
||||
hitCols[col] = { edge: hit.edge, kind: hit.frame ? "frame" : hit.kind, top, h: wallH };
|
||||
const tex = hit.frame ? textures.doorframe! : textures[hit.kind] ?? textures.wall!;
|
||||
// Sample by the texture's own size: painted files may be any scale.
|
||||
// Fire shifts its slice per world cell, so a blaze spanning edges
|
||||
@@ -363,6 +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: [...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.
|
||||
@@ -392,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;
|
||||
@@ -519,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 {
|
||||
@@ -535,6 +653,8 @@
|
||||
fallback?: string;
|
||||
clampL?: number;
|
||||
clampR?: number;
|
||||
hit?: { kind: "player" | "creature"; id: string };
|
||||
cell?: { x: number; y: number };
|
||||
/** Sort key only: the near-bias orders sprites among THEMSELVES (a
|
||||
* hedge over the wizard standing in it) but must never let one
|
||||
* cheat past a wall — occlusion always uses the true depth. */
|
||||
@@ -544,7 +664,9 @@
|
||||
b: { x: number; y: number; src: string; scale: number; rise: number;
|
||||
aspect?: number; alpha?: number; glow?: boolean; bias?: number;
|
||||
fallback?: string; warped?: boolean; warpId?: number;
|
||||
clip?: { x: number; y: number } },
|
||||
clip?: { x: number; y: number };
|
||||
hit?: { kind: "player" | "creature"; id: string };
|
||||
cell?: { x: number; y: number } },
|
||||
ex: number, ey: number,
|
||||
): Projected | null {
|
||||
const relX = b.x - ex, relY = b.y - ey;
|
||||
@@ -597,11 +719,126 @@
|
||||
return {
|
||||
src: b.src, depth, sort: depth - (b.bias ?? 0), warped: b.warped, warpId: b.warpId,
|
||||
alpha: b.alpha, glow: b.glow, fallback: b.fallback, clampL, clampR,
|
||||
hit: b.hit, cell: b.cell,
|
||||
left: screenX - wide / 2, right: screenX + wide / 2,
|
||||
top: bottom - size, bottom,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve a canvas-pixel click to what stood under it: the nearest
|
||||
* visible sprite, else the struck wall or door face, else the floor
|
||||
* (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. */
|
||||
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));
|
||||
const half = f.H / 2;
|
||||
|
||||
// Sprites first, nearest first, honoring the draw pass's own
|
||||
// visibility rules for this column.
|
||||
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 (!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.
|
||||
const c = f.cols[col];
|
||||
if (c && py >= c.top && py <= c.top + c.h) {
|
||||
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 {
|
||||
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 ? { target: { kind: "cell", cell: pt }, label: "solid stone", shape: { kind: "ground", cell: pt } } : null;
|
||||
}
|
||||
return null; // rims and frame posts are nobody's target
|
||||
}
|
||||
|
||||
// Ground (or vault): each row below the horizon lies at one depth.
|
||||
const dz = py > half ? py - half : half - py;
|
||||
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 ? { 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 —
|
||||
* mapping through the column's warp when the ray bent. */
|
||||
function groundPoint(
|
||||
f: NonNullable<typeof hitFrame>, col: number, d: number,
|
||||
): { x: number; y: number } | null {
|
||||
const flen = (f.W / 2) / Math.tan(FOV / 2);
|
||||
const side = (col - f.W / 2) * (d / flen);
|
||||
const cosF = Math.cos(f.facing), sinF = Math.sin(f.facing);
|
||||
let wx = f.ex + d * cosF - side * sinF;
|
||||
let wy = f.ey + d * sinF + side * cosF;
|
||||
if (f.warpIdCol[col]! >= 0 && d > f.warpDistCol[col]!) {
|
||||
const w = view.board.warps[f.warpIdCol[col]!];
|
||||
if (!w) return null;
|
||||
const real = warpMotion(w).toReal({ x: wx, y: wy });
|
||||
wx = real.x; wy = real.y;
|
||||
}
|
||||
const cell = { x: Math.floor(wx), y: Math.floor(wy) };
|
||||
return view.board.cells[`${cell.x},${cell.y}`] ? cell : null;
|
||||
}
|
||||
|
||||
function onCanvasClick(e: MouseEvent) {
|
||||
if (!ontarget || !canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const px = (e.clientX - rect.left) * (width / rect.width);
|
||||
const py = (e.clientY - rect.top) * (height / rect.height);
|
||||
const t = hitTest(px, py);
|
||||
if (t) ontarget(t);
|
||||
}
|
||||
|
||||
// Redraw every frame: the fire flickers and the warps swirl even when
|
||||
// the camera holds still.
|
||||
$effect(() => {
|
||||
@@ -612,9 +849,17 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<canvas bind:this={canvas} {width} {height} class="fpv-canvas"></canvas>
|
||||
<canvas bind:this={canvas} {width} {height} class="fpv-canvas" class:targeting={!!ontarget}
|
||||
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; }
|
||||
.fpv-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// The directorial eye the reel and the live pane share: given a batch
|
||||
// of events, where should the camera look — and which bodies glide
|
||||
// between one view and the next. One ladder, so the replay and the
|
||||
// table never learn different instincts.
|
||||
|
||||
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);
|
||||
if (d > Math.PI) d -= 2 * Math.PI;
|
||||
if (d < -Math.PI) d += 2 * Math.PI;
|
||||
return d;
|
||||
}
|
||||
|
||||
export interface Aim {
|
||||
x: number;
|
||||
y: number;
|
||||
/** The deed lands on the wizard's OWN square — a summon at their
|
||||
* feet, invisible from inside their own eyes. Take the cutaway. */
|
||||
self?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where this batch's deeds should point the pov wizard's eyes: their
|
||||
* own casts, attacks, throws, summons, and edge-deeds first; then, when
|
||||
* the pov is the acting player, wherever the fx layer will visibly play.
|
||||
* Null when nothing asks to be watched.
|
||||
*/
|
||||
export function aimOfEvents(
|
||||
events: GameEvent[], view: GameView, povId: string,
|
||||
myCell: { x: number; y: number }, actor: string,
|
||||
): Aim | null {
|
||||
for (const e of events) {
|
||||
let cell: { x: number; y: number } | null = null;
|
||||
if (e.type === "creatureCreated" && "controller" in e && e.controller === povId) {
|
||||
cell = (e as { at: { x: number; y: number } }).at;
|
||||
} else if (e.type === "spellCast" && e.caster === povId) {
|
||||
cell = e.targetCell ?? view.players.find((p) => p.id === e.target)?.position ?? null;
|
||||
} else if (e.type === "attackResolved" && e.attacker === povId) {
|
||||
cell = view.players.find((p) => p.id === e.defender)?.position ?? null;
|
||||
} else if (e.type === "objectThrown" && e.attacker === povId) {
|
||||
cell = e.landedAt;
|
||||
} else if (
|
||||
// Deeds done to an EDGE — a lock picked or jammed, a door
|
||||
// unlocked, a wall raised, razed, or drowned — turn the eyes
|
||||
// to the edge's midpoint just the same.
|
||||
"edge" in e && typeof e.edge === "object" && e.edge !== null && "cell" in e.edge &&
|
||||
(("caster" in e && e.caster === povId) || ("player" in e && e.player === povId))
|
||||
) {
|
||||
const edge = e.edge as { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" };
|
||||
const m = edgeMid(edge.cell, edge.side);
|
||||
// edgeMid sits ON the boundary; step half a texel toward the
|
||||
// cell so the same-cell check below behaves.
|
||||
if (Math.abs(m.x - (myCell.x + 0.5)) > 0.3 || Math.abs(m.y - (myCell.y + 0.5)) > 0.3) {
|
||||
return { x: m.x - 0.5, y: m.y - 0.5 };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (cell && (cell.x !== myCell.x || cell.y !== myCell.y)) return cell;
|
||||
if (cell) return { ...cell, self: true };
|
||||
}
|
||||
// Last resort, only for the pov wizard's own step: wherever the fx
|
||||
// layer is about to play something visible — an impact, a
|
||||
// projectile's landing — the eyes should already be.
|
||||
if (actor === povId) {
|
||||
for (const e of events) {
|
||||
if (e.type === "creatureMoved" && "by" in e && e.by === povId) {
|
||||
const to = (e as { to: { x: number; y: number } }).to;
|
||||
if (to.x !== myCell.x || to.y !== myCell.y) return to;
|
||||
}
|
||||
if (
|
||||
(e.type === "treasurePickedUp" || e.type === "treasureDropped" || e.type === "objectDropped") &&
|
||||
"player" in e && (e as { player: string }).player === povId
|
||||
) {
|
||||
const at = (e as { at: { x: number; y: number } }).at;
|
||||
return { ...at, self: at.x === myCell.x && at.y === myCell.y };
|
||||
}
|
||||
}
|
||||
for (const { fx } of fpFxForEvents(events, view, povId)) {
|
||||
const spot = fx.kind === "impact" ? fx.at : fx.kind === "projectile" ? fx.to : null;
|
||||
if (!spot) continue;
|
||||
const cx = Math.floor(spot.x), cy = Math.floor(spot.y);
|
||||
if (cx === myCell.x && cy === myCell.y) return { x: cx, y: cy, self: true };
|
||||
return { x: cx, y: cy };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Does this batch hurl the pov wizard bodily — a blow to glide with,
|
||||
* eyes held steady, rather than a stride or a cut? */
|
||||
export function hurledIn(events: GameEvent[], povId: string): boolean {
|
||||
return events.some((e) =>
|
||||
(e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" ||
|
||||
e.type === "retreatedInHorror") && e.player === povId);
|
||||
}
|
||||
|
||||
export interface Glide {
|
||||
id: string;
|
||||
fx: number;
|
||||
fy: number;
|
||||
tx: number;
|
||||
ty: number;
|
||||
}
|
||||
|
||||
/** Bodies that walked between two views: each glides rather than
|
||||
* blinking cell to cell. A leap across the maze is a teleport and
|
||||
* simply arrives. */
|
||||
export function gatherGlides(before: GameView, after: GameView, povId: string): Glide[] {
|
||||
const moves: Glide[] = [];
|
||||
const gather = (
|
||||
id: string,
|
||||
now: { x: number; y: number },
|
||||
was: { x: number; y: number } | undefined,
|
||||
) => {
|
||||
if (!was) return;
|
||||
const d = Math.hypot(now.x - was.x, now.y - was.y);
|
||||
if (d > 0.05 && d <= 3.5) {
|
||||
moves.push({ id, fx: was.x + 0.5, fy: was.y + 0.5, tx: now.x + 0.5, ty: now.y + 0.5 });
|
||||
}
|
||||
};
|
||||
for (const p of after.players) {
|
||||
if (!p.alive || p.id === povId) continue;
|
||||
const was = before.players.find((q) => q.id === p.id && q.alive);
|
||||
gather(p.id, p.position, was?.position);
|
||||
}
|
||||
for (const c of after.creatures) {
|
||||
gather(c.id, c.position, before.creatures.find((q) => q.id === c.id)?.position);
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -292,6 +292,14 @@ export function edgeMid(cell: Cell, side: Side): { x: number; y: number } {
|
||||
return { x: cell.x + 0.5, y: cell.y };
|
||||
}
|
||||
|
||||
/** What a click in the first-person pane resolved to — the pane speaks
|
||||
* the board's own click vocabulary. */
|
||||
export type FpvTarget =
|
||||
| { kind: "player"; id: string }
|
||||
| { kind: "creature"; id: string }
|
||||
| { kind: "edge"; cell: Cell; side: Side }
|
||||
| { kind: "cell"; cell: Cell };
|
||||
|
||||
export interface Billboard {
|
||||
/** World position (cell-centered). */
|
||||
x: number;
|
||||
@@ -324,6 +332,13 @@ export interface Billboard {
|
||||
* visible only through THAT warp's columns, beyond its mouth. */
|
||||
warped?: boolean;
|
||||
warpId?: number;
|
||||
/** Click identity: the specific body this sprite is (a wizard, a
|
||||
* creature) — the pane's hit test hands it straight to the board's
|
||||
* own click handlers. */
|
||||
hit?: { kind: "player" | "creature"; id: string };
|
||||
/** Click fallback: the REAL square this sprite stands in (a virtual
|
||||
* warp copy keeps its source's square, not its drawn position). */
|
||||
cell?: Cell;
|
||||
}
|
||||
|
||||
/** The along=0 corner of a warp mouth — the anchor castRay's lane
|
||||
@@ -384,13 +399,16 @@ export function billboards(
|
||||
out.push({
|
||||
x: o?.x ?? p.position.x + 0.5, y: o?.y ?? p.position.y + 0.5,
|
||||
src: art(`wizard-${p.colorIndex}`, "players"), scale: 0.85, rise: 0, label: p.id,
|
||||
hit: { kind: "player", id: p.id }, cell: { ...p.position },
|
||||
});
|
||||
}
|
||||
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,
|
||||
src: art(c.kind, "creatures"), scale: 0.75, rise: 0, label: c.kind, key: c.id,
|
||||
hit: { kind: "creature", id: c.id }, cell: { ...c.position },
|
||||
});
|
||||
}
|
||||
for (const t of view.treasures) {
|
||||
@@ -405,13 +423,14 @@ export function billboards(
|
||||
out.push({
|
||||
x: o?.x ?? carrier.position.x + 0.5, y: o?.y ?? carrier.position.y + 0.5,
|
||||
src, scale: 0.26, rise: 0.2, bias: 0.12, label: "treasure",
|
||||
hit: { kind: "player", id: carrier.id }, cell: { ...carrier.position },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!t.position) continue;
|
||||
out.push({
|
||||
x: t.position.x + 0.5, y: t.position.y + 0.5,
|
||||
src, scale: 0.4, rise: 0, label: "treasure",
|
||||
src, scale: 0.4, rise: 0, label: "treasure", cell: { ...t.position },
|
||||
});
|
||||
}
|
||||
// The floor's furniture, standing in the room. Bushes fill the square
|
||||
@@ -426,25 +445,25 @@ export function billboards(
|
||||
out.push({
|
||||
...at, src: `/terrain3d/${content.kind}.png`, fallback: content.kind,
|
||||
scale: 0.5, aspect: 2.1, rise: 0, bias: 0.18, label: content.kind, key: k,
|
||||
clip: { x: tx, y: ty },
|
||||
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
|
||||
});
|
||||
} else if (content.kind === "ooze") {
|
||||
out.push({
|
||||
...at, src: "/terrain3d/jello.png", fallback: "jello",
|
||||
scale: 0.96, aspect: 1.04, rise: 0, alpha: 0.6, bias: 0.18, label: "ooze", key: k,
|
||||
clip: { x: tx, y: ty },
|
||||
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
|
||||
});
|
||||
} else if (content.kind === "dust") {
|
||||
out.push({
|
||||
...at, src: "/terrain3d/dust.png", fallback: "dust",
|
||||
scale: 0.85, aspect: 1.1, rise: 0, alpha: 0.55, bias: 0.18, label: "dust", key: k,
|
||||
clip: { x: tx, y: ty },
|
||||
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
|
||||
});
|
||||
} else if (content.kind === "safe") {
|
||||
out.push({
|
||||
...at, src: "/terrain3d/safe.png", fallback: "safe",
|
||||
scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe", key: k,
|
||||
clip: { x: tx, y: ty },
|
||||
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,16 @@ export function humanize(e: GameEvent): string | null {
|
||||
case "doorHeld": return `${e.player} holds the door open.`;
|
||||
case "doorReleased": return `The held door swings shut.`;
|
||||
case "doorsRelocked": return `The door swings shut and relocks.`;
|
||||
case "washedBack": return `${e.player} is washed back ${e.blockedSpaces > 0 ? "and crushed against the stone" : "by the wave"}!`;
|
||||
case "enteredThornbush": return `${e.player} pushes into the thorns — and bleeds for it.`;
|
||||
case "objectDragged": return `The ${e.what.replace(/-/g, " ")} is dragged across the maze.`;
|
||||
case "cardsDealt": return `${e.player} is dealt ${e.count} card${e.count === 1 ? "" : "s"}.`;
|
||||
case "cardsStolenPrivate": return e.cards.length > 0
|
||||
? `Stolen into your hand: ${e.cards.map((c) => cardDef(c.cardId).name).join(", ")}.`
|
||||
: null;
|
||||
case "handRevealedPrivate": return `${e.player}'s hand lies open to you: ${e.cards.map((c) => cardDef(c.cardId).name).join(", ")}.`;
|
||||
case "creatureWarpStepped": return `The creature slips through the dimensional warp!`;
|
||||
case "mentalForceFizzled": return `${e.attacker}'s mental force strains at ${e.defender} — and fails to find a path.`;
|
||||
case "doorJammed": return `${e.player} jams a door's lock solid.`;
|
||||
case "lockRemoved": return `${e.player} removes a door's lock for good.`;
|
||||
case "cardDisplayed": return `${e.player} displays ${cardDef(e.card.cardId).name}.`;
|
||||
@@ -215,19 +225,6 @@ export interface LogLine {
|
||||
* momentSteps, so a chronicle turn number addresses the same commands. */
|
||||
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
|
||||
|
||||
/** Does this event make a turn worth reliving? Combat and spectacle. */
|
||||
export function isNotableEvent(e: GameEvent): boolean {
|
||||
if (e.type === "spellCast") return e.target !== null || e.targetCell !== null;
|
||||
return NOTABLE_EVENTS.has(e.type);
|
||||
}
|
||||
const NOTABLE_EVENTS = new Set([
|
||||
"punched", "attackResolved", "damaged", "died", "knockedBack", "shoved",
|
||||
"washedBack", "retreatedInHorror", "creatureAttacked", "boobytrapSprung",
|
||||
"firewallBurned", "objectThrown", "gameWon", "positionsSwapped",
|
||||
"teleported", "stonesDestroyed", "thumbOfGod", "stoneTurnedToWater",
|
||||
"waterwallCrashes", "sectorRotated", "sectorRelocated", "homeBasesSwapped",
|
||||
]);
|
||||
|
||||
const SEAT_KEY = "wizwar-seat";
|
||||
const SEATS_KEY = "wizwar-seats";
|
||||
const CHAT_SEEN_KEY = "wizwar-chat-seen";
|
||||
@@ -320,8 +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). */
|
||||
private eyeTurn = -1;
|
||||
/** The turn whose moment reel is open (share links point at it). */
|
||||
private momentTurn: number | null = null;
|
||||
private shareResolve: ((url: string) => void) | null = null;
|
||||
@@ -444,12 +439,11 @@ class Net {
|
||||
if (TURN_BOUNDARY.has(e.type)) this.turnCounter++;
|
||||
const line = humanize(e);
|
||||
if (line) {
|
||||
// One eye per turn — except the crown: the game-winning
|
||||
// line always carries its own, whatever came before it.
|
||||
// Every turn wears its eye, on the header line that opens
|
||||
// it — the players judge what is share-worthy. The game-
|
||||
// winning line carries its own besides.
|
||||
const notable = this.turnCounter >= 0 &&
|
||||
(e.type === "gameWon" ||
|
||||
(this.turnCounter !== this.eyeTurn && isNotableEvent(e)));
|
||||
if (notable) this.eyeTurn = this.turnCounter;
|
||||
(e.type === "gameWon" || TURN_BOUNDARY.has(e.type));
|
||||
this.log = [...this.log, { text: line, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable }];
|
||||
}
|
||||
}
|
||||
@@ -459,6 +453,14 @@ class Net {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "kicked":
|
||||
this.log = [...this.log, { text: `— the host cleared your seat in ${msg.roomId} —`, turn: null, notable: false }];
|
||||
this.leaveLocal();
|
||||
break;
|
||||
case "roomAbandoned":
|
||||
this.log = [...this.log, { text: `— the host closed room ${msg.roomId} —`, turn: null, notable: false }];
|
||||
this.leaveLocal();
|
||||
break;
|
||||
case "transferCode":
|
||||
this.transferCode = { code: msg.code, expiresAt: msg.expiresAt };
|
||||
break;
|
||||
@@ -487,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;
|
||||
@@ -577,6 +586,14 @@ class Net {
|
||||
this.send({ type: "addBot", ...(style ? { style } : {}), ...(tier ? { tier } : {}) });
|
||||
}
|
||||
|
||||
kickSeat(name: string): void {
|
||||
this.send({ type: "kickSeat", name });
|
||||
}
|
||||
|
||||
abandonRoom(): void {
|
||||
this.send({ type: "abandonRoom" });
|
||||
}
|
||||
|
||||
rollTableDie(): void {
|
||||
this.send({ type: "rollDie" });
|
||||
}
|
||||
@@ -647,21 +664,28 @@ class Net {
|
||||
} catch { /* blocked at the OS level; the tab title still shows it */ }
|
||||
}
|
||||
|
||||
/** Forget the remembered seat and return to the lobby. */
|
||||
leave(): void {
|
||||
this.send({ type: "leave" }); // detach server-side too (frees a gallery seat)
|
||||
/** 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;
|
||||
this.roomIdPending = null;
|
||||
this.view = null;
|
||||
this.started = false;
|
||||
this.players = [];
|
||||
this.resetChronicle();
|
||||
this.token = null;
|
||||
this.spectating = false;
|
||||
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)
|
||||
this.leaveLocal();
|
||||
this.resetChronicle();
|
||||
}
|
||||
|
||||
start(expansion: boolean): void {
|
||||
this.send({ type: "start", expansion });
|
||||
}
|
||||
@@ -698,7 +722,6 @@ class Net {
|
||||
private resetChronicle(): void {
|
||||
this.log = [];
|
||||
this.turnCounter = -1;
|
||||
this.eyeTurn = -1;
|
||||
}
|
||||
|
||||
/** Summon one turn's reel by its chronicle turn number. */
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface Prefs {
|
||||
botTier: "apprentice" | "adept" | "archmage";
|
||||
/** Instant replay: notable chronicle lines offer a first-person reel. */
|
||||
instantReplay: boolean;
|
||||
/** The live first-person pane above the board during play. */
|
||||
liveFp: boolean;
|
||||
}
|
||||
|
||||
const KEY = "wizwar-prefs";
|
||||
@@ -27,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,
|
||||
cautions: true, botTier: "adept", instantReplay: true, liveFp: false,
|
||||
};
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
@@ -42,6 +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 === true,
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// Claude's seat at the table: a one-shot websocket client. Each run
|
||||
// connects, resumes the seat by token, performs one verb, prints, exits.
|
||||
// node claude-seat.mjs join <ROOM> <NAME>
|
||||
// node claude-seat.mjs view
|
||||
// node claude-seat.mjs do '<command json>'
|
||||
// node claude-seat.mjs chat "text"
|
||||
import WebSocket from "ws";
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
|
||||
// Seat state (room, name, token) persists between one-shot invocations.
|
||||
const SEAT_FILE = process.env.WIZWAR_SEAT ?? "/tmp/wizwar-claude-seat.json";
|
||||
const URL_WS = process.env.WIZWAR_SERVER ?? "wss://wizwar.kestrelsnest.social/ws";
|
||||
const [verb, ...args] = process.argv.slice(2);
|
||||
const seat = existsSync(SEAT_FILE) ? JSON.parse(readFileSync(SEAT_FILE, "utf8")) : null;
|
||||
|
||||
const ws = new WebSocket(URL_WS);
|
||||
const send = (m) => ws.send(JSON.stringify(m));
|
||||
const die = (msg, code = 1) => { console.log(msg); process.exit(code); };
|
||||
setTimeout(() => die("timeout: no answer from the maze"), 20000);
|
||||
|
||||
let view = null;
|
||||
let pendingEvents = [];
|
||||
let historyEvents = [];
|
||||
|
||||
function cellK(c) { return `${c.x},${c.y}`; }
|
||||
|
||||
function renderView(v) {
|
||||
const B = v.board;
|
||||
const lines = [];
|
||||
const label = new Map(); // cellKey -> 2-char code
|
||||
const codes = {};
|
||||
v.players.forEach((p, i) => {
|
||||
codes[p.id] = String(i + 1);
|
||||
label.set(cellK(p.position), (p.id === v.you ? "C" : "P") + (i + 1));
|
||||
});
|
||||
const treas = new Map();
|
||||
for (const t of v.treasures) if (t.position && !t.carriedBy) treas.set(cellK(t.position), t);
|
||||
// top border row by row
|
||||
for (let y = 0; y < B.height; y++) {
|
||||
let top = "";
|
||||
let mid = "";
|
||||
for (let x = 0; x < B.width; x++) {
|
||||
const k = `${x},${y}`;
|
||||
if (!B.cells[k]) { top += " "; mid += " "; continue; }
|
||||
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");
|
||||
const wc = wEdge === "wall" ? "|" : wEdge === "door" ? "D" : wEdge === "firewall" ? "F" : " ";
|
||||
let body = label.get(k) ?? "";
|
||||
if (!body) {
|
||||
const sq = v.squareContents[k];
|
||||
if (sq) body = sq.kind.slice(0, 2).toUpperCase();
|
||||
else if (treas.has(k)) body = "$" + (treas.get(k).owner === v.you ? "c" : codes[treas.get(k).owner] ?? "?");
|
||||
else if (B.homes.some((h) => cellK(h) === k)) {
|
||||
const who = v.players.find((p) => cellK(p.home) === k);
|
||||
body = "h" + (who ? (who.id === v.you ? "C" : codes[who.id]) : "?");
|
||||
} else if ((v.groundObjects[k] ?? []).length) body = "ob";
|
||||
else if (v.creatures.some((c) => cellK(c.position) === k)) {
|
||||
body = v.creatures.find((c) => cellK(c.position) === k).kind.slice(0, 2);
|
||||
} else body = " ";
|
||||
}
|
||||
mid += wc + " " + body.padEnd(2, " ") + " ";
|
||||
}
|
||||
lines.push(top + "+");
|
||||
lines.push(mid + "|");
|
||||
}
|
||||
let bottom = "";
|
||||
for (let x = 0; x < B.width; x++) bottom += "+————";
|
||||
lines.push(bottom + "+");
|
||||
const out = [];
|
||||
out.push(`ROOM ${seat?.roomId} — you are ${v.you} (round ${v.turn.round}, ${v.activePlayerId}'s turn)`);
|
||||
out.push(lines.join("\n"));
|
||||
out.push("WARPS (rim passages): " + B.warps.map((w) => `${cellK(w.from.cell)}${w.from.side}→${cellK(w.to.cell)}`).join(" "));
|
||||
if (v.dimWarps.length) {
|
||||
out.push("!! DIMENSIONAL WARP TOKENS (player wormholes — CHECK EVERY TURN): " +
|
||||
v.dimWarps.map((w) => `${cellK(w.a)}↔${cellK(w.b)}`).join(" "));
|
||||
}
|
||||
for (const p of v.players) {
|
||||
const t = p.carriedTreasureId ? " CARRYING TREASURE" : "";
|
||||
out.push(` ${p.id === v.you ? "ME " : " "}${p.id}: ${p.life} life @${cellK(p.position)} home@${cellK(p.home)} hand:${p.handCount}${t}${p.alive ? "" : " DEAD"} displayed:[${p.displayed.map((c) => c.cardId).join(",")}]`);
|
||||
}
|
||||
out.push("TREASURES: " + v.treasures.map((t) => `${t.id}(${t.owner})${t.carriedBy ? `held by ${t.carriedBy}` : t.position ? `@${cellK(t.position)}` : "?"}`).join(" "));
|
||||
if (v.sustained.length) out.push("SUSTAINED: " + v.sustained.map((s) => `${s.cardId} on ${s.targetId} (${s.remainingTurns})`).join(" "));
|
||||
const sq = Object.entries(v.squareContents);
|
||||
if (sq.length) out.push("CONTENTS: " + sq.map(([k, c]) => `${c.kind}@${k}`).join(" "));
|
||||
if (v.creatures.length) out.push("CREATURES: " + v.creatures.map((c) => `${c.kind}@${cellK(c.position)} (${c.controllerId}, ${c.life} life)`).join(" "));
|
||||
out.push(`TURN: moves ${v.turn.movementUsed}/${v.turn.movementAllowance} attackUsed:${v.turn.attackUsed} actionsEnded:${v.turn.actionsEnded} numberPlayed:${v.turn.numberPlayedForMovement}`);
|
||||
out.push("MY HAND: " + v.yourHand.map((c) => `${c.instanceId}`).join(" "));
|
||||
if (v.stack) out.push("STACK! " + JSON.stringify(v.stack));
|
||||
if (v.wardPending) out.push("WARD PENDING: " + JSON.stringify(v.wardPending));
|
||||
if (v.chaosPending) out.push("CHAOS PENDING: " + JSON.stringify(v.chaosPending));
|
||||
if (v.outOfTurnWindow) out.push("OUT-OF-TURN WINDOW: " + JSON.stringify(v.outOfTurnWindow));
|
||||
if (v.phase === "finished") out.push(`GAME OVER — winner: ${v.winner} (${v.winReason})`);
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function eventLine(e) {
|
||||
const skip = new Set(["cardsDealtPrivate"]);
|
||||
if (skip.has(e.type)) return null;
|
||||
return JSON.stringify(e);
|
||||
}
|
||||
|
||||
ws.on("message", (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.type === "error") die(`SERVER: ${msg.message}`);
|
||||
if (msg.type === "seat") {
|
||||
// A join names its room explicitly; only a resume inherits the file's.
|
||||
const s = { roomId: verb === "join" ? args[0].toUpperCase() : seat.roomId, name: msg.playerId, token: msg.token };
|
||||
writeFileSync(SEAT_FILE, JSON.stringify(s));
|
||||
if (verb === "join") { console.log(`seated as ${msg.playerId} in ${s.roomId}`); afterJoin(); }
|
||||
}
|
||||
if (msg.type === "events") {
|
||||
// A join replays the whole chronicle (replayed: true); only live
|
||||
// events are news. `view` prints the tail of history instead.
|
||||
if (!msg.replayed) {
|
||||
for (const e of msg.events) { const l = eventLine(e); if (l) pendingEvents.push(l); }
|
||||
} else if (verb === "view") {
|
||||
for (const e of msg.events) { const l = eventLine(e); if (l) historyEvents.push(l); }
|
||||
}
|
||||
}
|
||||
if (msg.type === "state") { view = msg.view; finishAfterState(); }
|
||||
if (msg.type === "catchUp") {
|
||||
const steps = msg.steps;
|
||||
if (steps.length) view = steps[steps.length - 1].view;
|
||||
finish();
|
||||
}
|
||||
if (msg.type === "chat") pendingEvents.push(`CHAT ${msg.player}: ${msg.text}`);
|
||||
if (msg.type === "room") { /* roster updates, ignore */ }
|
||||
});
|
||||
|
||||
let done = false;
|
||||
function finish() {
|
||||
if (done) return; done = true;
|
||||
if (verb === "view" && historyEvents.length) {
|
||||
console.log("RECENT EVENTS (history tail):\n" + historyEvents.slice(-12).join("\n") + "\n");
|
||||
}
|
||||
if (pendingEvents.length) console.log("NEW EVENTS:\n" + pendingEvents.join("\n") + "\n");
|
||||
if (view) console.log(renderView(view));
|
||||
else console.log("(no game running yet — waiting for the start)");
|
||||
process.exit(0);
|
||||
}
|
||||
let stateTimer = null;
|
||||
function finishAfterState() {
|
||||
// commands produce one state per player broadcast; give trailing events a beat
|
||||
if (stateTimer) clearTimeout(stateTimer);
|
||||
stateTimer = setTimeout(finish, 700);
|
||||
}
|
||||
function afterJoin() {
|
||||
send({ type: "catchUp", sinceSeq: 0 });
|
||||
setTimeout(() => { if (!done) finish(); }, 6000);
|
||||
}
|
||||
|
||||
ws.on("open", () => {
|
||||
if (verb === "join") {
|
||||
if (!args[0] || !args[1]) die("usage: join <ROOM> <NAME>");
|
||||
send({ type: "join", roomId: args[0].toUpperCase(), name: args[1], token: null });
|
||||
} else if (!seat) {
|
||||
die("no seat yet — join first");
|
||||
} else {
|
||||
send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
|
||||
setTimeout(() => {
|
||||
if (verb === "view") {
|
||||
send({ type: "catchUp", sinceSeq: 0 });
|
||||
} else if (verb === "do") {
|
||||
send({ type: "command", command: JSON.parse(args[0]) });
|
||||
setTimeout(() => { if (!done) finish(); }, 8000);
|
||||
} else if (verb === "chat") {
|
||||
send({ type: "chat", text: args[0] });
|
||||
setTimeout(() => { console.log("said."); process.exit(0); }, 800);
|
||||
} else die(`unknown verb: ${verb}`);
|
||||
}, 600);
|
||||
}
|
||||
});
|
||||
ws.on("error", (e) => die(`socket error: ${e.message}`));
|
||||
Reference in New Issue
Block a user