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)
|
||||
|
||||
- **CHECK `dimWarps` EVERY SINGLE VIEW** — Kestrel has won two games
|
||||
with quietly-placed wormholes ending beside his home. The client now
|
||||
- **CHECK `dimWarps` EVERY SINGLE VIEW** — Kestrel (Eric's wizard) has
|
||||
won two games
|
||||
with quietly-placed wormholes ending beside his home. The client
|
||||
prints a !! line for them; treat any new token pair as a five-alarm
|
||||
fire and recompute both players' delivery distances through it.
|
||||
- **Never lead the attack against Kestrel.** Three games, three saved
|
||||
|
||||
@@ -296,13 +296,13 @@ function pathDenial(view: GameView): Command | null {
|
||||
}
|
||||
// Enemy gold banked at MY home: my score, snatchable by anyone. A
|
||||
// raider closing on the bank gets the same road-lengthening treatment.
|
||||
const me2 = me(view);
|
||||
const self = me(view);
|
||||
const banked = view.treasures.find(
|
||||
(t) => t.owner !== view.you && t.position && cellKey(t.position) === cellKey(me2.home));
|
||||
(t) => t.owner !== view.you && t.position && cellKey(t.position) === cellKey(self.home));
|
||||
if (banked) {
|
||||
for (const e of livingEnemies(view)) {
|
||||
const d = Math.abs(e.position.x - me2.home.x) + Math.abs(e.position.y - me2.home.y);
|
||||
if (d <= 6) threats.push({ enemy: e.position, goal: me2.home });
|
||||
const d = Math.abs(e.position.x - self.home.x) + Math.abs(e.position.y - self.home.y);
|
||||
if (d <= 6) threats.push({ enemy: e.position, goal: self.home });
|
||||
}
|
||||
}
|
||||
if (threats.length === 0) return null;
|
||||
@@ -758,9 +758,8 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
||||
}
|
||||
|
||||
// The LAST counter in hand is a treasure of its own: at a healthy life
|
||||
// total it waits for something big rather than answering every jab —
|
||||
// patience the clockwork's best opponent taught it, three counters at
|
||||
// a time. (Lethal blows override this below.)
|
||||
// total it waits for something big rather than answering every jab.
|
||||
// (Lethal blows override this below.)
|
||||
const counterCount = view.yourHand.filter((c) => {
|
||||
const t = cardDef(c.cardId).cardType;
|
||||
return t === "counteraction" || t === "neutral/counteraction";
|
||||
@@ -797,6 +796,8 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
||||
// when the incoming points reach the clockwork's life, any counter
|
||||
// that can save it is cheap at the price.
|
||||
const lethal = !affliction && incoming >= me(view).life;
|
||||
// 9 clears every thrift threshold below: a killing blow is answered
|
||||
// with whatever can answer it, price be damned.
|
||||
const weight = lethal ? Math.max(incoming, 9) : incoming;
|
||||
if (stack.kind === "spell") {
|
||||
// REVERSE eats points; a pure duration offers it nothing.
|
||||
@@ -943,6 +944,7 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
|
||||
if (sd && biggest) {
|
||||
const shown = target.displayed.filter((c) => STONES.has(c.cardId)).length;
|
||||
const dmg = biggestValue * shown;
|
||||
// `best` is assigned only inside offer(), so TS narrows it to null here.
|
||||
const cur = best as BestPick | null;
|
||||
if (dmg >= 4 && (!cur || (dmg >= target.life && !cur.kill) || dmg > cur.damage)) {
|
||||
best = {
|
||||
@@ -1132,9 +1134,8 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
|
||||
}
|
||||
}
|
||||
// INFRASTRUCTURE: a wormhole anchored beside home turns every future
|
||||
// delivery into a three-move stroll — the pattern that won its best
|
||||
// opponent two games. Placed early, from home's doorstep, far mouth
|
||||
// by the richest distant gold.
|
||||
// delivery into a three-move stroll. Placed early, from home's
|
||||
// doorstep, far mouth by the richest distant gold.
|
||||
{
|
||||
const dwarp = inHand(view, "dimensional-warp");
|
||||
const nearHome = Math.abs(self.position.x - self.home.x) + Math.abs(self.position.y - self.home.y) <= 1;
|
||||
@@ -1547,7 +1548,7 @@ export function automatonCommand(
|
||||
const bankedCount = view.treasures.filter(
|
||||
(t) => t.owner !== you && t.position && cellKey(t.position) === cellKey(self.home)).length;
|
||||
// The predicate reads only ENEMY positions — my own steps must not
|
||||
// flip it mid-march or the walker shuttles (the thief-chase lesson).
|
||||
// flip it mid-march or the walker shuttles between goals.
|
||||
const bankThreatened = bankedCount > 0 && !self.carriedTreasureId &&
|
||||
livingEnemies(view).some((p) =>
|
||||
!p.carriedTreasureId &&
|
||||
|
||||
+46
-54
@@ -389,13 +389,11 @@ function doorsAjar(state: GameState, viewerId: PlayerId | undefined, board: Asse
|
||||
}
|
||||
|
||||
/** LOS including square-filling blockers. */
|
||||
function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean {
|
||||
const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state)));
|
||||
const blockers = losBlockers(state);
|
||||
if (sightBetween(board, from, to, blockers)) return true;
|
||||
if (!stone) return false;
|
||||
const viewer = viewerId ? state.players.find((p) => p.id === viewerId) : undefined;
|
||||
if (!viewer || !viewer.alive || !displays(viewer, "visionstone")) return false;
|
||||
/** Sight granted by removing any ONE closed edge — the VISIONSTONE's
|
||||
* whole power, shared by every path that honors it. */
|
||||
function sightThroughOneEdge(
|
||||
board: AssembledBoard, from: Cell, to: Cell, blockers: Record<string, true>,
|
||||
): boolean {
|
||||
for (const key of Object.keys(board.edges)) {
|
||||
if ((board.edges[key] ?? "open") === "open") continue;
|
||||
const edges = { ...board.edges };
|
||||
@@ -405,11 +403,21 @@ function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | un
|
||||
return false;
|
||||
}
|
||||
|
||||
function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean {
|
||||
const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state)));
|
||||
const blockers = losBlockers(state);
|
||||
if (sightBetween(board, from, to, blockers)) return true;
|
||||
if (!stone) return false;
|
||||
const viewer = viewerId ? state.players.find((p) => p.id === viewerId) : undefined;
|
||||
if (!viewer || !viewer.alive || !displays(viewer, "visionstone")) return false;
|
||||
return sightThroughOneEdge(board, from, to, blockers);
|
||||
}
|
||||
|
||||
/** LOS including square-filling blockers. VISIONSTONE is the bearer's
|
||||
* sight wherever sight is asked of them — creations, dispels, utility
|
||||
* spells — not just attacks: one wall or door, any type, falls away.
|
||||
* Safe at every revision: widening what a cast may target never changes
|
||||
* how a recorded command replays. */
|
||||
* Ungated by design: validation may widen without a rev bump, because
|
||||
* refused commands never entered any ledger. */
|
||||
export function gameLos(state: GameState, from: Cell, to: Cell, viewerId?: PlayerId): boolean {
|
||||
return losWith(state, from, to, viewerId, true);
|
||||
}
|
||||
@@ -472,13 +480,7 @@ function casterLos(
|
||||
const blockers = losBlockers(state);
|
||||
if (sightBetween(board, from, to, blockers)) return true;
|
||||
if (!displays(caster, "visionstone")) return false;
|
||||
for (const key of Object.keys(board.edges)) {
|
||||
if ((board.edges[key] ?? "open") === "open") continue;
|
||||
const edges = { ...board.edges };
|
||||
delete edges[key];
|
||||
if (sightBetween({ ...board, edges }, from, to, blockers)) return true;
|
||||
}
|
||||
return false;
|
||||
return sightThroughOneEdge(board, from, to, blockers);
|
||||
}
|
||||
|
||||
/** Bent LOS for AROUND THE CORNER: caster sees a middle cell, which sees the target. */
|
||||
@@ -2941,6 +2943,31 @@ function impCheck(state: GameState, events: GameEvent[], onlyPlayer?: PlayerId):
|
||||
checkVictory(state, events);
|
||||
}
|
||||
|
||||
/** A creature arriving in a player's square touches: the wraith's chill
|
||||
* and the democratic monster's claw open a counteraction window ("REFLECTIONs
|
||||
* used on the wraith's touch will damage the wraith" — the card assumes
|
||||
* exactly this window). True = a stack opened and the turn should return.
|
||||
* "May attack only one player per round of turns" spends attackUsed. */
|
||||
function creatureTouchOnArrival(state: GameState, events: GameEvent[], creature: CreatureState): boolean {
|
||||
for (const p of state.players) {
|
||||
if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue;
|
||||
if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator
|
||||
if (creature.kind === "wraith" && !creature.attackUsed) {
|
||||
creature.attackUsed = true;
|
||||
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
|
||||
openCreatureStack(state, creature, p, 2, "wraith");
|
||||
return true;
|
||||
}
|
||||
if (creature.kind === "democratic-monster" && !creature.attackUsed && !creature.justCreated) {
|
||||
creature.attackUsed = true;
|
||||
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
|
||||
openCreatureStack(state, creature, p, 2, "claw");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A creature standing on a DIMENSIONAL WARP token steps through it like
|
||||
* any walker: its commander spends one of its moves, solid stone on the
|
||||
* far side refuses it, and a monster is no braver than a wizard about
|
||||
@@ -2964,7 +2991,7 @@ function doCreatureWarpStep(prev: GameState, creatureId: string): CommandResult
|
||||
if (state.squareContents[cellKey(dest)]?.kind === "stone") return err("the far side is solid stone");
|
||||
if (state.players.some((o) => o.alive && cellKey(o.position) === cellKey(dest) &&
|
||||
sustainedOn(state, o.id, "big-man").length > 0)) {
|
||||
return err("a giant fills that square");
|
||||
return err("a giant fills that corridor");
|
||||
}
|
||||
const from = creature.position;
|
||||
// FEAR holds monsters off too: "no player or monster".
|
||||
@@ -2972,23 +2999,7 @@ function doCreatureWarpStep(prev: GameState, creatureId: string): CommandResult
|
||||
creature.position = { ...dest };
|
||||
creature.movementUsed++;
|
||||
const events: GameEvent[] = [{ type: "creatureWarpStepped", creatureId, from, to: creature.position, by: active.id }];
|
||||
// Touch effects on arriving in a player's square, as any step has.
|
||||
for (const p of state.players) {
|
||||
if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue;
|
||||
if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue;
|
||||
if (creature.kind === "wraith" && !creature.attackUsed) {
|
||||
creature.attackUsed = true;
|
||||
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
|
||||
openCreatureStack(state, creature, p, 2, "wraith");
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
if (creature.kind === "democratic-monster" && !creature.attackUsed && !creature.justCreated) {
|
||||
creature.attackUsed = true;
|
||||
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
|
||||
openCreatureStack(state, creature, p, 2, "claw");
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
}
|
||||
creatureTouchOnArrival(state, events, creature);
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
@@ -3046,26 +3057,7 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C
|
||||
}
|
||||
creature.movementUsed++;
|
||||
events.push({ type: "creatureMoved", creatureId: creature.id, from, to: creature.position, direction, by: active.id });
|
||||
|
||||
// Touch effects on entering a player's square.
|
||||
for (const p of state.players) {
|
||||
if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue;
|
||||
if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator
|
||||
if (creature.kind === "wraith" && !creature.attackUsed) {
|
||||
creature.attackUsed = true;
|
||||
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
|
||||
// The victim may counteract ("REFLECTIONs used on the wraith's touch
|
||||
// will damage the wraith" — the card assumes exactly this window).
|
||||
openCreatureStack(state, creature, p, 2, "wraith");
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
if (creature.kind === "democratic-monster" && !creature.attackUsed && !creature.justCreated) {
|
||||
creature.attackUsed = true; // "may attack only one player per round of turns"
|
||||
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
|
||||
openCreatureStack(state, creature, p, 2, "claw");
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
}
|
||||
if (creatureTouchOnArrival(state, events, creature)) return { ok: true, state, events };
|
||||
checkVictory(state, events);
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
@@ -757,7 +757,7 @@ describe("the thief-chase holds one goal per turn", () => {
|
||||
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "open";
|
||||
// A number card and no attacks: the blow the chase serves cannot land.
|
||||
bot.hand = [];
|
||||
bot.hand.push({ cardId: "number-2", instanceId: "N2" } as never);
|
||||
bot.hand.push({ cardId: "number-2", instanceId: "N2" });
|
||||
const visited = [cellKey(bot.position)];
|
||||
for (let guard = 0; guard < 40; guard++) {
|
||||
const view = viewFor(state, "bot");
|
||||
@@ -776,8 +776,9 @@ describe("the thief-chase holds one goal per turn", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the archmage's sharpened instincts", () => {
|
||||
function botTurn(seed = 42) {
|
||||
describe("the archmage's key, shield, and race discipline", () => {
|
||||
function botTurn() {
|
||||
const seed = 42;
|
||||
let { state } = createGame({ playerIds: ["foe", "bot"], seed, sets: ["basic", "expansion1"] });
|
||||
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
@@ -790,7 +791,7 @@ describe("the archmage's sharpened instincts", () => {
|
||||
it("a MASTER KEY drawn goes straight on display", () => {
|
||||
const state = botTurn();
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
bot.hand.push({ cardId: "master-key", instanceId: "MK" } as never);
|
||||
bot.hand.push({ cardId: "master-key", instanceId: "MK" });
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toEqual({ type: "cast", instanceId: "MK" });
|
||||
});
|
||||
@@ -806,8 +807,8 @@ describe("the archmage's sharpened instincts", () => {
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
bot.position = { ...foe.position };
|
||||
bot.life = 2;
|
||||
bot.hand = [{ cardId: "full-shield", instanceId: "FS" } as never];
|
||||
foe.hand.push({ cardId: "fireball", instanceId: "FB" } as never);
|
||||
bot.hand = [{ cardId: "full-shield", instanceId: "FS" }];
|
||||
foe.hand.push({ cardId: "fireball", instanceId: "FB" });
|
||||
const r = applyCommand(state, "foe", {
|
||||
type: "cast", instanceId: "FB", target: { kind: "player", playerId: "bot" },
|
||||
});
|
||||
@@ -833,15 +834,20 @@ describe("the archmage's sharpened instincts", () => {
|
||||
gold[1]!.position = null;
|
||||
bot.carriedTreasureId = gold[1]!.id;
|
||||
foe.position = { ...bot.home }; // a foe in sight: the war chest would hoard
|
||||
bot.hand = [
|
||||
{ cardId: "fireball", instanceId: "FB" } as never,
|
||||
{ cardId: "number-3", instanceId: "N3" } as never,
|
||||
];
|
||||
// Stand the bot a straight, open four squares from home.
|
||||
// Legs only: an attack in hand would fire at the foe in sight and
|
||||
// open a stack this single-seat loop cannot answer.
|
||||
bot.hand = [{ cardId: "number-3", instanceId: "N3" }];
|
||||
// Carve a straight, open four-square run to home; the rig owns its
|
||||
// geometry rather than praying the seed provides it.
|
||||
const home = bot.home;
|
||||
const column = [0, 1, 2, 3, 4].map((d) => ({ x: home.x, y: home.y + d }));
|
||||
if (!column.every((c) => viewFor(state, "bot").board.cells[cellKey(c)])) return;
|
||||
bot.position = { ...column[4]! };
|
||||
const column = [4, 3, 2, 1, 0].map((d) => ({ x: home.x, y: home.y - d }));
|
||||
for (const c of column) {
|
||||
if (!viewFor(state, "bot").board.cells[cellKey(c)]) {
|
||||
throw new Error("setup: the column north of home runs off the board");
|
||||
}
|
||||
delete state.squareContents[cellKey(c)];
|
||||
}
|
||||
bot.position = { ...column[0]! };
|
||||
for (const c of column.slice(0, 4)) state.edgeOverrides[edgeKey(c, "S")] = "open";
|
||||
for (let guard = 0; guard < 12; guard++) {
|
||||
const view = viewFor(state, "bot");
|
||||
@@ -857,8 +863,9 @@ describe("the archmage's sharpened instincts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("lessons from the human duels", () => {
|
||||
function rig(players = ["foe", "bot"]) {
|
||||
describe("bank guarding, teleport delivery, and turn theft", () => {
|
||||
function rig() {
|
||||
const players = ["foe", "bot"];
|
||||
let { state } = createGame({ playerIds: players, seed: 42, sets: ["basic", "expansion1"] });
|
||||
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
@@ -878,9 +885,28 @@ describe("lessons from the human duels", () => {
|
||||
foe.position = { x: bot.home.x, y: bot.home.y + 1 };
|
||||
bot.position = { ...foe.home };
|
||||
bot.hand = [];
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
// Whatever step it picks, the march must be TOWARD home, not the gold map.
|
||||
expect(cmd?.type).toBe("move");
|
||||
// The march may wind through the maze; what matters is that it ENDS
|
||||
// at the threatened bank, not out on the gold map.
|
||||
let cur = state;
|
||||
for (let guard = 0; guard < 60; guard++) {
|
||||
const seat = actingSeat(cur);
|
||||
if (seat !== "bot") {
|
||||
// The foe answers stacks with a pass and burns its turns.
|
||||
let r2 = applyCommand(cur, seat, { type: "pass" });
|
||||
if (!r2.ok) r2 = applyCommand(cur, seat, { type: "endTurn", draw: 0 });
|
||||
if (!r2.ok) throw new Error(`foe stuck: ${r2.error}`);
|
||||
cur = r2.state;
|
||||
continue;
|
||||
}
|
||||
const view = viewFor(cur, "bot");
|
||||
const cmd = automatonCommand(view, "hunter", "archmage") ?? automatonFallback(view, "archmage");
|
||||
const r = applyCommand(cur, "bot", cmd);
|
||||
if (!r.ok) throw new Error(`refused: ${JSON.stringify(cmd)} — ${r.error}`);
|
||||
cur = r.state;
|
||||
const at = cur.players.find((p) => p.id === "bot")!;
|
||||
if (cellKey(at.position) === cellKey(bot.home)) return;
|
||||
}
|
||||
throw new Error("the clockwork never came home to its threatened bank");
|
||||
});
|
||||
|
||||
it("carrying with home a blink away, it teleports the delivery", () => {
|
||||
@@ -895,7 +921,7 @@ describe("lessons from the human duels", () => {
|
||||
for (const side of ["N", "S", "E", "W"] as const) {
|
||||
state.edgeOverrides[edgeKey(bot.position, side)] = "wall";
|
||||
}
|
||||
bot.hand = [{ cardId: "teleport", instanceId: "TP" } as never];
|
||||
bot.hand = [{ cardId: "teleport", instanceId: "TP" }];
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toEqual({ type: "cast", instanceId: "TP", target: { kind: "cell", cell: { ...bot.home } } });
|
||||
});
|
||||
@@ -908,15 +934,15 @@ describe("lessons from the human duels", () => {
|
||||
const banked = state.treasures.find((t) => t.owner === "bot")!;
|
||||
banked.position = { ...foe.home };
|
||||
const carried = state.treasures.filter((t) => t.owner === "bot")[1];
|
||||
if (!carried) return;
|
||||
if (!carried) throw new Error("setup: the bot owns fewer than two treasures");
|
||||
carried.carriedBy = "foe";
|
||||
carried.position = null;
|
||||
foe.carriedTreasureId = carried.id;
|
||||
foe.position = { ...bot.position };
|
||||
bot.hand = [
|
||||
{ cardId: "fireball", instanceId: "FB" } as never,
|
||||
{ cardId: "lightning-blast", instanceId: "LB" } as never,
|
||||
{ cardId: "number-4", instanceId: "N4" } as never,
|
||||
{ cardId: "fireball", instanceId: "FB" },
|
||||
{ cardId: "lightning-blast", instanceId: "LB" },
|
||||
{ cardId: "number-4", instanceId: "N4" },
|
||||
];
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toMatchObject({ type: "cast", instanceId: "LB" });
|
||||
@@ -924,10 +950,10 @@ describe("lessons from the human duels", () => {
|
||||
});
|
||||
|
||||
describe("the denial planner offers only castable blocks", () => {
|
||||
it("tacks at range are never proposed — the RNRX coma", () => {
|
||||
// Room RNRX froze Automaton II for three turns: pathDenial proposed
|
||||
// scattering tacks four squares away, the engine refused ("you must
|
||||
// be adjacent"), and the refusal-fallback loop read as a coma.
|
||||
it("tacks are offered only at the caster's feet", () => {
|
||||
// TACKS demand adjacency; a planner that proposes them at range has
|
||||
// its cast refused every turn, and the refusal-fallback loop reads
|
||||
// as an idle bot.
|
||||
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
|
||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||
@@ -941,14 +967,13 @@ describe("the denial planner offers only castable blocks", () => {
|
||||
const gold = state.treasures.find((t) => t.owner === "bot" && t.position)!;
|
||||
foe.position = { ...gold.position! };
|
||||
bot.position = { ...bot.home };
|
||||
bot.hand = [{ cardId: "handful-of-tacks", instanceId: "HT" } as never];
|
||||
bot.hand = [{ cardId: "handful-of-tacks", instanceId: "HT" }];
|
||||
for (let guard = 0; guard < 6; guard++) {
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
if (!cmd) break;
|
||||
const r = applyCommand(state, "bot", cmd);
|
||||
// Whatever the brain proposes, the engine must accept it.
|
||||
expect(r.ok, `refused: ${JSON.stringify(cmd)} — ${!r.ok ? r.error : ""}`).toBe(true);
|
||||
if (!r.ok) break;
|
||||
if (!r.ok) throw new Error(`brain proposed a refused command: ${JSON.stringify(cmd)} — ${r.error}`);
|
||||
state = r.state;
|
||||
if (cmd.type === "endTurn") break;
|
||||
}
|
||||
|
||||
@@ -1186,12 +1186,13 @@ describe("the bent sight-trace shows AROUND THE CORNER's legs", () => {
|
||||
powerAttackPoints: 0, params: null, kind: "spell",
|
||||
counters: [], waitingOn: d.id, bentCorner: true,
|
||||
};
|
||||
// Wall the straight diagonal shut so only the bend can answer.
|
||||
state.edgeOverrides[edgeKey({ x: 2, y: 2 }, "S")] = "wall";
|
||||
state.edgeOverrides[edgeKey({ x: 2, y: 3 }, "E")] = "wall";
|
||||
const traced = stackSightTrace(viewFor(state, d.id));
|
||||
expect(traced).not.toBeNull();
|
||||
if (traced) {
|
||||
// Whether sight ran straight (free-angle found a gap) or bent, the
|
||||
// overlay has something to draw; a bend names its middle square.
|
||||
if (traced.bend) expect(traced.bend.mid).toBeDefined();
|
||||
}
|
||||
// The engine promises A workable corner, not which one.
|
||||
expect(traced?.bend).toBeDefined();
|
||||
expect(traced?.bend?.mid).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
|
||||
import {
|
||||
type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
|
||||
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers";
|
||||
@@ -709,7 +710,7 @@ describe("fear holds off monsters and unwilling feet alike", () => {
|
||||
damage: 0, maxDamage: 6, movesPerTurn: 3, movementUsed: 0,
|
||||
wallPassesPerTurn: 0, wallPassUsed: 0, attackUsed: false, justCreated: false,
|
||||
scorchedThisTurn: [],
|
||||
} as never);
|
||||
});
|
||||
for (const y of [5, 6, 7, 8]) state.edgeOverrides[edgeKey({ x: 2, y }, "S")] = "open";
|
||||
while (state.players[state.turn.activeIndex]!.id !== "a") {
|
||||
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
|
||||
@@ -749,7 +750,6 @@ describe("fear holds off monsters and unwilling feet alike", () => {
|
||||
// The closer step is the only way out: permitted.
|
||||
const r = applyCommand(state, "a", { type: "move", direction: "S" });
|
||||
if (!r.ok) throw new Error("escape step refused: " + r.error);
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -760,9 +760,9 @@ describe("creatures and the dimensional warp", () => {
|
||||
state.dimWarps.push({ a: { x: 1, y: 1 }, b: { x: 3, y: 8 } });
|
||||
state.creatures.push({
|
||||
id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 },
|
||||
life: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
|
||||
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0,
|
||||
} as never);
|
||||
damage: 0, maxDamage: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
|
||||
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
|
||||
});
|
||||
const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" });
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
@@ -779,9 +779,9 @@ describe("creatures and the dimensional warp", () => {
|
||||
state.squareContents[cellKey({ x: 3, y: 8 })] = { kind: "stone", damage: 0, createdBy: "b" };
|
||||
state.creatures.push({
|
||||
id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 },
|
||||
life: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
|
||||
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0,
|
||||
} as never);
|
||||
damage: 0, maxDamage: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
|
||||
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
|
||||
});
|
||||
const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain("stone");
|
||||
|
||||
@@ -488,7 +488,9 @@ describe("a held door is an open doorway to the eye", () => {
|
||||
// no longer on their threshold; clear their path to the doorway.
|
||||
const near = neighbor(cell, side);
|
||||
const far = neighbor(near, side);
|
||||
if (!boardView(state).cells[cellKey(far)]) return; // the maze ends here; geometry unavailable
|
||||
if (!boardView(state).cells[cellKey(far)]) {
|
||||
throw new Error("setup: seed 42 lost the hallway this rig stands in");
|
||||
}
|
||||
state.players.find((p) => p.id === pursuer)!.position = far;
|
||||
state.edgeOverrides[edgeKey(near, side)] = "open";
|
||||
const rm = giveCard(state, holder, "remove-lock");
|
||||
|
||||
@@ -396,6 +396,7 @@ function botRemark(room: Room, actor: string, events: { type: string; [k: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Spoken when a clockwork's chosen command was refused by the engine —
|
||||
* the table sees a stumble instead of an unexplained idle turn. */
|
||||
const HESITATION_LINES = [
|
||||
@@ -549,11 +550,12 @@ wss.on("connection", (socket) => {
|
||||
case "kickSeat": {
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
const problem = kickSeat(room, session.playerId, String(msg.name ?? ""));
|
||||
const kickName = String(msg.name ?? "");
|
||||
const problem = kickSeat(room, session.playerId, kickName);
|
||||
if (problem) return send(socket, { type: "error", message: problem });
|
||||
// A kicked live socket is set adrift so it cannot act on a seat it lost.
|
||||
for (const other of sessions) {
|
||||
if (other.roomId === room.id && other.playerId === msg.name) {
|
||||
if (other.roomId === room.id && other.playerId === kickName) {
|
||||
other.playerId = null;
|
||||
other.roomId = null;
|
||||
other.token = null;
|
||||
|
||||
@@ -54,11 +54,6 @@ export interface Room {
|
||||
|
||||
const rooms = new Map<string, Room>();
|
||||
|
||||
/** Rules revision new games are dealt under (stored games keep their own).
|
||||
* A rules change while games are live must bump this and gate the engine;
|
||||
* local hotseat games ride the engine's default and follow in lockstep. */
|
||||
const RULES_REV = CURRENT_RULES_REV;
|
||||
|
||||
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
/** Tokens live hashed at rest (memory and disk); clients hold the raw form. */
|
||||
@@ -164,7 +159,7 @@ export function kickSeat(room: Room, byId: PlayerId, name: PlayerId): string | n
|
||||
room.tokens.delete(name);
|
||||
room.bots.delete(name);
|
||||
room.colorChoices.delete(name);
|
||||
appendLine(room.id, { kind: "kick", name });
|
||||
appendLine(room.id, { kind: "kick", name, at: new Date().toISOString() });
|
||||
recordRoom(room);
|
||||
return null;
|
||||
}
|
||||
@@ -228,9 +223,9 @@ function startInMemory(room: Room, expansion: boolean, colors?: number[], deckRe
|
||||
export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } {
|
||||
if (room.state) return { error: "already started" };
|
||||
const colors = resolveColors(room);
|
||||
const result = startInMemory(room, expansion, colors, RULES_REV);
|
||||
const result = startInMemory(room, expansion, colors, CURRENT_RULES_REV);
|
||||
if ("error" in result) return result;
|
||||
appendLine(room.id, { kind: "start", expansion, colors, deckRev: RULES_REV });
|
||||
appendLine(room.id, { kind: "start", expansion, colors, deckRev: CURRENT_RULES_REV });
|
||||
recordRoom(room);
|
||||
return result;
|
||||
}
|
||||
@@ -320,22 +315,22 @@ function actingSeat(room: Room): PlayerId | null {
|
||||
*/
|
||||
export function driveOneAutomaton(
|
||||
room: Room,
|
||||
): { seat: PlayerId; events: GameEvent[]; hesitated?: { refused: Command; error: string } } | null {
|
||||
): { seat: PlayerId; events: GameEvent[]; hesitated?: true } | null {
|
||||
const seat = actingSeat(room);
|
||||
if (!seat || !room.bots.has(seat)) return null;
|
||||
const view = viewFor(room.state!, seat);
|
||||
const bot = room.bots.get(seat);
|
||||
const chosen = automatonCommand(view, bot?.style, bot?.tier);
|
||||
let hesitated: { refused: Command; error: string } | undefined;
|
||||
let hesitated: true | undefined;
|
||||
let r = runCommand(room, seat, chosen ?? automatonFallback(view, bot?.tier));
|
||||
if ("error" in r) {
|
||||
// A refused choice retries with the fallback — unless the fallback IS
|
||||
// what just failed — then burns down the ladder to endTurn and pass.
|
||||
// The refusal is remembered: a brain whose choice the engine rejects
|
||||
// is a bug in the brain, and the table deserves to see the stumble
|
||||
// rather than an unexplained idle turn (the RNRX coma lesson).
|
||||
// rather than an unexplained idle turn.
|
||||
if (chosen) {
|
||||
hesitated = { refused: chosen, error: r.error };
|
||||
hesitated = true;
|
||||
console.warn(
|
||||
`automaton ${seat} hesitated in ${room.id}: ${JSON.stringify(chosen)} refused (${r.error})`);
|
||||
r = runCommand(room, seat, automatonFallback(view, bot?.tier));
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface KickLine {
|
||||
kind: "kick";
|
||||
/** The seat the host removed from an unstarted room. */
|
||||
name: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface AbandonLine {
|
||||
@@ -119,7 +120,7 @@ export function readAllRooms(): Map<string, RoomLine[]> {
|
||||
/** Retire an abandoned room's ledger to the graveyard — never deleted,
|
||||
* only moved out of the living rooms directory. */
|
||||
export function archiveRoomFile(roomId: string): void {
|
||||
const src = join(DATA_DIR, `${roomId}.jsonl`);
|
||||
const src = fileFor(roomId);
|
||||
if (!existsSync(src)) return;
|
||||
const graveyard = join(DATA_DIR, "..", "rooms-abandoned");
|
||||
mkdirSync(graveyard, { recursive: true });
|
||||
|
||||
@@ -120,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;
|
||||
@@ -596,19 +608,7 @@
|
||||
{/if}
|
||||
</g>
|
||||
{#if povFacing && !povCreatureId && p.id === view.you}
|
||||
<!-- A short wide ribbon hugging the token's leading edge: base as
|
||||
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"
|
||||
/>
|
||||
<polygon points={facingWedge(povFacing, CELL * 0.3 * wizardScale(p.id))} class="pov-wedge" />
|
||||
{/if}
|
||||
</g>
|
||||
{/each}
|
||||
@@ -658,16 +658,7 @@
|
||||
</g>
|
||||
{#if povFacing && povCreatureId === c.id}
|
||||
<!-- 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}
|
||||
{@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"
|
||||
/>
|
||||
<polygon points={facingWedge(povFacing, CELL * 0.26)} class="pov-wedge" />
|
||||
{/if}
|
||||
</g>
|
||||
{/each}
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
// here first.
|
||||
import FirstPerson from "./fpv/FirstPerson.svelte";
|
||||
import type { FpvTarget } from "./fpv/raycast";
|
||||
import { tokenArt } from "./art";
|
||||
import { objectArt } from "./art";
|
||||
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";
|
||||
@@ -108,12 +108,7 @@
|
||||
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;
|
||||
let deepest = -1, face = 0;
|
||||
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;
|
||||
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
|
||||
@@ -164,11 +159,15 @@
|
||||
let manualUntil = 0;
|
||||
let turning = false;
|
||||
|
||||
/** Manual steering holds the director's idle aims off this long. */
|
||||
const STEER_HOLD_MS = 4000;
|
||||
/** Mounting holds them off for the whole ride. */
|
||||
const RIDE_HOLD_MS = 60000;
|
||||
const SIDES4 = ["E", "S", "W", "N"] as const;
|
||||
function manualTurn(dir: -1 | 1) {
|
||||
if (turning) return;
|
||||
turning = true;
|
||||
manualUntil = performance.now() + 4000;
|
||||
manualUntil = performance.now() + STEER_HOLD_MS;
|
||||
const from = cam.facing;
|
||||
const to = from + dir * (Math.PI / 2);
|
||||
const t0 = performance.now();
|
||||
@@ -203,7 +202,7 @@
|
||||
|
||||
function manualStride(back: boolean) {
|
||||
if (!canStride) return;
|
||||
manualUntil = performance.now() + 4000;
|
||||
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);
|
||||
@@ -321,15 +320,12 @@
|
||||
const willCut = !camReady || (dist > 1.6 && !hurled);
|
||||
cutawayShot = false;
|
||||
const takeCutaway = (ax: number, ay: number) => {
|
||||
let best = { d: -1, a: 0 };
|
||||
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
||||
const h = castRay(v, ax, ay, a);
|
||||
if (h.dist > best.d) best = { d: h.dist, a };
|
||||
}
|
||||
const back = Math.min(1.6, Math.max(0.35, best.d - 0.4));
|
||||
cam.x = ax + Math.cos(best.a) * back;
|
||||
cam.y = ay + Math.sin(best.a) * back;
|
||||
cam.facing = best.a + Math.PI;
|
||||
const a = deepestFacing(v, ax, ay);
|
||||
const d = castRay(v, ax, ay, a).dist;
|
||||
const back = Math.min(1.6, Math.max(0.35, d - 0.4));
|
||||
cam.x = ax + Math.cos(a) * back;
|
||||
cam.y = ay + Math.sin(a) * back;
|
||||
cam.facing = a + Math.PI;
|
||||
camReady = true;
|
||||
cutawayShot = true;
|
||||
};
|
||||
@@ -372,11 +368,7 @@
|
||||
// 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.
|
||||
let deepest = -1;
|
||||
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
||||
const h = castRay(v, tx, ty, a);
|
||||
if (h.dist > deepest) { deepest = h.dist; targetFacing = a; }
|
||||
}
|
||||
targetFacing = deepestFacing(v, tx, ty);
|
||||
}
|
||||
if (willCut) {
|
||||
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
|
||||
@@ -424,8 +416,8 @@
|
||||
|
||||
{#if me}
|
||||
<div class="live-fp">
|
||||
<!-- The bezel: instruments frame the windshield instead of covering
|
||||
it — every canvas pixel stays target glass. -->
|
||||
<!-- The bezel: instruments live in the frame, never on the glass —
|
||||
every canvas pixel is target. -->
|
||||
<div class="bezel-top">
|
||||
{#if possessed}
|
||||
<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>
|
||||
{:else if rideable.length > 0 && (onCreatureMove || onCreatureAttack)}
|
||||
{#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, " ")}
|
||||
</button>
|
||||
{/each}
|
||||
@@ -459,10 +451,7 @@
|
||||
<button class="drive" onclick={() => manualTurn(1)} aria-label="turn right">›</button>
|
||||
</div>
|
||||
<div class="bezel-bottom">
|
||||
{#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 class="bezel-zone">
|
||||
{#if canStride && ((!possessed && atFeet.length > 0) || underfoot.hazard || underfoot.warpHere)}
|
||||
<span class="feet-label">at your feet</span>
|
||||
{#if !possessed && onpickup}
|
||||
@@ -484,8 +473,16 @@
|
||||
<button class="stamp tiny" onclick={() => onWarpStep?.(possessedId)}>⇋ step through the warp</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<span class="bezel-spacer"></span>
|
||||
<span class="live-fp-hint">← → turn · ↑ ↓ walk</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>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -508,6 +505,9 @@
|
||||
.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;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { scheduleFx, type BoardFx } from "./fx";
|
||||
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
||||
import { castRay, edgeMid } from "./fpv/raycast";
|
||||
import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
|
||||
import { deepestFacing, aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
|
||||
import { prefs } from "./prefs.svelte";
|
||||
import { stackSightTrace } from "@wizwar/engine";
|
||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||
@@ -249,15 +249,12 @@
|
||||
* corridor, facing it, holding a beat of the world-before so the
|
||||
* deed happens ON screen — with the reel's own wizard visible. */
|
||||
const takeCutaway = (ax: number, ay: number) => {
|
||||
let best = { d: -1, a: 0 };
|
||||
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
||||
const h = castRay(v, ax, ay, a);
|
||||
if (h.dist > best.d) best = { d: h.dist, a };
|
||||
}
|
||||
const back = Math.min(1.6, Math.max(0.35, best.d - 0.4));
|
||||
cam.x = ax + Math.cos(best.a) * back;
|
||||
cam.y = ay + Math.sin(best.a) * back;
|
||||
cam.facing = best.a + Math.PI;
|
||||
const a = deepestFacing(v, ax, ay);
|
||||
const d = castRay(v, ax, ay, a).dist;
|
||||
const back = Math.min(1.6, Math.max(0.35, d - 0.4));
|
||||
cam.x = ax + Math.cos(a) * back;
|
||||
cam.y = ay + Math.sin(a) * back;
|
||||
cam.facing = a + Math.PI;
|
||||
camReady = true;
|
||||
cutawayShot = true;
|
||||
const i2 = Math.min(idx, steps.length - 1);
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
// GameView. Columns of wall shaded by distance and facing; token art
|
||||
// billboarded for whatever stands in the corridors, occluded per column
|
||||
// by the same depth buffer the walls wrote.
|
||||
import {
|
||||
warpMotion,
|
||||
type FpvTarget, castRay, billboards } from "./raycast";
|
||||
import { billboards, castRay, warpMotion, type FpvTarget } from "./raycast";
|
||||
import { materialTextures } from "./textures";
|
||||
import { doorOpenness, fxFallback, growProgress, type FpFx } from "./fx3d";
|
||||
import { terrainFallback, TERRAIN3D } from "./terrain3d";
|
||||
@@ -59,8 +57,23 @@
|
||||
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;
|
||||
@@ -403,7 +416,10 @@
|
||||
if (s) sprites.push({ ...s, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art });
|
||||
}
|
||||
sprites.sort((a, b) => b.sort - a.sort);
|
||||
hitFrame = { W, H, ex, ey, facing, zbuf, warpIdCol, warpDistCol, cols: hitCols, sprites };
|
||||
hitFrame = {
|
||||
W, H, ex, ey, facing, zbuf, warpIdCol, warpDistCol, cols: hitCols,
|
||||
sprites: [...sprites].sort((a, b) => a.depth - b.depth),
|
||||
};
|
||||
// The nearest sprite each column carries — depth AND vertical span —
|
||||
// so the overlay passes (veils, ghosts, lintels, risings) can paint
|
||||
// around a body standing in front of them instead of over it.
|
||||
@@ -433,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;
|
||||
@@ -590,6 +602,8 @@
|
||||
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);
|
||||
@@ -716,7 +730,7 @@
|
||||
* (or vault) square the pixel lies on — warp-bent columns mapping
|
||||
* their virtual ground back to real cells through the warp's own
|
||||
* rigid motion. */
|
||||
export function hitTest(px: number, py: number): FpvTarget | null {
|
||||
function hitTest(px: number, py: number): FpvTarget | null {
|
||||
return resolveHover(px, py)?.target ?? null;
|
||||
}
|
||||
|
||||
@@ -728,7 +742,8 @@
|
||||
shape:
|
||||
| { kind: "rect"; left: number; right: number; top: number; bottom: number }
|
||||
| { kind: "face"; edge: string }
|
||||
| { kind: "ground"; cell: { x: number; y: number } };
|
||||
| { kind: "ground"; cell: { x: number; y: number } }
|
||||
| { kind: "none" };
|
||||
} | null {
|
||||
const f = hitFrame;
|
||||
if (!f) return null;
|
||||
@@ -737,15 +752,10 @@
|
||||
|
||||
// Sprites first, nearest first, honoring the draw pass's own
|
||||
// visibility rules for this column.
|
||||
const byDepth = [...f.sprites].sort((a, b) => a.depth - b.depth);
|
||||
for (const sp of byDepth) {
|
||||
for (const sp of f.sprites) {
|
||||
if (!sp.hit && !sp.cell) continue; // pure spectacle (projectiles, rubble)
|
||||
if (px < sp.left || px >= sp.right || py < sp.top || py > sp.bottom) continue;
|
||||
if (sp.clampL !== undefined && (col < sp.clampL || col > sp.clampR!)) continue;
|
||||
if (sp.depth >= f.zbuf[col]!) continue;
|
||||
if (sp.warped) {
|
||||
if (f.warpIdCol[col] !== sp.warpId || sp.depth <= f.warpDistCol[col]!) continue;
|
||||
} else if (sp.depth >= f.warpDistCol[col]!) continue;
|
||||
if (!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 };
|
||||
@@ -783,7 +793,7 @@
|
||||
// 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: "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);
|
||||
return pt ? { target: { kind: "cell", cell: pt }, label: "", shape: { kind: "ground", cell: pt } } : null;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { edgeMid } from "./raycast";
|
||||
import { fpFxForEvents } from "./fx3d";
|
||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||
import { castRay } from "./raycast";
|
||||
|
||||
export function shortestArc(from: number, to: number): number {
|
||||
let d = (to - from) % (2 * Math.PI);
|
||||
@@ -131,3 +132,14 @@ export function gatherGlides(before: GameView, after: GameView, povId: string):
|
||||
}
|
||||
return moves;
|
||||
}
|
||||
|
||||
/** The facing that stares down the longest corridor from a square — the
|
||||
* default view wherever nothing asks to be watched. */
|
||||
export function deepestFacing(view: GameView, x: number, y: number): number {
|
||||
let deepest = -1, face = 0;
|
||||
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
||||
const h = castRay(view, x, y, a);
|
||||
if (h.dist > deepest) { deepest = h.dist; face = a; }
|
||||
}
|
||||
return face;
|
||||
}
|
||||
|
||||
@@ -225,7 +225,6 @@ export interface LogLine {
|
||||
* momentSteps, so a chronicle turn number addresses the same commands. */
|
||||
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
|
||||
|
||||
|
||||
const SEAT_KEY = "wizwar-seat";
|
||||
const SEATS_KEY = "wizwar-seats";
|
||||
const CHAT_SEEN_KEY = "wizwar-chat-seen";
|
||||
@@ -318,7 +317,6 @@ class Net {
|
||||
/** Turns witnessed so far: counts the same boundary events the server
|
||||
* counts, so a chronicle line can name the turn it belongs to. */
|
||||
private turnCounter = -1;
|
||||
/** The turn that already carries an instant-replay eye (one per turn). */
|
||||
/** The turn whose moment reel is open (share links point at it). */
|
||||
private momentTurn: number | null = null;
|
||||
private shareResolve: ((url: string) => void) | null = null;
|
||||
@@ -494,9 +492,8 @@ class Net {
|
||||
// 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 treating ignorance as
|
||||
// deletion once cost a live player his seat mid-game. The
|
||||
// wallet is capped by age instead of by trust.
|
||||
// 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);
|
||||
@@ -510,10 +507,9 @@ class Net {
|
||||
// "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 —
|
||||
// and burning credentials on a transient locked a real player
|
||||
// out of a live game. The seat stays; the error shows; a later
|
||||
// reconnect against the right server walks back in.
|
||||
// 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);
|
||||
}
|
||||
@@ -668,8 +664,9 @@ class Net {
|
||||
} catch { /* blocked at the OS level; the tab title still shows it */ }
|
||||
}
|
||||
|
||||
/** Forget the remembered seat and return to the lobby. */
|
||||
/** Client-side teardown when the SERVER detached us (kick, abandon). */
|
||||
/** Client-side teardown when the SERVER detached us (kick, abandon).
|
||||
* The chronicle survives: the detachment appended its own explanatory
|
||||
* line, and wiping it would erase the only notice of why. */
|
||||
leaveLocal(): void {
|
||||
localStorage.removeItem(SEAT_KEY);
|
||||
this.roomId = null;
|
||||
@@ -682,18 +679,11 @@ class Net {
|
||||
this.audience = 0;
|
||||
}
|
||||
|
||||
/** Walk away from the table (or gallery) by choice. */
|
||||
leave(): void {
|
||||
this.send({ type: "leave" }); // detach server-side too (frees a gallery seat)
|
||||
localStorage.removeItem(SEAT_KEY);
|
||||
this.roomId = null;
|
||||
this.roomIdPending = null;
|
||||
this.view = null;
|
||||
this.started = false;
|
||||
this.players = [];
|
||||
this.leaveLocal();
|
||||
this.resetChronicle();
|
||||
this.token = null;
|
||||
this.spectating = false;
|
||||
this.audience = 0;
|
||||
}
|
||||
|
||||
start(expansion: boolean): void {
|
||||
|
||||
@@ -42,7 +42,7 @@ function renderView(v) {
|
||||
for (let x = 0; x < B.width; x++) {
|
||||
const k = `${x},${y}`;
|
||||
if (!B.cells[k]) { top += " "; mid += " "; continue; }
|
||||
const nEdge = y === 0 ? (B.edges[`H:${x},${y - 1}`] ?? "open") : (B.edges[`H:${x},${y - 1}`] ?? "open");
|
||||
const nEdge = B.edges[`H:${x},${y - 1}`] ?? "open";
|
||||
const north = y === 0 ? "wall" : nEdge; // rim renders solid; warps noted separately
|
||||
top += "+" + (north === "wall" ? "————" : north === "door" ? "—DD—" : north === "firewall" ? "~FF~" : " ");
|
||||
const wEdge = x === 0 ? "wall" : (B.edges[`V:${x - 1},${y}`] ?? "open");
|
||||
|
||||
Reference in New Issue
Block a user