Duels between people: rooms, the hall, and a games ledger
The store plays any seat, locally against the bot or remotely through the server. A room has a four-letter code that is also its invite link; the hall opens or joins one and lists the seats this browser holds with whose move it is. The ledger and summary show every seat, threats name their wizard, and the board is one component shared by the hall and the room page. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
8366c9d7ff
commit
e1bda87987
10 files changed
+1430
-524
No files matched your search
@@ -0,0 +1,111 @@
|
||||
// The browser's side of the duel server: a few JSON calls, and the seats this
|
||||
// browser holds, remembered so a shared link never has to carry a token.
|
||||
|
||||
import type { TurnInput, WizardId } from './state';
|
||||
import type { RoomView } from './view';
|
||||
|
||||
export interface HeldSeat {
|
||||
seat: WizardId;
|
||||
token: string;
|
||||
}
|
||||
|
||||
const SEATS_KEY = 'wh:seats';
|
||||
|
||||
function heldSeats(): Record<string, HeldSeat> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SEATS_KEY) ?? '{}') as Record<string, HeldSeat>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function seatFor(roomId: string): HeldSeat | null {
|
||||
return heldSeats()[roomId] ?? null;
|
||||
}
|
||||
|
||||
/** Every seat this browser holds, newest last. */
|
||||
export function allSeats(): { roomId: string; seat: WizardId; token: string }[] {
|
||||
return Object.entries(heldSeats()).map(([roomId, h]) => ({ roomId, ...h }));
|
||||
}
|
||||
|
||||
export function forgetSeat(roomId: string): void {
|
||||
try {
|
||||
const seats = heldSeats();
|
||||
delete seats[roomId];
|
||||
localStorage.setItem(SEATS_KEY, JSON.stringify(seats));
|
||||
} catch {
|
||||
// Nothing to forget without storage.
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberSeat(roomId: string, held: HeldSeat): void {
|
||||
try {
|
||||
localStorage.setItem(SEATS_KEY, JSON.stringify({ ...heldSeats(), [roomId]: held }));
|
||||
} catch {
|
||||
// Without storage the seat lasts for this page only.
|
||||
}
|
||||
}
|
||||
|
||||
export class ServerError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: body ? { 'content-type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
if (!res.ok) throw new ServerError(data.error ?? `The server answered ${res.status}.`, res.status);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
type Seated = { seat: WizardId; token: string; view: RoomView };
|
||||
|
||||
export const api = {
|
||||
create: (name: string, size: number) => call<Seated>('POST', '/api/rooms', { name, size }),
|
||||
join: (roomId: string, name: string) => call<Seated>('POST', `/api/rooms/${roomId}/join`, { name }),
|
||||
addBot: (roomId: string, token: string) => call<RoomView>('POST', `/api/rooms/${roomId}/bot`, { token }),
|
||||
view: (roomId: string, token: string) => call<RoomView>('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`),
|
||||
turn: (roomId: string, token: string, input: TurnInput) => call<RoomView>('POST', `/api/rooms/${roomId}/turn`, { token, input })
|
||||
};
|
||||
|
||||
/** A socket that only ever says "fetch the view again"; reconnects on its own. */
|
||||
export function watchRoom(roomId: string, token: string, onUpdate: () => void, onStatus: (open: boolean) => void): () => void {
|
||||
let socket: WebSocket | null = null;
|
||||
let closed = false;
|
||||
let delay = 1000;
|
||||
const open = () => {
|
||||
if (closed) return;
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
socket = new WebSocket(`${protocol}://${location.host}/ws?room=${encodeURIComponent(roomId)}&token=${encodeURIComponent(token)}`);
|
||||
socket.onopen = () => {
|
||||
delay = 1000;
|
||||
onStatus(true);
|
||||
onUpdate();
|
||||
};
|
||||
socket.onmessage = (e) => {
|
||||
try {
|
||||
if (JSON.parse(String(e.data)).type === 'update') onUpdate();
|
||||
} catch {
|
||||
// Not a message this client understands.
|
||||
}
|
||||
};
|
||||
socket.onclose = () => {
|
||||
onStatus(false);
|
||||
if (!closed) setTimeout(open, delay);
|
||||
delay = Math.min(delay * 2, 30000);
|
||||
};
|
||||
};
|
||||
open();
|
||||
return () => {
|
||||
closed = true;
|
||||
socket?.close();
|
||||
};
|
||||
}
|
||||
+216
-56
@@ -1,7 +1,10 @@
|
||||
// Reactive wrapper around the engine for a duel of one person against the bot.
|
||||
// Reactive wrapper around the engine for the wizard at this keyboard. A local
|
||||
// duel resolves against the bot in this browser; a remote one sends each turn
|
||||
// to the server and shows whatever view comes back.
|
||||
|
||||
import { tick } from 'svelte';
|
||||
import { chooseBotTurn } from './bot';
|
||||
import { api, rememberSeat, watchRoom, type HeldSeat } from './client';
|
||||
import {
|
||||
cellsOverlap,
|
||||
completableIn,
|
||||
@@ -31,6 +34,7 @@ import {
|
||||
import {
|
||||
NOWHERE,
|
||||
createGame,
|
||||
enemyOf,
|
||||
visibleHistory,
|
||||
type CastChoice,
|
||||
type GameState,
|
||||
@@ -39,9 +43,11 @@ import {
|
||||
type TurnInput,
|
||||
type WizardId
|
||||
} from './state';
|
||||
import type { RoomView } from './view';
|
||||
|
||||
export const YOU: WizardId = 'A';
|
||||
export const FOE: WizardId = 'B';
|
||||
/** The seats of a duel played in this browser against the bot. */
|
||||
const LOCAL_ME: WizardId = 'A';
|
||||
const LOCAL_BOT: WizardId = 'B';
|
||||
|
||||
/** Narrow layouts: shorter lists and a sticky bar. The stylesheets in +page, MobileBar and TurnSummary use the same width. */
|
||||
export const COMPACT_QUERY = '(max-width: 860px)';
|
||||
@@ -113,8 +119,9 @@ export interface Plan {
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
/** A spell the opponent's hand could finish soon, with the gestures that say so. */
|
||||
/** A spell an opponent's hand could finish soon, with the gestures that say so. */
|
||||
export interface Threat {
|
||||
who: WizardId;
|
||||
hand: Hand;
|
||||
spell: Spell;
|
||||
done: Token[];
|
||||
@@ -126,6 +133,7 @@ export interface Threat {
|
||||
}
|
||||
|
||||
export interface Highlight {
|
||||
who: WizardId;
|
||||
hand: Hand;
|
||||
from: number;
|
||||
to: number;
|
||||
@@ -160,6 +168,7 @@ function loadSaved(): SavedDuel | null {
|
||||
const saved = JSON.parse(raw) as SavedDuel;
|
||||
if (saved.v !== SAVE_VERSION || !saved.state?.wizards?.A || !saved.state?.wizards?.B) return null;
|
||||
// Saves at this version may lack fields that came later; the engine expects them present.
|
||||
saved.state.seats ??= Object.keys(saved.state.wizards);
|
||||
saved.state.lessonsShown ??= [];
|
||||
if (saved.state.lastTurn) {
|
||||
saved.state.lastTurn.events ??= [];
|
||||
@@ -188,10 +197,24 @@ function readPreference(key: string, fallback: boolean): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** A seat at a duel the server is keeping. */
|
||||
export interface Remote extends HeldSeat {
|
||||
roomId: string;
|
||||
size: number;
|
||||
seats: RoomView['seats'];
|
||||
waitingOn: WizardId[];
|
||||
started: boolean;
|
||||
connected: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export class Duel {
|
||||
state = $state<GameState>(createGame(pickNames()));
|
||||
/** The seat this browser plays. */
|
||||
me = $state<WizardId>(LOCAL_ME);
|
||||
remote = $state<Remote | null>(null);
|
||||
sets = $state<Record<SetName, Record<Hand, HandDraft>>>(blankSets());
|
||||
stabTarget = $state<TargetId>(FOE);
|
||||
stabTarget = $state<TargetId | ''>('');
|
||||
monsterOrders = $state<Record<string, TargetId>>({});
|
||||
/** Narrow screens get shorter lists and a sticky bar; the page sets this from a media query. */
|
||||
compact = $state(false);
|
||||
@@ -209,16 +232,22 @@ export class Duel {
|
||||
sheetOpen = $state(false);
|
||||
/** Opponent gestures to light up in the ledger. */
|
||||
highlight = $state<Highlight | null>(null);
|
||||
/** A turn is on its way to the server. */
|
||||
sending = $state(false);
|
||||
|
||||
constructor(initial?: GameState) {
|
||||
constructor(initial?: GameState | Remote) {
|
||||
const saved = typeof localStorage === 'undefined' ? null : loadSaved();
|
||||
if (initial) {
|
||||
if (initial && 'roomId' in initial) {
|
||||
this.remote = initial;
|
||||
this.me = initial.seat;
|
||||
this.state = createGame({ [initial.seat]: 'You' });
|
||||
} else if (initial) {
|
||||
this.state = initial;
|
||||
} else if (saved) {
|
||||
this.state = saved.state;
|
||||
this.pins = saved.pins ?? { left: null, right: null };
|
||||
this.sets = saved.sets ?? blankSets();
|
||||
this.stabTarget = saved.stabTarget ?? FOE;
|
||||
this.stabTarget = saved.stabTarget ?? '';
|
||||
this.monsterOrders = saved.monsterOrders ?? {};
|
||||
this.release = saved.release ?? '';
|
||||
}
|
||||
@@ -243,15 +272,17 @@ export class Duel {
|
||||
} catch {
|
||||
// The name still applies to this duel.
|
||||
}
|
||||
if (this.remote) return;
|
||||
const next = name || pickNames('').A;
|
||||
if (next.toLowerCase() === this.foe.name.toLowerCase()) {
|
||||
this.state.wizards[FOE].name = pickNames(next).B;
|
||||
this.state.wizards[LOCAL_BOT].name = pickNames(next).B;
|
||||
}
|
||||
this.state.wizards[YOU].name = next;
|
||||
this.state.wizards[LOCAL_ME].name = next;
|
||||
}
|
||||
|
||||
/** Write the duel and the half-written turn to local storage. Reads everything it saves, so an effect can track it. */
|
||||
persist(): void {
|
||||
if (this.remote) return;
|
||||
const bundle: SavedDuel = {
|
||||
v: SAVE_VERSION,
|
||||
state: $state.snapshot(this.state),
|
||||
@@ -271,11 +302,21 @@ export class Duel {
|
||||
/** A duel worth asking about before it is thrown away. */
|
||||
inProgress = $derived(this.state.turn > 0 && !this.state.over);
|
||||
|
||||
you = $derived(this.state.wizards[YOU]);
|
||||
foe = $derived(this.state.wizards[FOE]);
|
||||
you = $derived(this.state.wizards[this.me]);
|
||||
/** The opponent spells go at by default: the first still standing. */
|
||||
foeId = $derived(enemyOf(this.state, this.me));
|
||||
foe = $derived(this.state.wizards[this.foeId]);
|
||||
/** Every other seat, in table order. */
|
||||
others = $derived(this.state.seats.filter((id) => id !== this.me));
|
||||
turnNumber = $derived(this.state.turn + 1);
|
||||
/** Your extra turn under time stop: nobody else moves and last turn's enchantments do not bind. */
|
||||
timeStopped = $derived(this.state.timeStops[0] === YOU);
|
||||
timeStopped = $derived(this.state.timeStops[0] === this.me);
|
||||
/** Your move is in and the turn waits on someone else. */
|
||||
moved = $derived(!!this.remote && this.remote.started && !this.state.over && !this.remote.waitingOn.includes(this.me));
|
||||
/** Names of the seats the turn is waiting on. */
|
||||
awaiting = $derived(
|
||||
this.remote ? this.remote.waitingOn.filter((id) => id !== this.me).map((id) => this.state.wizards[id]?.name ?? id) : []
|
||||
);
|
||||
hasted = $derived(this.you.hasteTurns > 0 && !this.timeStopped);
|
||||
activeSets = $derived<SetName[]>(this.hasted ? ['main', 'haste'] : ['main']);
|
||||
|
||||
@@ -295,8 +336,9 @@ export class Duel {
|
||||
return set === 'haste' ? this.forcedSecond[hand] : this.forced[hand];
|
||||
}
|
||||
charmedHand = $derived<Hand | undefined>(this.timeStopped ? undefined : this.you.constraints.charmed?.hand);
|
||||
/** True when the bot's hand is yours to command this turn. */
|
||||
youCharmedFoe = $derived(this.foe.constraints.charmed?.by === YOU && !this.timeStopped);
|
||||
/** The opponent whose hand is yours to command this turn, if any. */
|
||||
charmedFoe = $derived(this.others.find((id) => this.state.wizards[id].constraints.charmed?.by === this.me));
|
||||
youCharmedFoe = $derived(this.charmedFoe !== undefined && !this.timeStopped);
|
||||
|
||||
/** The gesture a hand will make: forced by an enchantment, or chosen, or nothing yet. */
|
||||
chosenGesture(set: SetName, hand: Hand): Gesture | null {
|
||||
@@ -392,20 +434,28 @@ export class Duel {
|
||||
}
|
||||
|
||||
summary = $derived(this.state.lastTurn);
|
||||
foeVisible = $derived(visibleHistory(this.state, YOU, FOE));
|
||||
/** Hostile spells the opponent could finish this turn or next, with the evidence. */
|
||||
/** Each opponent's gestures as you are allowed to see them. */
|
||||
visible(id: WizardId): HistoryEntry[] {
|
||||
return visibleHistory(this.state, this.me, id);
|
||||
}
|
||||
/** Hostile spells any opponent could finish this turn or next, with the evidence. */
|
||||
threats = $derived.by(() => {
|
||||
const out: Threat[] = [];
|
||||
for (const hand of HANDS) {
|
||||
const seq = handSequence(this.foeVisible, hand);
|
||||
for (const turnsAway of [1, 2]) {
|
||||
for (const c of completableIn(seq, this.foe.sequenceStart, turnsAway)) {
|
||||
if (!HOSTILE_SPELLS.includes(c.spell.id)) continue;
|
||||
if (c.spell.id === 'lightning_bolt_quick' && this.foe.usedQuickLightning) continue;
|
||||
const tokens = c.spell.tokens[c.seqIndex];
|
||||
const done = tokens.slice(0, tokens.length - turnsAway);
|
||||
if (out.some((t) => t.hand === hand && t.spell.id === c.spell.id)) continue;
|
||||
out.push({ hand, spell: c.spell, done, remaining: c.remaining, from: seq.length - done.length, to: seq.length - 1, soon: turnsAway === 1 });
|
||||
for (const who of this.others) {
|
||||
const w = this.state.wizards[who];
|
||||
if (!w.alive || w.surrendered) continue;
|
||||
const hist = this.visible(who);
|
||||
for (const hand of HANDS) {
|
||||
const seq = handSequence(hist, hand);
|
||||
for (const turnsAway of [1, 2]) {
|
||||
for (const c of completableIn(seq, w.sequenceStart, turnsAway)) {
|
||||
if (!HOSTILE_SPELLS.includes(c.spell.id)) continue;
|
||||
if (c.spell.id === 'lightning_bolt_quick' && w.usedQuickLightning) continue;
|
||||
const tokens = c.spell.tokens[c.seqIndex];
|
||||
const done = tokens.slice(0, tokens.length - turnsAway);
|
||||
if (out.some((t) => t.who === who && t.hand === hand && t.spell.id === c.spell.id)) continue;
|
||||
out.push({ who, hand, spell: c.spell, done, remaining: c.remaining, from: seq.length - done.length, to: seq.length - 1, soon: turnsAway === 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -415,8 +465,15 @@ export class Duel {
|
||||
threatsNext = $derived(this.threats.filter((t) => !t.soon).slice(0, 6));
|
||||
|
||||
toggleHighlight(t: Threat): void {
|
||||
const same = this.highlight && this.highlight.hand === t.hand && this.highlight.from === t.from && this.highlight.to === t.to;
|
||||
this.highlight = same ? null : { hand: t.hand, from: t.from, to: t.to };
|
||||
const h = this.highlight;
|
||||
const same = h && h.who === t.who && h.hand === t.hand && h.from === t.from && h.to === t.to;
|
||||
this.highlight = same ? null : { who: t.who, hand: t.hand, from: t.from, to: t.to };
|
||||
}
|
||||
|
||||
/** Whether a threat is the one lit up in the ledger. */
|
||||
isHighlighted(t: Threat): boolean {
|
||||
const h = this.highlight;
|
||||
return !!h && h.who === t.who && h.hand === t.hand && h.from === t.from && h.to === t.to;
|
||||
}
|
||||
|
||||
/** Spells each hand is part-way through, based on the gestures already on the ledger. */
|
||||
@@ -506,9 +563,11 @@ export class Duel {
|
||||
|
||||
surrendering = $derived(this.activeSets.some((set) => this.gestureFor(set, 'left') === 'P' && this.gestureFor(set, 'right') === 'P'));
|
||||
stabbing = $derived(this.activeSets.some((set) => HANDS.some((h) => this.gestureFor(set, h) === '>')));
|
||||
yourMonsters = $derived(this.state.monsters.filter((m) => m.owner === YOU));
|
||||
yourMonsters = $derived(this.state.monsters.filter((m) => m.owner === this.me));
|
||||
ready = $derived(
|
||||
!this.state.over &&
|
||||
!this.moved &&
|
||||
!this.sending &&
|
||||
this.activeSets.every((set) =>
|
||||
HANDS.every((h) => this.forced[h] || this.charmedHand === h || this.sets[set][h].gesture !== null)
|
||||
) &&
|
||||
@@ -518,14 +577,14 @@ export class Duel {
|
||||
/** A delayed effect is waiting for a spell to bank, counting one cast this turn. */
|
||||
bankingActive = $derived(
|
||||
!this.you.banked &&
|
||||
(this.you.delayedTurns > 0 || this.planned.some((p) => p.completion.spell.id === 'delayed_effect' && (p.draft.target || YOU) === YOU))
|
||||
(this.you.delayedTurns > 0 || this.planned.some((p) => p.completion.spell.id === 'delayed_effect' && (p.draft.target || this.me) === this.me))
|
||||
);
|
||||
bankCandidates = $derived(this.bankingActive ? this.planned.filter((p) => p.completion.spell.id !== 'delayed_effect') : []);
|
||||
willBank = $derived(
|
||||
this.bankCandidates.find((p) => castKey(p.set, p.hand) === this.bankPick) ?? this.bankCandidates[0]
|
||||
);
|
||||
permanencyActive = $derived(
|
||||
this.you.permanencyTurns > 0 || this.planned.some((p) => p.completion.spell.id === 'permanency' && (p.draft.target || YOU) === YOU)
|
||||
this.you.permanencyTurns > 0 || this.planned.some((p) => p.completion.spell.id === 'permanency' && (p.draft.target || this.me) === this.me)
|
||||
);
|
||||
permanentCandidates = $derived(
|
||||
this.permanencyActive
|
||||
@@ -539,17 +598,15 @@ export class Duel {
|
||||
|
||||
/** Where a spell goes unless the player says otherwise. */
|
||||
defaultTarget(spell: Spell | undefined): TargetId | '' {
|
||||
return spell ? likelyTarget(this.state, YOU, spell) : '';
|
||||
return spell ? likelyTarget(this.state, this.me, spell) : '';
|
||||
}
|
||||
|
||||
/** Every possible subject, the likeliest first, creatures marked by whose they are. */
|
||||
targetsFor(spell: Spell | undefined): { id: TargetId; label: string }[] {
|
||||
const list: { id: TargetId; label: string }[] = [
|
||||
{ id: YOU, label: 'yourself' },
|
||||
{ id: FOE, label: this.foe.name }
|
||||
];
|
||||
const list: { id: TargetId; label: string }[] = [{ id: this.me, label: 'yourself' }];
|
||||
for (const id of this.others) list.push({ id, label: this.state.wizards[id].name });
|
||||
for (const m of this.state.monsters) {
|
||||
list.push({ id: m.id, label: `${m.name} (${m.owner === YOU ? 'yours' : `${this.foe.name}'s`})` });
|
||||
list.push({ id: m.id, label: `${m.name} (${m.owner === this.me ? 'yours' : `${this.state.wizards[m.owner].name}'s`})` });
|
||||
}
|
||||
const likely = this.defaultTarget(spell);
|
||||
list.sort((a, b) => Number(b.id === likely) - Number(a.id === likely));
|
||||
@@ -588,21 +645,21 @@ export class Duel {
|
||||
};
|
||||
if (spell.id === 'summon_elemental') cast.elemental = p.draft.elemental;
|
||||
if (spell.id === 'paralysis' || spell.id === 'charm_person') cast.chosenHand = p.draft.chosenHand;
|
||||
if (spell.category === 'summons') cast.monsterTarget = FOE;
|
||||
if (spell.category === 'summons') cast.monsterTarget = this.foeId;
|
||||
if (p === this.willBank) cast.bank = true;
|
||||
if (p === this.willExtend) cast.permanent = true;
|
||||
return cast;
|
||||
}
|
||||
|
||||
reveal(): void {
|
||||
if (!this.ready) return;
|
||||
/** The move as the engine wants it. */
|
||||
private input(): TurnInput {
|
||||
const orders: Record<string, TargetId> = {};
|
||||
for (const m of this.yourMonsters) orders[m.id] = this.monsterOrders[m.id] ?? FOE;
|
||||
for (const m of this.yourMonsters) orders[m.id] = this.monsterOrders[m.id] ?? this.foeId;
|
||||
const yours: TurnInput = {
|
||||
left: this.sets.main.left.gesture ?? '-',
|
||||
right: this.sets.main.right.gesture ?? '-',
|
||||
casts: this.planned.filter((p) => p.set === 'main').map((p) => this.castFor(p)),
|
||||
stabTarget: this.stabTarget,
|
||||
stabTarget: this.stabTarget || this.foeId,
|
||||
monsterOrders: orders
|
||||
};
|
||||
if (this.hasted) {
|
||||
@@ -610,23 +667,87 @@ export class Duel {
|
||||
left: this.sets.haste.left.gesture ?? '-',
|
||||
right: this.sets.haste.right.gesture ?? '-',
|
||||
casts: this.planned.filter((p) => p.set === 'haste').map((p) => this.castFor(p)),
|
||||
stabTarget: this.stabTarget
|
||||
stabTarget: this.stabTarget || this.foeId
|
||||
};
|
||||
}
|
||||
if (this.release && this.you.banked) yours.release = { target: this.release, monsterTarget: FOE };
|
||||
if (this.release && this.you.banked) yours.release = { target: this.release, monsterTarget: this.foeId };
|
||||
if (this.youCharmedFoe) yours.charmGesture = this.charmGesture;
|
||||
return yours;
|
||||
}
|
||||
|
||||
reveal(): void {
|
||||
if (!this.ready) return;
|
||||
const yours = this.input();
|
||||
if (this.remote) {
|
||||
void this.send(yours);
|
||||
return;
|
||||
}
|
||||
// The engine clones plain data; hand it a snapshot rather than the reactive proxy.
|
||||
let plain = $state.snapshot(this.state);
|
||||
const theirs = chooseBotTurn(plain, FOE);
|
||||
plain = resolveTurn(plain, { A: yours, B: theirs });
|
||||
const theirs = chooseBotTurn(plain, LOCAL_BOT);
|
||||
plain = resolveTurn(plain, { [LOCAL_ME]: yours, [LOCAL_BOT]: theirs });
|
||||
// The bot takes any stopped moments it is owed straight away.
|
||||
while (!plain.over && plain.timeStops[0] === FOE) {
|
||||
plain = resolveTurn(plain, { A: yours, B: chooseBotTurn(plain, FOE) });
|
||||
while (!plain.over && plain.timeStops[0] === LOCAL_BOT) {
|
||||
plain = resolveTurn(plain, { [LOCAL_BOT]: chooseBotTurn(plain, LOCAL_BOT) });
|
||||
}
|
||||
this.state = plain;
|
||||
this.turnOver(plain);
|
||||
}
|
||||
|
||||
private async send(yours: TurnInput): Promise<void> {
|
||||
const r = this.remote!;
|
||||
this.sending = true;
|
||||
r.error = '';
|
||||
try {
|
||||
this.apply(await api.turn(r.roomId, r.token, yours));
|
||||
} catch (e) {
|
||||
r.error = e instanceof Error ? e.message : 'The move did not reach the server.';
|
||||
} finally {
|
||||
this.sending = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Take a fresh view from the server; a new turn clears the drafts. */
|
||||
apply(view: RoomView): void {
|
||||
const r = this.remote!;
|
||||
r.seats = view.seats;
|
||||
r.size = view.size;
|
||||
r.waitingOn = view.waitingOn;
|
||||
const wasStarted = r.started;
|
||||
r.started = view.state !== null;
|
||||
if (!view.state) return;
|
||||
const advanced = !wasStarted || view.state.turn !== this.state.turn || view.state.log.length !== this.state.log.length;
|
||||
if (advanced) this.turnOver(view.state);
|
||||
else this.state = view.state;
|
||||
}
|
||||
|
||||
/** Fetch the view now, and keep fetching whenever the server says something changed. */
|
||||
connect(): () => void {
|
||||
const r = this.remote;
|
||||
if (!r) return () => {};
|
||||
const refresh = () => {
|
||||
api.view(r.roomId, r.token).then((v) => this.apply(v)).catch((e: Error) => (r.error = e.message));
|
||||
};
|
||||
return watchRoom(r.roomId, r.token, refresh, (open) => (r.connected = open));
|
||||
}
|
||||
|
||||
/** Seat the bot in the empty chair. */
|
||||
async addBot(): Promise<void> {
|
||||
const r = this.remote;
|
||||
if (!r) return;
|
||||
try {
|
||||
this.apply(await api.addBot(r.roomId, r.token));
|
||||
} catch (e) {
|
||||
r.error = e instanceof Error ? e.message : 'The bot could not be seated.';
|
||||
}
|
||||
}
|
||||
|
||||
/** A resolved turn has arrived, from the engine here or from the server. */
|
||||
private turnOver(next: GameState): void {
|
||||
const finished = this.planned;
|
||||
this.state = next;
|
||||
for (const h of HANDS) {
|
||||
const pinned = this.pins[h];
|
||||
if (pinned && this.planned.some((p) => p.hand === h && p.completion.spell.id === pinned)) this.pins[h] = null;
|
||||
if (pinned && finished.some((p) => p.hand === h && p.completion.spell.id === pinned)) this.pins[h] = null;
|
||||
}
|
||||
this.resetDrafts();
|
||||
// On a phone the hands sit far below the ledger; bring the result into view.
|
||||
@@ -642,14 +763,15 @@ export class Duel {
|
||||
private resetDrafts(): void {
|
||||
this.sets = blankSets();
|
||||
this.syncAll();
|
||||
this.stabTarget = FOE;
|
||||
this.stabTarget = '';
|
||||
// Monsters keep attacking what they attacked last, until told otherwise or the target is gone.
|
||||
const orders: Record<string, TargetId> = {};
|
||||
for (const m of this.state.monsters) {
|
||||
if (m.owner !== YOU) continue;
|
||||
if (m.owner !== this.me) continue;
|
||||
const last = m.lastTarget;
|
||||
const valid = last === FOE || this.state.monsters.some((o) => o.id === last && o.owner === FOE);
|
||||
orders[m.id] = valid && last ? last : FOE;
|
||||
const standing = last !== undefined && this.others.includes(last) && this.state.wizards[last].alive;
|
||||
const creature = this.state.monsters.some((o) => o.id === last && o.owner !== this.me);
|
||||
orders[m.id] = last && (standing || creature) ? last : this.foeId;
|
||||
}
|
||||
this.monsterOrders = orders;
|
||||
this.release = '';
|
||||
@@ -659,11 +781,49 @@ export class Duel {
|
||||
}
|
||||
|
||||
newGame(): void {
|
||||
if (this.remote) return;
|
||||
this.state = createGame(pickNames());
|
||||
this.pins = { left: null, right: null };
|
||||
this.resetDrafts();
|
||||
}
|
||||
|
||||
/** Open a duel for another person on the server, seated as the first wizard. */
|
||||
static async createRoom(name: string, size = 2): Promise<Remote> {
|
||||
const seated = await api.create(name, size);
|
||||
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
|
||||
return remoteFrom(seated.view, seated.token);
|
||||
}
|
||||
|
||||
static async joinRoom(roomId: string, name: string): Promise<Remote> {
|
||||
const seated = await api.join(roomId, name);
|
||||
rememberSeat(roomId, { seat: seated.seat, token: seated.token });
|
||||
return remoteFrom(seated.view, seated.token);
|
||||
}
|
||||
|
||||
/** Return to a seat this browser already holds. */
|
||||
static async resumeRoom(roomId: string, held: HeldSeat): Promise<Remote> {
|
||||
return remoteFrom(await api.view(roomId, held.token), held.token);
|
||||
}
|
||||
}
|
||||
|
||||
/** The one duel this browser is playing; it survives moving between pages. */
|
||||
function remoteFrom(view: RoomView, token: string): Remote {
|
||||
return {
|
||||
roomId: view.roomId,
|
||||
seat: view.me,
|
||||
token,
|
||||
size: view.size,
|
||||
seats: view.seats,
|
||||
waitingOn: view.waitingOn,
|
||||
started: view.state !== null,
|
||||
connected: false,
|
||||
error: ''
|
||||
};
|
||||
}
|
||||
|
||||
/** The single-player duel this browser is playing; it survives moving between pages. */
|
||||
export const duel = new Duel();
|
||||
|
||||
/** The name the player last chose, for seating them at a shared duel. */
|
||||
export function playerName(): string {
|
||||
return readName();
|
||||
}
|
||||
@@ -14,3 +14,15 @@ export function viewFor(state: GameState, viewer: WizardId): GameState {
|
||||
view.log = view.log.filter((l) => !l.hiddenFrom?.includes(viewer));
|
||||
return view;
|
||||
}
|
||||
|
||||
/** What a seated player is told by the server. Other seats' moves and tokens never leave it. */
|
||||
export interface RoomView {
|
||||
roomId: string;
|
||||
seq: number;
|
||||
size: number;
|
||||
me: WizardId;
|
||||
seats: { id: WizardId; name: string; bot: boolean }[];
|
||||
/** Seats that still have to move before the turn resolves. */
|
||||
waitingOn: WizardId[];
|
||||
state: GameState | null;
|
||||
}
|
||||
Reference in new issue
Block a user