diff --git a/README.md b/README.md index 0c8d672ba..9ed8ddd61 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i - **Other maps.** Turn the whole building into a castle: sit on a throne of iron blades while your workers line up before you when they're done, send new ones off through the Hand of the King, and watch their beards grow long and grey as they toil. Send one home and the Kingsguard runs up from the dungeon, marches it down the stairs and throws it in a cell, where it starves, dies and rots down to a skeleton. Or into a space station in orbit, the Earth turning outside its windows: you run it from the captain's chair on the bridge, and a worker sent home is marched to the airlock and blown out into space, to drift off past the observation windows with everyone who went before it. Or make a map of your own, with its own way of seeing workers off in JSON ([docs/maps.md](docs/maps.md)). -There's a lot more (a rooftop bar, an office dog, an arcade, supercars in the garage to drive round a scenic loop past a farm, pines, mountains and a beach): see [docs/features.md](docs/features.md). +There's a lot more (a rooftop bar, an office dog, an arcade, a chess corner behind the couch, supercars in the garage to drive round a scenic loop past a farm, pines, mountains and a beach): see [docs/features.md](docs/features.md). ## Requirements diff --git a/docs/controls.md b/docs/controls.md index 808603aae..c936666ee 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -7,7 +7,7 @@ Back to the [README](../README.md). | W A S D / arrows | Walk (hold Shift to run); on the ladder, W and S climb; in a car, W is the gas, S brakes and reverses, A and D steer | | Space | Jump (you can land on desks, couches and the cars in the garage); in a car, brake | | Mouse drag / wheel | Orbit / zoom the camera | -| E | Interact: hire a worker, open its terminal, read a board, take an issue's note off the board, prompt a board agent, call a meeting in the meeting room, draw on the whiteboard, read the docs at the bookshelf, watch the TV, sit down (or get up), ride the elevator, climb the ladder (or get off it), slide down a fire pole, grab a coffee, take a smoke break, tee off at the golf tee, pet the dog, pick up the basketball (then hold E and let go to shoot), order a drink at the rooftop bar, blow the DJ's air horn, step up to the dart board or the axe lane on the roof (then hold Space and let go to throw), get into one of the cars in the garage (behind the wheel, or beside whoever's driving) or out of it, knock through the north wall past the gong for 2 more desks (at the **🚧 Room to grow** sign). In the [castle](maps.md#the-castle): sit on the throne, where E is for whoever's first in line (or the Hand of the King, with nobody waiting), and speak to the Hand to send out a new worker. On the [space station](maps.md#the-space-station) it's the captain's chair and the First Officer | +| E | Interact: hire a worker, open its terminal, read a board, take an issue's note off the board, prompt a board agent, call a meeting in the meeting room, draw on the whiteboard, read the docs at the bookshelf, watch the TV, sit down (or get up), play chess from a chess chair behind the couch, ride the elevator, climb the ladder (or get off it), slide down a fire pole, grab a coffee, take a smoke break, tee off at the golf tee, pet the dog, pick up the basketball (then hold E and let go to shoot), order a drink at the rooftop bar, blow the DJ's air horn, step up to the dart board or the axe lane on the roof (then hold Space and let go to throw), get into one of the cars in the garage (behind the wheel, or beside whoever's driving) or out of it, knock through the north wall past the gong for 2 more desks (at the **🚧 Room to grow** sign). In the [castle](maps.md#the-castle): sit on the throne, where E is for whoever's first in line (or the Hand of the King, with nobody waiting), and speak to the Hand to send out a new worker. On the [space station](maps.md#the-space-station) it's the captain's chair and the First Officer | | K | On the castle's throne (or in the station's captain's chair): speak to the Hand of the King (the First Officer), to send out a new worker | | P | Prompt: give a task to a new worker, or to the one at this desk | | C | Changes: the files the worker at this desk changed and their diff; commit, discard or open a PR | diff --git a/docs/features.md b/docs/features.md index f320d9b99..24b42b00f 100644 --- a/docs/features.md +++ b/docs/features.md @@ -19,6 +19,7 @@ Everything in the office, room by room. Back to the [README](../README.md). - **Golf off the balcony.** Next to the ashtray there's a tee: a square of turf, a ball on a tee and a bag of clubs. Across the street, where the neighbours leave a gap, a fairway runs up to a green with a flag on it, 43 m out and a storey down (further down from the floors above). Press **E** at the tee to step up with a club. The camera drops down behind the ball. The mouse or **A**/**D** aims, **W**/**S** set the loft (too flat and it hits the railing), and holding **Space** takes the club back while the power meter runs up and down: let go to hit. The camera follows the ball out and down, and a label says where it stopped and how far from the pin. Everyone on your floor sees you swing and your ball fly, landing in the same spot. Hole one and the green goes up in confetti. Your closest shot and your holes in one are kept in your browser. **E** puts the club back. - **Take a seat.** Press **E** at the lounge couch, a beanbag, the balcony bench or a stool at its bistro table, or the couch or the boss's chair up in the boss office to sit down. Walk off, jump or press **E** again to get up. Everyone on your floor sees you sitting. Sit on the couch facing the TV while someone's sharing their screen and it opens full screen for you. - **Minesweeper on the boss's monitor.** In the boss's chair, **E** plays Minesweeper on the monitor: the camera moves in close and the board sits right on the screen. Click to dig, right-click (or Shift-click) to flag, and click a number to dig around it once its mines are flagged. The first click is never a mine. **Stop playing** leaves your game up on the monitor, with its clock paused, until you come back. Walk off to get up. +- **Chess behind the couch.** Behind the lounge couch (its back to the room) there's a little chess corner: a modern table with two chairs facing each other and a teal-and-foam board on it. Press **E** on a chair to sit down, and **E** again to play chess: pick one of your idle agents as your opponent (chair 1 plays White, chair 2 Black) or play both sides yourself. The window shows whose turn it is, what each side has taken and the moves so far, with **New game** and **Resign**. Pawns choose what they become, and checkmate, stalemate and draws all end the game. Walk off to get up. - **Pick your character.** The first time you join, a character select screen lets you choose your name, skin tone, hair style, hair color and shirt, with a spinning preview. No name in mind? Leave it blank (or skip the screen) and you go by the made-up one shown in the box, like *Sunny Otter*, or press **🎲** for another. The office remembers your name and look in that browser, so you only pick once. Change it any time from **βš™οΈ** or by clicking your name under *In the office* (turn on **πŸ‘₯ People** in the **☰** menu). - **Hire workers.** Walk up to an empty desk and press **E** to choose Claude Code, OpenCode, Codex, Grok, Muse, DeepSeek Harness, Pi or Cursor, or press **P** to write a task first. A little worker sits down, a laptop opens, and the agent's live screen appears on it. Choose the agent for each queue task too. - **Signs over the desks.** Press **L** at any desk, empty or not, to hang a big sign from the ceiling over it: *Operations*, *Code cleanup*, *Frontend*, whatever that desk is for. Type up to 32 characters (or pick one of the ideas), choose one of seven colors and press **Enter**. It hangs over the far edge of the desk, facing the chair, so it reads over the shoulder of whoever sits there; while the desk back to back with it has no sign of its own, it says the same on the back, so you can read it from across the room either way. Everyone on the floor sees it, the hint at the desk names it, and it stays up across restarts. **L** again changes it or takes it down. Each floor has its own. diff --git a/src/client/core/maps.ts b/src/client/core/maps.ts index b9adbe085..3f909c5ba 100644 --- a/src/client/core/maps.ts +++ b/src/client/core/maps.ts @@ -12,7 +12,7 @@ import { builtFloors } from './floors'; import type { Parts } from './parts'; import { streetOf } from './worlds'; -export type MapsParts = Pick; +export type MapsParts = Pick; /** Registers the floor's paint (store 'floors'), the map (store 'map') and the floors' waiting count (the 'floors' message). */ export function installMaps(ctx: Ctx, core: CoreState, parts: MapsParts) { @@ -62,6 +62,7 @@ export function installMaps(ctx: Ctx, core: CoreState, parts: MapsParts) { hands.holdBall(false); for (const r of parts.peers.remotes.values()) r.person.holdBall(false); parts.arcade.stop(); + parts.chess.stop(); parts.cabinet.stop(); worlds.world().group.visible = false; worlds.enter(next); diff --git a/src/client/core/parts.ts b/src/client/core/parts.ts index 09c274ae2..bd0f30ef3 100644 --- a/src/client/core/parts.ts +++ b/src/client/core/parts.ts @@ -24,6 +24,7 @@ import type { installBookshelf } from '../features/bookshelf'; import type { installCabinet } from '../features/cabinet'; import type { installCarrying } from '../features/carrying'; import type { installCars } from '../features/cars'; +import type { installChess } from '../features/chess'; import type { installClimbing } from '../features/climbing'; import type { installCoffee } from '../features/coffee'; import type { installDog } from '../features/dog'; @@ -97,6 +98,7 @@ export interface Parts { gallery: Made; tv: Made; arcade: Made; + chess: Made; rooftop: Made; telescope: Made; dog: Made; diff --git a/src/client/features/chess/ai.ts b/src/client/features/chess/ai.ts new file mode 100644 index 000000000..f8f6aecf2 --- /dev/null +++ b/src/client/features/chess/ai.ts @@ -0,0 +1,84 @@ +// The idle agents' moves: a greedy 1-ply that takes what's free, keeps its own pieces, likes the +// middle and never misses a mate in one. Enough for a lunch-break game in the lounge. + +import { ChessGame, file, rank, type Move, type PieceType } from './engine'; +import { store } from '../../state'; + +const VALUE = { p: 100, n: 320, b: 330, r: 500, q: 900, k: 0 } as const; +const MATE = 100_000; + +/** Shuffles in place with `rng`, so equally good moves don't always come out the same way. */ +function shuffle(xs: T[], rng: () => number): T[] { + for (let i = xs.length - 1; i > 0; i--) { + const j = (rng() * (i + 1)) | 0; + [xs[i], xs[j]] = [xs[j], xs[i]]; + } + return xs; +} + +/** How the board looks for `me` once it has moved: material, the middle, and whether it's over. */ +function score(g: ChessGame, me: 'w' | 'b'): number { + const mat = material(g, me); + const status = g.status(); + if (status.over) { + if (status.reason === 'checkmate') return status.winner === me ? MATE + g.history.length : -MATE; + // Winning and settling for a draw is throwing the game away. + return mat > 200 ? -50_000 : 0; + } + // Material counted once above; here only the pull toward the middle. + let n = mat; + for (let sq = 0; sq < 64; sq++) { + const p = g.pieces[sq]; + if (!p) continue; + const v = center(p.t, sq); + n += p.c === me ? v : -v; + } + // A check is worth a nudge: most checks here come with something behind them. (Off the end of + // the game the opponent is the side to move; `winner` is only set on checkmate, above.) + if (status.check && g.turn !== me) n += 30; + return n; +} + +/** A nudge toward the middle for the little pieces, so games don't all hug the edges. */ +function center(t: PieceType, sq: number): number { + if (t !== 'p' && t !== 'n') return 0; + const d = Math.abs(3.5 - file(sq)) + Math.abs(3.5 - rank(sq)); + return (t === 'p' ? 6 : 10) * (7 - d); +} + +function material(g: ChessGame, me: 'w' | 'b'): number { + let n = 0; + for (const p of g.pieces) { + if (!p) continue; + n += p.c === me ? VALUE[p.t] : -VALUE[p.t]; + } + return n; +} + +/** An idle agent (waiting for a first prompt) the chess window offers as an opponent, by name. */ +export function idleOpponents(): { id: string; name: string }[] { + return [...store.workers.values()] + .filter((w) => w.status === 'idle' && w.kind === 'agent') + .map((w) => ({ id: w.id, name: w.name })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** The agent's move in `game` (it must be its turn), or undefined when the game is over. */ +export function chooseMove(game: ChessGame, rng: () => number = Math.random): Move | undefined { + const me = game.turn; + const moves = shuffle(game.allMoves(), rng); + if (!moves.length) return undefined; + let best: Move | undefined; + let bestScore = -Infinity; + for (const m of moves) { + const next = game.clone(); + next.play({ from: m.from, to: m.to, promo: m.promo ?? 'q' }); + // It always queens in the tactics below; queening for real is whoever's idea it was. + const s = score(next, me) + rng() * 8; + if (s > bestScore) { + bestScore = s; + best = m; + } + } + return best; +} diff --git a/src/client/features/chess/engine.ts b/src/client/features/chess/engine.ts new file mode 100644 index 000000000..9f82e0e20 --- /dev/null +++ b/src/client/features/chess/engine.ts @@ -0,0 +1,416 @@ +// The chess corner's rules: a complete little engine (legal moves with castling, en passant and +// promotion, check, checkmate, stalemate and the draws, with a SAN move list). Pure TypeScript with +// no imports, so the window (ui.ts), the idle-agent opponents (ai.ts) and the tests all share it. + +export type Color = 'w' | 'b'; +export type PieceType = 'p' | 'n' | 'b' | 'r' | 'q' | 'k'; +export interface Piece { + c: Color; + t: PieceType; +} +/** A move: `promo` when a pawn reaches the last rank (always one of q/r/b/n there). */ +export interface Move { + from: number; + to: number; + promo?: Exclude; +} + +export const other = (c: Color): Color => (c === 'w' ? 'b' : 'w'); +/** Square 0 is a1, 63 is h8: file 0 is the a-file, rank 0 White's home rank. */ +export const file = (sq: number): number => sq % 8; +export const rank = (sq: number): number => (sq / 8) | 0; +export const at = (f: number, r: number): number => r * 8 + f; +export const onBoard = (f: number, r: number): boolean => f >= 0 && f < 8 && r >= 0 && r < 8; +export const squareName = (sq: number): string => `${'abcdefgh'[file(sq)]}${rank(sq) + 1}`; + +const KNIGHTS: readonly (readonly [number, number])[] = [ + [1, 2], [2, 1], [2, -1], [1, -2], [-1, -2], [-2, -1], [-2, 1], [-1, 2], +]; +const KINGS: readonly (readonly [number, number])[] = [ + [1, 0], [1, 1], [0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1], [1, -1], +]; +const DIAGONALS: readonly (readonly [number, number])[] = [[1, 1], [1, -1], [-1, 1], [-1, -1]]; +const STRAIGHTS: readonly (readonly [number, number])[] = [[1, 0], [0, 1], [-1, 0], [0, -1]]; +const QUEEN_DIRS: readonly (readonly [number, number])[] = [...DIAGONALS, ...STRAIGHTS]; +const PROMOS: readonly Exclude[] = ['q', 'r', 'b', 'n']; + +/** Each home corner names its side's castling right: leaving it, or taken on it, takes it away. */ +const RIGHTS_HOME: Readonly> = { + 7: { right: 'K', c: 'w' }, + 0: { right: 'Q', c: 'w' }, + 63: { right: 'k', c: 'b' }, + 56: { right: 'q', c: 'b' }, +}; + +/** Whether `sq` is attacked by `by` on `b`. */ +function attacked(b: readonly (Piece | null)[], sq: number, by: Color): boolean { + const f = file(sq); + const r = rank(sq); + // Pawns: a pawn of `by` one step behind the square, a file to either side. + const pr = r + (by === 'w' ? -1 : 1); + for (const df of [-1, 1]) { + const p = onBoard(f + df, pr) ? b[at(f + df, pr)] : null; + if (p && p.c === by && p.t === 'p') return true; + } + for (const [df, dr] of KNIGHTS) { + const p = onBoard(f + df, r + dr) ? b[at(f + df, r + dr)] : null; + if (p && p.c === by && p.t === 'n') return true; + } + for (const [df, dr] of KINGS) { + const p = onBoard(f + df, r + dr) ? b[at(f + df, r + dr)] : null; + if (p && p.c === by && p.t === 'k') return true; + } + for (const [dirs, kinds] of [[DIAGONALS, 'bq'], [STRAIGHTS, 'rq']] as const) { + for (const [df, dr] of dirs) { + let g = f + df; + let h = r + dr; + while (onBoard(g, h)) { + const p = b[at(g, h)]; + if (p) { + if (p.c === by && kinds.includes(p.t)) return true; + break; + } + g += df; + h += dr; + } + } + } + return false; +} + +export interface GameStatus { + over: boolean; + /** Who won, when it's over with a winner. */ + winner?: Color; + /** Why it's over. */ + reason?: 'checkmate' | 'stalemate' | 'fifty' | 'material' | 'repetition'; + /** The side to move is in check (true of checkmate too). */ + check: boolean; +} + +interface HistoryEntry { + move: Move; + san: string; + /** What came off the board, if anything. */ + captured: Piece | null; + /** Who moved. */ + by: Color; +} + +/** A game of chess, from the starting position. */ +export class ChessGame { + pieces: (Piece | null)[] = []; + turn: Color = 'w'; + /** Castling still allowed: White/Black kingside/queenside. */ + castle = { K: true, Q: true, k: true, q: true }; + /** The square a pawn could take en passant, or -1. */ + ep = -1; + half = 0; + full = 1; + history: HistoryEntry[] = []; + private counts = new Map(); + + constructor() { + this.reset(); + } + + reset(): void { + const back: PieceType[] = ['r', 'n', 'b', 'q', 'k', 'b', 'n', 'r']; + this.pieces = Array(64).fill(null); + for (let f = 0; f < 8; f++) { + this.pieces[at(f, 0)] = { c: 'w', t: back[f] }; + this.pieces[at(f, 1)] = { c: 'w', t: 'p' }; + this.pieces[at(f, 6)] = { c: 'b', t: 'p' }; + this.pieces[at(f, 7)] = { c: 'b', t: back[f] }; + } + this.turn = 'w'; + this.castle = { K: true, Q: true, k: true, q: true }; + this.ep = -1; + this.half = 0; + this.full = 1; + this.history = []; + this.counts = new Map([[this.key(), 1]]); + } + + /** A copy to try moves on (what the AI thinks with): filled directly, no reset to throw away. */ + clone(): ChessGame { + const g = Object.create(ChessGame.prototype) as ChessGame; + g.pieces = this.pieces.map((p) => (p ? { ...p } : null)); + g.turn = this.turn; + g.castle = { ...this.castle }; + g.ep = this.ep; + g.half = this.half; + g.full = this.full; + g.history = [...this.history]; + g.counts = new Map(this.counts); + return g; + } + + private kingSquare(b: readonly (Piece | null)[], c: Color): number { + return b.findIndex((p) => p && p.c === c && p.t === 'k'); + } + + inCheck(c: Color = this.turn): boolean { + return attacked(this.pieces, this.kingSquare(this.pieces, c), other(c)); + } + + /** Every legal move, or only the piece on `sq`'s. */ + allMoves(): Move[] { + const out: Move[] = []; + for (let sq = 0; sq < 64; sq++) { + const p = this.pieces[sq]; + if (p && p.c === this.turn) out.push(...this.legalFrom(sq)); + } + return out; + } + + movesFrom(sq: number): Move[] { + const p = this.pieces[sq]; + return p && p.c === this.turn ? this.legalFrom(sq) : []; + } + + /** The pseudo-legal moves of the piece on `sq`, tried and kept when the king stays safe. */ + private legalFrom(sq: number): Move[] { + return this.pseudo(sq).filter((m) => { + const b = this.pieces.map((p) => (p ? { ...p } : null)); + applyOn(b, m, this.ep); + return !attacked(b, this.kingSquare(b, this.turn), other(this.turn)); + }); + } + + /** The moves of the piece on `sq` before checking what they leave the king in. */ + private pseudo(sq: number): Move[] { + const p = this.pieces[sq]; + if (!p) return []; + const f = file(sq); + const r = rank(sq); + const out: Move[] = []; + const slide = (dirs: readonly (readonly [number, number])[]) => { + for (const [df, dr] of dirs) { + let g = f + df; + let h = r + dr; + while (onBoard(g, h)) { + const q = this.pieces[at(g, h)]; + if (!q) out.push({ from: sq, to: at(g, h) }); + else { + if (q.c !== p.c) out.push({ from: sq, to: at(g, h) }); + break; + } + g += df; + h += dr; + } + } + }; + if (p.t === 'p') { + const dir = p.c === 'w' ? 1 : -1; + const home = p.c === 'w' ? 1 : 6; + const last = p.c === 'w' ? 7 : 0; + if (onBoard(f, r + dir) && !this.pieces[at(f, r + dir)]) { + const to = at(f, r + dir); + if (r + dir === last) for (const promo of PROMOS) out.push({ from: sq, to, promo }); + else { + out.push({ from: sq, to }); + if (r === home && !this.pieces[at(f, r + 2 * dir)]) out.push({ from: sq, to: at(f, r + 2 * dir) }); + } + } + for (const df of [-1, 1]) { + if (!onBoard(f + df, r + dir)) continue; + const to = at(f + df, r + dir); + const q = this.pieces[to]; + if (q && q.c !== p.c) { + if (r + dir === last) for (const promo of PROMOS) out.push({ from: sq, to, promo }); + else out.push({ from: sq, to }); + } else if (!q && to === this.ep) out.push({ from: sq, to }); + } + } else if (p.t === 'n') { + for (const [df, dr] of KNIGHTS) { + if (!onBoard(f + df, r + dr)) continue; + const q = this.pieces[at(f + df, r + dr)]; + if (!q || q.c !== p.c) out.push({ from: sq, to: at(f + df, r + dr) }); + } + } else if (p.t === 'b') slide(DIAGONALS); + else if (p.t === 'r') slide(STRAIGHTS); + else if (p.t === 'q') slide(QUEEN_DIRS); + else { + for (const [df, dr] of KINGS) { + if (!onBoard(f + df, r + dr)) continue; + const q = this.pieces[at(f + df, r + dr)]; + if (!q || q.c !== p.c) out.push({ from: sq, to: at(f + df, r + dr) }); + } + // Castling: the rights, a rook still on its corner, empty squares between, and the king + // neither in check nor crossing it. + const home = p.c === 'w' ? 0 : 7; + const foe: Color = other(p.c); + if (r === home && f === 4) { + const kingside = p.c === 'w' ? this.castle.K : this.castle.k; + const queenside = p.c === 'w' ? this.castle.Q : this.castle.q; + const empty = (s: number) => !this.pieces[s]; + const rookAt = (f: number) => { + const q = this.pieces[at(f, home)]; + return !!q && q.c === p.c && q.t === 'r'; + }; + if (kingside && rookAt(7) && empty(at(5, home)) && empty(at(6, home)) && !this.kingInCheckOn(this.pieces, p.c, [at(4, home), at(5, home), at(6, home)], foe)) + out.push({ from: sq, to: at(6, home) }); + if (queenside && rookAt(0) && empty(at(3, home)) && empty(at(2, home)) && empty(at(1, home)) && !this.kingInCheckOn(this.pieces, p.c, [at(4, home), at(3, home), at(2, home)], foe)) + out.push({ from: sq, to: at(2, home) }); + } + } + return out; + } + + private kingInCheckOn(b: readonly (Piece | null)[], c: Color, squares: number[], by: Color): boolean { + return squares.some((s) => attacked(b, s, by)); + } + + /** Plays a legal move, returning its SAN for the move list. Throws when it isn't legal. */ + play(move: Move): string { + // One legal list for finding the move and naming it; one for the position it leaves. + const legal = this.allMoves(); + const found = legal.find((m) => m.from === move.from && m.to === move.to && (m.promo ?? 'q') === (move.promo ?? 'q')); + if (!found) throw new Error(`illegal move ${squareName(move.from)}${squareName(move.to)}`); + const san = this.sanFor(found, legal); + const by = this.turn; + const captured = this.capturedByMove(found); + const piece = this.pieces[found.from]!; + applyOn(this.pieces, found, this.ep); + if (piece.t === 'p' && found.promo) this.pieces[found.to] = { c: by, t: found.promo }; + // The castling rights the move (or the capture) takes away. + this.rightsAfter(found, piece, captured); + // The square a pawn could take en passant next: only after a double push, beside it. + this.ep = piece.t === 'p' && Math.abs(rank(found.to) - rank(found.from)) === 2 ? at(file(found.from), (rank(found.from) + rank(found.to)) / 2) : -1; + this.half = piece.t === 'p' || captured ? 0 : this.half + 1; + if (by === 'b') this.full++; + this.turn = other(by); + this.history.push({ move: found, san, captured, by }); + const status = this.status(this.allMoves()); + const done = status.over && status.reason === 'checkmate' ? '#' : status.check ? '+' : ''; + const marked = `${san}${done}`; + this.history[this.history.length - 1].san = marked; + const n = (this.counts.get(this.key()) ?? 0) + 1; + this.counts.set(this.key(), n); + return marked; + } + + /** What comes off the board for `move`: the piece there, or the pawn behind an en-passant to-square. */ + private capturedByMove(m: Move): Piece | null { + const piece = this.pieces[m.from]!; + if (piece.t === 'p' && m.to === this.ep && !this.pieces[m.to]) { + return this.pieces[at(file(m.to), rank(m.from))]; + } + return this.pieces[m.to]; + } + + private rightsAfter(m: Move, piece: Piece, captured: Piece | null): void { + if (piece.t === 'k') { + if (piece.c === 'w') this.castle.K = this.castle.Q = false; + else this.castle.k = this.castle.q = false; + } + const from = RIGHTS_HOME[m.from]; + if (from && piece.t === 'r' && piece.c === from.c) this.castle[from.right] = false; + const to = RIGHTS_HOME[m.to]; + if (to && captured?.t === 'r' && captured.c === to.c) this.castle[to.right] = false; + } + + private sanFor(m: Move, legal?: Move[]): string { + const piece = this.pieces[m.from]!; + // Castling has its own name. + if (piece.t === 'k' && Math.abs(file(m.to) - file(m.from)) === 2) return file(m.to) > file(m.from) ? 'O-O' : 'O-O-O'; + const dest = squareName(m.to); + const takes = this.capturedByMove(m) ? 'x' : ''; + if (piece.t === 'p') { + const prefix = takes ? `${'abcdefgh'[file(m.from)]}x` : ''; + const promo = m.promo ? `=${m.promo.toUpperCase()}` : ''; + return `${prefix}${dest}${promo}`; + } + const letter = piece.t === 'n' ? 'N' : piece.t.toUpperCase(); + // Two of them could go there: name the file, the rank, or both to tell them apart. + const others = (legal ?? this.allMoves()).filter((o) => o.to === m.to && o.from !== m.from && this.pieces[o.from]?.t === piece.t); + let between = ''; + if (others.length) { + const sameFile = others.some((o) => file(o.from) === file(m.from)); + const sameRank = others.some((o) => rank(o.from) === rank(m.from)); + if (!sameFile) between = 'abcdefgh'[file(m.from)]; + else if (!sameRank) between = String(rank(m.from) + 1); + else between = squareName(m.from); + } + return `${letter}${between}${takes}${dest}`; + } + + status(legal?: Move[]): GameStatus { + const check = this.inCheck(); + const moves = legal ?? this.allMoves(); + if (!moves.length) { + if (check) return { over: true, winner: other(this.turn), reason: 'checkmate', check }; + return { over: true, reason: 'stalemate', check }; + } + if (this.half >= 100) return { over: true, reason: 'fifty', check }; + if (bareKings(this.pieces)) return { over: true, reason: 'material', check }; + if ((this.counts.get(this.key()) ?? 0) >= 3) return { over: true, reason: 'repetition', check }; + return { over: false, check }; + } + + /** What each side has taken, for the window's captured rows. */ + capturedBy(c: Color): Piece[] { + return this.history.filter((h) => h.by === c && h.captured).map((h) => h.captured!); + } + + lastMove(): { from: number; to: number } | null { + const h = this.history[this.history.length - 1]; + return h ? { from: h.move.from, to: h.move.to } : null; + } + + moves(): string[] { + return this.history.map((h) => h.san); + } + + key(): string { + const board = this.pieces.map((p) => (p ? `${p.c}${p.t}` : '..')).join(''); + const rights = `${this.castle.K ? 'K' : ''}${this.castle.Q ? 'Q' : ''}${this.castle.k ? 'k' : ''}${this.castle.q ? 'q' : ''}` || '-'; + return `${board}${this.turn}${rights}${this.epKey()}`; + } + + /** + * The en-passant square for repetition purposes: only a square some pawn of the side to move + * could actually take matters (otherwise identical positions would hash differently and a real + * threefold would be missed). + */ + private epKey(): string { + if (this.ep < 0) return '-'; + const f = file(this.ep); + const r = rank(this.ep); + const pr = r + (this.turn === 'w' ? -1 : 1); + for (const df of [-1, 1]) { + if (!onBoard(f + df, pr)) continue; + const p = this.pieces[at(f + df, pr)]; + if (p && p.c === this.turn && p.t === 'p') return String(this.ep); + } + return '-'; + } +} + +/** Whether neither side can possibly mate: bare kings, a lone minor, or bishops on one color each. */ +function bareKings(b: readonly (Piece | null)[]): boolean { + const rest = b.filter((p) => p && p.t !== 'k') as Piece[]; + if (!rest.length) return true; + if (rest.length === 1 && (rest[0].t === 'b' || rest[0].t === 'n')) return true; + if (rest.length === 2 && rest[0].c !== rest[1].c && rest[0].t === 'b' && rest[1].t === 'b') { + const squares = b.flatMap((p, i) => (p && p.t === 'b' ? [i] : [])); + if ((file(squares[0]) + rank(squares[0])) % 2 === (file(squares[1]) + rank(squares[1])) % 2) return true; + } + return false; +} + +/** Moves the piece on a board copy: plain moves, castling's rook, and en passant's pawn behind. */ +function applyOn(b: (Piece | null)[], m: Move, ep: number): void { + const piece = b[m.from]!; + if (piece.t === 'k' && Math.abs(file(m.to) - file(m.from)) === 2) { + const home = rank(m.from); + const rookFrom = file(m.to) > file(m.from) ? at(7, home) : at(0, home); + const rookTo = file(m.to) > file(m.from) ? at(5, home) : at(3, home); + b[rookTo] = b[rookFrom]; + b[rookFrom] = null; + } + if (piece.t === 'p' && m.to === ep && !b[m.to]) b[at(file(m.to), rank(m.from))] = null; + b[m.to] = b[m.from]; + b[m.from] = null; +} diff --git a/src/client/features/chess/index.ts b/src/client/features/chess/index.ts new file mode 100644 index 000000000..bed47c924 --- /dev/null +++ b/src/client/features/chess/index.ts @@ -0,0 +1,7 @@ +import type { Ctx } from '../../core/context'; +import { Chess } from './ui'; + +/** The chess corner behind the lounge couch: sit on a chair and press E again to play an idle agent (see features/seating). */ +export function installChess(ctx: Ctx): Chess { + return new Chess(ctx); +} diff --git a/src/client/features/chess/sound.ts b/src/client/features/chess/sound.ts new file mode 100644 index 000000000..8c4972f8b --- /dev/null +++ b/src/client/features/chess/sound.ts @@ -0,0 +1,28 @@ +import type { AudioCore } from '../../sound/core'; +import { CHESS_AT } from '../../sound/places'; + +// ---- The chess corner --------------------------------------------------------------------------- + +export type ChessSound = 'select' | 'move' | 'capture' | 'check' | 'win' | 'lose' | 'draw'; + +/** Wooden knocks and little chimes from the chess table behind the couch, so you hear which way it is. */ +export function chess(a: AudioCore, kind: ChessSound) { + const ctx = a.ctx; + if (!ctx) return; + a.count(`chess.${kind}`); + const out = a.panner(CHESS_AT, 2.5, 0.9); + out.connect(a.ambience); + const t0 = ctx.currentTime + 0.02; + if (kind === 'select') a.blip(out, t0, 660, 1.02, 0.05, 0.06, 'sine'); + else if (kind === 'move') { + // A piece set down: a soft wooden knock. + a.blip(out, t0, 220, 0.6, 0.08, 0.16, 'triangle'); + a.blip(out, t0 + 0.03, 440, 1.01, 0.06, 0.05, 'sine'); + } else if (kind === 'capture') { + a.blip(out, t0, 180, 0.55, 0.1, 0.2, 'triangle'); + a.blip(out, t0 + 0.06, 330, 0.8, 0.09, 0.12, 'triangle'); + } else if (kind === 'check') [880, 1175].forEach((f, i) => a.blip(out, t0 + i * 0.09, f, 1.0, 0.1, 0.09, 'sine')); + else if (kind === 'win') [523, 659, 784, 1047].forEach((f, i) => a.blip(out, t0 + i * 0.11, f, 1.0, 0.14, 0.1, 'triangle')); + else if (kind === 'lose') [392, 330, 262].forEach((f, i) => a.blip(out, t0 + i * 0.16, f, 0.97, 0.16, 0.1, 'triangle')); + else [440, 440].forEach((f, i) => a.blip(out, t0 + i * 0.18, f, 1.0, 0.14, 0.09, 'sine')); +} diff --git a/src/client/features/chess/ui.css b/src/client/features/chess/ui.css new file mode 100644 index 000000000..280fed266 --- /dev/null +++ b/src/client/features/chess/ui.css @@ -0,0 +1,165 @@ +/* The chess window: a modern teal-and-foam board (like the one on the table) beside its side panel. */ +.chess-modal { + width: min(94vw, 620px); + max-height: min(88vh, 720px); + display: flex; + flex-direction: column; +} +.chess-top { + display: flex; + align-items: baseline; + gap: 12px; + padding: 0 16px 8px; + flex-wrap: wrap; +} +.chess-opp { + display: flex; + align-items: center; + gap: 8px; + font-weight: 700; +} +.chess-opp select { + font: inherit; + border-radius: 8px; + padding: 2px 6px; +} +.chess-hint { + font-size: 12px; + opacity: 0.75; +} +.chess-status { + font-weight: 800; + margin-left: auto; +} +.chess-main { + display: flex; + gap: 12px; + padding: 0 16px; + min-height: 0; + flex: 1; +} +.chess-board { + display: grid; + grid-template-columns: repeat(8, 1fr); + width: min(58vw, 380px); + max-width: 100%; + aspect-ratio: 1; + border: 6px solid #1e293b; + border-radius: 10px; + overflow: hidden; + flex: none; +} +.chess-sq { + position: relative; + aspect-ratio: 1; + border: 0; + padding: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.chess-sq.light { + background: #e8f1ee; +} +.chess-sq.dark { + background: #0f766e; +} +.chess-piece { + font-size: clamp(20px, 4.5vmin, 38px); + line-height: 1; + pointer-events: none; +} +.chess-piece.white { + color: #f8fafc; + text-shadow: 0 0 3px #1e293b, 1px 1px 0 #1e293b, -1px -1px 0 #1e293b, 1px -1px 0 #1e293b, -1px 1px 0 #1e293b; +} +.chess-piece.black { + color: #1e293b; + text-shadow: 0 0 3px #e8f1ee; +} +.chess-sq.sel { + outline: 3px solid #e8c547; + outline-offset: -3px; +} +.chess-sq.last { + box-shadow: inset 0 0 0 100px rgb(232 197 71 / 28%); +} +.chess-sq.check { + box-shadow: inset 0 0 0 100px rgb(239 71 111 / 55%); +} +.chess-sq.tgt::after { + content: ''; + position: absolute; + width: 28%; + height: 28%; + border-radius: 50%; + background: rgb(232 197 71 / 85%); + pointer-events: none; +} +.chess-side { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + min-height: 0; +} +.chess-side h3 { + margin: 4px 0 0; + font-size: 13px; +} +.chess-taken { + min-height: 22px; + font-size: 18px; + line-height: 1.2; +} +.chess-who { + font-size: 12px; + font-weight: 700; + opacity: 0.8; +} +.chess-min.white { + color: #f8fafc; + text-shadow: 1px 1px 0 #1e293b, -1px -1px 0 #1e293b, 1px -1px 0 #1e293b, -1px 1px 0 #1e293b; +} +.chess-min.black { + color: #1e293b; +} +.chess-moves { + margin: 0; + padding-left: 24px; + overflow-y: auto; + flex: 1; + min-height: 60px; + font-size: 13px; +} +.chess-promo { + display: flex; + align-items: center; + gap: 6px; + min-height: 30px; +} +.chess-promo-btn { + font-size: 20px; + padding: 0 8px; +} +.chess-modal footer { + display: flex; + align-items: center; + gap: 8px; +} +.chess-modal .grow { + flex: 1; +} +@media (max-width: 560px) { + .chess-main { + flex-direction: column; + } + .chess-board { + width: 100%; + } + .chess-moves { + max-height: 110px; + } +} diff --git a/src/client/features/chess/ui.ts b/src/client/features/chess/ui.ts new file mode 100644 index 000000000..7875a43c2 --- /dev/null +++ b/src/client/features/chess/ui.ts @@ -0,0 +1,340 @@ +// The chess window: a board to play on, whose turn it is, what each side has taken, the moves so +// far, and new-game/resign buttons. Opened with E from a chess chair (see features/seating), played +// against an idle agent (ai.ts moves for them) or both sides yourself. An ordinary modal, so its βœ• +// and Esc put you straight back into mouse-look (see input/focus.ts). + +import { h, openModal, type Modal } from '../../ui/dom'; +import { store } from '../../state'; +import { ChessGame, file, rank, squareName, type Color, type GameStatus, type Move, type PieceType } from './engine'; +import { chooseMove, idleOpponents } from './ai'; +import type { Ctx } from '../../core/context'; +import './ui.css'; + +const GLYPH: Record = { k: 'β™š', q: 'β™›', r: 'β™œ', b: '♝', n: 'β™ž', p: 'β™Ÿ' }; +const VALUES: Record = { p: 1, n: 3, b: 3, r: 5, q: 9, k: 0 }; +const PROMO_CHOICE: Exclude[] = ['q', 'r', 'b', 'n']; + +/** Display order per side (your side along the bottom): `flip` never changes mid-game. */ +const ORDER_W = [7, 6, 5, 4, 3, 2, 1, 0].flatMap((r) => [0, 1, 2, 3, 4, 5, 6, 7].map((f) => r * 8 + f)); +const ORDER_B = [0, 1, 2, 3, 4, 5, 6, 7].flatMap((r) => [7, 6, 5, 4, 3, 2, 1, 0].map((f) => r * 8 + f)); + +const sideName = (c: Color): string => (c === 'w' ? 'White' : 'Black'); + +/** A game of chess against an idle agent, from a chess chair. */ +export class Chess { + private modal: Modal | null = null; + + constructor(private readonly ctx: Ctx) {} + + /** Puts it away, if you're at it (the building changed maps under you). */ + stop() { + this.modal?.close(); + } + + /** E on a chess chair, sitting down: play from that chair's side (chair 2 is Black). */ + play(seatId?: string) { + if (this.modal) return; + const ctx = this.ctx; + const game = new ChessGame(); + const me: Color = seatId === 'chess-chair-2' ? 'b' : 'w'; + // No board of your own: White's pieces start at the bottom from chair 1. + const flip = me === 'b'; + let selected = -1; + let targets: Move[] = []; + let targetSquares = new Set(); + let over: string | null = null; + let resigned: Color | null = null; + let thinking = false; + let timer: ReturnType | null = null; + // The opponent by worker id (names are not unique); the name is display-only and follows + // renames, falling back to the last-seen name when it goes home mid-game. + const first = idleOpponents()[0]; + let aiId: string | null = first?.id ?? null; + let aiName: string | null = first?.name ?? null; + + const title = h('h2', {}, 'β™ŸοΈ Chess'); + const close = h('button.btn.close', { type: 'button', 'aria-label': 'Close' }, 'βœ•'); + const opp = h('div.chess-opp', {}); + const status = h('div.chess-status', { role: 'status' }, ''); + const board = h('div.chess-board', { role: 'grid', 'aria-label': 'Chessboard' }); + const promoRow = h('div.chess-promo', {}); + const takenByMe = h('div.chess-taken', {}); + const takenByAi = h('div.chess-taken', {}); + const moves = h('ol.chess-moves', {}); + const newGame = h('button.btn', { type: 'button' }, 'β†Ί New game'); + const resign = h('button.btn', { type: 'button' }, '🏳️ Resign'); + const el = h( + 'div.modal.chess-modal', + { role: 'dialog', 'aria-label': 'Chess' }, + h('header', {}, title, close), + h('div.chess-top', {}, opp, status), + h('div.chess-main', {}, board, h('div.chess-side', {}, takenByMe, takenByAi, h('h3', {}, 'Moves'), moves, promoRow)), + h('footer', {}, h('span.grow', {}, 'Click one of your pieces, then where it goes. Pawns choose what they become.'), newGame, resign), + ); + + /** What each side is called: you and the agent, or White and Black playing solo. */ + const nameOf = (c: Color): string => (aiId ? (c === me ? 'You' : (aiName ?? 'Opponent')) : sideName(c)); + + const say = (text: string) => { + status.textContent = text; + }; + + const cells = new Map(); + for (const sq of flip ? ORDER_B : ORDER_W) { + const dark = (file(sq) + rank(sq)) % 2 === 0; + const cell = h('button.chess-sq', { + type: 'button', + role: 'gridcell', + 'aria-label': squareName(sq), + class: dark ? 'dark' : 'light', + }); + cell.addEventListener('click', () => click(sq)); + board.append(cell); + cells.set(sq, cell); + } + + // The opponent list as last rendered: rebuilt only when the roster actually changes, and never + // while the picker has focus (choosing must not be interrupted). + let oppSig = ''; + const refreshOpp = () => { + const list = idleOpponents(); + const sig = list.map((o) => `${o.id}=${o.name}`).join(','); + const focused = opp.contains(document.activeElement); + if (sig === oppSig && (opp.querySelector('select') as HTMLSelectElement | null)?.value === (aiId ?? '')) return; + if (focused && oppSig) return; + oppSig = sig; + // Follow renames; a worker that goes home mid-game keeps its last-seen name as a ghost. + if (aiId) aiName = list.find((o) => o.id === aiId)?.name ?? aiName; + opp.replaceChildren(); + const label = h('label', {}, 'Opponent: '); + const select = h('select', { 'aria-label': 'Opponent' }) as HTMLSelectElement; + const solo = document.createElement('option'); + solo.value = ''; + solo.textContent = 'Just me (both sides)'; + select.append(solo); + for (const o of list) { + const opt = document.createElement('option'); + opt.value = o.id; + opt.textContent = `πŸ€– ${o.name}`; + select.append(opt); + } + select.value = aiId ?? ''; + select.addEventListener('change', () => { + aiId = select.value || null; + aiName = list.find((o) => o.id === aiId)?.name ?? null; + start(); + }); + label.append(select); + opp.append(label); + if (!list.length) opp.append(h('span.chess-hint', {}, 'No idle agents β€” hire one at an empty desk.')); + }; + + // What each square showed last refresh: only changed cells are touched (the thinking toggle and + // worker activity ticks must not rebuild the board from under a click). + const seen = new Map(); + let seenMoves = -1; + let seenTaken = ''; + const refresh = () => { + const last = game.lastMove(); + const st = game.status(); + for (const [sq, cell] of cells) { + const p = game.pieces[sq]; + const glyph = p ? GLYPH[p.t] : ''; + const cls = `${p ? (p.c === 'w' ? 'white' : 'black') : ''}|${sq === selected}|${targetSquares.has(sq)}|${!!last && (last.from === sq || last.to === sq)}|${!!p && p.t === 'k' && p.c === game.turn && st.check}`; + if (seen.get(sq) === `${glyph}|${cls}`) continue; + seen.set(sq, `${glyph}|${cls}`); + cell.textContent = ''; + if (p) cell.append(h('span.chess-piece', { class: p.c === 'w' ? 'white' : 'black' }, glyph)); + cell.classList.toggle('sel', sq === selected); + cell.classList.toggle('tgt', targetSquares.has(sq)); + cell.classList.toggle('last', !!last && (last.from === sq || last.to === sq)); + cell.classList.toggle('check', !!p && p.t === 'k' && p.c === game.turn && st.check); + } + // What each side has taken, most valuable first; rebuilt only when the spoils change. + const takenKey = `${game.capturedBy(me).map((p) => p.t).join('')}|${game.capturedBy(me === 'w' ? 'b' : 'w').map((p) => p.t).join('')}`; + if (takenKey !== seenTaken) { + seenTaken = takenKey; + const show = (el: HTMLElement, by: Color, label: string) => { + const taken = [...game.capturedBy(by)].sort((a, b) => VALUES[b.t] - VALUES[a.t]); + el.replaceChildren(); + el.append(h('span.chess-who', {}, label)); + for (const p of taken) el.append(h('span.chess-min', { class: p.c === 'w' ? 'white' : 'black' }, GLYPH[p.t])); + }; + show(takenByMe, me, `${nameOf(me)} took: `); + show(takenByAi, me === 'w' ? 'b' : 'w', `${nameOf(me === 'w' ? 'b' : 'w')} took: `); + } + const sans = game.moves(); + if (sans.length !== seenMoves) { + seenMoves = sans.length; + moves.replaceChildren(); + for (let i = 0; i < sans.length; i += 2) { + moves.append(h('li', {}, `${i / 2 + 1}. ${sans[i]}${sans[i + 1] ? ` ${sans[i + 1]}` : ''}`)); + } + moves.scrollTop = moves.scrollHeight; + } + resign.toggleAttribute('disabled', over !== null); + if (over) say(over); + else if (thinking) say(`${aiName ?? 'Opponent'} is thinking…`); + else { + const who = aiId ? (game.turn === me ? 'Your move' : `${aiName ?? 'Opponent'} to move`) : `${nameOf(game.turn)} to move`; + say(st.check ? `${who} β€” check!` : who); + } + }; + + const endSounds = (st: GameStatus) => { + if (!st.over) return; + if (st.reason === 'checkmate') ctx.sound.chess(st.winner === me || !aiId ? 'win' : 'lose'); + else ctx.sound.chess('draw'); + }; + + const finish = (text: string) => { + over = text; + selected = -1; + targets = []; + targetSquares = new Set(); + refresh(); + }; + + const resultText = (st?: GameStatus): string | null => { + if (resigned) { + const winner = resigned === 'w' ? 'b' : 'w'; + const verb = aiId && resigned === me ? 'resign' : 'resigns'; + return `${nameOf(resigned)} ${verb} β€” ${nameOf(winner)} wins!`; + } + const s = st ?? game.status(); + if (!s.over) return null; + if (s.reason === 'checkmate') return `Checkmate β€” ${nameOf(s.winner!)} wins!`; + if (s.reason === 'stalemate') return 'Stalemate β€” draw.'; + if (s.reason === 'fifty') return 'Draw β€” fifty moves without a pawn move or capture.'; + if (s.reason === 'material') return 'Draw β€” neither side can mate.'; + return 'Draw β€” the same position three times.'; + }; + + const move = (m: Move) => { + const piece = game.pieces[m.from]!; + const takes = !!game.pieces[m.to] || (piece.t === 'p' && m.to === game.ep && !game.pieces[m.to]); + game.play(m); + selected = -1; + targets = []; + targetSquares = new Set(); + promoRow.replaceChildren(); + const st = game.status(); + const done = resultText(st); + if (done) { + finish(done); + endSounds(st); + return; + } + ctx.sound.chess(takes ? 'capture' : 'move'); + if (st.check) ctx.sound.chess('check'); + refresh(); + maybeAi(); + }; + + const click = (sq: number) => { + if (over || thinking) return; + const p = game.pieces[sq]; + // One of yours (or either side, playing solo): show where it goes. + if (p && p.c === game.turn && (!aiId || game.turn === me)) { + const ms = game.movesFrom(sq); + if (!ms.length) { + ctx.sound.chess('select'); + return; + } + selected = sq; + targets = ms; + targetSquares = new Set(ms.map((m) => m.to)); + promoRow.replaceChildren(); + ctx.sound.chess('select'); + refresh(); + return; + } + // Somewhere it goes: pawns on the last rank choose what they become first. + const to = targets.filter((m) => m.to === sq); + if (selected >= 0 && to.length) { + if (to.some((m) => m.promo)) { + promoRow.replaceChildren(); + promoRow.append(h('span.chess-who', {}, 'Promote to: ')); + for (const kind of PROMO_CHOICE) { + const b = h('button.btn.chess-promo-btn', { type: 'button', 'aria-label': `Promote to ${kind}` }, GLYPH[kind]); + b.addEventListener('click', () => move({ from: selected, to: sq, promo: kind })); + promoRow.append(b); + } + refresh(); + return; + } + move({ from: selected, to: sq }); + } + }; + + const maybeAi = () => { + if (over || !aiId || game.turn === me) return; + thinking = true; + refresh(); + timer = setTimeout(() => { + timer = null; + // Resigned (or closed) while thinking: the result stands, nothing moves underneath it. + if (!this.modal || over) return; + const m = chooseMove(game); + thinking = false; + if (!m) { + const done = resultText(); + if (done) finish(done); + endSounds(game.status()); + return; + } + move(m); + }, 450 + Math.random() * 500); + }; + + const start = () => { + if (timer) { + clearTimeout(timer); + timer = null; + } + game.reset(); + over = null; + resigned = null; + thinking = false; + selected = -1; + targets = []; + targetSquares = new Set(); + seen.clear(); + seenMoves = -1; + seenTaken = ''; + promoRow.replaceChildren(); + refreshOpp(); + refresh(); + maybeAi(); + }; + + newGame.addEventListener('click', start); + resign.addEventListener('click', () => { + if (over) return; + // Drop the pending reply first: resigning mid-think ends the game now, not on the timer. + if (timer) { + clearTimeout(timer); + timer = null; + } + thinking = false; + resigned = aiId ? me : game.turn; + ctx.sound.chess('lose'); + finish(resultText()!); + }); + + const offWorkers = store.on('workers', () => { + if (this.modal) refreshOpp(); + }); + + this.modal = openModal(el, { + doing: 'β™ŸοΈ playing chess', + onClose: () => { + if (timer) clearTimeout(timer); + offWorkers(); + this.modal = null; + }, + }); + close.addEventListener('click', () => this.modal?.close()); + start(); + } +} diff --git a/src/client/features/seating/index.ts b/src/client/features/seating/index.ts index 068ee9903..d23893569 100644 --- a/src/client/features/seating/index.ts +++ b/src/client/features/seating/index.ts @@ -7,6 +7,7 @@ import type { Ctx } from '../../core/context'; import { aside, hintTitle, key, onE } from '../../core/hint'; import { store } from '../../state'; import type { Arcade } from '../arcade/ui'; +import type { Chess } from '../chess/ui'; import { toast } from '../../ui/dom'; import type { Interactable } from '../../world/types'; @@ -24,6 +25,8 @@ export interface SeatingDeps { watchShare(): void; /** The boss's monitor (see features/arcade). */ arcade: Arcade; + /** The chess corner behind the lounge couch (see features/chess). */ + chess: Chess; /** The bar's menu (see features/bar). */ showBar(): void; /** What you can use where you are, and what's in the way of looking at it (see usable in input/pointer.ts). */ @@ -62,6 +65,7 @@ export function installSeating(ctx: Ctx, deps: SeatingDeps) { if (player.seat?.seatId === seatId) { if (seat.tv && tvShowing()) deps.watchShare(); else if (seat.game) deps.arcade.play(); + else if (seat.chess) deps.chess.play(seat.id); else if (seat.bar) deps.showBar(); else standUp(); return; @@ -112,11 +116,11 @@ export function installSeating(ctx: Ctx, deps: SeatingDeps) { if (!seat) return { k: '', parts: [] }; if (ctx.player.seat?.seatId === seat.id) { const tv = !!seat.tv && tvShowing(); - const use = tv ? 'Watch the TV' : seat.game ? 'Play Minesweeper' : seat.bar ? 'Order a drink' : ''; + const use = tv ? 'Watch the TV' : seat.game ? 'Play Minesweeper' : seat.chess ? 'Play chess' : seat.bar ? 'Order a drink' : ''; return { k: `${seat.id}|sitting|${tv}`, parts: [hintTitle(seat.label), aside('sitting'), ...(use ? [key('E', use), key('W A S D', 'Get up')] : [key('E', 'Get up')])] }; } const full = !freePlace(seat); - return { k: `${seat.id}|${full}`, parts: [hintTitle(seat.label), seat.game ? aside('πŸ’£ Minesweeper on the monitor') : '', full ? aside('no room') : key('E', 'Sit down')] }; + return { k: `${seat.id}|${full}`, parts: [hintTitle(seat.label), seat.game ? aside('πŸ’£ Minesweeper on the monitor') : seat.chess ? aside('β™ŸοΈ Chess against an idle agent') : '', full ? aside('no room') : key('E', 'Sit down')] }; }, use: onE((it) => { if (it.seatId) useSeat(it.seatId); diff --git a/src/client/lab/props.ts b/src/client/lab/props.ts index 20823c8de..e5bda4ceb 100644 --- a/src/client/lab/props.ts +++ b/src/client/lab/props.ts @@ -18,7 +18,7 @@ import { buildGong } from '../features/gong/world'; import { buildJukebox } from '../features/jukebox/world'; import { buildKitchen } from '../world/kitchen'; import { preloadModels } from '../world/models'; -import { DESK_BOOKS, FLOOR_PLANTS, buildDesk, coffeeTable, deskBooks, deskMug, loungeCouch, plant, pouf } from '../world/office'; +import { DESK_BOOKS, FLOOR_PLANTS, buildDesk, chessBoard, chessChair, chessTable, coffeeTable, deskBooks, deskMug, loungeCouch, plant, pouf } from '../world/office'; import { toon } from '../world/toon'; import { ready, stage } from './stage'; @@ -76,6 +76,23 @@ const SHOW: Record Shown> = { [loungeCouch(), coffeeTable(), pouf('#06d6a0'), pouf('#ffd166')].forEach((o, i) => object.add(o.translateX(at[i]))); return { object }; }, + chess: () => { + // The chess corner's pieces the way the lounge lays them out: the table with its board on top, + // and a chair in each color facing it across the table. + const object = new THREE.Group(); + const table = chessTable(); + const board = chessBoard(); + board.position.y = 0.75; + board.rotation.y = -Math.PI / 2; + const west = chessChair('#e76f51'); + west.position.x = -0.8; + west.rotation.y = Math.PI / 2; + const east = chessChair('#2a9d8f'); + east.position.x = 0.85; + east.rotation.y = -Math.PI / 2; + object.add(table, board, west, east); + return { object }; + }, lambo: () => ({ object: supercar('lambo', '#ffd166').root }), ferrari: () => ({ object: supercar('ferrari', '#ef476f').root }), }; diff --git a/src/client/main.ts b/src/client/main.ts index 7cc7da7d8..b4053a189 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -37,6 +37,7 @@ import { installCabinet } from './features/cabinet'; import { installCarrying } from './features/carrying'; import { installCars } from './features/cars'; import { installChat } from './features/chat'; +import { installChess } from './features/chess'; import { installClimbing } from './features/climbing'; import { installCoffee } from './features/coffee'; import { installDictation } from './features/dictation'; @@ -102,6 +103,7 @@ parts.confetti = new Confetti((x, z, y) => groundAt(ctx.player.colliders, x, z, ctx.scene.add(parts.confetti.mesh); parts.tv = installTv(ctx, { shares: () => parts.talk.currentShares(), watch: () => parts.talk.watchShare() }); parts.arcade = installArcade(ctx); +parts.chess = installChess(ctx); parts.rooftop = installRooftop(ctx, { ambient: parts.stage.ambient, hemi: parts.stage.hemi }); // You, and how you talk to the office. @@ -167,7 +169,7 @@ parts.cards = installCarrying(ctx, { officeIsFull: parts.actions.officeIsFull, showMeeting: parts.meeting.showMeeting, }); -parts.seating = installSeating(ctx, { shares: () => parts.talk.currentShares(), watchShare: () => parts.talk.watchShare(), arcade: parts.arcade, showBar: parts.bar.showBar, usable: () => parts.pointer.usable() }); +parts.seating = installSeating(ctx, { shares: () => parts.talk.currentShares(), watchShare: () => parts.talk.watchShare(), arcade: parts.arcade, chess: parts.chess, showBar: parts.bar.showBar, usable: () => parts.pointer.usable() }); installGong(ctx, { burstOver: parts.views.burstOver, workerViews: parts.views.workerViews, court: () => parts.worlds.court(), idleAgents: () => parts.worlds.idleAgents() }); parts.hintbar = installHintBar(ctx, core, parts); diff --git a/src/client/sound/index.ts b/src/client/sound/index.ts index 321c2ea74..91820eec1 100644 --- a/src/client/sound/index.ts +++ b/src/client/sound/index.ts @@ -26,6 +26,7 @@ import { AudioCore, type Hall, type Listener } from './core'; import { bark, yip } from '../features/dog/sound'; import { airlock, cellDoor, thud } from '../features/workers/sound'; import { golf, type GolfSound } from '../features/golf/sound'; +import { chess, type ChessSound } from '../features/chess/sound'; import { gong } from '../features/gong/sound'; import { Jukebox, type JukeboxPlay } from '../features/jukebox/sound'; import { needsYou } from '../features/needsyou/sound'; @@ -222,6 +223,10 @@ export class OfficeSound { arcade(this.a, kind, lines); } + chess(kind: ChessSound) { + chess(this.a, kind); + } + // ---- The cars in the garage (features/cars) ----------------------------------------------------- setEngines(running: Engine[]) { diff --git a/src/client/sound/places.ts b/src/client/sound/places.ts index 7b1bcfd89..4e37ece81 100644 --- a/src/client/sound/places.ts +++ b/src/client/sound/places.ts @@ -16,3 +16,5 @@ export const WINDOWS: Pos[] = OPENINGS.filter((o) => o.y0 < 2).map((o) => export const GONG_AT: Pos = { x: GONG.x, y: GONG.height - 1.36, z: GONG.z }; /** The arcade cabinet's speaker, under its screen. */ export const CABINET_AT: Pos = { x: CABINET.x - 0.2, y: 1.2, z: CABINET.z }; +/** The chess table behind the lounge couch, at board height. */ +export const CHESS_AT: Pos = { x: 8.1, y: 1, z: 0 }; diff --git a/src/client/ui/help.ts b/src/client/ui/help.ts index ac1f51c5a..f4093fc58 100644 --- a/src/client/ui/help.ts +++ b/src/client/ui/help.ts @@ -14,6 +14,7 @@ export const HELP_ROWS: readonly (readonly [string, string])[] = [ ['πŸ€–', 'An agent stands by the issues board, the PR board and the task queue. Press E at one and type what you want: it runs as an agent that knows that board. O there opens its terminal, X sends it home'], ['πŸ“', 'The whiteboard on wheels between the desks and the lounge: press E to draw on it with everyone on your floor, live. What you draw stays up on the board'], ['πŸ•ΉοΈ', 'The arcade cabinet in the lounge plays BLOCKFALL: arrows (or WASD) move and turn, Space drops, C holds, P pauses. Everyone on the floor sees your game on it, and E there watches whoever is playing. One of your workers needing input pauses it'], + ['β™ŸοΈ', 'The chess corner behind the lounge couch: E on a chair sits down, and E again plays chess against one of your idle agents (or both sides yourself). Click one of your pieces, then where it goes'], ['πŸŽ‰', 'Whenever a pull request merges, the gong next to the PR board rings, confetti rains down all over the floor and every worker gets up on its desk for a quick dance. Walk up to the gong and press E to bang it yourself'], ['N', "Next worker that needs you: go to whoever needs input (they have a red beacon over their desk, and a banner up top says what each is asking), then whoever is done and nobody's looked at, longest-waiting first, and again for the next one. Arrows at the edge of the screen point to the ones out of sight"], ['πŸ€', 'The hoop on the west wall, by the exit door: E at the ball picks it up. Hold E (or the mouse, in first person) and let go when the meter is in the green to sink it. In first person it goes where you look. Q drops it. Everyone on your floor sees your shot'], diff --git a/src/client/world/office/index.ts b/src/client/world/office/index.ts index a1b4bb3d4..086614b90 100644 --- a/src/client/world/office/index.ts +++ b/src/client/world/office/index.ts @@ -1,6 +1,6 @@ // The office: the room every floor of the building is (see build.ts), and the pieces of it other maps // and the lab reuse. The world's shared types live in ../types.ts. export { buildOffice } from './build'; -export { DESK_BOOKS, FLOOR_PLANTS, coffeeTable, deskBooks, deskMug, loungeCouch, plant, plantLeaves, pouf, type PlantSpecies } from './props'; +export { DESK_BOOKS, FLOOR_PLANTS, chessBoard, chessChair, chessTable, coffeeTable, deskBooks, deskMug, loungeCouch, plant, plantLeaves, pouf, type PlantSpecies } from './props'; export { buildDesk, vacancyMarker } from './seats'; export type { WingView } from './wing'; diff --git a/src/client/world/office/props.ts b/src/client/world/office/props.ts index 6b7c3c3d6..2f95b953a 100644 --- a/src/client/world/office/props.ts +++ b/src/client/world/office/props.ts @@ -1,5 +1,5 @@ import * as THREE from 'three'; -import { mesh, roundedBox, toon } from '../toon'; +import { mergeByMaterial, mesh, roundedBox, toon } from '../toon'; import { palette, piece } from '../models'; import { PALETTE } from './materials'; @@ -123,6 +123,112 @@ export function coffeeTable(): THREE.Object3D { return piece('lounge', 'coffee_table', paintLounge); } +// The chess corner behind the couch is code-built (toon/roundedBox like the pendants and wall +// boards above), its board in modern contrasting colors: deep teal and sea foam in an ink frame. + +/** A modern side table for the chess board: a white square top on a dark pedestal, its top 0.75 up. */ +export function chessTable(): THREE.Group { + const g = new THREE.Group(); + g.add(mesh(CHESS_TABLE_TOP, toon('#f8fafc'), 0, 0.72, 0)); + g.add(mesh(CHESS_PEDESTAL, toon('#1e293b'), 0, 0.39, 0)); + g.add(mesh(CHESS_BASE, toon('#1e293b'), 0, 0.025, 0)); + // One static prop: merge to a mesh per material instead of one draw call per part. + return mergeByMaterial(g); +} + +/** + * A modern molded chair in `color`, facing +z (its backrest behind it at -z), its origin on the + * floor under its middle. The seat's top is 0.49 up, where a chess sitter's hips (SEATING, 0.5) go. + */ +export function chessChair(color: string): THREE.Group { + const g = new THREE.Group(); + const shell = toon(color); + g.add(mesh(CHESS_SEAT, shell, 0, 0.45, 0)); + const back = mesh(CHESS_BACK, shell, 0, 0.78, -0.24); + back.rotation.x = 0.12; + g.add(back); + const legs = toon('#1e293b'); + for (const sx of [-1, 1]) for (const sz of [-1, 1]) g.add(mesh(CHESS_LEG, legs, sx * 0.22, 0.21, sz * 0.19)); + return mergeByMaterial(g); +} + +const CHESS_SQ = 0.09; +const CHESS_LIGHT = '#e8f1ee'; +const CHESS_DARK = '#0f766e'; + +// One board prop would otherwise be ~75 meshes (a geometry per square): every instance reuses these, +// and the finished board merges to a mesh per material. +const CHESS_TABLE_TOP = roundedBox(1.1, 0.06, 1.1, 0.05); +const CHESS_PEDESTAL = new THREE.CylinderGeometry(0.06, 0.08, 0.66, 12); +const CHESS_BASE = new THREE.CylinderGeometry(0.3, 0.34, 0.05, 20); +const CHESS_SEAT = roundedBox(0.55, 0.08, 0.5, 0.09); +const CHESS_BACK = roundedBox(0.5, 0.55, 0.08, 0.09); +const CHESS_LEG = new THREE.CylinderGeometry(0.025, 0.02, 0.42, 8); +const CHESS_BOARD_SQ = new THREE.BoxGeometry(CHESS_SQ, 0.045, CHESS_SQ); +const CHESS_FRAME = roundedBox(0.8, 0.04, 0.8, 0.03); +const CHESS_PAWN_BODY = new THREE.CylinderGeometry(0.016, 0.03, 0.07, 10); +const CHESS_PAWN_HEAD = new THREE.SphereGeometry(0.021, 10, 8); +const CHESS_KING_BODY = new THREE.CylinderGeometry(0.02, 0.034, 0.1, 10); +const CHESS_KING_HEAD = new THREE.SphereGeometry(0.02, 10, 8); +const CHESS_KING_BAR = new THREE.BoxGeometry(0.036, 0.012, 0.012); +const CHESS_KING_STEM = new THREE.BoxGeometry(0.012, 0.036, 0.012); + +/** A decorative pawn in `color`: a little turned body with a ball head, `CHESS_SQ`-scale. */ +function chessPawn(color: string): THREE.Group { + const g = new THREE.Group(); + const mat = toon(color); + g.add(mesh(CHESS_PAWN_BODY, mat, 0, 0.035, 0)); + g.add(mesh(CHESS_PAWN_HEAD, mat, 0, 0.085, 0)); + return g; +} + +/** A decorative king in `color`: a body with a cross on top, `CHESS_SQ`-scale. */ +function chessKing(color: string): THREE.Group { + const g = new THREE.Group(); + const mat = toon(color); + g.add(mesh(CHESS_KING_BODY, mat, 0, 0.05, 0)); + g.add(mesh(CHESS_KING_HEAD, mat, 0, 0.11, 0)); + g.add(mesh(CHESS_KING_BAR, mat, 0, 0.14, 0)); + g.add(mesh(CHESS_KING_STEM, mat, 0, 0.14, 0)); + return g; +} + +/** Where the square of file `f` and rank `r` (both 0–7, rank 0 White's home) sits on the board. */ +function chessSquare(f: number, r: number): [number, number] { + return [(f - 3.5) * CHESS_SQ, (3.5 - r) * CHESS_SQ]; +} + +/** + * A modern chessboard prop: an ink frame with deep-teal and sea-foam squares, 0.8 across, its + * origin under its middle, with a few decorative pieces mid-game on it (the real game is the + * chess window). Rank 0 (White's home) runs along its +z edge. + */ +export function chessBoard(): THREE.Group { + const g = new THREE.Group(); + g.add(mesh(CHESS_FRAME, toon('#1e293b'), 0, 0.02, 0)); + for (let f = 0; f < 8; f++) { + for (let r = 0; r < 8; r++) { + const [x, z] = chessSquare(f, r); + g.add(mesh(CHESS_BOARD_SQ, toon((f + r) % 2 ? CHESS_LIGHT : CHESS_DARK), x, 0.022, z, false)); + } + } + // A game in progress: kings castled apart with a few pawns between them. + const at = (piece: THREE.Group, f: number, r: number) => { + const [x, z] = chessSquare(f, r); + piece.position.set(x, 0.045, z); + g.add(piece); + }; + at(chessKing('#f8fafc'), 6, 0); + at(chessPawn('#f8fafc'), 4, 3); + at(chessPawn('#f8fafc'), 5, 3); + at(chessPawn('#f8fafc'), 3, 2); + at(chessKing('#1e293b'), 2, 7); + at(chessPawn('#1e293b'), 4, 4); + at(chessPawn('#1e293b'), 3, 5); + at(chessPawn('#1e293b'), 5, 5); + return mergeByMaterial(g); +} + /** A pendant lamp, its shade at 0, on a cord `cord` meters long. */ export function pendant(cord = 0.48): THREE.Group { const lamp = new THREE.Group(); diff --git a/src/client/world/office/room.ts b/src/client/world/office/room.ts index cb41cdccc..952ed6e2e 100644 --- a/src/client/world/office/room.ts +++ b/src/client/world/office/room.ts @@ -6,7 +6,7 @@ import { mesh, roundedBox, textPlane, toon, toonUnique } from '../toon'; import type { Collider, Interactable } from '../types'; import type { Fixture } from './fixture'; import { PALETTE } from './materials'; -import { coffeeTable, floorPlant, loungeCouch, pendant, plant, pouf, wallBoard } from './props'; +import { coffeeTable, chessBoard, chessChair, chessTable, floorPlant, loungeCouch, pendant, plant, pouf, wallBoard } from './props'; import { seatable } from './seats'; // The room itself, past its walls and its seats: the rugs, what the sky lights and darkens, the boards @@ -153,6 +153,26 @@ export const lounge: Fixture = (site) => { site.colliders.push({ minX: x - 0.5, maxX: x + 0.5, minZ: z - 0.5, maxZ: z + 0.5, top: 0.42 }); seatable(seat, id, 1.4, site.interactables); } + + // The chess corner behind the couch (room side): a little table with two modern chairs facing + // each other across it, and a board on the table (its White side toward chair 1, in the west). + const chessDesk = chessTable(); + chessDesk.position.set(8.1, 0, 0); + site.group.add(chessDesk); + site.colliders.push({ minX: 7.55, maxX: 8.65, minZ: -0.4, maxZ: 0.4, top: 0.75 }); + const board = chessBoard(); + board.position.set(8.1, 0.75, 0); + board.rotation.y = -Math.PI / 2; + site.group.add(board); + for (const [id, color] of [['chess-chair-1', '#e76f51'], ['chess-chair-2', '#2a9d8f']] as const) { + const seat = SEATING_BY_ID.get(id)!; + const chair = chessChair(color); + chair.position.set(seat.x, 0, seat.z); + chair.rotation.y = seat.rotY; + site.group.add(chair); + site.colliders.push({ minX: seat.x - 0.3, maxX: seat.x + 0.3, minZ: seat.z - 0.3, maxZ: seat.z + 0.3, top: 0.5 }); + seatable(chair, id, 1.6, site.interactables); + } return {}; }; diff --git a/src/shared/layout.ts b/src/shared/layout.ts index a9e3bba45..afddab1f1 100644 --- a/src/shared/layout.ts +++ b/src/shared/layout.ts @@ -446,6 +446,8 @@ export interface SeatDef { tv?: boolean; /** It faces the boss's monitor: E there, sitting down, plays Minesweeper on it. */ game?: boolean; + /** At the chess table behind the lounge couch: E there, sitting down, plays chess against an idle agent. */ + chess?: boolean; /** Up on the rooftop bar, not in the office. */ roof?: boolean; /** At the bar: E there, sitting down, orders a drink. */ @@ -459,6 +461,9 @@ export interface SeatDef { export const SEATING: SeatDef[] = [ // The lounge couch, its back to the room, facing the TV. { id: 'couch', label: 'πŸ›‹οΈ Couch', x: 10.5, y: 0, z: 0, rotY: Math.PI / 2, places: [-1.2, 0, 1.2], hips: 0.5, depth: -0.05, out: 0.9, tv: true }, + // The chess corner behind the couch (room side): two chairs facing each other across a little table. + { id: 'chess-chair-1', label: 'β™ŸοΈ Chess chair', x: 7.3, y: 0, z: 0, rotY: Math.PI / 2, places: [0], hips: 0.5, depth: 0, out: -0.7, chess: true }, + { id: 'chess-chair-2', label: 'β™ŸοΈ Chess chair', x: 8.95, y: 0, z: 0, rotY: -Math.PI / 2, places: [0], hips: 0.5, depth: 0, out: -0.7, chess: true }, // Beanbags either side of the lounge, turned to the TV. { id: 'lounge-beanbag-1', label: '🫘 Beanbag', x: 12.5, y: 0, z: 3.5, rotY: Math.atan2(TV.x - 12.5, TV.z - 3.5), places: [0], hips: 0.42, depth: -0.1, out: 1.2 }, { id: 'lounge-beanbag-2', label: '🫘 Beanbag', x: 14.5, y: 0, z: -3.4, rotY: Math.atan2(TV.x - 14.5, TV.z + 3.4), places: [0], hips: 0.42, depth: -0.1, out: 1.2 }, diff --git a/src/shared/nav.ts b/src/shared/nav.ts index 569f6c2dd..9f76e7348 100644 --- a/src/shared/nav.ts +++ b/src/shared/nav.ts @@ -55,6 +55,8 @@ function obstacles(wing: number): Obstacles { } rects.push([10, 11, -2.2, 2.2]); // couch rects.push([12.2, 13.8, -0.8, 0.8]); // coffee table + rects.push([7.55, 8.65, -0.4, 0.4]); // chess table behind the couch (see the lounge in world/office/room.ts) + circles.push([7.3, 0, 0.32], [8.95, 0, 0.32]); // its chairs, tucked in facing each other circles.push([12.5, 3.5, 0.5], [14.5, -3.4, 0.5]); // beanbags rects.push([-17, -10.75, 11.7, 12.7]); // kitchen counter and fridge for (const [x, z, s] of plantsAt(wing)) circles.push([x, z, 0.3 * s]); diff --git a/tests/chess.test.ts b/tests/chess.test.ts new file mode 100644 index 000000000..245937171 --- /dev/null +++ b/tests/chess.test.ts @@ -0,0 +1,200 @@ +// The chess corner: the engine's rules, the idle-agent AI, and the corner's place in the office. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { ChessGame, at, squareName } from '../src/client/features/chess/engine.js'; +import { SEATING_BY_ID, seatAt } from '../src/shared/layout.js'; +import { route, walkable } from '../src/shared/nav.js'; + +// idleOpponents and chooseMove read the store, which keeps things in localStorage: stand one in first. +const storage = new Map(); +Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: { getItem: (k: string) => storage.get(k) ?? null, setItem: (k: string, v: string) => void storage.set(k, String(v)), removeItem: (k: string) => void storage.delete(k) }, +}); +const { store } = await import('../src/client/state/index.js'); +const { chooseMove, idleOpponents } = await import('../src/client/features/chess/ai.js'); + +/** Plays `moves` (like 'e2e4', or 'e7e8q' to promote) on a fresh game, returning it. */ +function played(moves: string[]): ChessGame { + const g = new ChessGame(); + for (const m of moves) { + const promo = m.length === 5 ? (m[4] as 'q') : undefined; + const from = 'abcdefgh'.indexOf(m[0]) + (Number(m[1]) - 1) * 8; + const to = 'abcdefgh'.indexOf(m[2]) + (Number(m[3]) - 1) * 8; + g.play(promo ? { from, to, promo } : { from, to }); + } + return g; +} + +test('the starting position has 20 legal moves, and pawns march from e2', () => { + const g = new ChessGame(); + assert.equal(g.allMoves().length, 20); + assert.deepEqual( + g.movesFrom(at(4, 1)).map((m) => squareName(m.to)), + ['e3', 'e4'], + ); + assert.equal(g.turn, 'w'); + assert.deepEqual(g.status(), { over: false, check: false }); +}); + +test("fool's mate is checkmate for Black", () => { + const g = played(['f2f3', 'e7e5', 'g2g4', 'd8h4']); + assert.deepEqual(g.moves(), ['f3', 'e5', 'g4', 'Qh4#']); + const st = g.status(); + assert.equal(st.over, true); + assert.equal(st.reason, 'checkmate'); + assert.equal(st.winner, 'b'); + assert.equal(st.check, true); + assert.equal(g.allMoves().length, 0); +}); + +test('castling works with SAN, and takes the rights away', () => { + const g = played(['e2e4', 'e7e5', 'g1f3', 'b8c6', 'f1c4', 'f8c5']); + assert.ok(g.movesFrom(at(4, 0)).some((m) => squareName(m.to) === 'g1'), 'White can castle kingside'); + const san = g.play({ from: at(4, 0), to: at(6, 0) }); + assert.equal(san, 'O-O'); + assert.equal(g.castle.K, false); + assert.equal(g.castle.Q, false); +}); + +test('queenside castling has its own name', () => { + const g = played(['d2d4', 'd7d5', 'c1f4', 'c8f5', 'b1c3', 'b8c6', 'd1d3', 'd8d6']); + assert.ok(g.movesFrom(at(4, 0)).some((m) => squareName(m.to) === 'c1'), 'White can castle queenside'); + assert.equal(g.play({ from: at(4, 0), to: at(2, 0) }), 'O-O-O'); + assert.equal(g.pieces[at(3, 0)]?.t, 'r'); +}); + +test('en passant takes the pawn that just pushed past', () => { + const g = played(['e2e4', 'a7a6', 'e4e5', 'd7d5', 'e5d6']); + assert.deepEqual(g.moves().slice(-1), ['exd6']); + assert.equal(g.pieces[at(3, 5)]?.t, 'p'); + assert.equal(g.pieces[at(3, 5)]?.c, 'w'); + assert.equal(g.pieces[at(3, 4)], null); +}); + +test('a pawn marches up and becomes a queen', () => { + const g = played(['e2e4', 'd7d5', 'e4d5', 'a7a6', 'd5d6', 'h7h6', 'd6c7', 'g8f6', 'c7b8q']); + assert.deepEqual(g.moves().slice(-1), ['cxb8=Q']); + assert.deepEqual(g.pieces[at(1, 7)], { c: 'w', t: 'q' }); + // The march took a pawn, another pawn and the knight it promoted on. + assert.deepEqual( + g.capturedBy('w').map((p) => p.t).sort(), + ['n', 'p', 'p'], + ); +}); + +test('knights out and back twice is the same position three times: draw', () => { + const g = played(['g1f3', 'g8f6', 'f3g1', 'f6g8', 'g1f3', 'g8f6', 'f3g1', 'f6g8']); + const st = g.status(); + assert.equal(st.over, true); + assert.equal(st.reason, 'repetition'); +}); + +test('a queen hunt ends in stalemate, naming the rook that could', () => { + const g = played(['e2e3', 'a7a5', 'd1h5', 'a8a6', 'h5a5', 'h7h5', 'h2h4', 'a6h6', 'a5c7', 'f7f6', 'c7d7', 'e8f7', 'd7b7', 'd8d3', 'b7b8', 'd3h7', 'b8c8', 'f7g6', 'c8e6']); + assert.ok(g.moves().includes('Rah6'), 'the rooks are told apart'); + const st = g.status(); + assert.equal(st.over, true); + assert.equal(st.reason, 'stalemate'); +}); + +test('an illegal move throws', () => { + const g = new ChessGame(); + assert.throws(() => g.play({ from: at(4, 1), to: at(4, 4) }), /illegal/); +}); + +test('the repetition key counts en passant only when a pawn could take it', () => { + // 1. e4 leaves an en-passant square no black pawn can reach: same key as without it. + const g = played(['e2e4']); + const cleared = g.clone(); + cleared.ep = -1; + assert.equal(g.key(), cleared.key()); + // ...a6 e5 d5: White can take the d-pawn en passant, so the square matters. + const h = played(['e2e4', 'a7a6', 'e4e5', 'd7d5']); + const hCleared = h.clone(); + hCleared.ep = -1; + assert.notEqual(h.key(), hCleared.key()); +}); + +test('no rook on the corner means no castle, even with the right to', () => { + const g = new ChessGame(); + g.pieces[at(5, 0)] = null; + g.pieces[at(6, 0)] = null; + assert.ok(g.movesFrom(at(4, 0)).some((m) => squareName(m.to) === 'g1'), 'rook home: castle offered'); + g.pieces[at(7, 0)] = null; + assert.ok(!g.movesFrom(at(4, 0)).some((m) => squareName(m.to) === 'g1'), 'rook gone: no castle out of thin air'); +}); + +test('the agent never misses mate in one', () => { + const g = played(['f2f3', 'e7e5', 'g2g4']); + const m = chooseMove(g, () => 0); + assert.ok(m, 'Black has a move'); + g.play(m!); + assert.equal(g.status().reason, 'checkmate'); + assert.equal(g.status().winner, 'b'); +}); + +test('the agent takes a free pawn back', () => { + const g = played(['e2e4', 'd7d5', 'e4d5']); + const m = chooseMove(g, () => 0); + assert.ok(m, 'Black has a move'); + assert.equal(squareName(m!.to), 'd5'); +}); + +test('only idle agents are offered as opponents, by name', () => { + store.workers.clear(); + store.workers.set('w1', { id: 'w1', kind: 'agent', status: 'working', deskId: 'desk-1', name: 'Zed', color: '#fff', acked: true, createdBy: 't', createdAt: 0, cols: 80, rows: 24, viewers: [], viewerIds: [] }); + store.workers.set('w2', { id: 'w2', kind: 'agent', status: 'idle', deskId: 'desk-2', name: 'Mochi', color: '#fff', acked: true, createdBy: 't', createdAt: 0, cols: 80, rows: 24, viewers: [], viewerIds: [] }); + store.workers.set('w3', { id: 'w3', kind: 'shell', status: 'idle', deskId: 'desk-3', name: 'Shell', color: '#fff', acked: true, createdBy: 't', createdAt: 0, cols: 80, rows: 24, viewers: [], viewerIds: [] }); + store.workers.set('w4', { id: 'w4', kind: 'agent', status: 'idle', deskId: 'desk-4', name: 'Ada', color: '#fff', acked: true, createdBy: 't', createdAt: 0, cols: 80, rows: 24, viewers: [], viewerIds: [] }); + try { + assert.deepEqual(idleOpponents(), [ + { id: 'w4', name: 'Ada' }, + { id: 'w2', name: 'Mochi' }, + ]); + } finally { + store.workers.clear(); + } +}); + +test('agents sharing a name stay distinguishable by id', () => { + store.workers.clear(); + const base = { kind: 'agent', status: 'idle', deskId: 'desk-1', color: '#fff', acked: true, createdBy: 't', createdAt: 0, cols: 80, rows: 24, viewers: [], viewerIds: [] } as const; + store.workers.set('w1', { ...base, id: 'w1', name: 'Sam' }); + store.workers.set('w2', { ...base, id: 'w2', deskId: 'desk-2', name: 'Sam' }); + try { + assert.deepEqual(idleOpponents(), [ + { id: 'w1', name: 'Sam' }, + { id: 'w2', name: 'Sam' }, + ]); + } finally { + store.workers.clear(); + } +}); + +test('the chess chairs face each other across the table behind the couch', () => { + const one = SEATING_BY_ID.get('chess-chair-1')!; + const two = SEATING_BY_ID.get('chess-chair-2')!; + assert.ok(one.chess && two.chess); + assert.equal(one.rotY, Math.PI / 2); + assert.equal(two.rotY, -Math.PI / 2); + // Chair 1 west of the table at x 8.1, chair 2 east of it. + assert.ok(one.x < 8.1 && two.x > 8.1); + assert.ok(seatAt('chess-chair-1:0')); + assert.ok(seatAt('chess-chair-2:0')); +}); + +test('both chess chairs are reachable on open floor, and the room stays connected', () => { + // Up beside either chair, clear of the table and the fire pole. + assert.ok(walkable(7.3, -1.3), 'beside chair 1'); + assert.ok(walkable(8.95, 1.3), 'beside chair 2'); + // From the spawn to the east chair, and from the elevator past the corner to the south. + for (const [from, to] of [ + [[8, 7], [8.95, -1.3]], + [[8.5, -10], [8, 7]], + ] as const) { + const way = route(from, to); + const [ex, ez] = way[way.length - 1]; + assert.ok(Math.hypot(ex - to[0], ez - to[1]) < 0.6, `gets to ${to}`); + } +});