A table's options and its size are read one way, in spec.ts, by the server, the tally and the replay; a game may seat fewer for some options
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
faa2845e70
commit
6133a2469a
+5
-1
@@ -99,7 +99,11 @@ README.
|
|||||||
- **Table options** are the host's choices when opening a table (a
|
- **Table options** are the host's choices when opening a table (a
|
||||||
variant, a side, a length): the game declares them in `GameSpec.options`,
|
variant, a side, a length): the game declares them in `GameSpec.options`,
|
||||||
the hall renders them as selects, the room line records them, and
|
the hall renders them as selects, the room line records them, and
|
||||||
`create` receives them. A game with one way to play declares none.
|
`create` receives them. A game with one way to play declares none. An
|
||||||
|
option that changes how many can sit (a smaller map) says so through
|
||||||
|
`GameSpec.seatsFor`. The server, the tally and the ledger replay all read
|
||||||
|
a room line through `cleanOptions` and size a table with `tableSize`,
|
||||||
|
both in `spec.ts`, so each creates the same game from the same ledger.
|
||||||
|
|
||||||
## The engine
|
## The engine
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import { readdirSync, readFileSync } from 'node:fs';
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { game } from '../src/lib/game';
|
import { game } from '../src/lib/game';
|
||||||
import type { SeatId } from '../src/lib/game/spec';
|
import { cleanOptions, type SeatId } from '../src/lib/game/spec';
|
||||||
|
|
||||||
interface Seat {
|
interface Seat {
|
||||||
id: SeatId;
|
id: SeatId;
|
||||||
@@ -34,8 +34,8 @@ async function main(): Promise<void> {
|
|||||||
const seats: Seat[] = [];
|
const seats: Seat[] = [];
|
||||||
let state: ReturnType<typeof game.create> | null = null;
|
let state: ReturnType<typeof game.create> | null = null;
|
||||||
let turns = 0;
|
let turns = 0;
|
||||||
// The host's table options ride the room line; a game created without them is a different game.
|
// The host's table options ride the room line, read exactly as the server reads them.
|
||||||
const options = (lines[0]?.t === 'room' ? lines[0].options : undefined) as Record<string, string> | undefined;
|
const options = cleanOptions(game, lines[0]?.t === 'room' ? lines[0].options : undefined);
|
||||||
try {
|
try {
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.t === 'seat') seats.push(line);
|
if (line.t === 'seat') seats.push(line);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// game beyond the GameSpec it is given.
|
// game beyond the GameSpec it is given.
|
||||||
|
|
||||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
import { SPECTATOR, type GameSpec, type SeatId, type TableOptions } from '../../src/lib/game/spec';
|
import { cleanOptions, SPECTATOR, tableSize, type GameSpec, type SeatId, type TableOptions } from '../../src/lib/game/spec';
|
||||||
import type { ChatLine, RoomView } from '../../src/lib/net/view';
|
import type { ChatLine, RoomView } from '../../src/lib/net/view';
|
||||||
import type { LedgerLine, Store } from './store';
|
import type { LedgerLine, Store } from './store';
|
||||||
|
|
||||||
@@ -182,21 +182,12 @@ export class Rooms<State, Input> {
|
|||||||
|
|
||||||
create(name: string, size: number, rawOptions?: unknown): { room: Room<State>; seat: Seat; token: string } {
|
create(name: string, size: number, rawOptions?: unknown): { room: Room<State>; seat: Seat; token: string } {
|
||||||
const { minSeats } = this.game;
|
const { minSeats } = this.game;
|
||||||
if (!Number.isInteger(size) || size < minSeats || size > this.maxSeats) {
|
const options = cleanOptions(this.game, rawOptions);
|
||||||
throw new RoomError(`A table seats ${minSeats} to ${this.maxSeats} players.`);
|
const most = tableSize(this.game, options);
|
||||||
|
if (!Number.isInteger(size) || size < minSeats || size > most) {
|
||||||
|
throw new RoomError(`A table seats ${minSeats} to ${most} players.`);
|
||||||
}
|
}
|
||||||
return this.open(name, size, this.cleanOptions(rawOptions));
|
return this.open(name, size, options);
|
||||||
}
|
|
||||||
|
|
||||||
/** The host's choices, kept to the options the game declares and the choices it offers; the rest default. */
|
|
||||||
private cleanOptions(raw: unknown): TableOptions {
|
|
||||||
const given = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
|
|
||||||
const options: TableOptions = {};
|
|
||||||
for (const o of this.game.options ?? []) {
|
|
||||||
const v = given[o.key];
|
|
||||||
options[o.key] = typeof v === 'string' && o.choices.some((c) => c.value === v) ? v : o.default;
|
|
||||||
}
|
|
||||||
return options;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A table with nobody seated yet. */
|
/** A table with nobody seated yet. */
|
||||||
@@ -528,7 +519,7 @@ export class Rooms<State, Input> {
|
|||||||
private replay(id: string, lines: LedgerLine[]): Room<State> | null {
|
private replay(id: string, lines: LedgerLine[]): Room<State> | null {
|
||||||
const first = lines[0];
|
const first = lines[0];
|
||||||
if (!first || first.t !== 'room') return null;
|
if (!first || first.t !== 'room') return null;
|
||||||
const room = this.blank(id, first.seats, first.createdAt, first.rematchOf ?? null, first.expected ?? [], this.cleanOptions(first.options), lines.length);
|
const room = this.blank(id, first.seats, first.createdAt, first.rematchOf ?? null, first.expected ?? [], cleanOptions(this.game, first.options), lines.length);
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
this.apply(room, line);
|
this.apply(room, line);
|
||||||
if (line.t === 'turn' || line.t === 'input') room.updatedAt = line.at;
|
if (line.t === 'turn' || line.t === 'input') room.updatedAt = line.at;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// costs nothing and a busy hall costs one scan a minute. The counts are
|
// costs nothing and a busy hall costs one scan a minute. The counts are
|
||||||
// the kit's; a game may add its own lines through GameSpec.tally.
|
// the kit's; a game may add its own lines through GameSpec.tally.
|
||||||
|
|
||||||
import type { GameSpec, SeatId } from '../../src/lib/game/spec';
|
import { cleanOptions, type GameSpec, type SeatId } from '../../src/lib/game/spec';
|
||||||
import type { Tally } from '../../src/lib/net/view';
|
import type { Tally } from '../../src/lib/net/view';
|
||||||
import type { LedgerLine, Store } from './store';
|
import type { LedgerLine, Store } from './store';
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ export class Tallies<State, Input> {
|
|||||||
if (hasBot) t.gamesWithBot += 1;
|
if (hasBot) t.gamesWithBot += 1;
|
||||||
if (lines[0].createdAt < firstStart) firstStart = lines[0].createdAt;
|
if (lines[0].createdAt < firstStart) firstStart = lines[0].createdAt;
|
||||||
try {
|
try {
|
||||||
state = this.game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, lines[0].options);
|
state = this.game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, cleanOptions(this.game, lines[0].options));
|
||||||
} catch {
|
} catch {
|
||||||
state = null;
|
state = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import type { Report, RoomView, Tally } from '$lib/net/view';
|
import type { Report, RoomView, Tally } from '$lib/net/view';
|
||||||
import type { State } from '$lib/game';
|
import type { State } from '$lib/game';
|
||||||
import { game } from '$lib/game';
|
import { game } from '$lib/game';
|
||||||
|
import { tableSize } from '$lib/game/spec';
|
||||||
|
|
||||||
let name = $state(playerName());
|
let name = $state(playerName());
|
||||||
let code = $state('');
|
let code = $state('');
|
||||||
@@ -219,7 +220,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<button type="button" class="commit primary" disabled={!ready || busy} onclick={() => open(() => Room.createWithBot(name.trim(), options))}>Play now against the bot</button>
|
<button type="button" class="commit primary" disabled={!ready || busy} onclick={() => open(() => Room.createWithBot(name.trim(), options))}>Play now against the bot</button>
|
||||||
<div class="ways">
|
<div class="ways">
|
||||||
<button type="button" class="quiet" disabled={!ready || busy} onclick={() => open(() => Room.create(name.trim(), game.seatIds.length, options))}>Open a table</button>
|
<button type="button" class="quiet" disabled={!ready || busy} onclick={() => open(() => Room.create(name.trim(), tableSize(game, options), options))}>Open a table</button>
|
||||||
<form class="joinform" onsubmit={join}>
|
<form class="joinform" onsubmit={join}>
|
||||||
<span class="or">or join one</span>
|
<span class="or">or join one</span>
|
||||||
<input type="text" class="code" bind:value={code} maxlength="4" placeholder="CODE" autocapitalize="characters" autocomplete="off" aria-label="room code" />
|
<input type="text" class="code" bind:value={code} maxlength="4" placeholder="CODE" autocapitalize="characters" autocomplete="off" aria-label="room code" />
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { cleanOptions, tableSize, type TableOption } from './spec';
|
||||||
|
|
||||||
|
const options: TableOption[] = [
|
||||||
|
{ key: 'length', label: 'Length', choices: [{ value: 'short', label: 'Short' }, { value: 'long', label: 'Long' }], default: 'short' }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('table options', () => {
|
||||||
|
it('keeps only declared options and offered choices, the rest at their defaults', () => {
|
||||||
|
expect(cleanOptions({ options }, { length: 'long', colour: 'red' })).toEqual({ length: 'long' });
|
||||||
|
expect(cleanOptions({ options }, { length: 'endless' })).toEqual({ length: 'short' });
|
||||||
|
expect(cleanOptions({ options }, null)).toEqual({ length: 'short' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seats as many as the options allow, never more than the seats there are', () => {
|
||||||
|
const seatIds = ['A', 'B', 'C', 'D'];
|
||||||
|
expect(tableSize({ seatIds, options })).toBe(4);
|
||||||
|
const seatsFor = (o: Record<string, string>) => (o.length === 'short' ? 2 : 9);
|
||||||
|
expect(tableSize({ seatIds, options, seatsFor }, { length: 'short' })).toBe(2);
|
||||||
|
expect(tableSize({ seatIds, options, seatsFor }, { length: 'long' })).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -48,6 +48,8 @@ export interface Preference {
|
|||||||
export interface GameSpec<State, Input> {
|
export interface GameSpec<State, Input> {
|
||||||
/** Seats in table order; the table takes at most this many. */
|
/** Seats in table order; the table takes at most this many. */
|
||||||
seatIds: SeatId[];
|
seatIds: SeatId[];
|
||||||
|
/** The most seats a table with these options takes, when an option makes it fewer than `seatIds`. */
|
||||||
|
seatsFor?(options: TableOptions): number;
|
||||||
/** What the host may choose when opening a table; empty for a game with one way to play. */
|
/** What the host may choose when opening a table; empty for a game with one way to play. */
|
||||||
options?: TableOption[];
|
options?: TableOption[];
|
||||||
/** The game's own settings for the preferences panel. */
|
/** The game's own settings for the preferences panel. */
|
||||||
@@ -83,3 +85,24 @@ export interface GameSpec<State, Input> {
|
|||||||
/** The game's own lines for the hall's tally, counted from a finished game: a label and how many it adds. */
|
/** The game's own lines for the hall's tally, counted from a finished game: a label and how many it adds. */
|
||||||
tally?(state: State): Record<string, number>;
|
tally?(state: State): Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A table's options as the game will play them: only the options it
|
||||||
|
* declares, only the choices it offers, the rest at their defaults. The
|
||||||
|
* server, the tally and the ledger replay all read a room line through this,
|
||||||
|
* so each creates the same game from the same ledger.
|
||||||
|
*/
|
||||||
|
export function cleanOptions(game: Pick<GameSpec<unknown, unknown>, 'options'>, raw: unknown): TableOptions {
|
||||||
|
const given = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||||
|
const options: TableOptions = {};
|
||||||
|
for (const o of game.options ?? []) {
|
||||||
|
const v = given[o.key];
|
||||||
|
options[o.key] = typeof v === 'string' && o.choices.some((c) => c.value === v) ? v : o.default;
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The most seats a table with these options takes. */
|
||||||
|
export function tableSize(game: Pick<GameSpec<unknown, unknown>, 'options' | 'seatIds' | 'seatsFor'>, raw?: unknown): number {
|
||||||
|
return Math.min(game.seatIds.length, game.seatsFor?.(cleanOptions(game, raw)) ?? game.seatIds.length);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user