Compare commits
5
Commits
e91d209cbe
...
75428b5a9b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75428b5a9b | ||
|
|
62cb00be33 | ||
|
|
dcd2293b42 | ||
|
|
cbe85d2d31 | ||
|
|
5e7ac999db |
No files matched your search
@@ -33,6 +33,14 @@
|
||||
"pitch": "Seven powers share one map, and none of them has to keep its word.",
|
||||
"players": "2 to 7",
|
||||
"origin": "Allan B. Calhamer's Diplomacy, 1959; an unofficial rebuild"
|
||||
},
|
||||
{
|
||||
"slug": "wyrm",
|
||||
"name": "Wyrm",
|
||||
"url": "https://wyrm.kestrelsnest.social",
|
||||
"pitch": "Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.",
|
||||
"players": "2",
|
||||
"origin": "Gary Gygax's Dragonchess, Dragon Magazine No. 100, 1985; an unofficial rebuild"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -36,7 +36,7 @@ LOGS = "/var/lib/caddy/gamehall-access*.log*"
|
||||
# The games that share this host, by the name the hall links to. A visitor
|
||||
# who follows a card arrives with the hall as referrer (the hall sends its
|
||||
# origin and nothing more). Wiz-War keeps its own host and its own count.
|
||||
GAMES = {"dip": "dip.kestrelsnest.social", "waving-hands": "hands.kestrelsnest.social", "hnefatafl": "tafl.kestrelsnest.social"}
|
||||
GAMES = {"dip": "dip.kestrelsnest.social", "waving-hands": "hands.kestrelsnest.social", "hnefatafl": "tafl.kestrelsnest.social", "wyrm": "wyrm.kestrelsnest.social"}
|
||||
ASSET = (".png", ".jpg", ".svg", ".ico", ".txt", ".webmanifest")
|
||||
|
||||
|
||||
|
||||
@@ -212,6 +212,21 @@
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="game" href="https://wyrm.kestrelsnest.social">
|
||||
<div class="art"><img class="fill" src="wyrm.png" alt="Wyrm's upper board, the air, at the opening: Gold's Sylphs, Griffons and Dragon on the near ranks and Scarlet's on the far ones, on light blue and white squares."></div>
|
||||
<div class="body">
|
||||
<h2>Wyrm</h2>
|
||||
<p class="pitch">Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.</p>
|
||||
<p class="players">2 players, or solo against the drake</p>
|
||||
<p class="origin">An unofficial rebuild of Gary Gygax's 1985 Dragonchess, from Dragon Magazine No. 100</p>
|
||||
<p class="length"></p>
|
||||
<div class="go">
|
||||
<span class="play">Play Wyrm →</span>
|
||||
<span class="site">wyrm.kestrelsnest.social</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
<section class="cols">
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
+1
-1
@@ -2,7 +2,7 @@
|
||||
# Stamp out a new game from the template.
|
||||
# ./new-game.sh <slug> "<Name>" [port] [domain]
|
||||
# e.g. ./new-game.sh tumbleweed "Tumbleweed" 8791 tumbleweed.kestrelsnest.social
|
||||
# Ports so far: wizwar 8787, waving-hands 8788, hnefatafl 8789, dip 8790. The
|
||||
# Ports so far: wizwar 8787, waving-hands 8788, hnefatafl 8789, dip 8790, wyrm 8791. The
|
||||
# games share one host (deploy/README.md), so a new game takes the next free port.
|
||||
set -euo pipefail
|
||||
SLUG="${1:?usage: new-game.sh <slug> \"<Name>\" [port] [domain]}"
|
||||
|
||||
@@ -18,7 +18,7 @@ service user, /opt/<slug> and /var/lib/<slug> trees, access log
|
||||
small Node process capped at 300 MB by its unit file, and a room is a few
|
||||
kilobytes of ledger, so the host carries several. Each game needs a port
|
||||
of its own on the host: wizwar 8787, waving-hands 8788, hnefatafl 8789,
|
||||
dip 8790.
|
||||
dip 8790, wyrm 8791.
|
||||
|
||||
## Current production
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** The bots move once the request that made their move due has been answered: after this tick. */
|
||||
const answered = () => new Promise<void>((r) => setImmediate(r));
|
||||
|
||||
/** A table of the demo game: Ann, who opened it, Bo, and Cy when asked for, the game begun. */
|
||||
function table(spec: GameSpec<State, Input> = game, withCy = false) {
|
||||
const rooms = new Rooms(spec, new Store(dir));
|
||||
@@ -161,7 +164,7 @@ describe('rooms', () => {
|
||||
expect(() => rooms.rank(host.room, host.token, ['A'])).toThrow(/asks for no ranking/);
|
||||
});
|
||||
|
||||
it('lets a game give its bots words: when a round opens and when spoken to, with notes kept from every view', () => {
|
||||
it('lets a game give its bots words: when a round opens and when spoken to, with notes kept from every view', async () => {
|
||||
const heard: string[][] = [];
|
||||
const talking: GameSpec<State, Input> = {
|
||||
...game,
|
||||
@@ -179,6 +182,7 @@ describe('rooms', () => {
|
||||
const cy = rooms.join(host.room, 'Cy');
|
||||
const room = host.room;
|
||||
rooms.begin(room, host.token);
|
||||
await answered();
|
||||
rooms.say(room, host.token, 'Psst.', ['B']);
|
||||
rooms.say(room, cy.token, 'Hello, all.');
|
||||
const annSees = rooms.view(room, 'A').chat;
|
||||
@@ -188,6 +192,7 @@ describe('rooms', () => {
|
||||
expect(rooms.view(room, '').chat.map((l) => l.text)).toEqual(['Hello, all.']);
|
||||
rooms.submit(room, host.token, { pick: 1 }, 0);
|
||||
rooms.submit(room, cy.token, { pick: 2 }, 0);
|
||||
await answered();
|
||||
expect(heard[0]).toEqual(['Round 1.', 'Psst.', 'I hear you.', 'Hello, all.', 'I hear you.']);
|
||||
expect(room.chat.filter((l) => l.note === 'secret').map((l) => l.text)).toEqual(['Round 1.', 'Round 2.']);
|
||||
expect(rooms.view(room, 'A').parting).toEqual([]);
|
||||
@@ -229,6 +234,7 @@ describe('rooms', () => {
|
||||
rooms.addBot(host.room, host.token);
|
||||
rooms.begin(host.room, host.token);
|
||||
const room = host.room;
|
||||
vi.runOnlyPendingTimers();
|
||||
expect(room.state!.n).toBe(4);
|
||||
expect(rooms.view(room, 'A').playingOn).toBe(true);
|
||||
vi.advanceTimersByTime(2000);
|
||||
@@ -243,16 +249,18 @@ describe('rooms', () => {
|
||||
});
|
||||
|
||||
describe('the host and the other seats', () => {
|
||||
it("gives a person's seat to a bot, which plays it at once, and the person's token opens nothing", () => {
|
||||
it("gives a person's seat to a bot, which plays it at once, and the person's token opens nothing", async () => {
|
||||
const { rooms, room, ann, bo } = table();
|
||||
rooms.submit(room, ann, { pick: 5 }, 0);
|
||||
rooms.standIn(room, ann, 'B');
|
||||
// The round waited on Bo alone; the bot played it.
|
||||
// The round waited on Bo alone; the bot plays it as soon as the host has been answered.
|
||||
await answered();
|
||||
expect(room.resolved).toBe(1);
|
||||
expect(rooms.waitingOn(room)).toEqual(['A']);
|
||||
expect(() => rooms.seatOf(room, bo)).toThrow(/gave your seat to a bot/);
|
||||
expect(rooms.view(room, 'A').seats[1]).toEqual({ id: 'B', name: 'Bo', bot: true, standIn: true });
|
||||
rooms.submit(room, ann, { pick: 3 }, 1);
|
||||
await answered();
|
||||
expect(room.resolved).toBe(2);
|
||||
});
|
||||
|
||||
@@ -305,12 +313,15 @@ describe('the host and the other seats', () => {
|
||||
expect(() => rooms.hostPhrase(withBot.room, withBot.token, bot.id)).toThrow(/No other person/);
|
||||
});
|
||||
|
||||
it('voids a phrase for the seat once a bot stands in, and holds the person\'s seat at a rematch without the stand-in', () => {
|
||||
it('voids a phrase for the seat once a bot stands in, and holds the person\'s seat at a rematch without the stand-in', async () => {
|
||||
const { rooms, room, ann } = table();
|
||||
const { phrase } = rooms.hostPhrase(room, ann, 'B');
|
||||
rooms.standIn(room, ann, 'B');
|
||||
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
|
||||
while (!game.over(room.state!)) rooms.submit(room, ann, { pick: 5 }, room.resolved);
|
||||
while (!game.over(room.state!)) {
|
||||
rooms.submit(room, ann, { pick: 5 }, room.resolved);
|
||||
await answered();
|
||||
}
|
||||
const next = rooms.get(rooms.rematch(room, ann).roomId);
|
||||
expect(next.expected).toEqual(['Bo']);
|
||||
expect(next.seats.map((s) => s.name)).toEqual(['Ann']);
|
||||
|
||||
@@ -351,8 +351,32 @@ export class Rooms<State, Input> {
|
||||
if (room.seats.length < this.game.minSeats) throw new RoomError(`The game needs at least ${this.game.minSeats} players.`, 409);
|
||||
this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules, ...(Object.keys(room.rankings).length ? { rankings: { ...room.rankings } } : {}) });
|
||||
// A game whose first move belongs to a bot must not wait for a human who is not owed a move.
|
||||
this.driveBots(room);
|
||||
this.botsSpeak(room, 'phase');
|
||||
this.botsAfterAnswering(room);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bots' moves, once the request that made them due has been answered.
|
||||
* A bot that thinks for a second or two would otherwise hold up the reply
|
||||
* to the person whose move it is answering, so their own move would seem
|
||||
* to hang and the bot's to come at once. The table is told as each move
|
||||
* lands, the same as any other.
|
||||
*/
|
||||
/** A room forgotten and loaded again drives itself; a timer or tick that wakes with the old one must do nothing. */
|
||||
private isTable(room: Room<State>): boolean {
|
||||
return this.rooms.get(room.id) === room;
|
||||
}
|
||||
|
||||
private botsAfterAnswering(room: Room<State>): void {
|
||||
setImmediate(() => {
|
||||
if (!this.isTable(room)) return;
|
||||
try {
|
||||
this.driveBots(room);
|
||||
this.botsSpeak(room, 'phase');
|
||||
} catch (e) {
|
||||
// Nothing is waiting on this callback: an error here would take the process down, so it is logged and the table left as it stands.
|
||||
console.error(`${room.id}: the bots could not move: ${e instanceof Error ? e.stack ?? e.message : String(e)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -383,8 +407,7 @@ export class Rooms<State, Input> {
|
||||
const hurried = this.hurried.has(room.id);
|
||||
const next = setTimeout(() => {
|
||||
this.pacing.delete(room.id);
|
||||
// A room forgotten and loaded again drives itself; this one is no longer the table.
|
||||
if (this.rooms.get(room.id) !== room) return;
|
||||
if (!this.isTable(room)) return;
|
||||
this.driveBots(room, hurried ? HURRIED_STEP : 1);
|
||||
// Played out: the bots have their say once, as after any round, which ends a game with parting words.
|
||||
if (!this.pacing.has(room.id)) this.botsSpeak(room, 'phase');
|
||||
@@ -538,8 +561,7 @@ export class Rooms<State, Input> {
|
||||
}
|
||||
}
|
||||
this.commit(room, { t: 'turn', at: Date.now(), inputs });
|
||||
this.driveBots(room);
|
||||
this.botsSpeak(room, 'phase');
|
||||
this.botsAfterAnswering(room);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="description" content="__NAME__, a game between friends, free in the browser." />
|
||||
<link rel="canonical" href="https://__DOMAIN__/" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="__NAME__" />
|
||||
|
||||
@@ -45,14 +45,13 @@
|
||||
{ n: t.turnsPlayed, label: 'turns recorded' },
|
||||
{ n: hours(t.minutesAtTable), label: 'at the table' }
|
||||
];
|
||||
const onCards = new Set<string>([]);
|
||||
const details = [
|
||||
{ n: t.gamesBegun, label: `games begun, at ${t.tablesOpened} ${t.tablesOpened === 1 ? 'table' : 'tables'}` },
|
||||
{ n: t.namesSeated, label: t.namesSeated === 1 ? 'name has taken a seat' : 'names have taken a seat' },
|
||||
{ n: t.turnsPlayed, label: `turns recorded; ${t.longestGameTurns} in the longest game` },
|
||||
{ n: t.gamesWithBot, label: t.gamesWithBot === 1 ? 'game with a bot seated' : 'games with a bot seated' },
|
||||
...(t.talkLines ? [{ n: t.talkLines, label: t.talkLines === 1 ? 'line of table talk' : 'lines of table talk' }] : []),
|
||||
...Object.entries(t.byGame).filter(([label]) => !onCards.has(label)).map(([label, n]) => ({ n, label }))
|
||||
...Object.entries(t.byGame).map(([label, n]) => ({ n, label }))
|
||||
];
|
||||
const first = t.firstGameAt ? `The first game began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : '';
|
||||
return { headline, details, note: `${first}Names are counted, not people: one player under two names is two here. Table time counts only the minutes between moves, so a game left open overnight adds nothing for the night.` };
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
let beginAsked = $state(false);
|
||||
const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host');
|
||||
const keeperSeated = $derived(v.seats.some((s) => s.name.toLowerCase() === v.keeper.toLowerCase()));
|
||||
/** A server with no KEEPER set calls its keeper "the keeper", which reads badly beside the title. */
|
||||
const named = $derived(v.keeper.toLowerCase() !== 'the keeper');
|
||||
|
||||
// A table that asks for rankings: this seat's, best first, kept on the server as it changes.
|
||||
const choices = $derived(game.rankable?.(v.options) ?? null);
|
||||
@@ -134,11 +136,11 @@
|
||||
{/if}
|
||||
{#if v.keeper && !v.challenge && !keeperSeated && room.seatUnheld}
|
||||
<div class="keeper">
|
||||
<button type="button" class="quiet" title="A seat is held for {v.keeper}, who keeps this site, and their phone rings" onclick={() => room.callKeeper()}>Challenge {v.keeper}, the keeper</button>
|
||||
<p class="muted small note">{v.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour} It is {keeperHour} where {v.keeper} lives.{/if} The seat is held either way.</p>
|
||||
<button type="button" class="quiet" title="A seat is held for {v.keeper}, who keeps this site, and their phone rings" onclick={() => room.callKeeper()}>Challenge {named ? `${v.keeper}, the keeper` : 'the keeper'}</button>
|
||||
<p class="muted small note">{named ? v.keeper : 'The keeper'} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour} It is {keeperHour} where {named ? v.keeper : 'the keeper'} lives.{/if} The seat is held either way.</p>
|
||||
</div>
|
||||
{:else if v.challenge}
|
||||
<p class="muted small note">{v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour} It is {keeperHour} there.{/if} A seat is held for them. If they can come now, they will take it. If they are asleep or away, they will leave a word below when they can. In the meantime you can seat a bot, invite a friend, or play another game in a new tab. This table keeps its place in your hall until you come back.</p>
|
||||
<p class="muted small note">{named ? v.keeper : 'The keeper'} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour} It is {keeperHour} there.{/if} A seat is held for them. If they can come now, they will take it. If they are asleep or away, they will leave a word below when they can. In the meantime you can seat a bot, invite a friend, or play another game in a new tab. This table keeps its place in your hall until you come back.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if room.error}<p class="warning">{room.error}</p>{/if}
|
||||
|
||||
Reference in new issue
Block a user