c0fe10e664
Three things were missing: a way to get many decklists in at once, a UI that works on a phone, and somewhere to keep the state of a game in progress. Batch import — ImportDialog now takes a folder of .txt files or a pasted block and splits it on `-----` runs or on each list's own `// name` header, and reads a JSON backup directly (auto-detected by first character). Settings gains a matching Restore. Names resolve against the card index in 150-name chunks; unresolved names keep their entry with an empty id so the rules panel names them instead of the deck silently arriving short. importDecks merges by id, so restoring the same backup twice updates rather than duplicates. Mobile — the root cause of the layout problems was .app/.main, which had no min-width:0 and let every page overflow its viewport by 22px at 390. Fixed that, then stacked the three-column layouts at 1180, made Browse's card detail a modal below that breakpoint instead of a display:none third column, and added 44px targets under `pointer: coarse`, dvh units, safe-area insets and a 16px input floor to stop iOS zoom. Table mode — new /table route, one board for the game rather than one per device. src/lib/table.js is plain JS on purpose so node --test exercises the same reducer the UI imports: 40 life, poison on every seat, per-commander damage, arbitrary counters, and a turn/phase tracker that rotates the active player. Wake lock and fullscreen keep the screen up, and damage changes are announced through one role="status" region (WCAG 4.1.3). 58 tests, up from 49. Co-Authored-By: Claude Code <noreply@anthropic.com>
207 lines
8.2 KiB
JavaScript
207 lines
8.2 KiB
JavaScript
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import {
|
|
PHASES,
|
|
addCounter,
|
|
addCommander,
|
|
addPlayer,
|
|
advancePhase,
|
|
changeCounter,
|
|
changeLife,
|
|
damageCommander,
|
|
isGameOver,
|
|
isOut,
|
|
newTable,
|
|
removeCounter,
|
|
removePlayer,
|
|
resetTable,
|
|
setActive,
|
|
setStartingLife,
|
|
} from '../src/lib/table.js'
|
|
|
|
/**
|
|
* The table is read, not typed: a life total is a number people at a table
|
|
* trust to be right. So these assert the arithmetic and the transitions that
|
|
* silently corrupt it — losing the active seat when a player is removed,
|
|
* letting a counter go negative, resetting the wrong fields between games.
|
|
*/
|
|
|
|
const four = () => newTable(4)
|
|
|
|
test('a new table starts everyone on 40 with the first player active', () => {
|
|
const state = four()
|
|
assert.equal(state.players.length, 4)
|
|
assert.ok(state.players.every((p) => p.life === 40))
|
|
assert.equal(state.turn, 1)
|
|
assert.equal(state.phase, 0)
|
|
assert.equal(state.active, 0)
|
|
assert.ok(state.players.every((p) => p.commanders.length === 0))
|
|
assert.deepEqual(
|
|
state.players.map((p) => p.counters.map((c) => c.label)),
|
|
[['Poison'], ['Poison'], ['Poison'], ['Poison']],
|
|
'every seat starts with a poison counter',
|
|
)
|
|
})
|
|
|
|
test('the player count is clamped to what a table can hold', () => {
|
|
assert.equal(newTable(0).players.length, 1)
|
|
assert.equal(newTable(99).players.length, 6)
|
|
assert.equal(newTable(2.7).players.length, 2)
|
|
})
|
|
|
|
test('life moves by the step it is given and is allowed to go negative', () => {
|
|
const state = four()
|
|
const id = state.players[0].id
|
|
assert.equal(changeLife(state, id, 5).players[0].life, 45)
|
|
assert.equal(changeLife(state, id, -12).players[0].life, 28)
|
|
// A rules violation can put a player below zero before the game ends; the
|
|
// table has to show the number that caused it, not round it up.
|
|
assert.equal(changeLife(changeLife(state, id, -39), id, -1).players[0].life, 0)
|
|
assert.equal(changeLife(changeLife(state, id, -39), id, -1).players[0].life, 0)
|
|
assert.equal(changeLife(changeLife(state, id, -41), id, -1).players[0].life, -2)
|
|
assert.ok(isOut(changeLife(changeLife(state, id, -41), id, -1).players[0]))
|
|
})
|
|
|
|
test('damage on a player does not touch anyone else', () => {
|
|
const state = four()
|
|
const after = changeLife(state, state.players[1].id, -5)
|
|
assert.equal(after.players[1].life, 35)
|
|
assert.deepEqual(
|
|
after.players.filter((_, i) => i !== 1).map((p) => p.life),
|
|
[40, 40, 40],
|
|
)
|
|
})
|
|
|
|
test('a player is out at zero or less, and the game ends when one is left', () => {
|
|
let state = four()
|
|
for (let i = 1; i < 4; i++) state = changeLife(state, state.players[i].id, -40)
|
|
assert.deepEqual(state.players.map(isOut), [false, true, true, true])
|
|
assert.ok(isGameOver(state))
|
|
assert.equal(isGameOver(changeLife(state, state.players[0].id, -40)), true)
|
|
// Two players still standing is not a finished game.
|
|
assert.equal(isGameOver(changeLife(state, state.players[3].id, 40)), false)
|
|
})
|
|
|
|
test('advancing runs the phases in order and then rolls the turn over', () => {
|
|
let state = four()
|
|
for (let i = 0; i < PHASES.length - 1; i++) {
|
|
state = advancePhase(state)
|
|
assert.equal(state.phase, i + 1)
|
|
}
|
|
assert.equal(state.turn, 1)
|
|
state = advancePhase(state)
|
|
assert.equal(state.turn, 2)
|
|
assert.equal(state.phase, 0)
|
|
assert.equal(state.active, 1, 'the turn passes to the next seat')
|
|
})
|
|
|
|
test('the active seat rotates and can be set directly', () => {
|
|
let state = setActive(four(), 2)
|
|
assert.equal(state.active, 2)
|
|
for (let i = 0; i < PHASES.length; i++) state = advancePhase(state)
|
|
assert.equal(state.active, 3)
|
|
for (let i = 0; i < PHASES.length; i++) state = advancePhase(state)
|
|
assert.equal(state.active, 0, 'turn order wraps around the table')
|
|
assert.equal(setActive(state, 9).active, 0, 'an out-of-range seat is ignored')
|
|
assert.equal(setActive(state, -1).active, 0)
|
|
})
|
|
|
|
test('removing a player keeps the active seat pointing at somebody', () => {
|
|
let state = setActive(four(), 3)
|
|
state = removePlayer(state, state.players[0].id)
|
|
assert.equal(state.players.length, 3)
|
|
assert.equal(state.active, 2, 'the seat index moves with the players below it')
|
|
state = removePlayer(state, state.players[2].id)
|
|
assert.equal(state.active, 1)
|
|
state = removePlayer(state, state.players[1].id)
|
|
assert.equal(state.players.length, 1)
|
|
assert.equal(state.active, 0)
|
|
assert.equal(removePlayer(state, state.players[0].id).players.length, 1, 'the last player stays')
|
|
})
|
|
|
|
test('commander damage is tracked per card and separately from life', () => {
|
|
let state = four()
|
|
const me = state.players[0].id
|
|
state = addCommander(state, me, { id: 'uuid-a', name: 'Atraxa, Grand Unifier' })
|
|
state = addCommander(state, me, { id: '', name: 'Partnerless card' })
|
|
assert.deepEqual(state.players[0].commanders.map((c) => c.name), [
|
|
'Atraxa, Grand Unifier',
|
|
'Partnerless card',
|
|
])
|
|
|
|
state = damageCommander(state, me, 'uuid-a', 21)
|
|
assert.equal(state.players[0].commanders[0].damage, 21)
|
|
assert.equal(state.players[0].commanders[1].damage, 0)
|
|
assert.equal(state.players[0].life, 40, 'commander damage is not life loss')
|
|
|
|
// A slot added by hand has no card uuid, so it is addressed by its position.
|
|
state = damageCommander(state, me, 'slot1', -5)
|
|
assert.equal(state.players[0].commanders[1].damage, -5)
|
|
})
|
|
|
|
test('counters never count below zero and cannot be added twice', () => {
|
|
let state = four()
|
|
const me = state.players[0].id
|
|
state = addCounter(state, me, 'Poison')
|
|
state = addCounter(state, me, 'poison')
|
|
assert.equal(state.players[0].counters.length, 1, 'the same counter is not added again')
|
|
const id = state.players[0].counters[0].id
|
|
|
|
assert.equal(changeCounter(state, me, id, 3).players[0].counters[0].value, 3)
|
|
assert.equal(changeCounter(state, me, id, 3).players[1].counters[0].value, 0)
|
|
assert.equal(changeCounter(state, me, id, -10).players[0].counters[0].value, 0)
|
|
assert.equal(addCounter(state, me, ' ').players[0].counters.length, 1, 'a blank label is not a counter')
|
|
})
|
|
|
|
test('counters are per player', () => {
|
|
let state = four()
|
|
const id = state.players[0].counters[0].id
|
|
state = changeCounter(state, state.players[0].id, id, 2)
|
|
assert.equal(state.players[0].counters[0].value, 2)
|
|
assert.equal(state.players[1].counters[0].value, 0, 'the other seats are untouched')
|
|
assert.notEqual(state.players[1].counters[0].id, id, 'each seat has its own counter')
|
|
assert.equal(removeCounter(state, state.players[0].id, id).players[0].counters.length, 0)
|
|
})
|
|
|
|
test('adding a player seats them at the end with a full life total', () => {
|
|
const state = addPlayer(newTable(2))
|
|
assert.equal(state.players.length, 3)
|
|
assert.equal(state.players[2].name, 'Player 3')
|
|
assert.equal(state.players[2].life, 40)
|
|
})
|
|
|
|
test('restarting a game clears life, damage and counters but keeps the seats', () => {
|
|
let state = newTable(2, 30)
|
|
const me = state.players[0].id
|
|
state = addCommander(state, me, { id: 'uuid-a', name: 'Atraxa' })
|
|
state = damageCommander(state, me, 'uuid-a', 21)
|
|
state = changeCounter(state, me, state.players[0].counters[0].id, 9)
|
|
state = addCounter(state, me, 'Experience')
|
|
state = changeLife(state, me, -12)
|
|
state = advancePhase(state)
|
|
|
|
const fresh = resetTable(state)
|
|
assert.deepEqual(fresh.players.map((p) => p.life), [30, 30])
|
|
assert.equal(fresh.players[0].commanders[0].damage, 0)
|
|
assert.equal(fresh.players[0].commanders[0].name, 'Atraxa', 'the same card is played again next game')
|
|
assert.equal(fresh.players[0].counters[0].value, 0)
|
|
assert.deepEqual(
|
|
fresh.players[0].counters.map((c) => c.label),
|
|
['Poison'],
|
|
'a counter this game happened to need is not carried into the next one',
|
|
)
|
|
assert.equal(fresh.turn, 1)
|
|
assert.equal(fresh.phase, 0)
|
|
assert.equal(fresh.active, 0, 'a Commander game always starts with the same player')
|
|
})
|
|
|
|
test('the starting life is a whole number of at least one', () => {
|
|
assert.equal(setStartingLife(four(), 0).startingLife, 40, 'an empty field falls back to 40')
|
|
assert.equal(setStartingLife(four(), -5).startingLife, 1, 'a nonsense value clamps to one')
|
|
assert.equal(setStartingLife(four(), 25.8).startingLife, 25)
|
|
})
|
|
|
|
test('a new table can start at a different life total', () => {
|
|
assert.ok(newTable(2, 30).players.every((p) => p.life === 30))
|
|
})
|