WARD's text says "you may play at that time" — under rules revision 3 the choice is made by setting the trap: an armWard command toggles it on your own turn (your secret; a private wardSet event and a rail note), and the spring fires only while set, consuming the card and the arming together. Async games keep their agency without an interrupt window, exactly as the ambush system solved this before. CHAOS gains its printed interactions: "FULL SHIELD removes a player from participation" — a defender's shield sits them out instead of stopping the spell, and after the counter chain every bystander gets a shield window in seat order (chaosPending; queue head owes a response in lobby summaries, the rail says whose hand hangs in the balance) before the pile forms among the unshielded. "REFLECTIONS have no effect" — both reflections are refused as counters. ABSORB SPELL still eats the whole thing through the existing absorb path. Both changes are frozen behind deckRev 3 so every stored game — and the live one — replays byte-for-byte under its own rules. Five new tests cover armed/unarmed wards, shielded defenders and bystanders, the reflection refusal, and the legacy auto-ward. Not deployed — a live game is in progress. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
473 lines
22 KiB
TypeScript
473 lines
22 KiB
TypeScript
// Websocket client + reactive session state (Svelte 5 runes).
|
|
|
|
import type { Command, GameEvent, GameView } from "@wizwar/engine";
|
|
import { cardDef } from "@wizwar/engine";
|
|
|
|
const SERVER_URL =
|
|
import.meta.env.VITE_WIZWAR_SERVER ??
|
|
(location.hostname === "localhost"
|
|
? "ws://localhost:8787"
|
|
: `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`);
|
|
|
|
export function humanize(e: GameEvent): string | null {
|
|
switch (e.type) {
|
|
case "gameStarted": return `Game started — ${e.players.join(", ")}. ${e.firstPlayer} goes first.`;
|
|
case "turnStarted": return `— ${e.player}'s turn (round ${e.round}) —`;
|
|
case "turnSkipped": return `${e.player} loses a turn.`;
|
|
case "extraTurnStarted": return `${e.player} takes an extra turn!`;
|
|
case "moved": return null; // too chatty for the log
|
|
case "numberPlayedForMovement": return `${e.player} plays a ${e.value} for movement (${e.newAllowance} total).`;
|
|
case "punched": return `${e.attacker} punches ${e.target}!`;
|
|
case "spellCast": {
|
|
const num = e.numberValue ? ` with a ${e.numberValue}` : "";
|
|
const at = e.target ? ` at ${e.target}` : "";
|
|
return `${e.caster} casts ${cardDef(e.cardId).name}${num}${at}.`;
|
|
}
|
|
case "counteractionPlayed": return `${e.player} counters with ${cardDef(e.cardId).name}!`;
|
|
case "counterNullified": return `${cardDef(e.card.cardId).name} is nullified by Anti-Anti!`;
|
|
case "attackAbsorbedIntoHand": return `${e.player} absorbs the spell into their hand!`;
|
|
case "attackResolved":
|
|
if (e.redirected) return `The spell is reflected back at ${e.attacker}!`;
|
|
if (e.fullyStopped) return `The attack is completely stopped.`;
|
|
return null; // the damaged event tells the story
|
|
case "damaged": return `${e.player} takes ${e.amount} damage (${e.source}) — ${e.lifeAfter} life left.`;
|
|
case "stunned": return `${e.player} is stunned and loses a turn!`;
|
|
case "knockedBack": return `${e.player} is knocked back ${e.squares} square(s)!`;
|
|
case "stonesDestroyed": return `${e.player}'s magic stones are destroyed!`;
|
|
case "wallCreated": return `A wall appears!`;
|
|
case "wallDestroyed": return e.wasDoor ? `A door is blasted to rubble!` : `A wall crumbles!`;
|
|
case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`;
|
|
case "trapSprung": return `${e.player} walked into an old TRAP! Lose a turn.`;
|
|
case "attackMissed": return e.because === "invisible"
|
|
? `The attack passes through empty air — ${e.defender} is invisible!`
|
|
: `${e.defender} is too small to hit — the attack misses!`;
|
|
case "damageImmune": return `${e.player} is stone — the damage has no effect.`;
|
|
case "lifeGained": return `${e.player} gains ${e.amount} life (${e.source}) — now ${e.lifeAfter}.`;
|
|
case "spellSustained": return `${cardDef(e.cardId).name} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`;
|
|
case "spellExpired": return `${cardDef(e.cardId).name} wears off ${e.target}.`;
|
|
case "teleported": return e.by === e.player
|
|
? `${e.player} teleports across the maze!`
|
|
: `${e.player} is teleported away by ${e.by}!`;
|
|
case "positionsSwapped": return `${e.a} and ${e.b} swap places!`;
|
|
case "cardErased": return e.found
|
|
? `${e.player}'s ${e.cardId ? cardDef(e.cardId).name : "card"} is erased from their mind!`
|
|
: `${e.player} wasn't holding that card — the erasure fizzles.`;
|
|
case "cardsStolen": return `${e.to} steals ${e.count} card(s) from ${e.from}'s thoughts!`;
|
|
case "handRevealed": return `${e.to} reads ${e.player}'s mind — their hand is revealed.`;
|
|
case "doorUnlocked": return `${e.player} unlocks a door.`;
|
|
case "doorsRelocked": return `The door swings shut and relocks.`;
|
|
case "doorJammed": return `${e.player} jams a door's lock solid.`;
|
|
case "lockRemoved": return `${e.player} removes a door's lock for good.`;
|
|
case "cardDisplayed": return `${e.player} displays ${cardDef(e.card.cardId).name}.`;
|
|
case "lifeTraded": return `${e.player} burns ${e.points} life for speed!`;
|
|
case "castAroundCorner": return `The spell bends around the corner!`;
|
|
case "moveBumped": return `${e.player} blunders into a wall!`;
|
|
case "attackMisdirected": return e.newTarget
|
|
? `${e.attacker}'s blind attack veers off — and hits ${e.newTarget}!`
|
|
: `${e.attacker}'s blind attack flies off into the darkness.`;
|
|
case "retreatedInHorror": return `${e.player} flees the hideous sight!`;
|
|
case "illusionWallCreated": return `A wall appears... or does it?`;
|
|
case "illusionTested": return e.result === "seesThrough"
|
|
? `${e.player} sees right through the illusion!`
|
|
: `${e.player} is convinced the wall is real.`;
|
|
case "sectorRotated": return `The maze GRINDS — a sector rotates ${e.clockwise ? "clockwise" : "counterclockwise"}!`;
|
|
case "sectorRelocated": return `The maze SHUDDERS — an entire sector slides away!`;
|
|
case "creatureCreated": return `${e.controller} summons a ${e.kind.replace(/-/g, " ")}!`;
|
|
case "creatureMoved": return null;
|
|
case "creatureAttacked": return e.dieRoll != null ? `The troll swings (rolled ${e.dieRoll})!` : `The creature strikes!`;
|
|
case "creatureTouched": return `The creature falls upon ${e.player}!`;
|
|
case "creatureDamaged": return e.amount > 0 ? `The ${e.creatureId} takes ${e.amount} damage.` : `The attack has no effect on it.`;
|
|
case "creatureDestroyed": return `The ${e.kind.replace(/-/g, " ")} is destroyed (${e.by})!`;
|
|
case "trollRegenerated": return `The troll's stony hide knits itself back together.`;
|
|
case "shadowUpkeep": return `The shadow drains its master (${e.lifeAfter} life left).`;
|
|
case "impScorches": return `The fire imp scorches ${e.player}!`;
|
|
case "monsterBoosted": return `The monster GROWS — its ${e.boost} doubles!`;
|
|
case "wandCharged": return `${e.player} charges a wand (${e.charges} charges).`;
|
|
case "wandUsed": return e.chargesLeft > 0 ? `The wand crackles (${e.chargesLeft} left).` : null;
|
|
case "wandExhausted": return `${e.player}'s wand crumbles to dust.`;
|
|
case "wallWarpedOpen": return `A section of wall shimmers out of existence!`;
|
|
case "wallsWarpedBack": return `The warped wall snaps back into place.`;
|
|
case "shoved": return `${e.player} is shoved bodily by ${e.by}!`;
|
|
case "webbed": return `${e.player} is tangled in sticky webs!`;
|
|
case "cardRetrieved": return `${e.player} plucks a card from the discard pile.`;
|
|
case "slippedInOoze": return `${e.player} slips flat on their face in the ooze!`;
|
|
case "struggledInOoze": return e.stood ? `${e.player} staggers upright.` : `${e.player} flounders in the ooze.`;
|
|
case "steppedOnTacks": return `${e.player} steps on tacks! OW OW OW.`;
|
|
case "jumpedPit": return `${e.player} leaps the pit!`;
|
|
case "fellInPit": return `${e.player} misjudges the jump and plummets in!`;
|
|
case "climbedFromPit": return e.success ? `${e.player} hauls themselves out of the pit.` : `${e.player} scrabbles at the pit walls in vain.`;
|
|
case "stuckInSlime": return `${e.player} squelches into the slime and sticks fast.`;
|
|
case "boobytrapPlaced": return `${e.caster} places four suspicious tokens...`;
|
|
case "boobytrapSprung": return `SNAP! ${e.player} finds the real boobytrap!`;
|
|
case "objectsGlued": return `Everything on that square is glued down (${e.turns} turns).`;
|
|
case "safeCreated": return `A massive safe slams down around the loot.`;
|
|
case "safeOpened": return null;
|
|
case "itemsTraded": return `Two items blink and trade places.`;
|
|
case "stoneTurnedToWater": return `Stone runs like water — a wave crashes out!`;
|
|
case "handsSwapped": return `${e.a} and ${e.b} trade entire hands of cards!`;
|
|
case "handsScrambled": return `CHAOS! Every hand is thrown in a pile and redealt!`;
|
|
case "rammed": return `BAAA! ${e.attacker} turns into a goat and rams ${e.target} (${e.distance} spaces)!`;
|
|
case "treasureThrown": return `${e.attacker} HURLS their treasure (${e.distance} spaces)!`;
|
|
case "illusionBelieved": return e.believed ? `${e.player} flinches — the illusion feels real!` : `${e.player} laughs off the illusion.`;
|
|
case "itemStolen": return `${e.to} picks ${e.from}'s pocket.`;
|
|
case "itemsSwapped": return `${e.a} and ${e.b} swap items.`;
|
|
case "wardSprung": return `${e.owner}'s treasure was WARDED — it bites ${e.victim}!`;
|
|
case "curseRemoved": return `${e.caster} lifts a curse from ${e.target}.`;
|
|
case "objectEnchanted": return `An object gleams with Swarthmore's enchantment.`;
|
|
case "warpTokensPlaced": return `Two dimensional warp tokens hum to life.`;
|
|
case "warpStepped": return `${e.player} steps through the dimensional warp!`;
|
|
case "exitsRedirected": return `The maze's outer exits twist and reconnect!`;
|
|
case "outOfTurnWindow": return `${e.player} interrupts the flow of time (${e.kind === "interrupt" ? "Interrupt" : "Opportunity Fire"})!`;
|
|
case "thumbOfGod": return `THE THUMB OF GOD descends! The die crashes down${e.aimedAt.x === e.landedAt.x && e.aimedAt.y === e.landedAt.y ? " dead on target" : " — and drifts"}!`;
|
|
case "tokenScattered": return `${e.what} goes flying!`;
|
|
case "ambushSet": return `You commit ${e.spell} to an ambush (${e.via}).`;
|
|
case "ambushCancelled": return `You quietly disarm your ambush.`;
|
|
case "ambushSprung": return `AMBUSH! ${e.owner}'s hidden ${e.via.replace(/-/g, " ")} springs on ${e.victim}!`;
|
|
case "trapRedrawnDuringDeal": return null;
|
|
case "died": return `☠ ${e.player} is dead${e.killedBy ? ` — killed by ${e.killedBy}` : ""}.`;
|
|
case "handTaken": return `${e.to} takes ${e.count} cards from ${e.from}'s body.`;
|
|
case "treasurePickedUp": return `${e.player} grabs ${e.owner}'s treasure!`;
|
|
case "objectDropped": return `${e.player} sets down the ${cardDef(e.card.cardId).name}${e.forced ? " (forced)" : ""}.`;
|
|
case "objectPickedUp": return `${e.player} picks up the ${cardDef(e.card.cardId).name} — actions over.`;
|
|
case "wardSet": return e.armed ? "Your ward is set — the next thief bleeds." : "Your ward stands down.";
|
|
case "chaosShielded": return `${e.player} raises a FULL SHIELD and sits out the chaos.`;
|
|
case "wallDamaged": {
|
|
const what = e.needed === 15 ? "door" : "wall";
|
|
return `${e.player} batters the ${what} with ${e.source === "punch" ? "bare fists" : cardDef(e.source).name} — ${e.total}/${e.needed}.`;
|
|
}
|
|
case "treasureDropped": return e.onHomeOf ? `${e.player} drops a treasure on ${e.onHomeOf}'s home base!` : `${e.player} drops a treasure.`;
|
|
case "playerEliminated": return e.reason === "treasuresLost" ? `${e.player} is eliminated — both treasures lost!` : null;
|
|
case "cardsDiscarded": return `${e.player} discards ${e.cards.length} card(s).`;
|
|
case "cardsDrawn": return e.count > 0 ? `${e.player} draws ${e.count} card(s).` : null;
|
|
case "deckReshuffled": return `The discard pile is reshuffled (${e.size} cards).`;
|
|
case "turnEnded": return null;
|
|
case "gameWon": return `🏆 ${e.player} WINS ${e.reason === "treasures" ? "by treasure!" : "— last wizard standing!"}`;
|
|
default: return null;
|
|
}
|
|
}
|
|
|
|
const SEAT_KEY = "wizwar-seat";
|
|
const SEATS_KEY = "wizwar-seats";
|
|
const SEEN_KEY = "wizwar-seen";
|
|
|
|
function loadSeen(): Record<string, number> {
|
|
try { return JSON.parse(localStorage.getItem(SEEN_KEY) ?? "{}"); }
|
|
catch { return {}; }
|
|
}
|
|
|
|
export interface Seat { name: string; roomId: string; token: string }
|
|
export interface GameSummary {
|
|
roomId: string;
|
|
name: string;
|
|
players: string[];
|
|
started: boolean;
|
|
finished: boolean;
|
|
winner: string | null;
|
|
activePlayerId: string | null;
|
|
yourTurn: boolean;
|
|
attention: "turn" | "counteract" | "discard" | "interrupt" | null;
|
|
round: number | null;
|
|
lastMoveAt: string | null;
|
|
}
|
|
|
|
export function attentionLabel(a: GameSummary["attention"]): string {
|
|
switch (a) {
|
|
case "counteract": return "UNDER ATTACK — respond!";
|
|
case "discard": return "DISCARD to your hand limit";
|
|
case "interrupt": return "your interruption is waiting";
|
|
default: return "YOUR TURN";
|
|
}
|
|
}
|
|
|
|
function loadSeats(): Seat[] {
|
|
try { return JSON.parse(localStorage.getItem(SEATS_KEY) ?? "[]"); }
|
|
catch { return []; }
|
|
}
|
|
function saveSeats(seats: Seat[]): void {
|
|
localStorage.setItem(SEATS_KEY, JSON.stringify(seats));
|
|
}
|
|
|
|
class Net {
|
|
status = $state<"disconnected" | "connected">("disconnected");
|
|
roomId = $state<string | null>(null);
|
|
players = $state<string[]>([]);
|
|
hostId = $state<string | null>(null);
|
|
started = $state(false);
|
|
/** Lobby standee choices, by player name. */
|
|
roomColors = $state<Record<string, number>>({});
|
|
you = $state<string | null>(null);
|
|
view = $state<GameView | null>(null);
|
|
log = $state<string[]>([]);
|
|
error = $state<string | null>(null);
|
|
/** Every seat this browser holds, across rooms. */
|
|
seats = $state<Seat[]>(loadSeats());
|
|
/** Lobby ledger: one summary per live seat. */
|
|
stats = $state<Record<string, number | string | null> | null>(null);
|
|
games = $state<GameSummary[]>([]);
|
|
notificationsEnabled = $state(
|
|
typeof Notification !== "undefined" && Notification.permission === "granted",
|
|
);
|
|
/** A transfer phrase we minted, to show the user. */
|
|
transferCode = $state<{ code: string; expiresAt: number } | null>(null);
|
|
/** Moves you haven't watched yet in the current room. */
|
|
missedMoves = $state(0);
|
|
/** A catch-up reel delivered by the server. */
|
|
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView }[] | null>(null);
|
|
private seen: Record<string, number> = loadSeen();
|
|
private currentSeq = 0;
|
|
private lastYourTurn = new Map<string, boolean>();
|
|
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
private ws: WebSocket | null = null;
|
|
private token: string | null = null;
|
|
private roomIdPending: string | null = null;
|
|
|
|
connect(): void {
|
|
if (this.ws) return;
|
|
const ws = new WebSocket(SERVER_URL);
|
|
this.ws = ws;
|
|
ws.onopen = () => {
|
|
this.status = "connected";
|
|
// Mid-game reconnects (the socket dropped, not the page) walk straight
|
|
// back to the table; otherwise the lobby ledger is the front door.
|
|
const saved = localStorage.getItem(SEAT_KEY);
|
|
if (saved && this.roomId) {
|
|
try {
|
|
const seat = JSON.parse(saved) as { name: string; roomId: string; token: string };
|
|
if (seat.roomId === this.roomId) {
|
|
this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
|
|
}
|
|
} catch { localStorage.removeItem(SEAT_KEY); }
|
|
}
|
|
this.refreshGames();
|
|
};
|
|
ws.onclose = () => {
|
|
this.status = "disconnected";
|
|
this.ws = null;
|
|
setTimeout(() => this.connect(), 1500);
|
|
};
|
|
ws.onmessage = (raw) => {
|
|
const msg = JSON.parse(raw.data as string);
|
|
switch (msg.type) {
|
|
case "seat": {
|
|
this.token = msg.token;
|
|
const seatRoom = (this.roomIdPending ?? this.roomId ?? "").toUpperCase();
|
|
if (seatRoom) {
|
|
const seat: Seat = { name: msg.playerId, roomId: seatRoom, token: msg.token };
|
|
localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
|
|
this.rememberSeat(seat);
|
|
}
|
|
break;
|
|
}
|
|
case "room":
|
|
this.roomId = msg.roomId;
|
|
this.roomColors = msg.colors ?? {};
|
|
if (this.you && this.token) {
|
|
const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
|
|
localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
|
|
this.rememberSeat(seat);
|
|
}
|
|
this.players = msg.players;
|
|
this.hostId = msg.hostId;
|
|
this.started = msg.started;
|
|
break;
|
|
case "state": {
|
|
this.view = msg.view;
|
|
if (typeof msg.seq === "number" && this.roomId) {
|
|
this.currentSeq = msg.seq;
|
|
const last = this.seen[this.roomId] ?? 0;
|
|
this.missedMoves = Math.max(0, msg.seq - last);
|
|
// Watching live counts as seeing; only a fresh arrival has a gap.
|
|
if (this.missedMoves === 0 || document.visibilityState === "visible") {
|
|
// A live update while present marks itself seen.
|
|
if (last >= msg.seq - 1) this.markSeen();
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case "catchUp":
|
|
this.catchUp = msg.steps;
|
|
break;
|
|
case "events":
|
|
for (const e of msg.events as GameEvent[]) {
|
|
const line = humanize(e);
|
|
if (line) this.log = [...this.log, line];
|
|
}
|
|
break;
|
|
case "transferCode":
|
|
this.transferCode = { code: msg.code, expiresAt: msg.expiresAt };
|
|
break;
|
|
case "transferClaimed": {
|
|
const seat = msg.seat as Seat & { roomId: string };
|
|
this.rememberSeat({ name: seat.name, roomId: seat.roomId, token: seat.token });
|
|
this.refreshGames();
|
|
this.resume({ name: seat.name, roomId: seat.roomId, token: seat.token });
|
|
break;
|
|
}
|
|
case "stats": {
|
|
this.stats = msg.stats;
|
|
break;
|
|
}
|
|
case "games": {
|
|
this.games = msg.games;
|
|
for (const g of msg.games as GameSummary[]) {
|
|
const key = `${g.roomId}:${g.name}`;
|
|
const was = this.lastYourTurn.get(key) ?? false;
|
|
if (g.yourTurn && !was) this.notifyTurn(g);
|
|
this.lastYourTurn.set(key, g.yourTurn);
|
|
}
|
|
break;
|
|
}
|
|
case "error":
|
|
if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) {
|
|
localStorage.removeItem(SEAT_KEY);
|
|
this.roomIdPending = null;
|
|
}
|
|
this.error = msg.message;
|
|
setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000);
|
|
break;
|
|
}
|
|
};
|
|
}
|
|
|
|
private send(message: unknown): void {
|
|
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message));
|
|
}
|
|
|
|
create(name: string): void {
|
|
this.you = name;
|
|
this.send({ type: "create", name });
|
|
}
|
|
|
|
join(roomId: string, name: string): void {
|
|
this.you = name;
|
|
this.roomIdPending = roomId.toUpperCase();
|
|
const existing = this.seats.find(
|
|
(s) => s.roomId === this.roomIdPending && s.name === name,
|
|
);
|
|
this.send({ type: "join", roomId, name, token: existing?.token ?? this.token });
|
|
}
|
|
|
|
/** Sit back down at a remembered seat. */
|
|
resume(seat: Seat): void {
|
|
this.you = seat.name;
|
|
this.token = seat.token;
|
|
this.roomIdPending = seat.roomId;
|
|
this.log = [];
|
|
this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
|
|
}
|
|
|
|
private rememberSeat(seat: Seat): void {
|
|
const rest = this.seats.filter((s) => !(s.roomId === seat.roomId && s.name === seat.name));
|
|
this.seats = [seat, ...rest];
|
|
saveSeats(this.seats);
|
|
}
|
|
|
|
/** Drop a finished (or abandoned) game from the ledger. */
|
|
forgetSeat(roomId: string): void {
|
|
this.seats = this.seats.filter((s) => s.roomId !== roomId);
|
|
saveSeats(this.seats);
|
|
this.games = this.games.filter((g) => g.roomId !== roomId);
|
|
}
|
|
|
|
requestTransferCode(): void {
|
|
this.send({ type: "makeTransfer" });
|
|
}
|
|
claimTransfer(code: string): void {
|
|
this.send({ type: "claimTransfer", code });
|
|
}
|
|
|
|
/** Ask the server how all our games are doing. */
|
|
requestStats(): void {
|
|
this.send({ type: "stats" });
|
|
}
|
|
|
|
/** Anonymous count-only ping so hotseat tables show in the tally. */
|
|
reportHotseat(report: {
|
|
id: string; stage: "started" | "finished";
|
|
players?: number; commands?: number; minutes?: number; winReason?: string;
|
|
}): void {
|
|
this.send({ type: "hotseatReport", ...report });
|
|
}
|
|
|
|
refreshGames(): void {
|
|
if (this.seats.length > 0) this.send({ type: "myGames", seats: $state.snapshot(this.seats) });
|
|
}
|
|
|
|
startGamePolling(): void {
|
|
if (this.pollTimer) return;
|
|
this.refreshGames();
|
|
this.pollTimer = setInterval(() => this.refreshGames(), 45_000);
|
|
}
|
|
stopGamePolling(): void {
|
|
if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; }
|
|
}
|
|
|
|
async enableNotifications(): Promise<void> {
|
|
if (typeof Notification === "undefined") return;
|
|
const perm = await Notification.requestPermission();
|
|
this.notificationsEnabled = perm === "granted";
|
|
}
|
|
|
|
private notifyTurn(g: GameSummary): void {
|
|
if (!this.notificationsEnabled || typeof Notification === "undefined") return;
|
|
if (this.roomId === g.roomId && !document.hidden) return; // already looking at it
|
|
try {
|
|
new Notification(
|
|
g.attention === "counteract"
|
|
? `Wiz-War — you are under attack in ${g.roomId}!`
|
|
: `Wiz-War — your turn in ${g.roomId}`,
|
|
{
|
|
body: `${g.players.join(" vs ")} · round ${g.round ?? "?"}`,
|
|
tag: `wizwar-${g.roomId}`,
|
|
},
|
|
);
|
|
} catch { /* blocked at the OS level; the tab title still shows it */ }
|
|
}
|
|
|
|
/** Forget the remembered seat and return to the lobby. */
|
|
leave(): void {
|
|
localStorage.removeItem(SEAT_KEY);
|
|
this.roomId = null;
|
|
this.roomIdPending = null;
|
|
this.view = null;
|
|
this.started = false;
|
|
this.players = [];
|
|
this.log = [];
|
|
this.token = null;
|
|
}
|
|
|
|
start(expansion: boolean): void {
|
|
this.send({ type: "start", expansion });
|
|
}
|
|
|
|
pickColor(color: number): void {
|
|
this.send({ type: "pickColor", color });
|
|
}
|
|
|
|
/** Ask for the reel of everything since we last watched. */
|
|
requestCatchUp(): void {
|
|
if (!this.roomId) return;
|
|
this.send({ type: "catchUp", sinceSeq: this.seen[this.roomId] ?? 0 });
|
|
}
|
|
|
|
/** All caught up: remember it and clear the banner. */
|
|
markSeen(): void {
|
|
if (!this.roomId) return;
|
|
this.seen[this.roomId] = this.currentSeq;
|
|
localStorage.setItem(SEEN_KEY, JSON.stringify(this.seen));
|
|
this.missedMoves = 0;
|
|
}
|
|
|
|
closeCatchUp(): void {
|
|
this.catchUp = null;
|
|
this.markSeen();
|
|
}
|
|
|
|
command(command: Command): void {
|
|
this.send({ type: "command", command });
|
|
}
|
|
}
|
|
|
|
export const net = new Net();
|