SLOPSHOPPER

smash-and-clash-mod

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

newpanebandcommandtoastnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · smash-and-clash-mod
│ ┃ Smash&Clash ✕ › fix the failing auth test and add an audit log call │ ┃ SMASH&CLASH │ ┃ ⏺ Read(src/auth.ts) │ ┃ Smash&Clash ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ The server said 0. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ n: New game ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /smash │ │ ⟨Claude Code's own drawing⟩ Waiting on Claude? Play Smash&Clash: /smash ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ Waiting on Claude? Play Smash&Clash: /smash
Pane · Smash&Clash
SMASH&CLASH Smash&Clash The server said 0. n: New game
README

smash-and-clash-mod

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.

Play

KeyDoes
1-5Pick a card from your hand
w a s dMove over the board (legal tiles are marked +)
fPlay the card on the tile (an effect card plays at once)
gSuggest a move
xStay put when a hop is offered
nNew game, once one ends
EscClose 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.

CommandDoes
/smashOpens the board: resumes your game, or starts one against the house
/smash newA new game against the Smash&Clash house opponent
/smash duelOpens a duel; the other side joins with the code
/smash join CODEJoins a duel (from the mod, the CLI, the SDK or an agent over MCP)
/smash closeCloses the pane

Settings

/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.

What it reaches

  • Network: only https://www.smashandclash.in, the public games API, through $.http.fetch.
  • Saved data: your current game id and its player token, in the mod's own $.store.
  • Nothing else: no files, no processes, no model calls.

Develop

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 runtime
Source 2 files
hooks/register.js 461 lines
1// 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}
461
hooks/game.js 207 lines
1// 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