Play Smash&Clash, a two-player strategy board game where every move matters, in a pane while Claude works. /smash opens the board.

A Claude Code mod for Smash&Clash, a two-player strategy board game where every move matters. /smash opens the board in a pane, so you can play while Claude works.
/plugin marketplace add smashandclash/plugin
/plugin install smash-and-clash-mod@smashandclash
It needs Claude Code 2.1.287 or later, with mods enabled for your account. It's tested with 2.1.287.
| Key | Does |
|---|---|
1-5 | Pick a card from your hand |
w a s d | Move over the board (legal tiles are marked +) |
f | Play the card on the tile (an effect card plays at once) |
g | Suggest a move |
x | Stay put when a hop is offered |
n | New game, once one ends |
| Esc | Close the pane (the game stays saved) |
In the terminal the board is a colour grid: your cards are blue, theirs red, and the cursor is yellow. The Desktop app draws it as text.
While Claude works and the pane is closed, a line above the prompt shows the score and says /smash.
| Command | Does |
|---|---|
/smash | Opens the board: resumes your game, or starts one against the house |
/smash new | A new game against the Smash&Clash house opponent |
/smash duel | Opens a duel; the other side joins with the code |
/smash join CODE | Joins a duel (from the mod, the CLI, the SDK or an agent over MCP) |
/smash close | Closes the pane |
/plugin configure smash-and-clash-mod@smashandclash, or /config:
player_name: your name on the board and on replays.strength: how strong the house plays, an ELO from 800 to 1600 (default 1200).auto_open: open the board when Claude starts working. It opens only in a terminal at least 144 columns wide.https://www.smashandclash.in, the public games API, through $.http.fetch.$.store.claude --plugin-dir ./com.anthropic.claude-code/smash-and-clash-mod
claude plugin validate ./com.anthropic.claude-code/smash-and-clash-mod
claude plugin test ./com.anthropic.claude-code/smash-and-clash-mod # tests/smash.test.ts
node --test tests/logic.test.mjs # the logic, on a stand-in runtimehooks/register.js 461 lines1// Smash&Clash mod for Claude Code: play Smash&Clash, a two-player strategy
2// board game where every move matters, in a pane while Claude works.
3//
4// /smash open the board (resumes your game, or starts one)
5// /smash new a new game against the Smash&Clash house opponent
6// /smash duel open a duel; /smash join CODE joins one
7// /smash close close the pane (the game stays saved)
8//
9// Keys in the pane: 1-5 pick a card, w a s d move over the board, f plays,
10// g suggests a move, x stays put when a hop is offered, n starts a new game.
11// The game lives on www.smashandclash.in and is saved between sessions.
12
13import { boardRaster, boardText, cellAt, handLabel, movesFor, outcome, parseMove, screenOf, targetsFor } from './game.js'
14
15const PANE = 'smash'
16const VERSION = '0.1.0'
17
18let opts = { base: 'https://www.smashandclash.in', name: 'Claude Code player', strength: 1200, autoOpen: false }
19
20/** The game as the API last returned it, and your seat's secret token. */
21let game = null
22let token = null
23/** The hand card picked, the board cursor, and whether the cursor is in play. */
24let card = 0
25let cursor = { sx: 2, sy: 2 }
26let onBoard = false
27let recruitFrom = null
28/** What the pane says: work in flight, the last thing that happened. */
29let busy = ''
30let note = ''
31let working = false
32let paneOpen = false
33let polling = false
34
35/* --------------------------------- network --------------------------------- */
36
37async function api($, method, path, body) {
38 const headers = { accept: 'application/json', 'x-sdk': `smash-and-clash-mod/${VERSION}` }
39 if (body !== undefined) headers['content-type'] = 'application/json'
40 if (token && !path.endsWith('/games') && !path.endsWith('/join')) headers.authorization = `Bearer ${token}`
41 const r = await $.http.fetch(opts.base + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) })
42 let data = null
43 try {
44 data = r.text ? JSON.parse(r.text) : null
45 } catch {
46 data = null
47 }
48 if (!r.ok) {
49 const msg = typeof data?.error === 'string' ? data.error : data?.error?.message ?? `The server said ${r.status}.`
50 throw new Error(msg)
51 }
52 return data
53}
54
55async function save($) {
56 if (game && token) await $.store.set('game', { id: game.id, token, base: opts.base })
57}
58
59/** A new game, a joined duel or a resumed one: point the controls at it. */
60function adopt(next, nextToken) {
61 game = next
62 if (nextToken) token = nextToken
63 card = 0
64 onBoard = false
65 recruitFrom = null
66 settle()
67}
68
69/** After any change: keep the picked card playable and the cursor on a legal tile. */
70function settle() {
71 const v = game?.view
72 recruitFrom = null
73 if (!v?.yourTurn) {
74 onBoard = false
75 return
76 }
77 if (v.pendingHop) {
78 onBoard = true
79 const first = [...targetsFor(v, card, null).keys()][0]
80 if (first) cursor = screenOf(game.seat ?? 'A', first)
81 return
82 }
83 const playable = (v.hand ?? []).map((h) => movesFor(v, h).length > 0)
84 if (!playable[card]) card = Math.max(0, playable.indexOf(true))
85 onBoard = false
86}
87
88async function startHouse($) {
89 busy = 'Starting a game against the house'
90 $.ui.invalidate('ui.render')
91 try {
92 token = null
93 const r = await api($, 'POST', '/api/v1/games', { mode: 'house', name: opts.name, strength: opts.strength })
94 adopt(r.game, r.playerToken)
95 note = 'Your turn: pick a card (1-5), move over the board (w a s d), f plays.'
96 await save($)
97 } catch (err) {
98 note = err.message
99 }
100 busy = ''
101 $.ui.invalidate('ui.render')
102}
103
104async function createDuel($) {
105 busy = 'Opening a duel'
106 $.ui.invalidate('ui.render')
107 try {
108 token = null
109 const r = await api($, 'POST', '/api/v1/games', { mode: 'duel', name: opts.name })
110 adopt(r.game, r.playerToken)
111 note = `Duel code ${r.game.code}: the other side runs /smash join ${r.game.code}, or smashandclash duel join ${r.game.code}.`
112 await save($)
113 pollTurn($)
114 } catch (err) {
115 note = err.message
116 }
117 busy = ''
118 $.ui.invalidate('ui.render')
119}
120
121async function joinDuel($, code) {
122 busy = `Joining duel ${code}`
123 $.ui.invalidate('ui.render')
124 try {
125 token = null
126 const r = await api($, 'POST', '/api/v1/games/join', { code: code.toUpperCase(), name: opts.name })
127 adopt(r.game, r.playerToken)
128 note = `Joined ${r.game.players?.A ?? 'the duel'}.`
129 await save($)
130 pollTurn($)
131 } catch (err) {
132 note = err.message
133 }
134 busy = ''
135 $.ui.invalidate('ui.render')
136}
137
138/** Pick up the saved game, or start one if there is none (or it ended). */
139async function resumeOrStart($) {
140 const saved = await $.store.get('game')
141 if (saved?.id && saved?.token && (saved.base ?? opts.base) === opts.base) {
142 busy = 'Picking your game up again'
143 $.ui.invalidate('ui.render')
144 try {
145 token = saved.token
146 const g = await api($, 'GET', `/api/v1/games/${encodeURIComponent(saved.id)}`)
147 if (g.status !== 'finished' && g.status !== 'abandoned') {
148 adopt(g)
149 note = g.view?.yourTurn ? 'Your turn.' : 'Welcome back.'
150 busy = ''
151 pollTurn($)
152 $.ui.invalidate('ui.render')
153 return
154 }
155 } catch {
156 // gone or expired: start a new one
157 }
158 busy = ''
159 }
160 await startHouse($)
161}
162
163async function play($, move) {
164 if (!game || busy) return
165 const before = game
166 busy = `Playing ${move}`
167 $.ui.invalidate('ui.render')
168 try {
169 const next = await api($, 'POST', `/api/v1/games/${encodeURIComponent(game.id)}/moves`, { move })
170 game = next
171 const theirs = next.lastMove && next.lastMove !== move && next.moveCount - before.moveCount > 1 ? `, they played ${next.lastMove}` : ''
172 note = next.status === 'finished' ? outcome(next) : `You played ${move}${theirs}.`
173 settle()
174 await save($)
175 if (next.status === 'active' && !next.view?.yourTurn) pollTurn($)
176 } catch (err) {
177 note = err.message
178 }
179 busy = ''
180 $.ui.invalidate('ui.render')
181}
182
183/** Duels: wait for the other side in the background, a long poll at a time. */
184function pollTurn($) {
185 if (polling) return
186 polling = true
187 $.clock.after(0, () => waitOnce($))
188}
189
190async function waitOnce($) {
191 if (!game || game.kind !== 'duel' || game.status === 'finished' || game.status === 'abandoned' || game.view?.yourTurn) {
192 polling = false
193 return
194 }
195 try {
196 const before = game
197 const next = await api($, 'GET', `/api/v1/games/${encodeURIComponent(game.id)}/wait?timeout=20`)
198 if (game?.id === next.id) {
199 game = next
200 if (next.moveCount !== before.moveCount || next.status !== before.status) {
201 note = next.status === 'finished' ? outcome(next) : next.lastMove ? `They played ${next.lastMove}. Your turn.` : 'They joined. Your turn.'
202 settle()
203 $.ui.invalidate('ui.render')
204 if (next.view?.yourTurn) $.ui.toast(paneOpen ? 'Your move.' : 'Your move in Smash&Clash: /smash')
205 }
206 }
207 } catch (err) {
208 note = err.message
209 polling = false
210 $.ui.invalidate('ui.render')
211 return
212 }
213 $.clock.after(250, () => waitOnce($))
214}
215
216/* ----------------------------------- keys ---------------------------------- */
217
218function pickCard(i) {
219 const v = game?.view
220 if (!v?.yourTurn || !v.hand?.[i]) return
221 card = i
222 recruitFrom = null
223 const moves = movesFor(v, v.hand[i])
224 if (!moves.length) {
225 note = `No legal move for ${v.hand[i].card} right now.`
226 onBoard = false
227 return
228 }
229 onBoard = true
230 const seat = game.seat ?? 'A'
231 const targets = targetsFor(v, card, null)
232 if (!targets.has(cellAt(seat, cursor.sx, cursor.sy))) {
233 const first = [...targets.keys()][0]
234 if (first) cursor = screenOf(seat, first)
235 }
236 note = moves.some((m) => m.kind === 'instant') ? `${v.hand[i].card}: f plays it.` : `${v.hand[i].card}: pick a tile (w a s d), f plays.`
237}
238
239function moveCursor(dx, dy) {
240 onBoard = true
241 cursor = { sx: Math.max(0, Math.min(4, cursor.sx + dx)), sy: Math.max(0, Math.min(2, cursor.sy + dy)) }
242}
243
244/** f: play the picked card on the cursor's tile (or the instant effect). */
245async function playHere($) {
246 const v = game?.view
247 if (!v?.yourTurn || busy) return
248 const seat = game.seat ?? 'A'
249 const h = v.hand?.[card]
250 if (!v.pendingHop && h) {
251 const instant = movesFor(v, h).find((m) => m.kind === 'instant')
252 if (instant) return play($, instant.name)
253 }
254 const cell = cellAt(seat, cursor.sx, cursor.sy)
255 const move = targetsFor(v, card, recruitFrom).get(cell)
256 if (!move) {
257 note = v.pendingHop ? `Can't hop to ${cell}.` : `${h?.card ?? 'That card'} can't go to ${cell}.`
258 $.ui.invalidate('ui.render')
259 return
260 }
261 const m = parseMove(move)
262 if (!v.pendingHop && m.kind === 'recruit' && !recruitFrom) {
263 recruitFrom = cell
264 note = `Recruit the card at ${cell}: now pick where it goes, f plays.`
265 $.ui.invalidate('ui.render')
266 return
267 }
268 return play($, move)
269}
270
271/** g: a simple suggestion - the move that wins the most touching sides. */
272function suggest() {
273 const v = game?.view
274 if (!v?.yourTurn) return
275 const seat = game.seat ?? 'A'
276 let best = null
277 for (const [i, h] of (v.hand ?? []).entries()) {
278 if (h.kind !== 'character') continue
279 const mine = seat === 'B' ? { north: h.bottom, east: h.left, south: h.top, west: h.right } : { north: h.top, east: h.right, south: h.bottom, west: h.left }
280 for (const m of movesFor(v, h)) {
281 if (m.kind !== 'place') continue
282 const c = 'ABCDE'.indexOf(m.cell[0])
283 const r = Number(m.cell.slice(1))
284 let gain = 0
285 for (const [dc, dr, my, their] of [[0, 1, 'north', 'south'], [0, -1, 'south', 'north'], [1, 0, 'east', 'west'], [-1, 0, 'west', 'east']]) {
286 const t = v.board.find((b) => b.cell === `${'ABCDE'[c + dc] ?? '?'}${r + dr}`)
287 if (t?.owner === 'opponent' && !t.frozen && t.sides && mine[my] >= t.sides[their]) gain++
288 }
289 if (!best || gain > best.gain) best = { i, cell: m.cell, gain, name: m.name }
290 }
291 }
292 if (!best) {
293 note = 'No suggestion - try an effect card.'
294 return
295 }
296 card = best.i
297 onBoard = true
298 recruitFrom = null
299 cursor = screenOf(seat, best.cell)
300 note = `Suggestion: ${best.name}${best.gain ? ` (smashes ${best.gain})` : ''} - f plays it.`
301}
302
303/* --------------------------------- drawing --------------------------------- */
304
305function statusLine() {
306 if (!game) return 'Smash&Clash'
307 const v = game.view
308 const you = v?.score?.you ?? 0
309 const them = v?.score?.opponent ?? 0
310 const turn = game.status === 'waiting' ? `waiting for someone to join (code ${game.code})` : game.status === 'finished' || game.status === 'abandoned' ? outcome(game) : v?.yourTurn ? 'your turn' : `${v?.opponent ?? 'their'} turn`
311 return `You ${you} - ${them} ${v?.opponent ?? ''} · ${turn}`
312}
313
314export function register(on, options) {
315 opts = {
316 base: String(options?.api_base || 'https://www.smashandclash.in').replace(/\/+$/, ''),
317 name: String(options?.player_name || 'Claude Code player').slice(0, 40),
318 strength: Math.max(800, Math.min(1600, Number(options?.strength) || 1200)),
319 autoOpen: options?.auto_open === true,
320 }
321
322 on('session.start', async ($, e, next) => {
323 await $.command.register({
324 name: 'smash',
325 description: 'Play Smash&Clash while Claude works',
326 argumentHint: '[new | duel | join CODE | close]',
327 immediate: true,
328 })
329 return next(e)
330 })
331
332 on('command.run', { command: 'smash' }, async ($, e) => {
333 const [verb = '', arg = ''] = String(e.args ?? '').trim().split(/\s+/)
334 if (verb === 'close') {
335 await $.ui.close({ id: PANE })
336 paneOpen = false
337 return {}
338 }
339 const placed = await $.ui.open({ id: PANE, title: 'Smash&Clash', focus: true, closeOnEscape: true })
340 paneOpen = placed?.isPlaced !== false
341 if (verb === 'new') await startHouse($)
342 else if (verb === 'duel') await createDuel($)
343 else if (verb === 'join' && arg) await joinDuel($, arg)
344 else if (!game || game.status === 'finished' || game.status === 'abandoned') await resumeOrStart($)
345 return {}
346 })
347
348 on('turn.start', async ($, e, next) => {
349 working = true
350 if (opts.autoOpen && !paneOpen) {
351 const placed = await $.ui.open({ id: PANE, title: 'Smash&Clash' })
352 paneOpen = placed?.isPlaced === true
353 }
354 $.ui.invalidate('ui.render')
355 return next(e)
356 })
357
358 on('turn.complete', async ($, e, next) => {
359 working = false
360 $.ui.invalidate('ui.render')
361 return next(e)
362 })
363
364 on('ui.close', async ($, e, next) => {
365 if (e.id === PANE) paneOpen = false
366 return next(e)
367 })
368
369 // The band above the prompt, while Claude works and the pane is closed.
370 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
371 if (!e.props.isWorking || paneOpen) return next(e)
372 const { Box, Text } = $.ui.resolve(e)
373 const theirs = await next(e)
374 const line = Text({
375 dimColor: true,
376 wrap: 'truncate',
377 children: [game && game.status === 'active' ? `Smash&Clash · ${statusLine()} · /smash to play` : 'Waiting on Claude? Play Smash&Clash: /smash'],
378 })
379 return Box({ flexDirection: 'column', children: theirs ? [theirs, line] : [line] })
380 })
381
382 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
383 if (e.requestId !== PANE) return next(e)
384 const { Box, Text, Button, Link } = $.ui.resolve(e)
385 const redraw = () => $.ui.invalidate('ui.render')
386 const width = e.props.bodyColumns ?? 60
387 const rows = []
388
389 rows.push(Text({ bold: true, color: 'blue', children: ['SMASH&CLASH'] }))
390 rows.push(Text({ wrap: 'truncate', children: [statusLine()] }))
391 if (working) rows.push(Text({ dimColor: true, children: ['Claude is working - your move.'] }))
392
393 if (!game || !game.view) {
394 if (game?.status === 'waiting') rows.push(Text({ children: [`Duel code ${game.code}. The other side runs /smash join ${game.code}.`] }))
395 rows.push(Text({ dimColor: true, children: [busy || note || 'Starting...'] }))
396 if (!busy) rows.push(Button({ key: 'new', label: 'New game', hotkey: 'n', plain: true, onPress: () => startHouse($) }))
397 return Box({ flexDirection: 'column', rowGap: 1, children: rows })
398 }
399
400 const v = game.view
401 const seat = game.seat ?? 'A'
402 const over = game.status === 'finished' || game.status === 'abandoned'
403 const targets = over ? new Map() : targetsFor(v, card, recruitFrom)
404 const cellW = width >= 58 ? 9 : 7
405 const drawOpts = { cellW, cursor: onBoard && v.yourTurn && !over ? cellAt(seat, cursor.sx, cursor.sy) : null, targets: new Set(targets.keys()) }
406 if (e.surface === 'terminal') {
407 const { Raster } = $.ui.resolve(e)
408 const r = boardRaster(v, seat, drawOpts)
409 rows.push(Raster({ key: 'board', columns: r.columns, rows: r.rows, cells: r.cells }))
410 } else {
411 rows.push(Text({ children: [boardText(v, seat, drawOpts).join('\n')] }))
412 }
413
414 if (!over && v.yourTurn && !v.pendingHop) {
415 rows.push(
416 Box({
417 flexDirection: 'row',
418 flexWrap: 'wrap',
419 columnGap: 2,
420 children: (v.hand ?? []).map((h, i) =>
421 Button({
422 key: `card-${i}`,
423 label: `${i === card ? '>' : ''}${handLabel(h)}`,
424 hotkey: String(i + 1),
425 plain: true,
426 dimColor: i !== card || movesFor(v, h).length === 0,
427 onPress: () => {
428 pickCard(i)
429 redraw()
430 },
431 }),
432 ),
433 }),
434 )
435 }
436
437 if (!over && v.yourTurn) {
438 const controls = [
439 Button({ key: 'up', label: 'up', hotkey: 'w', plain: true, onPress: () => (moveCursor(0, -1), redraw()) }),
440 Button({ key: 'left', label: 'left', hotkey: 'a', plain: true, onPress: () => (moveCursor(-1, 0), redraw()) }),
441 Button({ key: 'down', label: 'down', hotkey: 's', plain: true, onPress: () => (moveCursor(0, 1), redraw()) }),
442 Button({ key: 'right', label: 'right', hotkey: 'd', plain: true, onPress: () => (moveCursor(1, 0), redraw()) }),
443 Button({ key: 'play', label: 'play', hotkey: 'f', plain: true, autoFocus: true, onPress: () => playHere($) }),
444 ]
445 if (v.pendingHop) {
446 const stay = (v.legalMoves ?? []).map(parseMove).find((m) => m.kind === 'stay')
447 if (stay) controls.push(Button({ key: 'stay', label: 'stay', hotkey: 'x', plain: true, onPress: () => play($, stay.name) }))
448 } else controls.push(Button({ key: 'hint', label: 'suggest', hotkey: 'g', plain: true, onPress: () => (suggest(), redraw()) }))
449 rows.push(Box({ flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, children: controls }))
450 }
451
452 rows.push(Text({ wrap: 'wrap', dimColor: !busy, children: [busy ? `${busy}...` : note || ' '] }))
453
454 if (over) {
455 if (game.replayUrl) rows.push(Link({ href: game.replayUrl, label: 'Watch the replay' }))
456 rows.push(Button({ key: 'new', label: 'New game', hotkey: 'n', plain: true, autoFocus: true, onPress: () => startHouse($) }))
457 }
458 return Box({ flexDirection: 'column', rowGap: 1, children: rows })
459 })
460}
461hooks/game.js 207 lines1// Smash&Clash mod: pure helpers (no mods API here). The move grammar the
2// server speaks, the board turned to your seat, and the board drawn as a
3// terminal Raster (one cell = a character, its colour, its background).
4
5export const COLS = 'ABCDE'
6
7/** Case and punctuation do not matter: "Recruit!" is "RECRUIT". */
8export const plain = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '')
9
10/**
11 * A move's card and where it lands: "Pengu@C2" (place), "Pengu!C2" (overrun),
12 * "RECRUIT(A3→B1)", "FREEZE(D2)", "FLIP", "hop→B2", "hop: stay". Should two
13 * cards ever share a name, theirs carry the card id: "Name#12@C2".
14 */
15export function parseMove(name) {
16 let m = /^(.+?)(?:#(\d+))?([@!])([A-E][1-3])$/.exec(name)
17 if (m) {
18 return { name, card: m[1], ...(m[2] ? { cardId: Number(m[2]) } : {}), kind: m[3] === '!' ? 'overrun' : 'place', cell: m[4] }
19 }
20 m = /^([A-Z]+)\(([A-E][1-3])\s*(?:→|->|>)\s*([A-E][1-3])\)$/.exec(name)
21 if (m) return { name, card: m[1], kind: 'recruit', from: m[2], cell: m[3] }
22 m = /^([A-Z]+)\(([A-E][1-3])\)$/.exec(name)
23 if (m) return { name, card: m[1], kind: 'target', cell: m[2] }
24 if (/^[A-Z]+$/.test(name)) return { name, card: name, kind: 'instant' }
25 m = /^hop\s*(?:→|->|>)\s*([A-E][1-3])$/i.exec(name)
26 if (m) return { name, card: '', kind: 'hop', cell: m[1] }
27 if (/^hop:?\s*stay$/i.test(name)) return { name, card: '', kind: 'stay' }
28 return { name, card: '', kind: 'other' }
29}
30
31/** Does move `m` play hand card `h` (a hand card, or just its name)? By id when the move names one. */
32export function playsCard(m, h) {
33 const name = typeof h === 'string' ? h : h?.card
34 const id = typeof h === 'string' ? undefined : h?.cardId
35 return !!m.card && plain(m.card) === plain(name) && (m.cardId === undefined || id === undefined || m.cardId === id)
36}
37
38export function movesFor(view, card) {
39 return (view?.legalMoves ?? []).map(parseMove).filter((m) => playsCard(m, card))
40}
41
42/** The cell at screen column sx (0-4, left to right) and row sy (0-2, top to bottom). */
43export function cellAt(seat, sx, sy) {
44 return seat === 'B' ? `${COLS[4 - sx]}${1 + sy}` : `${COLS[sx]}${3 - sy}`
45}
46
47export function screenOf(seat, cell) {
48 const c = COLS.indexOf(cell[0])
49 const r = Number(cell.slice(1))
50 return seat === 'B' ? { sx: 4 - c, sy: r - 1 } : { sx: c, sy: 3 - r }
51}
52
53/**
54 * Where the selected card can go: cell -> move name. A hop offers its tiles;
55 * a recruit first offers the enemy cards, then (with `recruitFrom`) where the
56 * taken card lands.
57 */
58export function targetsFor(view, cardIndex, recruitFrom) {
59 const out = new Map()
60 if (!view?.yourTurn) return out
61 const moves = (view.legalMoves ?? []).map(parseMove)
62 if (view.pendingHop) {
63 for (const m of moves) if (m.kind === 'hop') out.set(m.cell, m.name)
64 return out
65 }
66 const h = view.hand?.[cardIndex]
67 if (!h) return out
68 for (const m of moves) {
69 if (!playsCard(m, h)) continue
70 if (recruitFrom) {
71 if (m.kind === 'recruit' && m.from === recruitFrom) out.set(m.cell, m.name)
72 } else if (m.kind === 'recruit') {
73 if (!out.has(m.from)) out.set(m.from, m.name)
74 } else if (m.cell) out.set(m.cell, m.name)
75 }
76 return out
77}
78
79/** A tile's four side values as you see them (seat B sees the board turned round). */
80function sidesOf(t, seat) {
81 const s = t.sides
82 if (!s) return ['?', '?', '?', '?']
83 return seat === 'B' ? [s.south, s.west, s.north, s.east] : [s.north, s.east, s.south, s.west]
84}
85
86const center = (s, w) => {
87 s = String(s).slice(0, w)
88 const l = Math.floor((w - s.length) / 2)
89 return ' '.repeat(l) + s + ' '.repeat(w - s.length - l)
90}
91
92export const COLOR = {
93 none: 0x01000000,
94 text: 0xf5f7fa,
95 dark: 0x0d1017,
96 muted: 0x8a8f98,
97 empty: 0x262b36,
98 you: 0x1f63c6,
99 them: 0xb83a3f,
100 cursor: 0xffd23f,
101 target: 0x4a4420,
102 label: 0x8a8f98,
103}
104
105/** The three text lines of one board cell, `w` wide. */
106function cellLines(view, seat, cell, w) {
107 const t = view.board.find((b) => b.cell === cell)
108 if (t?.card) {
109 const [up, right, down, left] = sidesOf(t, seat)
110 const name = t.card.slice(0, Math.max(3, w - 4))
111 return {
112 lines: [center(up, w), center(`${left} ${name} ${right}`, w), center(t.frozen ? `${down} ice` : down, w)],
113 owner: t.owner,
114 }
115 }
116 const chess = view.special?.chessTiles?.find((x) => x.cell === cell)
117 const power = view.special?.powerTiles?.find((x) => x.cell === cell)
118 const mid = chess ? `[${chess.piece}]` : power ? `+${power.boost} ${power.color}` : ''
119 return { lines: [' '.repeat(w), center(mid, w), center(cell, w)], owner: null }
120}
121
122/**
123 * The board as Raster cells: column letters on top, row numbers on the left,
124 * each tile `w` x 3 with a one-column gap, your row nearest the bottom.
125 */
126export function boardRaster(view, seat, o) {
127 const w = o.cellW
128 const cols = 2 + 5 * w + 4
129 const rows = 1 + 3 * 3 + 2
130 const grid = Array.from({ length: rows }, () => Array.from({ length: cols }, () => [' ', COLOR.none, COLOR.none]))
131 const put = (x, y, ch, fg, bg) => {
132 if (y >= 0 && y < rows && x >= 0 && x < cols) grid[y][x] = [ch, fg, bg]
133 }
134 for (let sx = 0; sx < 5; sx++) {
135 const letter = seat === 'B' ? COLS[4 - sx] : COLS[sx]
136 put(2 + sx * (w + 1) + Math.floor(w / 2), 0, letter, COLOR.label, COLOR.none)
137 }
138 for (let sy = 0; sy < 3; sy++) {
139 const rowNo = seat === 'B' ? 1 + sy : 3 - sy
140 put(0, 1 + sy * 4 + 1, String(rowNo), COLOR.label, COLOR.none)
141 for (let sx = 0; sx < 5; sx++) {
142 const cell = cellAt(seat, sx, sy)
143 const { lines, owner } = cellLines(view, seat, cell, w)
144 const isCursor = o.cursor === cell
145 const isTarget = o.targets?.has(cell)
146 const bg = isCursor ? COLOR.cursor : owner === 'you' ? COLOR.you : owner === 'opponent' ? COLOR.them : isTarget ? COLOR.target : COLOR.empty
147 const fg = isCursor ? COLOR.dark : owner ? COLOR.text : isTarget ? COLOR.cursor : COLOR.muted
148 for (let ly = 0; ly < 3; ly++) {
149 const text = lines[ly]
150 for (let lx = 0; lx < w; lx++) put(2 + sx * (w + 1) + lx, 1 + sy * 4 + ly, text[lx] ?? ' ', fg, bg)
151 }
152 if (isTarget && !owner && !isCursor) put(2 + sx * (w + 1), 1 + sy * 4, '+', COLOR.cursor, bg)
153 }
154 }
155 const numbers = []
156 for (const row of grid) for (const [ch, fg, bg] of row) numbers.push(ch.codePointAt(0), fg, bg)
157 return { columns: cols, rows, cells: base64(new Uint8Array(Uint32Array.from(numbers).buffer)) }
158}
159
160/** The board as plain text lines (the desktop app has no Raster). */
161export function boardText(view, seat, o) {
162 const w = o.cellW
163 const out = [' ' + [0, 1, 2, 3, 4].map((sx) => center(seat === 'B' ? COLS[4 - sx] : COLS[sx], w)).join(' ')]
164 for (let sy = 0; sy < 3; sy++) {
165 const cells = [0, 1, 2, 3, 4].map((sx) => {
166 const cell = cellAt(seat, sx, sy)
167 const { lines, owner } = cellLines(view, seat, cell, w)
168 const mark = o.cursor === cell ? '>' : o.targets?.has(cell) ? '+' : owner === 'you' ? '*' : ' '
169 return lines.map((l, i) => (i === 0 ? mark + l.slice(1) : l))
170 })
171 const rowNo = seat === 'B' ? 1 + sy : 3 - sy
172 for (let ly = 0; ly < 3; ly++) out.push((ly === 1 ? `${rowNo} ` : ' ') + cells.map((c) => c[ly]).join('|'))
173 }
174 return out
175}
176
177/** A hand card as one button label. */
178export function handLabel(h) {
179 return h.kind === 'character' ? `${h.card} ${h.top}/${h.right}/${h.bottom}/${h.left}` : h.card
180}
181
182/** "You won 9-6!" from your seat's side. */
183export function outcome(game) {
184 const seat = game.seat ?? 'A'
185 const mine = seat === 'B' ? `${game.score.B}-${game.score.A}` : `${game.score.A}-${game.score.B}`
186 if (game.status === 'abandoned') return 'The game was abandoned.'
187 if (game.winner === 'draw') return `A draw, ${mine}.`
188 return game.winner === seat ? `You won ${mine}!` : `You lost ${mine}.`
189}
190
191const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
192
193/** Base64 of bytes, without Buffer (a hooks module has no Node APIs). */
194export function base64(bytes) {
195 let out = ''
196 let i = 0
197 for (; i + 2 < bytes.length; i += 3) {
198 const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]
199 out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + B64[(n >> 6) & 63] + B64[n & 63]
200 }
201 if (i < bytes.length) {
202 const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8)
203 out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + (i + 1 < bytes.length ? B64[(n >> 6) & 63] : '=') + '='
204 }
205 return out
206}
207