Credibility pass: the duels leave the code, staying in the ledgers
Third pass, scoped from 7a32370. Three blind reviewers (engine, web,
server/tools) each concluded the work is coherent engineering with
seam-level tells; every finding was verified before touching a line.
Session biography left the comments: the bot brain's heuristics no
longer cite the opponent who taught them, the RNRX coma parenthetical
and the thief-chase citation are gone, the seat-wallet comments state
their invariants without the war stories, and process-named test
groups now name the behaviors they pin. The incident record lives
where history belongs — commit messages and the ledgers.
Structural dedup: one VISIONSTONE one-edge-sight loop serves both
LOS paths; one creature-arrival touch handler serves walking and
warp-stepping (error text aligned); one facingWedge helper draws both
keymap ribbons; one spriteVisibleInCol rule serves the draw pass and
the hover test (which also stops re-sorting per pointermove); and
deepestFacing joins the director, replacing four copied scans.
Test hardening exposed real rot the tells were hiding: the tight
CreatureState cast caught two literals with a bogus field masking
three missing ones; the number-hoarding rig had NEVER run (its
column didn't exist on seed 42 — it now carves its own geometry);
the bank-guard rig now drives the whole table to an arrival
assertion; the bent-trace test walls off straight sight so the bend
must answer. Silent `return`-on-rig-failure became loud throws, and
can-never-fail assertions were removed.
Sweep-up: the eyeTurn ghost comment, the stacked leave() doc
comments (leave now delegates to leaveLocal), the dead ternary in
the seat client, the kick handler's name-coercion drift, kick ledger
lines gain timestamps, archiveRoomFile reuses fileFor, the RULES_REV
alias retires in favor of the engine constant, hitTest un-exports,
the NUL-sentinel hover shape becomes an honest "none" variant, and
the steering holds get named constants. The bezel's stride cluster
also centers per the table's note.
288 tests, 24 ledgers verified, all workspaces typecheck.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
co-authored by
Claude Fable 5
parent
7bd8eff11c
commit
3bd7e3c81d
@@ -95,8 +95,9 @@ probing legality is free, so when unsure, try it and read the error.
|
|||||||
|
|
||||||
## Game-three scars (lost in four rounds)
|
## Game-three scars (lost in four rounds)
|
||||||
|
|
||||||
- **CHECK `dimWarps` EVERY SINGLE VIEW** — Kestrel has won two games
|
- **CHECK `dimWarps` EVERY SINGLE VIEW** — Kestrel (Eric's wizard) has
|
||||||
with quietly-placed wormholes ending beside his home. The client now
|
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
|
prints a !! line for them; treat any new token pair as a five-alarm
|
||||||
fire and recompute both players' delivery distances through it.
|
fire and recompute both players' delivery distances through it.
|
||||||
- **Never lead the attack against Kestrel.** Three games, three saved
|
- **Never lead the attack against Kestrel.** Three games, three saved
|
||||||
|
|||||||
@@ -296,13 +296,13 @@ function pathDenial(view: GameView): Command | null {
|
|||||||
}
|
}
|
||||||
// Enemy gold banked at MY home: my score, snatchable by anyone. A
|
// Enemy gold banked at MY home: my score, snatchable by anyone. A
|
||||||
// raider closing on the bank gets the same road-lengthening treatment.
|
// raider closing on the bank gets the same road-lengthening treatment.
|
||||||
const me2 = me(view);
|
const self = me(view);
|
||||||
const banked = view.treasures.find(
|
const banked = view.treasures.find(
|
||||||
(t) => t.owner !== view.you && t.position && cellKey(t.position) === cellKey(me2.home));
|
(t) => t.owner !== view.you && t.position && cellKey(t.position) === cellKey(self.home));
|
||||||
if (banked) {
|
if (banked) {
|
||||||
for (const e of livingEnemies(view)) {
|
for (const e of livingEnemies(view)) {
|
||||||
const d = Math.abs(e.position.x - me2.home.x) + Math.abs(e.position.y - me2.home.y);
|
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: me2.home });
|
if (d <= 6) threats.push({ enemy: e.position, goal: self.home });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (threats.length === 0) return null;
|
if (threats.length === 0) return null;
|
||||||
@@ -758,9 +758,8 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The LAST counter in hand is a treasure of its own: at a healthy life
|
// 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 —
|
// total it waits for something big rather than answering every jab.
|
||||||
// patience the clockwork's best opponent taught it, three counters at
|
// (Lethal blows override this below.)
|
||||||
// a time. (Lethal blows override this below.)
|
|
||||||
const counterCount = view.yourHand.filter((c) => {
|
const counterCount = view.yourHand.filter((c) => {
|
||||||
const t = cardDef(c.cardId).cardType;
|
const t = cardDef(c.cardId).cardType;
|
||||||
return t === "counteraction" || t === "neutral/counteraction";
|
return t === "counteraction" || t === "neutral/counteraction";
|
||||||
@@ -797,6 +796,8 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
|||||||
// when the incoming points reach the clockwork's life, any counter
|
// when the incoming points reach the clockwork's life, any counter
|
||||||
// that can save it is cheap at the price.
|
// that can save it is cheap at the price.
|
||||||
const lethal = !affliction && incoming >= me(view).life;
|
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;
|
const weight = lethal ? Math.max(incoming, 9) : incoming;
|
||||||
if (stack.kind === "spell") {
|
if (stack.kind === "spell") {
|
||||||
// REVERSE eats points; a pure duration offers it nothing.
|
// REVERSE eats points; a pure duration offers it nothing.
|
||||||
@@ -943,6 +944,7 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
|
|||||||
if (sd && biggest) {
|
if (sd && biggest) {
|
||||||
const shown = target.displayed.filter((c) => STONES.has(c.cardId)).length;
|
const shown = target.displayed.filter((c) => STONES.has(c.cardId)).length;
|
||||||
const dmg = biggestValue * shown;
|
const dmg = biggestValue * shown;
|
||||||
|
// `best` is assigned only inside offer(), so TS narrows it to null here.
|
||||||
const cur = best as BestPick | null;
|
const cur = best as BestPick | null;
|
||||||
if (dmg >= 4 && (!cur || (dmg >= target.life && !cur.kill) || dmg > cur.damage)) {
|
if (dmg >= 4 && (!cur || (dmg >= target.life && !cur.kill) || dmg > cur.damage)) {
|
||||||
best = {
|
best = {
|
||||||
@@ -1132,9 +1134,8 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// INFRASTRUCTURE: a wormhole anchored beside home turns every future
|
// INFRASTRUCTURE: a wormhole anchored beside home turns every future
|
||||||
// delivery into a three-move stroll — the pattern that won its best
|
// delivery into a three-move stroll. Placed early, from home's
|
||||||
// opponent two games. Placed early, from home's doorstep, far mouth
|
// doorstep, far mouth by the richest distant gold.
|
||||||
// by the richest distant gold.
|
|
||||||
{
|
{
|
||||||
const dwarp = inHand(view, "dimensional-warp");
|
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;
|
const nearHome = Math.abs(self.position.x - self.home.x) + Math.abs(self.position.y - self.home.y) <= 1;
|
||||||
@@ -1547,7 +1548,7 @@ export function automatonCommand(
|
|||||||
const bankedCount = view.treasures.filter(
|
const bankedCount = view.treasures.filter(
|
||||||
(t) => t.owner !== you && t.position && cellKey(t.position) === cellKey(self.home)).length;
|
(t) => t.owner !== you && t.position && cellKey(t.position) === cellKey(self.home)).length;
|
||||||
// The predicate reads only ENEMY positions — my own steps must not
|
// The predicate reads only ENEMY positions — my own steps must not
|
||||||
// flip it mid-march or the walker shuttles (the thief-chase lesson).
|
// flip it mid-march or the walker shuttles between goals.
|
||||||
const bankThreatened = bankedCount > 0 && !self.carriedTreasureId &&
|
const bankThreatened = bankedCount > 0 && !self.carriedTreasureId &&
|
||||||
livingEnemies(view).some((p) =>
|
livingEnemies(view).some((p) =>
|
||||||
!p.carriedTreasureId &&
|
!p.carriedTreasureId &&
|
||||||
|
|||||||
+46
-54
@@ -389,13 +389,11 @@ function doorsAjar(state: GameState, viewerId: PlayerId | undefined, board: Asse
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** LOS including square-filling blockers. */
|
/** LOS including square-filling blockers. */
|
||||||
function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean {
|
/** Sight granted by removing any ONE closed edge — the VISIONSTONE's
|
||||||
const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state)));
|
* whole power, shared by every path that honors it. */
|
||||||
const blockers = losBlockers(state);
|
function sightThroughOneEdge(
|
||||||
if (sightBetween(board, from, to, blockers)) return true;
|
board: AssembledBoard, from: Cell, to: Cell, blockers: Record<string, true>,
|
||||||
if (!stone) return false;
|
): boolean {
|
||||||
const viewer = viewerId ? state.players.find((p) => p.id === viewerId) : undefined;
|
|
||||||
if (!viewer || !viewer.alive || !displays(viewer, "visionstone")) return false;
|
|
||||||
for (const key of Object.keys(board.edges)) {
|
for (const key of Object.keys(board.edges)) {
|
||||||
if ((board.edges[key] ?? "open") === "open") continue;
|
if ((board.edges[key] ?? "open") === "open") continue;
|
||||||
const edges = { ...board.edges };
|
const edges = { ...board.edges };
|
||||||
@@ -405,11 +403,21 @@ function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | un
|
|||||||
return false;
|
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
|
/** LOS including square-filling blockers. VISIONSTONE is the bearer's
|
||||||
* sight wherever sight is asked of them — creations, dispels, utility
|
* sight wherever sight is asked of them — creations, dispels, utility
|
||||||
* spells — not just attacks: one wall or door, any type, falls away.
|
* spells — not just attacks: one wall or door, any type, falls away.
|
||||||
* Safe at every revision: widening what a cast may target never changes
|
* Ungated by design: validation may widen without a rev bump, because
|
||||||
* how a recorded command replays. */
|
* refused commands never entered any ledger. */
|
||||||
export function gameLos(state: GameState, from: Cell, to: Cell, viewerId?: PlayerId): boolean {
|
export function gameLos(state: GameState, from: Cell, to: Cell, viewerId?: PlayerId): boolean {
|
||||||
return losWith(state, from, to, viewerId, true);
|
return losWith(state, from, to, viewerId, true);
|
||||||
}
|
}
|
||||||
@@ -472,13 +480,7 @@ function casterLos(
|
|||||||
const blockers = losBlockers(state);
|
const blockers = losBlockers(state);
|
||||||
if (sightBetween(board, from, to, blockers)) return true;
|
if (sightBetween(board, from, to, blockers)) return true;
|
||||||
if (!displays(caster, "visionstone")) return false;
|
if (!displays(caster, "visionstone")) return false;
|
||||||
for (const key of Object.keys(board.edges)) {
|
return sightThroughOneEdge(board, from, to, blockers);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bent LOS for AROUND THE CORNER: caster sees a middle cell, which sees the target. */
|
/** Bent LOS for AROUND THE CORNER: caster sees a middle cell, which sees the target. */
|
||||||
@@ -2941,6 +2943,31 @@ function impCheck(state: GameState, events: GameEvent[], onlyPlayer?: PlayerId):
|
|||||||
checkVictory(state, events);
|
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
|
/** 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
|
* 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
|
* far side refuses it, and a monster is no braver than a wizard about
|
||||||
@@ -2964,7 +2991,7 @@ function doCreatureWarpStep(prev: GameState, creatureId: string): CommandResult
|
|||||||
if (state.squareContents[cellKey(dest)]?.kind === "stone") return err("the far side is solid stone");
|
if (state.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) &&
|
if (state.players.some((o) => o.alive && cellKey(o.position) === cellKey(dest) &&
|
||||||
sustainedOn(state, o.id, "big-man").length > 0)) {
|
sustainedOn(state, o.id, "big-man").length > 0)) {
|
||||||
return err("a giant fills that square");
|
return err("a giant fills that corridor");
|
||||||
}
|
}
|
||||||
const from = creature.position;
|
const from = creature.position;
|
||||||
// FEAR holds monsters off too: "no player or monster".
|
// FEAR holds monsters off too: "no player or monster".
|
||||||
@@ -2972,23 +2999,7 @@ function doCreatureWarpStep(prev: GameState, creatureId: string): CommandResult
|
|||||||
creature.position = { ...dest };
|
creature.position = { ...dest };
|
||||||
creature.movementUsed++;
|
creature.movementUsed++;
|
||||||
const events: GameEvent[] = [{ type: "creatureWarpStepped", creatureId, from, to: creature.position, by: active.id }];
|
const events: GameEvent[] = [{ type: "creatureWarpStepped", creatureId, from, to: creature.position, by: active.id }];
|
||||||
// Touch effects on arriving in a player's square, as any step has.
|
creatureTouchOnArrival(state, events, creature);
|
||||||
for (const p of state.players) {
|
|
||||||
if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue;
|
|
||||||
if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue;
|
|
||||||
if (creature.kind === "wraith" && !creature.attackUsed) {
|
|
||||||
creature.attackUsed = true;
|
|
||||||
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
|
|
||||||
openCreatureStack(state, creature, p, 2, "wraith");
|
|
||||||
return { ok: true, state, events };
|
|
||||||
}
|
|
||||||
if (creature.kind === "democratic-monster" && !creature.attackUsed && !creature.justCreated) {
|
|
||||||
creature.attackUsed = true;
|
|
||||||
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
|
|
||||||
openCreatureStack(state, creature, p, 2, "claw");
|
|
||||||
return { ok: true, state, events };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { ok: true, state, events };
|
return { ok: true, state, events };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3046,26 +3057,7 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C
|
|||||||
}
|
}
|
||||||
creature.movementUsed++;
|
creature.movementUsed++;
|
||||||
events.push({ type: "creatureMoved", creatureId: creature.id, from, to: creature.position, direction, by: active.id });
|
events.push({ type: "creatureMoved", creatureId: creature.id, from, to: creature.position, direction, by: active.id });
|
||||||
|
if (creatureTouchOnArrival(state, events, creature)) return { ok: true, state, events };
|
||||||
// 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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
checkVictory(state, events);
|
checkVictory(state, events);
|
||||||
return { ok: true, state, events };
|
return { ok: true, state, events };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -757,7 +757,7 @@ describe("the thief-chase holds one goal per turn", () => {
|
|||||||
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "open";
|
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "open";
|
||||||
// A number card and no attacks: the blow the chase serves cannot land.
|
// A number card and no attacks: the blow the chase serves cannot land.
|
||||||
bot.hand = [];
|
bot.hand = [];
|
||||||
bot.hand.push({ cardId: "number-2", instanceId: "N2" } as never);
|
bot.hand.push({ cardId: "number-2", instanceId: "N2" });
|
||||||
const visited = [cellKey(bot.position)];
|
const visited = [cellKey(bot.position)];
|
||||||
for (let guard = 0; guard < 40; guard++) {
|
for (let guard = 0; guard < 40; guard++) {
|
||||||
const view = viewFor(state, "bot");
|
const view = viewFor(state, "bot");
|
||||||
@@ -776,8 +776,9 @@ describe("the thief-chase holds one goal per turn", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("the archmage's sharpened instincts", () => {
|
describe("the archmage's key, shield, and race discipline", () => {
|
||||||
function botTurn(seed = 42) {
|
function botTurn() {
|
||||||
|
const seed = 42;
|
||||||
let { state } = createGame({ playerIds: ["foe", "bot"], seed, sets: ["basic", "expansion1"] });
|
let { state } = createGame({ playerIds: ["foe", "bot"], seed, sets: ["basic", "expansion1"] });
|
||||||
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
||||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
@@ -790,7 +791,7 @@ describe("the archmage's sharpened instincts", () => {
|
|||||||
it("a MASTER KEY drawn goes straight on display", () => {
|
it("a MASTER KEY drawn goes straight on display", () => {
|
||||||
const state = botTurn();
|
const state = botTurn();
|
||||||
const bot = state.players.find((p) => p.id === "bot")!;
|
const bot = state.players.find((p) => p.id === "bot")!;
|
||||||
bot.hand.push({ cardId: "master-key", instanceId: "MK" } as never);
|
bot.hand.push({ cardId: "master-key", instanceId: "MK" });
|
||||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||||
expect(cmd).toEqual({ type: "cast", instanceId: "MK" });
|
expect(cmd).toEqual({ type: "cast", instanceId: "MK" });
|
||||||
});
|
});
|
||||||
@@ -806,8 +807,8 @@ describe("the archmage's sharpened instincts", () => {
|
|||||||
const bot = state.players.find((p) => p.id === "bot")!;
|
const bot = state.players.find((p) => p.id === "bot")!;
|
||||||
bot.position = { ...foe.position };
|
bot.position = { ...foe.position };
|
||||||
bot.life = 2;
|
bot.life = 2;
|
||||||
bot.hand = [{ cardId: "full-shield", instanceId: "FS" } as never];
|
bot.hand = [{ cardId: "full-shield", instanceId: "FS" }];
|
||||||
foe.hand.push({ cardId: "fireball", instanceId: "FB" } as never);
|
foe.hand.push({ cardId: "fireball", instanceId: "FB" });
|
||||||
const r = applyCommand(state, "foe", {
|
const r = applyCommand(state, "foe", {
|
||||||
type: "cast", instanceId: "FB", target: { kind: "player", playerId: "bot" },
|
type: "cast", instanceId: "FB", target: { kind: "player", playerId: "bot" },
|
||||||
});
|
});
|
||||||
@@ -833,15 +834,20 @@ describe("the archmage's sharpened instincts", () => {
|
|||||||
gold[1]!.position = null;
|
gold[1]!.position = null;
|
||||||
bot.carriedTreasureId = gold[1]!.id;
|
bot.carriedTreasureId = gold[1]!.id;
|
||||||
foe.position = { ...bot.home }; // a foe in sight: the war chest would hoard
|
foe.position = { ...bot.home }; // a foe in sight: the war chest would hoard
|
||||||
bot.hand = [
|
// Legs only: an attack in hand would fire at the foe in sight and
|
||||||
{ cardId: "fireball", instanceId: "FB" } as never,
|
// open a stack this single-seat loop cannot answer.
|
||||||
{ cardId: "number-3", instanceId: "N3" } as never,
|
bot.hand = [{ cardId: "number-3", instanceId: "N3" }];
|
||||||
];
|
// Carve a straight, open four-square run to home; the rig owns its
|
||||||
// Stand the bot a straight, open four squares from home.
|
// geometry rather than praying the seed provides it.
|
||||||
const home = bot.home;
|
const home = bot.home;
|
||||||
const column = [0, 1, 2, 3, 4].map((d) => ({ x: home.x, y: home.y + d }));
|
const column = [4, 3, 2, 1, 0].map((d) => ({ x: home.x, y: home.y - d }));
|
||||||
if (!column.every((c) => viewFor(state, "bot").board.cells[cellKey(c)])) return;
|
for (const c of column) {
|
||||||
bot.position = { ...column[4]! };
|
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 (const c of column.slice(0, 4)) state.edgeOverrides[edgeKey(c, "S")] = "open";
|
||||||
for (let guard = 0; guard < 12; guard++) {
|
for (let guard = 0; guard < 12; guard++) {
|
||||||
const view = viewFor(state, "bot");
|
const view = viewFor(state, "bot");
|
||||||
@@ -857,8 +863,9 @@ describe("the archmage's sharpened instincts", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("lessons from the human duels", () => {
|
describe("bank guarding, teleport delivery, and turn theft", () => {
|
||||||
function rig(players = ["foe", "bot"]) {
|
function rig() {
|
||||||
|
const players = ["foe", "bot"];
|
||||||
let { state } = createGame({ playerIds: players, seed: 42, sets: ["basic", "expansion1"] });
|
let { state } = createGame({ playerIds: players, seed: 42, sets: ["basic", "expansion1"] });
|
||||||
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
||||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
@@ -878,9 +885,28 @@ describe("lessons from the human duels", () => {
|
|||||||
foe.position = { x: bot.home.x, y: bot.home.y + 1 };
|
foe.position = { x: bot.home.x, y: bot.home.y + 1 };
|
||||||
bot.position = { ...foe.home };
|
bot.position = { ...foe.home };
|
||||||
bot.hand = [];
|
bot.hand = [];
|
||||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
// The march may wind through the maze; what matters is that it ENDS
|
||||||
// Whatever step it picks, the march must be TOWARD home, not the gold map.
|
// at the threatened bank, not out on the gold map.
|
||||||
expect(cmd?.type).toBe("move");
|
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", () => {
|
it("carrying with home a blink away, it teleports the delivery", () => {
|
||||||
@@ -895,7 +921,7 @@ describe("lessons from the human duels", () => {
|
|||||||
for (const side of ["N", "S", "E", "W"] as const) {
|
for (const side of ["N", "S", "E", "W"] as const) {
|
||||||
state.edgeOverrides[edgeKey(bot.position, side)] = "wall";
|
state.edgeOverrides[edgeKey(bot.position, side)] = "wall";
|
||||||
}
|
}
|
||||||
bot.hand = [{ cardId: "teleport", instanceId: "TP" } as never];
|
bot.hand = [{ cardId: "teleport", instanceId: "TP" }];
|
||||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||||
expect(cmd).toEqual({ type: "cast", instanceId: "TP", target: { kind: "cell", cell: { ...bot.home } } });
|
expect(cmd).toEqual({ type: "cast", instanceId: "TP", target: { kind: "cell", cell: { ...bot.home } } });
|
||||||
});
|
});
|
||||||
@@ -908,15 +934,15 @@ describe("lessons from the human duels", () => {
|
|||||||
const banked = state.treasures.find((t) => t.owner === "bot")!;
|
const banked = state.treasures.find((t) => t.owner === "bot")!;
|
||||||
banked.position = { ...foe.home };
|
banked.position = { ...foe.home };
|
||||||
const carried = state.treasures.filter((t) => t.owner === "bot")[1];
|
const carried = state.treasures.filter((t) => t.owner === "bot")[1];
|
||||||
if (!carried) return;
|
if (!carried) throw new Error("setup: the bot owns fewer than two treasures");
|
||||||
carried.carriedBy = "foe";
|
carried.carriedBy = "foe";
|
||||||
carried.position = null;
|
carried.position = null;
|
||||||
foe.carriedTreasureId = carried.id;
|
foe.carriedTreasureId = carried.id;
|
||||||
foe.position = { ...bot.position };
|
foe.position = { ...bot.position };
|
||||||
bot.hand = [
|
bot.hand = [
|
||||||
{ cardId: "fireball", instanceId: "FB" } as never,
|
{ cardId: "fireball", instanceId: "FB" },
|
||||||
{ cardId: "lightning-blast", instanceId: "LB" } as never,
|
{ cardId: "lightning-blast", instanceId: "LB" },
|
||||||
{ cardId: "number-4", instanceId: "N4" } as never,
|
{ cardId: "number-4", instanceId: "N4" },
|
||||||
];
|
];
|
||||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||||
expect(cmd).toMatchObject({ type: "cast", instanceId: "LB" });
|
expect(cmd).toMatchObject({ type: "cast", instanceId: "LB" });
|
||||||
@@ -924,10 +950,10 @@ describe("lessons from the human duels", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("the denial planner offers only castable blocks", () => {
|
describe("the denial planner offers only castable blocks", () => {
|
||||||
it("tacks at range are never proposed — the RNRX coma", () => {
|
it("tacks are offered only at the caster's feet", () => {
|
||||||
// Room RNRX froze Automaton II for three turns: pathDenial proposed
|
// TACKS demand adjacency; a planner that proposes them at range has
|
||||||
// scattering tacks four squares away, the engine refused ("you must
|
// its cast refused every turn, and the refusal-fallback loop reads
|
||||||
// be adjacent"), and the refusal-fallback loop read as a coma.
|
// as an idle bot.
|
||||||
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
||||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
@@ -941,14 +967,13 @@ describe("the denial planner offers only castable blocks", () => {
|
|||||||
const gold = state.treasures.find((t) => t.owner === "bot" && t.position)!;
|
const gold = state.treasures.find((t) => t.owner === "bot" && t.position)!;
|
||||||
foe.position = { ...gold.position! };
|
foe.position = { ...gold.position! };
|
||||||
bot.position = { ...bot.home };
|
bot.position = { ...bot.home };
|
||||||
bot.hand = [{ cardId: "handful-of-tacks", instanceId: "HT" } as never];
|
bot.hand = [{ cardId: "handful-of-tacks", instanceId: "HT" }];
|
||||||
for (let guard = 0; guard < 6; guard++) {
|
for (let guard = 0; guard < 6; guard++) {
|
||||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||||
if (!cmd) break;
|
if (!cmd) break;
|
||||||
const r = applyCommand(state, "bot", cmd);
|
const r = applyCommand(state, "bot", cmd);
|
||||||
// Whatever the brain proposes, the engine must accept it.
|
// Whatever the brain proposes, the engine must accept it.
|
||||||
expect(r.ok, `refused: ${JSON.stringify(cmd)} — ${!r.ok ? r.error : ""}`).toBe(true);
|
if (!r.ok) throw new Error(`brain proposed a refused command: ${JSON.stringify(cmd)} — ${r.error}`);
|
||||||
if (!r.ok) break;
|
|
||||||
state = r.state;
|
state = r.state;
|
||||||
if (cmd.type === "endTurn") break;
|
if (cmd.type === "endTurn") break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1186,12 +1186,13 @@ describe("the bent sight-trace shows AROUND THE CORNER's legs", () => {
|
|||||||
powerAttackPoints: 0, params: null, kind: "spell",
|
powerAttackPoints: 0, params: null, kind: "spell",
|
||||||
counters: [], waitingOn: d.id, bentCorner: true,
|
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));
|
const traced = stackSightTrace(viewFor(state, d.id));
|
||||||
expect(traced).not.toBeNull();
|
expect(traced).not.toBeNull();
|
||||||
if (traced) {
|
// The engine promises A workable corner, not which one.
|
||||||
// Whether sight ran straight (free-angle found a gap) or bent, the
|
expect(traced?.bend).toBeDefined();
|
||||||
// overlay has something to draw; a bend names its middle square.
|
expect(traced?.bend?.mid).toBeDefined();
|
||||||
if (traced.bend) expect(traced.bend.mid).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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 { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||||
import type { CardInstance } from "../src/cards";
|
import type { CardInstance } from "../src/cards";
|
||||||
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers";
|
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,
|
damage: 0, maxDamage: 6, movesPerTurn: 3, movementUsed: 0,
|
||||||
wallPassesPerTurn: 0, wallPassUsed: 0, attackUsed: false, justCreated: false,
|
wallPassesPerTurn: 0, wallPassUsed: 0, attackUsed: false, justCreated: false,
|
||||||
scorchedThisTurn: [],
|
scorchedThisTurn: [],
|
||||||
} as never);
|
});
|
||||||
for (const y of [5, 6, 7, 8]) state.edgeOverrides[edgeKey({ x: 2, y }, "S")] = "open";
|
for (const y of [5, 6, 7, 8]) state.edgeOverrides[edgeKey({ x: 2, y }, "S")] = "open";
|
||||||
while (state.players[state.turn.activeIndex]!.id !== "a") {
|
while (state.players[state.turn.activeIndex]!.id !== "a") {
|
||||||
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
|
||||||
@@ -749,7 +750,6 @@ describe("fear holds off monsters and unwilling feet alike", () => {
|
|||||||
// The closer step is the only way out: permitted.
|
// The closer step is the only way out: permitted.
|
||||||
const r = applyCommand(state, "a", { type: "move", direction: "S" });
|
const r = applyCommand(state, "a", { type: "move", direction: "S" });
|
||||||
if (!r.ok) throw new Error("escape step refused: " + r.error);
|
if (!r.ok) throw new Error("escape step refused: " + r.error);
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -760,9 +760,9 @@ describe("creatures and the dimensional warp", () => {
|
|||||||
state.dimWarps.push({ a: { x: 1, y: 1 }, b: { x: 3, y: 8 } });
|
state.dimWarps.push({ a: { x: 1, y: 1 }, b: { x: 3, y: 8 } });
|
||||||
state.creatures.push({
|
state.creatures.push({
|
||||||
id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 },
|
id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 },
|
||||||
life: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
|
damage: 0, maxDamage: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
|
||||||
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0,
|
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
|
||||||
} as never);
|
});
|
||||||
const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" });
|
const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" });
|
||||||
expect(r.ok).toBe(true);
|
expect(r.ok).toBe(true);
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
@@ -779,9 +779,9 @@ describe("creatures and the dimensional warp", () => {
|
|||||||
state.squareContents[cellKey({ x: 3, y: 8 })] = { kind: "stone", damage: 0, createdBy: "b" };
|
state.squareContents[cellKey({ x: 3, y: 8 })] = { kind: "stone", damage: 0, createdBy: "b" };
|
||||||
state.creatures.push({
|
state.creatures.push({
|
||||||
id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 },
|
id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 },
|
||||||
life: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
|
damage: 0, maxDamage: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
|
||||||
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0,
|
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
|
||||||
} as never);
|
});
|
||||||
const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" });
|
const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" });
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
if (!r.ok) expect(r.error).toContain("stone");
|
if (!r.ok) expect(r.error).toContain("stone");
|
||||||
|
|||||||
@@ -488,7 +488,9 @@ describe("a held door is an open doorway to the eye", () => {
|
|||||||
// no longer on their threshold; clear their path to the doorway.
|
// no longer on their threshold; clear their path to the doorway.
|
||||||
const near = neighbor(cell, side);
|
const near = neighbor(cell, side);
|
||||||
const far = neighbor(near, 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.players.find((p) => p.id === pursuer)!.position = far;
|
||||||
state.edgeOverrides[edgeKey(near, side)] = "open";
|
state.edgeOverrides[edgeKey(near, side)] = "open";
|
||||||
const rm = giveCard(state, holder, "remove-lock");
|
const rm = giveCard(state, holder, "remove-lock");
|
||||||
|
|||||||
@@ -396,6 +396,7 @@ function botRemark(room: Room, actor: string, events: { type: string; [k: string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Spoken when a clockwork's chosen command was refused by the engine —
|
/** Spoken when a clockwork's chosen command was refused by the engine —
|
||||||
* the table sees a stumble instead of an unexplained idle turn. */
|
* the table sees a stumble instead of an unexplained idle turn. */
|
||||||
const HESITATION_LINES = [
|
const HESITATION_LINES = [
|
||||||
@@ -549,11 +550,12 @@ wss.on("connection", (socket) => {
|
|||||||
case "kickSeat": {
|
case "kickSeat": {
|
||||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||||
const problem = kickSeat(room, session.playerId, String(msg.name ?? ""));
|
const kickName = String(msg.name ?? "");
|
||||||
|
const problem = kickSeat(room, session.playerId, kickName);
|
||||||
if (problem) return send(socket, { type: "error", message: problem });
|
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.
|
// A kicked live socket is set adrift so it cannot act on a seat it lost.
|
||||||
for (const other of sessions) {
|
for (const other of sessions) {
|
||||||
if (other.roomId === room.id && other.playerId === msg.name) {
|
if (other.roomId === room.id && other.playerId === kickName) {
|
||||||
other.playerId = null;
|
other.playerId = null;
|
||||||
other.roomId = null;
|
other.roomId = null;
|
||||||
other.token = null;
|
other.token = null;
|
||||||
|
|||||||
@@ -54,11 +54,6 @@ export interface Room {
|
|||||||
|
|
||||||
const rooms = new Map<string, 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";
|
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|
||||||
/** Tokens live hashed at rest (memory and disk); clients hold the raw form. */
|
/** Tokens live hashed at rest (memory and disk); clients hold the raw form. */
|
||||||
@@ -164,7 +159,7 @@ export function kickSeat(room: Room, byId: PlayerId, name: PlayerId): string | n
|
|||||||
room.tokens.delete(name);
|
room.tokens.delete(name);
|
||||||
room.bots.delete(name);
|
room.bots.delete(name);
|
||||||
room.colorChoices.delete(name);
|
room.colorChoices.delete(name);
|
||||||
appendLine(room.id, { kind: "kick", name });
|
appendLine(room.id, { kind: "kick", name, at: new Date().toISOString() });
|
||||||
recordRoom(room);
|
recordRoom(room);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -228,9 +223,9 @@ function startInMemory(room: Room, expansion: boolean, colors?: number[], deckRe
|
|||||||
export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } {
|
export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } {
|
||||||
if (room.state) return { error: "already started" };
|
if (room.state) return { error: "already started" };
|
||||||
const colors = resolveColors(room);
|
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;
|
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);
|
recordRoom(room);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -320,22 +315,22 @@ function actingSeat(room: Room): PlayerId | null {
|
|||||||
*/
|
*/
|
||||||
export function driveOneAutomaton(
|
export function driveOneAutomaton(
|
||||||
room: Room,
|
room: Room,
|
||||||
): { seat: PlayerId; events: GameEvent[]; hesitated?: { refused: Command; error: string } } | null {
|
): { seat: PlayerId; events: GameEvent[]; hesitated?: true } | null {
|
||||||
const seat = actingSeat(room);
|
const seat = actingSeat(room);
|
||||||
if (!seat || !room.bots.has(seat)) return null;
|
if (!seat || !room.bots.has(seat)) return null;
|
||||||
const view = viewFor(room.state!, seat);
|
const view = viewFor(room.state!, seat);
|
||||||
const bot = room.bots.get(seat);
|
const bot = room.bots.get(seat);
|
||||||
const chosen = automatonCommand(view, bot?.style, bot?.tier);
|
const chosen = automatonCommand(view, bot?.style, bot?.tier);
|
||||||
let hesitated: { refused: Command; error: string } | undefined;
|
let hesitated: true | undefined;
|
||||||
let r = runCommand(room, seat, chosen ?? automatonFallback(view, bot?.tier));
|
let r = runCommand(room, seat, chosen ?? automatonFallback(view, bot?.tier));
|
||||||
if ("error" in r) {
|
if ("error" in r) {
|
||||||
// A refused choice retries with the fallback — unless the fallback IS
|
// A refused choice retries with the fallback — unless the fallback IS
|
||||||
// what just failed — then burns down the ladder to endTurn and pass.
|
// what just failed — then burns down the ladder to endTurn and pass.
|
||||||
// The refusal is remembered: a brain whose choice the engine rejects
|
// 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
|
// is a bug in the brain, and the table deserves to see the stumble
|
||||||
// rather than an unexplained idle turn (the RNRX coma lesson).
|
// rather than an unexplained idle turn.
|
||||||
if (chosen) {
|
if (chosen) {
|
||||||
hesitated = { refused: chosen, error: r.error };
|
hesitated = true;
|
||||||
console.warn(
|
console.warn(
|
||||||
`automaton ${seat} hesitated in ${room.id}: ${JSON.stringify(chosen)} refused (${r.error})`);
|
`automaton ${seat} hesitated in ${room.id}: ${JSON.stringify(chosen)} refused (${r.error})`);
|
||||||
r = runCommand(room, seat, automatonFallback(view, bot?.tier));
|
r = runCommand(room, seat, automatonFallback(view, bot?.tier));
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export interface KickLine {
|
|||||||
kind: "kick";
|
kind: "kick";
|
||||||
/** The seat the host removed from an unstarted room. */
|
/** The seat the host removed from an unstarted room. */
|
||||||
name: string;
|
name: string;
|
||||||
|
at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AbandonLine {
|
export interface AbandonLine {
|
||||||
@@ -119,7 +120,7 @@ export function readAllRooms(): Map<string, RoomLine[]> {
|
|||||||
/** Retire an abandoned room's ledger to the graveyard — never deleted,
|
/** Retire an abandoned room's ledger to the graveyard — never deleted,
|
||||||
* only moved out of the living rooms directory. */
|
* only moved out of the living rooms directory. */
|
||||||
export function archiveRoomFile(roomId: string): void {
|
export function archiveRoomFile(roomId: string): void {
|
||||||
const src = join(DATA_DIR, `${roomId}.jsonl`);
|
const src = fileFor(roomId);
|
||||||
if (!existsSync(src)) return;
|
if (!existsSync(src)) return;
|
||||||
const graveyard = join(DATA_DIR, "..", "rooms-abandoned");
|
const graveyard = join(DATA_DIR, "..", "rooms-abandoned");
|
||||||
mkdirSync(graveyard, { recursive: true });
|
mkdirSync(graveyard, { recursive: true });
|
||||||
|
|||||||
@@ -120,6 +120,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BIG MAN towers; SHRINK dwindles. The token says so at a glance.
|
// 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 {
|
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 === "big-man" && e.targetId === id)) return 1.5;
|
||||||
if (view.sustained.some((e) => e.cardId === "shrink" && e.targetId === id)) return 0.62;
|
if (view.sustained.some((e) => e.cardId === "shrink" && e.targetId === id)) return 0.62;
|
||||||
@@ -596,19 +608,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</g>
|
</g>
|
||||||
{#if povFacing && !povCreatureId && p.id === view.you}
|
{#if povFacing && !povCreatureId && p.id === view.you}
|
||||||
<!-- A short wide ribbon hugging the token's leading edge: base as
|
<polygon points={facingWedge(povFacing, CELL * 0.3 * wizardScale(p.id))} class="pov-wedge" />
|
||||||
wide as the token, rising a fifth of that outward. -->
|
|
||||||
{@const fa = povFacing === "E" ? 0 : povFacing === "S" ? Math.PI / 2 : povFacing === "W" ? Math.PI : -Math.PI / 2}
|
|
||||||
{@const fh = CELL * 0.3 * wizardScale(p.id)}
|
|
||||||
{@const fxv = Math.cos(fa)}
|
|
||||||
{@const fyv = Math.sin(fa)}
|
|
||||||
{@const px = -fyv}
|
|
||||||
{@const py = fxv}
|
|
||||||
{@const rise = fh * 0.5}
|
|
||||||
<polygon
|
|
||||||
points={`${fxv * fh + px * fh},${fyv * fh + py * fh} ${fxv * fh - px * fh},${fyv * fh - py * fh} ${fxv * (fh + rise)},${fyv * (fh + rise)}`}
|
|
||||||
class="pov-wedge"
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
</g>
|
</g>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -658,16 +658,7 @@
|
|||||||
</g>
|
</g>
|
||||||
{#if povFacing && povCreatureId === c.id}
|
{#if povFacing && povCreatureId === c.id}
|
||||||
<!-- The rider's facing ribbon, worn by the mount. -->
|
<!-- The rider's facing ribbon, worn by the mount. -->
|
||||||
{@const fa2 = povFacing === "E" ? 0 : povFacing === "S" ? Math.PI / 2 : povFacing === "W" ? Math.PI : -Math.PI / 2}
|
<polygon points={facingWedge(povFacing, CELL * 0.26)} class="pov-wedge" />
|
||||||
{@const ch = CELL * 0.26}
|
|
||||||
{@const cfx = Math.cos(fa2)}
|
|
||||||
{@const cfy = Math.sin(fa2)}
|
|
||||||
{@const cpx = -cfy}
|
|
||||||
{@const cpy = cfx}
|
|
||||||
<polygon
|
|
||||||
points={`${cfx * ch + cpx * ch},${cfy * ch + cpy * ch} ${cfx * ch - cpx * ch},${cfy * ch - cpy * ch} ${cfx * ch * 1.5},${cfy * ch * 1.5}`}
|
|
||||||
class="pov-wedge"
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
</g>
|
</g>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
// here first.
|
// here first.
|
||||||
import FirstPerson from "./fpv/FirstPerson.svelte";
|
import FirstPerson from "./fpv/FirstPerson.svelte";
|
||||||
import type { FpvTarget } from "./fpv/raycast";
|
import type { FpvTarget } from "./fpv/raycast";
|
||||||
import { tokenArt } from "./art";
|
import { objectArt, tokenArt } from "./art";
|
||||||
import { objectArt } from "./art";
|
|
||||||
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
||||||
import { castRay, SIDE_ANGLE, OPPOSITE } from "./fpv/raycast";
|
import { castRay, SIDE_ANGLE, OPPOSITE } from "./fpv/raycast";
|
||||||
|
import { deepestFacing } from "./fpv/director";
|
||||||
import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
|
import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import type { GameEvent, GameView, Side } from "@wizwar/engine";
|
import type { GameEvent, GameView, Side } from "@wizwar/engine";
|
||||||
@@ -108,12 +108,7 @@
|
|||||||
seatedMount = possessedId;
|
seatedMount = possessedId;
|
||||||
preMount = untrack(() => ({ x: cam.x, y: cam.y, facing: cam.facing }));
|
preMount = untrack(() => ({ x: cam.x, y: cam.y, facing: cam.facing }));
|
||||||
const mx = possessed.position.x + 0.5, my = possessed.position.y + 0.5;
|
const mx = possessed.position.x + 0.5, my = possessed.position.y + 0.5;
|
||||||
let deepest = -1, face = 0;
|
cam.x = mx; cam.y = my; cam.facing = deepestFacing(view, mx, my);
|
||||||
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
|
||||||
const h = castRay(view, mx, my, a);
|
|
||||||
if (h.dist > deepest) { deepest = h.dist; face = a; }
|
|
||||||
}
|
|
||||||
cam.x = mx; cam.y = my; cam.facing = face;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/** What the ACTIVE body stands in or on: hazards it cannot see below
|
/** What the ACTIVE body stands in or on: hazards it cannot see below
|
||||||
@@ -164,11 +159,15 @@
|
|||||||
let manualUntil = 0;
|
let manualUntil = 0;
|
||||||
let turning = false;
|
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;
|
const SIDES4 = ["E", "S", "W", "N"] as const;
|
||||||
function manualTurn(dir: -1 | 1) {
|
function manualTurn(dir: -1 | 1) {
|
||||||
if (turning) return;
|
if (turning) return;
|
||||||
turning = true;
|
turning = true;
|
||||||
manualUntil = performance.now() + 4000;
|
manualUntil = performance.now() + STEER_HOLD_MS;
|
||||||
const from = cam.facing;
|
const from = cam.facing;
|
||||||
const to = from + dir * (Math.PI / 2);
|
const to = from + dir * (Math.PI / 2);
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
@@ -203,7 +202,7 @@
|
|||||||
|
|
||||||
function manualStride(back: boolean) {
|
function manualStride(back: boolean) {
|
||||||
if (!canStride) return;
|
if (!canStride) return;
|
||||||
manualUntil = performance.now() + 4000;
|
manualUntil = performance.now() + STEER_HOLD_MS;
|
||||||
const q = Math.round(cam.facing / (Math.PI / 2));
|
const q = Math.round(cam.facing / (Math.PI / 2));
|
||||||
const side = SIDES4[((q % 4) + 4 + (back ? 2 : 0)) % 4]!;
|
const side = SIDES4[((q % 4) + 4 + (back ? 2 : 0)) % 4]!;
|
||||||
if (possessed) onCreatureMove?.(possessed.id, side);
|
if (possessed) onCreatureMove?.(possessed.id, side);
|
||||||
@@ -321,15 +320,12 @@
|
|||||||
const willCut = !camReady || (dist > 1.6 && !hurled);
|
const willCut = !camReady || (dist > 1.6 && !hurled);
|
||||||
cutawayShot = false;
|
cutawayShot = false;
|
||||||
const takeCutaway = (ax: number, ay: number) => {
|
const takeCutaway = (ax: number, ay: number) => {
|
||||||
let best = { d: -1, a: 0 };
|
const a = deepestFacing(v, ax, ay);
|
||||||
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
const d = castRay(v, ax, ay, a).dist;
|
||||||
const h = castRay(v, ax, ay, a);
|
const back = Math.min(1.6, Math.max(0.35, d - 0.4));
|
||||||
if (h.dist > best.d) best = { d: h.dist, a };
|
cam.x = ax + Math.cos(a) * back;
|
||||||
}
|
cam.y = ay + Math.sin(a) * back;
|
||||||
const back = Math.min(1.6, Math.max(0.35, best.d - 0.4));
|
cam.facing = a + Math.PI;
|
||||||
cam.x = ax + Math.cos(best.a) * back;
|
|
||||||
cam.y = ay + Math.sin(best.a) * back;
|
|
||||||
cam.facing = best.a + Math.PI;
|
|
||||||
camReady = true;
|
camReady = true;
|
||||||
cutawayShot = true;
|
cutawayShot = true;
|
||||||
};
|
};
|
||||||
@@ -372,11 +368,7 @@
|
|||||||
// A fresh CUT with nothing asking to be watched faces the deepest
|
// A fresh CUT with nothing asking to be watched faces the deepest
|
||||||
// corridor — but a STANDING camera is never "rescued": where you
|
// corridor — but a STANDING camera is never "rescued": where you
|
||||||
// pointed your own head is where it stays, brick or no brick.
|
// pointed your own head is where it stays, brick or no brick.
|
||||||
let deepest = -1;
|
targetFacing = deepestFacing(v, tx, ty);
|
||||||
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
|
||||||
const h = castRay(v, tx, ty, a);
|
|
||||||
if (h.dist > deepest) { deepest = h.dist; targetFacing = a; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (willCut) {
|
if (willCut) {
|
||||||
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
|
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
|
||||||
@@ -424,8 +416,8 @@
|
|||||||
|
|
||||||
{#if me}
|
{#if me}
|
||||||
<div class="live-fp">
|
<div class="live-fp">
|
||||||
<!-- The bezel: instruments frame the windshield instead of covering
|
<!-- The bezel: instruments live in the frame, never on the glass —
|
||||||
it — every canvas pixel stays target glass. -->
|
every canvas pixel is target. -->
|
||||||
<div class="bezel-top">
|
<div class="bezel-top">
|
||||||
{#if possessed}
|
{#if possessed}
|
||||||
<span class="ride-label">riding the {possessed.kind.replace(/-/g, " ")}</span>
|
<span class="ride-label">riding the {possessed.kind.replace(/-/g, " ")}</span>
|
||||||
@@ -433,7 +425,7 @@
|
|||||||
<button class="stamp tiny" onclick={dismount}>back to your eyes</button>
|
<button class="stamp tiny" onclick={dismount}>back to your eyes</button>
|
||||||
{:else if rideable.length > 0 && (onCreatureMove || onCreatureAttack)}
|
{:else if rideable.length > 0 && (onCreatureMove || onCreatureAttack)}
|
||||||
{#each rideable as c (c.id)}
|
{#each rideable as c (c.id)}
|
||||||
<button class="stamp tiny" onclick={() => { possessedId = c.id; manualUntil = performance.now() + 60000; }}>
|
<button class="stamp tiny" onclick={() => { possessedId = c.id; manualUntil = performance.now() + RIDE_HOLD_MS; }}>
|
||||||
👁 ride the {c.kind.replace(/-/g, " ")}
|
👁 ride the {c.kind.replace(/-/g, " ")}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -459,10 +451,7 @@
|
|||||||
<button class="drive" onclick={() => manualTurn(1)} aria-label="turn right">›</button>
|
<button class="drive" onclick={() => manualTurn(1)} aria-label="turn right">›</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="bezel-bottom">
|
<div class="bezel-bottom">
|
||||||
{#if canStride}
|
<span class="bezel-zone">
|
||||||
<button class="drive stride" onclick={() => manualStride(false)} aria-label="step forward">︿ forward</button>
|
|
||||||
<button class="drive stride" onclick={() => manualStride(true)} aria-label="step back">﹀ back</button>
|
|
||||||
{/if}
|
|
||||||
{#if canStride && ((!possessed && atFeet.length > 0) || underfoot.hazard || underfoot.warpHere)}
|
{#if canStride && ((!possessed && atFeet.length > 0) || underfoot.hazard || underfoot.warpHere)}
|
||||||
<span class="feet-label">at your feet</span>
|
<span class="feet-label">at your feet</span>
|
||||||
{#if !possessed && onpickup}
|
{#if !possessed && onpickup}
|
||||||
@@ -484,8 +473,16 @@
|
|||||||
<button class="stamp tiny" onclick={() => onWarpStep?.(possessedId)}>⇋ step through the warp</button>
|
<button class="stamp tiny" onclick={() => onWarpStep?.(possessedId)}>⇋ step through the warp</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<span class="bezel-spacer"></span>
|
</span>
|
||||||
|
<span class="bezel-zone bezel-center">
|
||||||
|
{#if canStride}
|
||||||
|
<button class="drive stride" onclick={() => manualStride(false)} aria-label="step forward">︿ forward</button>
|
||||||
|
<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 class="live-fp-hint">← → turn · ↑ ↓ walk</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -508,6 +505,9 @@
|
|||||||
.bezel-top { border-bottom: 1px solid #2a2620; }
|
.bezel-top { border-bottom: 1px solid #2a2620; }
|
||||||
.bezel-bottom { border-top: 1px solid #2a2620; }
|
.bezel-bottom { border-top: 1px solid #2a2620; }
|
||||||
.bezel-spacer { flex: 1; }
|
.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 {
|
.bezel-mid {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { scheduleFx, type BoardFx } from "./fx";
|
import { scheduleFx, type BoardFx } from "./fx";
|
||||||
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
||||||
import { castRay, edgeMid } from "./fpv/raycast";
|
import { castRay, edgeMid } from "./fpv/raycast";
|
||||||
import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
|
import { deepestFacing, aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
|
||||||
import { prefs } from "./prefs.svelte";
|
import { prefs } from "./prefs.svelte";
|
||||||
import { stackSightTrace } from "@wizwar/engine";
|
import { stackSightTrace } from "@wizwar/engine";
|
||||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||||
@@ -249,15 +249,12 @@
|
|||||||
* corridor, facing it, holding a beat of the world-before so the
|
* corridor, facing it, holding a beat of the world-before so the
|
||||||
* deed happens ON screen — with the reel's own wizard visible. */
|
* deed happens ON screen — with the reel's own wizard visible. */
|
||||||
const takeCutaway = (ax: number, ay: number) => {
|
const takeCutaway = (ax: number, ay: number) => {
|
||||||
let best = { d: -1, a: 0 };
|
const a = deepestFacing(v, ax, ay);
|
||||||
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
const d = castRay(v, ax, ay, a).dist;
|
||||||
const h = castRay(v, ax, ay, a);
|
const back = Math.min(1.6, Math.max(0.35, d - 0.4));
|
||||||
if (h.dist > best.d) best = { d: h.dist, a };
|
cam.x = ax + Math.cos(a) * back;
|
||||||
}
|
cam.y = ay + Math.sin(a) * back;
|
||||||
const back = Math.min(1.6, Math.max(0.35, best.d - 0.4));
|
cam.facing = a + Math.PI;
|
||||||
cam.x = ax + Math.cos(best.a) * back;
|
|
||||||
cam.y = ay + Math.sin(best.a) * back;
|
|
||||||
cam.facing = best.a + Math.PI;
|
|
||||||
camReady = true;
|
camReady = true;
|
||||||
cutawayShot = true;
|
cutawayShot = true;
|
||||||
const i2 = Math.min(idx, steps.length - 1);
|
const i2 = Math.min(idx, steps.length - 1);
|
||||||
|
|||||||
@@ -3,9 +3,7 @@
|
|||||||
// GameView. Columns of wall shaded by distance and facing; token art
|
// GameView. Columns of wall shaded by distance and facing; token art
|
||||||
// billboarded for whatever stands in the corridors, occluded per column
|
// billboarded for whatever stands in the corridors, occluded per column
|
||||||
// by the same depth buffer the walls wrote.
|
// by the same depth buffer the walls wrote.
|
||||||
import {
|
import { billboards, castRay, warpMotion, type FpvTarget } from "./raycast";
|
||||||
warpMotion,
|
|
||||||
type FpvTarget, castRay, billboards } from "./raycast";
|
|
||||||
import { materialTextures } from "./textures";
|
import { materialTextures } from "./textures";
|
||||||
import { doorOpenness, fxFallback, growProgress, type FpFx } from "./fx3d";
|
import { doorOpenness, fxFallback, growProgress, type FpFx } from "./fx3d";
|
||||||
import { terrainFallback, TERRAIN3D } from "./terrain3d";
|
import { terrainFallback, TERRAIN3D } from "./terrain3d";
|
||||||
@@ -59,8 +57,23 @@
|
|||||||
W: number; H: number; ex: number; ey: number; facing: number;
|
W: number; H: number; ex: number; ey: number; facing: number;
|
||||||
zbuf: Float64Array; warpIdCol: Int32Array; warpDistCol: Float64Array;
|
zbuf: Float64Array; warpIdCol: Int32Array; warpDistCol: Float64Array;
|
||||||
cols: ({ edge?: string; kind: string; top: number; h: number } | null)[];
|
cols: ({ edge?: string; kind: string; top: number; h: number } | null)[];
|
||||||
|
/** Depth-sorted nearest-first, ready for hover hit-testing. */
|
||||||
sprites: Projected[];
|
sprites: Projected[];
|
||||||
} | null = null;
|
} | 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
|
/** Crosshair position in canvas pixels, while the pointer is over the
|
||||||
* pane and the pane is an instrument. */
|
* pane and the pane is an instrument. */
|
||||||
let mouse: { x: number; y: number } | null = null;
|
let mouse: { x: number; y: number } | null = null;
|
||||||
@@ -403,7 +416,10 @@
|
|||||||
if (s) sprites.push({ ...s, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art });
|
if (s) sprites.push({ ...s, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art });
|
||||||
}
|
}
|
||||||
sprites.sort((a, b) => b.sort - a.sort);
|
sprites.sort((a, b) => b.sort - a.sort);
|
||||||
hitFrame = { W, H, ex, ey, facing, zbuf, warpIdCol, warpDistCol, cols: hitCols, sprites };
|
hitFrame = {
|
||||||
|
W, H, ex, ey, facing, zbuf, warpIdCol, warpDistCol, cols: hitCols,
|
||||||
|
sprites: [...sprites].sort((a, b) => a.depth - b.depth),
|
||||||
|
};
|
||||||
// The nearest sprite each column carries — depth AND vertical span —
|
// The nearest sprite each column carries — depth AND vertical span —
|
||||||
// so the overlay passes (veils, ghosts, lintels, risings) can paint
|
// so the overlay passes (veils, ghosts, lintels, risings) can paint
|
||||||
// around a body standing in front of them instead of over it.
|
// around a body standing in front of them instead of over it.
|
||||||
@@ -433,11 +449,7 @@
|
|||||||
if (s.glow) ctx.globalCompositeOperation = "lighter";
|
if (s.glow) ctx.globalCompositeOperation = "lighter";
|
||||||
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = s.alpha ?? 1;
|
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++) {
|
for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) {
|
||||||
if (s.depth >= zbuf[col]!) continue;
|
if (!spriteVisibleInCol(s, col, zbuf, warpIdCol, warpDistCol)) 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 (s.depth < spriteZ[col]!) {
|
if (s.depth < spriteZ[col]!) {
|
||||||
spriteZ[col] = s.depth;
|
spriteZ[col] = s.depth;
|
||||||
sprTop[col] = s.top;
|
sprTop[col] = s.top;
|
||||||
@@ -590,6 +602,8 @@
|
|||||||
ctx.fillRect(col, c.top, 1, c.h);
|
ctx.fillRect(col, c.top, 1, c.h);
|
||||||
}
|
}
|
||||||
label = found.label;
|
label = found.label;
|
||||||
|
} else if (found.shape.kind === "none") {
|
||||||
|
label = found.label;
|
||||||
} else {
|
} else {
|
||||||
// A ground square: its four corners projected onto the floor plane.
|
// A ground square: its four corners projected onto the floor plane.
|
||||||
const flen = (f.W / 2) / Math.tan(FOV / 2);
|
const flen = (f.W / 2) / Math.tan(FOV / 2);
|
||||||
@@ -716,7 +730,7 @@
|
|||||||
* (or vault) square the pixel lies on — warp-bent columns mapping
|
* (or vault) square the pixel lies on — warp-bent columns mapping
|
||||||
* their virtual ground back to real cells through the warp's own
|
* their virtual ground back to real cells through the warp's own
|
||||||
* rigid motion. */
|
* rigid motion. */
|
||||||
export function hitTest(px: number, py: number): FpvTarget | null {
|
function hitTest(px: number, py: number): FpvTarget | null {
|
||||||
return resolveHover(px, py)?.target ?? null;
|
return resolveHover(px, py)?.target ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,7 +742,8 @@
|
|||||||
shape:
|
shape:
|
||||||
| { kind: "rect"; left: number; right: number; top: number; bottom: number }
|
| { kind: "rect"; left: number; right: number; top: number; bottom: number }
|
||||||
| { kind: "face"; edge: string }
|
| { kind: "face"; edge: string }
|
||||||
| { kind: "ground"; cell: { x: number; y: number } };
|
| { kind: "ground"; cell: { x: number; y: number } }
|
||||||
|
| { kind: "none" };
|
||||||
} | null {
|
} | null {
|
||||||
const f = hitFrame;
|
const f = hitFrame;
|
||||||
if (!f) return null;
|
if (!f) return null;
|
||||||
@@ -737,15 +752,10 @@
|
|||||||
|
|
||||||
// Sprites first, nearest first, honoring the draw pass's own
|
// Sprites first, nearest first, honoring the draw pass's own
|
||||||
// visibility rules for this column.
|
// visibility rules for this column.
|
||||||
const byDepth = [...f.sprites].sort((a, b) => a.depth - b.depth);
|
for (const sp of f.sprites) {
|
||||||
for (const sp of byDepth) {
|
|
||||||
if (!sp.hit && !sp.cell) continue; // pure spectacle (projectiles, rubble)
|
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 (px < sp.left || px >= sp.right || py < sp.top || py > sp.bottom) continue;
|
||||||
if (sp.clampL !== undefined && (col < sp.clampL || col > sp.clampR!)) continue;
|
if (!spriteVisibleInCol(sp, col, f.zbuf, f.warpIdCol, f.warpDistCol)) continue;
|
||||||
if (sp.depth >= f.zbuf[col]!) continue;
|
|
||||||
if (sp.warped) {
|
|
||||||
if (f.warpIdCol[col] !== sp.warpId || sp.depth <= f.warpDistCol[col]!) continue;
|
|
||||||
} else if (sp.depth >= f.warpDistCol[col]!) continue;
|
|
||||||
const shape = { kind: "rect" as const, left: sp.left, right: sp.right, top: sp.top, bottom: sp.bottom };
|
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);
|
const label = sp.hit ? (sp.hit.kind === "player" ? sp.hit.id : labelOf(sp)) : labelOf(sp);
|
||||||
if (sp.hit) return { target: sp.hit, label, shape };
|
if (sp.hit) return { target: sp.hit, label, shape };
|
||||||
@@ -783,7 +793,7 @@
|
|||||||
// Warp-bent ground still TARGETS truly, but the highlight quad
|
// Warp-bent ground still TARGETS truly, but the highlight quad
|
||||||
// cannot be drawn in this frame's geometry: label it instead.
|
// cannot be drawn in this frame's geometry: label it instead.
|
||||||
const pt = groundPoint(f, col, d);
|
const pt = groundPoint(f, col, d);
|
||||||
return pt ? { target: { kind: "cell", cell: pt }, label: `through the warp (${pt.x},${pt.y})`, shape: { kind: "face", edge: "\u0000never" } } : null;
|
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);
|
const pt = groundPoint(f, col, d);
|
||||||
return pt ? { target: { kind: "cell", cell: pt }, label: "", shape: { kind: "ground", cell: pt } } : null;
|
return pt ? { target: { kind: "cell", cell: pt }, label: "", shape: { kind: "ground", cell: pt } } : null;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { edgeMid } from "./raycast";
|
import { edgeMid } from "./raycast";
|
||||||
import { fpFxForEvents } from "./fx3d";
|
import { fpFxForEvents } from "./fx3d";
|
||||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||||
|
import { castRay } from "./raycast";
|
||||||
|
|
||||||
export function shortestArc(from: number, to: number): number {
|
export function shortestArc(from: number, to: number): number {
|
||||||
let d = (to - from) % (2 * Math.PI);
|
let d = (to - from) % (2 * Math.PI);
|
||||||
@@ -131,3 +132,14 @@ export function gatherGlides(before: GameView, after: GameView, povId: string):
|
|||||||
}
|
}
|
||||||
return moves;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -225,7 +225,6 @@ export interface LogLine {
|
|||||||
* momentSteps, so a chronicle turn number addresses the same commands. */
|
* momentSteps, so a chronicle turn number addresses the same commands. */
|
||||||
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
|
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
|
||||||
|
|
||||||
|
|
||||||
const SEAT_KEY = "wizwar-seat";
|
const SEAT_KEY = "wizwar-seat";
|
||||||
const SEATS_KEY = "wizwar-seats";
|
const SEATS_KEY = "wizwar-seats";
|
||||||
const CHAT_SEEN_KEY = "wizwar-chat-seen";
|
const CHAT_SEEN_KEY = "wizwar-chat-seen";
|
||||||
@@ -318,7 +317,6 @@ class Net {
|
|||||||
/** Turns witnessed so far: counts the same boundary events the server
|
/** Turns witnessed so far: counts the same boundary events the server
|
||||||
* counts, so a chronicle line can name the turn it belongs to. */
|
* counts, so a chronicle line can name the turn it belongs to. */
|
||||||
private turnCounter = -1;
|
private turnCounter = -1;
|
||||||
/** The turn that already carries an instant-replay eye (one per turn). */
|
|
||||||
/** The turn whose moment reel is open (share links point at it). */
|
/** The turn whose moment reel is open (share links point at it). */
|
||||||
private momentTurn: number | null = null;
|
private momentTurn: number | null = null;
|
||||||
private shareResolve: ((url: string) => void) | null = null;
|
private shareResolve: ((url: string) => void) | null = null;
|
||||||
@@ -494,9 +492,8 @@ class Net {
|
|||||||
// Prune ONLY seats the server proved dead: the room exists and
|
// Prune ONLY seats the server proved dead: the room exists and
|
||||||
// refused this exact token. A seat merely ABSENT from the reply
|
// refused this exact token. A seat merely ABSENT from the reply
|
||||||
// stays — a restarting server, a stale restore, or the wrong
|
// stays — a restarting server, a stale restore, or the wrong
|
||||||
// backend all answer with ignorance, and treating ignorance as
|
// backend all answer with ignorance, and ignorance is not
|
||||||
// deletion once cost a live player his seat mid-game. The
|
// deletion. The wallet is capped by age instead of by trust.
|
||||||
// wallet is capped by age instead of by trust.
|
|
||||||
const voided = new Set((msg.voided as string[] | undefined) ?? []);
|
const voided = new Set((msg.voided as string[] | undefined) ?? []);
|
||||||
let kept = this.seats.filter((s) => !voided.has(`${s.roomId}:${s.name}`));
|
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 > 50) kept = kept.slice(kept.length - 50);
|
||||||
@@ -510,10 +507,9 @@ class Net {
|
|||||||
// "Name is taken" means THIS token was tried and refused: the
|
// "Name is taken" means THIS token was tried and refused: the
|
||||||
// credential itself is dead, and holding it helps nobody. But
|
// credential itself is dead, and holding it helps nobody. But
|
||||||
// "no such room" proves nothing about the seat — a restarting
|
// "no such room" proves nothing about the seat — a restarting
|
||||||
// server, a stale restore, or the wrong backend all say it —
|
// server, a stale restore, or the wrong backend all say it.
|
||||||
// and burning credentials on a transient locked a real player
|
// The seat stays; the error shows; a later reconnect against
|
||||||
// out of a live game. The seat stays; the error shows; a later
|
// the right server walks back in.
|
||||||
// reconnect against the right server walks back in.
|
|
||||||
if (this.roomIdPending && /name is taken/.test(msg.message)) {
|
if (this.roomIdPending && /name is taken/.test(msg.message)) {
|
||||||
localStorage.removeItem(SEAT_KEY);
|
localStorage.removeItem(SEAT_KEY);
|
||||||
}
|
}
|
||||||
@@ -668,8 +664,9 @@ class Net {
|
|||||||
} catch { /* blocked at the OS level; the tab title still shows it */ }
|
} catch { /* blocked at the OS level; the tab title still shows it */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Forget the remembered seat and return to the lobby. */
|
/** Client-side teardown when the SERVER detached us (kick, abandon).
|
||||||
/** Client-side teardown when the SERVER detached us (kick, abandon). */
|
* The chronicle survives: the detachment appended its own explanatory
|
||||||
|
* line, and wiping it would erase the only notice of why. */
|
||||||
leaveLocal(): void {
|
leaveLocal(): void {
|
||||||
localStorage.removeItem(SEAT_KEY);
|
localStorage.removeItem(SEAT_KEY);
|
||||||
this.roomId = null;
|
this.roomId = null;
|
||||||
@@ -682,18 +679,11 @@ class Net {
|
|||||||
this.audience = 0;
|
this.audience = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Walk away from the table (or gallery) by choice. */
|
||||||
leave(): void {
|
leave(): void {
|
||||||
this.send({ type: "leave" }); // detach server-side too (frees a gallery seat)
|
this.send({ type: "leave" }); // detach server-side too (frees a gallery seat)
|
||||||
localStorage.removeItem(SEAT_KEY);
|
this.leaveLocal();
|
||||||
this.roomId = null;
|
|
||||||
this.roomIdPending = null;
|
|
||||||
this.view = null;
|
|
||||||
this.started = false;
|
|
||||||
this.players = [];
|
|
||||||
this.resetChronicle();
|
this.resetChronicle();
|
||||||
this.token = null;
|
|
||||||
this.spectating = false;
|
|
||||||
this.audience = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
start(expansion: boolean): void {
|
start(expansion: boolean): void {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function renderView(v) {
|
|||||||
for (let x = 0; x < B.width; x++) {
|
for (let x = 0; x < B.width; x++) {
|
||||||
const k = `${x},${y}`;
|
const k = `${x},${y}`;
|
||||||
if (!B.cells[k]) { top += " "; mid += " "; continue; }
|
if (!B.cells[k]) { top += " "; mid += " "; continue; }
|
||||||
const nEdge = y === 0 ? (B.edges[`H:${x},${y - 1}`] ?? "open") : (B.edges[`H:${x},${y - 1}`] ?? "open");
|
const nEdge = B.edges[`H:${x},${y - 1}`] ?? "open";
|
||||||
const north = y === 0 ? "wall" : nEdge; // rim renders solid; warps noted separately
|
const north = y === 0 ? "wall" : nEdge; // rim renders solid; warps noted separately
|
||||||
top += "+" + (north === "wall" ? "————" : north === "door" ? "—DD—" : north === "firewall" ? "~FF~" : " ");
|
top += "+" + (north === "wall" ? "————" : north === "door" ? "—DD—" : north === "firewall" ? "~FF~" : " ");
|
||||||
const wEdge = x === 0 ? "wall" : (B.edges[`V:${x - 1},${y}`] ?? "open");
|
const wEdge = x === 0 ? "wall" : (B.edges[`V:${x - 1},${y}`] ?? "open");
|
||||||
|
|||||||
Reference in New Issue
Block a user