The host begins at every table size and may send a bot away
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
457a536f58
commit
ddb18cb08a
+1
-1
@@ -16,7 +16,7 @@ README.
|
|||||||
with no look-alikes, and its link is `/join/CODE`. The masthead button
|
with no look-alikes, and its link is `/join/CODE`. The masthead button
|
||||||
reads *invite friends · room CODE* and copies the link. Whoever opened
|
reads *invite friends · room CODE* and copies the link. Whoever opened
|
||||||
the table is **the host**; they may *Seat a bot* and they *Begin* when the
|
the table is **the host**; they may *Seat a bot* and they *Begin* when the
|
||||||
company suits them. A full table begins by itself.
|
company suits them, at any size of table; there is no automatic start.
|
||||||
- **The Peanut Gallery** is where a link takes anyone without a seat: a
|
- **The Peanut Gallery** is where a link takes anyone without a seat: a
|
||||||
read-only view showing only what every seat could see, counted for the
|
read-only view showing only what every seat could see, counted for the
|
||||||
table (*3 in the gallery*). While the table is laid, a watcher is offered
|
table (*3 in the gallery*). While the table is laid, a watcher is offered
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ for f in glob.glob("/var/lib/__SLUG__/rooms/*.jsonl"):
|
|||||||
if x["t"] == "seat":
|
if x["t"] == "seat":
|
||||||
if x["bot"]: bots += 1
|
if x["bot"]: bots += 1
|
||||||
else: humans.add(x["name"])
|
else: humans.add(x["name"])
|
||||||
|
elif x["t"] == "unseat": bots -= 1
|
||||||
day_turns = [x for x in lines if x["t"] == "turn" and t0 <= x["at"] / 1000 < t1]
|
day_turns = [x for x in lines if x["t"] == "turn" and t0 <= x["at"] / 1000 < t1]
|
||||||
turns += len(day_turns)
|
turns += len(day_turns)
|
||||||
# A game finished today: its last turn is today's and the engine would say so; approximate by replay-free means:
|
# A game finished today: its last turn is today's and the engine would say so; approximate by replay-free means:
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ async function main(): Promise<void> {
|
|||||||
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);
|
||||||
|
else if (line.t === 'unseat') seats.splice(seats.findIndex((s) => s.id === line.id), 1);
|
||||||
else if (line.t === 'start') state = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1);
|
else if (line.t === 'start') state = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1);
|
||||||
else if (line.t === 'turn' && state) {
|
else if (line.t === 'turn' && state) {
|
||||||
state = game.resolve(state, line.inputs);
|
state = game.resolve(state, line.inputs);
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ for f in glob.glob("/var/lib/__SLUG__/rooms/*.jsonl"):
|
|||||||
if x["t"] == "seat":
|
if x["t"] == "seat":
|
||||||
(bots_seated if x["bot"] else humans).append(x["name"])
|
(bots_seated if x["bot"] else humans).append(x["name"])
|
||||||
names[x["id"]] = x["name"]
|
names[x["id"]] = x["name"]
|
||||||
|
elif x["t"] == "unseat":
|
||||||
|
bots_seated.remove(names.pop(x["id"], None)) if x["id"] in names else None
|
||||||
elif x["t"] == "start":
|
elif x["t"] == "start":
|
||||||
started = True
|
started = True
|
||||||
elif x["t"] == "turn":
|
elif x["t"] == "turn":
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// POST /api/rooms/:id/join {name} take the next seat
|
// POST /api/rooms/:id/join {name} take the next seat
|
||||||
// POST /api/rooms/:id/bot {token} seat a bot (any seated player may, before the game begins)
|
// POST /api/rooms/:id/bot {token} seat a bot (any seated player may, before the game begins)
|
||||||
// POST /api/rooms/:id/begin {token} the host begins with the players seated so far
|
// POST /api/rooms/:id/begin {token} the host begins with the players seated so far
|
||||||
|
// POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the game begins
|
||||||
// GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view
|
// GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view
|
||||||
// POST /api/rooms/:id/turn {token, input} this seat's move
|
// POST /api/rooms/:id/turn {token, input} this seat's move
|
||||||
// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room
|
// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room
|
||||||
@@ -193,6 +194,10 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
|||||||
rooms.addBot(room, token);
|
rooms.addBot(room, token);
|
||||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||||
}
|
}
|
||||||
|
if (action === 'unseat') {
|
||||||
|
rooms.unseat(room, token, body.seat);
|
||||||
|
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||||
|
}
|
||||||
if (action === 'begin') {
|
if (action === 'begin') {
|
||||||
rooms.begin(room, token);
|
rooms.begin(room, token);
|
||||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export class Rooms<State, Input> {
|
|||||||
return this.sit(room, name, false);
|
return this.sit(room, name, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The host, who opened the table, begins once enough are seated. A full table begins by itself. */
|
/** The host, who opened the table, begins once enough are seated, however full the table. */
|
||||||
begin(room: Room<State>, token: string | undefined): void {
|
begin(room: Room<State>, token: string | undefined): void {
|
||||||
const seat = this.seatOf(room, token);
|
const seat = this.seatOf(room, token);
|
||||||
if (seat.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may begin.', 403);
|
if (seat.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may begin.', 403);
|
||||||
@@ -170,12 +170,22 @@ export class Rooms<State, Input> {
|
|||||||
if (room.seats.length >= room.size) throw new RoomError('Every seat at this table is taken.', 409);
|
if (room.seats.length >= room.size) throw new RoomError('Every seat at this table is taken.', 409);
|
||||||
const name = cleanName(rawName);
|
const name = cleanName(rawName);
|
||||||
if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another player here already has that name.', 409);
|
if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another player here already has that name.', 409);
|
||||||
const seat: Seat = { id: this.game.seatIds[room.seats.length], name, token: bot ? '' : newId(18), bot };
|
const id = this.game.seatIds.find((s) => !room.seats.some((taken) => taken.id === s))!;
|
||||||
|
const seat: Seat = { id, name, token: bot ? '' : newId(18), bot };
|
||||||
this.commit(room, { t: 'seat', id: seat.id, name, token: seat.token, bot });
|
this.commit(room, { t: 'seat', id: seat.id, name, token: seat.token, bot });
|
||||||
if (room.seats.length === room.size) this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules });
|
|
||||||
return seat;
|
return seat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The host sends a bot away before the game begins. People leave by not coming back. */
|
||||||
|
unseat(room: Room<State>, token: string | undefined, seatId: unknown): void {
|
||||||
|
const host = this.seatOf(room, token);
|
||||||
|
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may send a bot away.', 403);
|
||||||
|
if (room.state) throw new RoomError('The game has begun; the table is set.', 409);
|
||||||
|
const seat = room.seats.find((s) => s.id === seatId);
|
||||||
|
if (!seat || !seat.bot) throw new RoomError('Only a bot can be sent away.', 400);
|
||||||
|
this.commit(room, { t: 'unseat', id: seat.id });
|
||||||
|
}
|
||||||
|
|
||||||
/** Record a seat's move; resolve the round once nobody else is awaited. */
|
/** Record a seat's move; resolve the round once nobody else is awaited. */
|
||||||
submit(room: Room<State>, token: string | undefined, raw: unknown): void {
|
submit(room: Room<State>, token: string | undefined, raw: unknown): void {
|
||||||
const seat = this.seatOf(room, token);
|
const seat = this.seatOf(room, token);
|
||||||
@@ -287,6 +297,9 @@ export class Rooms<State, Input> {
|
|||||||
case 'seat':
|
case 'seat':
|
||||||
room.seats.push({ id: line.id, name: line.name, token: line.token, bot: line.bot });
|
room.seats.push({ id: line.id, name: line.name, token: line.token, bot: line.bot });
|
||||||
break;
|
break;
|
||||||
|
case 'unseat':
|
||||||
|
room.seats = room.seats.filter((s) => s.id !== line.id);
|
||||||
|
break;
|
||||||
case 'start':
|
case 'start':
|
||||||
room.state = this.game.create(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1);
|
room.state = this.game.create(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type { SeatId } from '../../src/lib/game/spec';
|
|||||||
export type LedgerLine =
|
export type LedgerLine =
|
||||||
| { t: 'room'; id: string; seats: number; createdAt: number }
|
| { t: 'room'; id: string; seats: number; createdAt: number }
|
||||||
| { t: 'seat'; id: SeatId; name: string; token: string; bot: boolean }
|
| { t: 'seat'; id: SeatId; name: string; token: string; bot: boolean }
|
||||||
|
/** A bot sent away by the host before the game began; its seat id is free again. */
|
||||||
|
| { t: 'unseat'; id: SeatId }
|
||||||
/** Ledgers from before revisions were recorded resolve under rules 1. */
|
/** Ledgers from before revisions were recorded resolve under rules 1. */
|
||||||
| { t: 'start'; seed: number; rules?: number }
|
| { t: 'start'; seed: number; rules?: number }
|
||||||
| { t: 'turn'; at: number; inputs: Record<SeatId, unknown> }
|
| { t: 'turn'; at: number; inputs: Record<SeatId, unknown> }
|
||||||
|
|||||||
@@ -31,14 +31,15 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<p class="eyebrow">room {v.roomId}</p>
|
<p class="eyebrow">room {v.roomId}</p>
|
||||||
<h2>The table is laid</h2>
|
<h2>The table is laid</h2>
|
||||||
<p>Send this link, or the code. Whoever opens it takes a seat. Anyone who arrives once the game has begun watches from the Peanut Gallery.</p>
|
<p>Send this link, or the code. Whoever opens it takes a seat, and the game begins when you say. Anyone who arrives once it has begun watches from the Peanut Gallery.</p>
|
||||||
<p class="link-row"><code>{link}</code><button type="button" class="quiet" onclick={copyLink}>{copied ? 'Copied' : 'Copy link'}</button></p>
|
<p class="link-row"><code>{link}</code><button type="button" class="quiet" onclick={copyLink}>{copied ? 'Copied' : 'Copy link'}</button></p>
|
||||||
{/if}
|
{/if}
|
||||||
<ul class="roster">
|
<ul class="roster">
|
||||||
{#each v.seats as s (s.id)}
|
{#each v.seats as s (s.id)}
|
||||||
<li>
|
<li>
|
||||||
<span class="seat-name">{s.name}</span>
|
<span class="seat-name">{s.name}</span>
|
||||||
<span class="muted">{s.id === v.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === v.me ? ', you' : ''}</span>
|
<span class="muted">{s.id === v.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === v.me ? ', you' : ''}{#if s.bot && room.isHost}
|
||||||
|
<button type="button" class="unseat" title="Send this bot away" aria-label="Send {s.name} away" onclick={() => room.unseat(s.id)}>×</button>{/if}</span>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
<li class="empty muted">{v.size - v.seats.length} of {v.size} seats empty{#if v.audience > 0}; {v.audience} in the gallery{/if}</li>
|
<li class="empty muted">{v.size - v.seats.length} of {v.size} seats empty{#if v.audience > 0}; {v.audience} in the gallery{/if}</li>
|
||||||
@@ -101,6 +102,20 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.unseat {
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
color: var(--bone-faint);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
margin-left: 0.3rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unseat:hover {
|
||||||
|
color: var(--blood);
|
||||||
|
}
|
||||||
|
|
||||||
.ways {
|
.ways {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export function makeApi<State, Input>() {
|
|||||||
join: (roomId: string, name: string) => call<Seated>('POST', `/api/rooms/${roomId}/join`, { name }),
|
join: (roomId: string, name: string) => call<Seated>('POST', `/api/rooms/${roomId}/join`, { name }),
|
||||||
addBot: (roomId: string, token: string) => call<View>('POST', `/api/rooms/${roomId}/bot`, { token }),
|
addBot: (roomId: string, token: string) => call<View>('POST', `/api/rooms/${roomId}/bot`, { token }),
|
||||||
begin: (roomId: string, token: string) => call<View>('POST', `/api/rooms/${roomId}/begin`, { token }),
|
begin: (roomId: string, token: string) => call<View>('POST', `/api/rooms/${roomId}/begin`, { token }),
|
||||||
|
unseat: (roomId: string, token: string, seat: SeatId) => call<View>('POST', `/api/rooms/${roomId}/unseat`, { token, seat }),
|
||||||
view: (roomId: string, token: string) => call<View>('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`),
|
view: (roomId: string, token: string) => call<View>('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`),
|
||||||
/** The gallery's view: no token, no seat, nothing any player could not see. */
|
/** The gallery's view: no token, no seat, nothing any player could not see. */
|
||||||
watch: (roomId: string) => call<View>('GET', `/api/rooms/${roomId}`),
|
watch: (roomId: string) => call<View>('GET', `/api/rooms/${roomId}`),
|
||||||
|
|||||||
@@ -104,6 +104,11 @@ export class Room {
|
|||||||
return this.act(() => api.addBot(this.roomId, this.token), 'The bot could not be seated.');
|
return this.act(() => api.addBot(this.roomId, this.token), 'The bot could not be seated.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Send a bot away before the game begins; only the host may. */
|
||||||
|
unseat(seat: SeatId): Promise<boolean> {
|
||||||
|
return this.act(() => api.unseat(this.roomId, this.token, seat), 'The bot could not be sent away.');
|
||||||
|
}
|
||||||
|
|
||||||
say(text: string): Promise<boolean> {
|
say(text: string): Promise<boolean> {
|
||||||
if (this.spectating) return Promise.resolve(false);
|
if (this.spectating) return Promise.resolve(false);
|
||||||
return this.act(() => api.say(this.roomId, this.token, text), 'The table did not hear you.');
|
return this.act(() => api.say(this.roomId, this.token, text), 'The table did not hear you.');
|
||||||
@@ -125,11 +130,12 @@ export class Room {
|
|||||||
return new Room(seated.view, seated.token);
|
return new Room(seated.view, seated.token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A room with a bot already seated, so a solo game is a ledger like any other. */
|
/** A room with a bot seated and the game begun, so a solo game is a ledger like any other. */
|
||||||
static async createWithBot(name: string): Promise<Room> {
|
static async createWithBot(name: string): Promise<Room> {
|
||||||
const seated = await api.create(name, 2);
|
const seated = await api.create(name, 2);
|
||||||
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
|
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
|
||||||
return new Room(await api.addBot(seated.view.roomId, seated.token), seated.token);
|
await api.addBot(seated.view.roomId, seated.token);
|
||||||
|
return new Room(await api.begin(seated.view.roomId, seated.token), seated.token);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async join(roomId: string, name: string): Promise<Room> {
|
static async join(roomId: string, name: string): Promise<Room> {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
<section id="table">
|
<section id="table">
|
||||||
<h2>The table</h2>
|
<h2>The table</h2>
|
||||||
<p>A table fills as players open the link. Whoever laid it is the host: they may seat a bot in any empty chair, and they begin the game when the company suits them. Once begun, the seats are closed.</p>
|
<p>A table fills as players open the link. Whoever laid it is the host: they may seat a bot in any empty chair or send one away, and they begin the game when the company suits them, however full the table. Once begun, the seats are closed.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="board">
|
<section id="board">
|
||||||
|
|||||||
Reference in New Issue
Block a user