3 Commits

Author SHA1 Message Date
masi 14daed4c54 Add market prices to the card index and the deck total
Prices were already in the Scryfall bulk download, so this adds no
dependency and no new fetch: the work is deciding what a card's price
means, given that the index keeps one row per card and a card has many
printings.

It is the median of the ordinary printings. The kept printing underprices
about a third of the legal pool because it is chosen for art and identity
and is usually a cheap reprint; the maximum is worse, since premium and
bundled listings put a $870 Sol Ring in the data. The median lands where a
player recognises the number and ignores the outliers. It is a market
value, not a build cost.

Two filter decisions are deliberate and easy to undo by accident: Reserved
List printings are included, because that is where Commander's banned
Power 9 live and excluding them priced Ancestral Recall and Time Walk at
nothing, while memorabilia is excluded because a Collectors' Edition box is
not a single card. Banned cards fall back to their banned printings.

deckValue counts unpriced cards instead of treating them as free, so a
total is always labelled with what it left out.

Also implements scripts/verify-cards.mjs. package.json and the index skill
both documented `pnpm cards:verify` but the script did not exist; it now
runs in deploy and fails on a card priced at exactly zero, which is the
failure mode the missing-price guard exists to prevent.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-02 13:17:09 -03:00
masi f1226b294e Make the table a board, not a control panel
On a phone the table page showed every life total twice — once in a
strip across the top and again in the seat below it — and each seat
carried a name field, an activate button, a remove button, six step
buttons, every commander slot and every counter with an add-counter
form. Four of those is a wall.

It is now one card per player holding what is used mid-turn and nothing
else: a name, the life total, a minus and a plus in opposite corners,
and the counters on that player. The cards share the viewport height
rather than being sized by their contents, so all four seats — or all
six — fit one screen with no scrolling at any phone size, portrait or
landscape. The number is sized from its own card with container query
units, so it fills the card it is in instead of the viewport.

Everything else is one tap into that player's sheet: the ±5 and ±10
steps, commander damage, adding a counter, renaming. Game-wide
settings — starting life, seats, turn order, restart, the screen
wake lock — moved behind a single menu button. The reasoning is the
same throughout: what gets pressed during a turn stays on the card,
what gets used between turns goes one tap away.

The card is a three-column grid so the corner buttons and the counter
grid cannot collide however many counters a player collects — they did
overlap, with the plus sitting on top of a counter tile, before.

Also fixes commander portraits, which were fetched by name through
getCards, which takes ids, so every sheet showed a blank frame.

58 tests unchanged; the reducer did not move.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-02 09:25:15 -03:00
masi c0fe10e664 Batch import, a usable phone layout, and table mode
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>
2026-10-02 00:57:55 -03:00
32 changed files with 3158 additions and 139 deletions
@@ -115,3 +115,37 @@ The download is cached at `data/.cache/default-cards-<date>.jsonl.gz` (~75 MB)
and the URL is read from the Scryfall bulk catalogue, never hardcoded — the
filename changes on every Scryfall regeneration, so a new release downloads by
itself. The cache is disposable.
## Prices
`price_usd` is the **median** non-foil price across the card's market printings,
not the price of the printing the row keeps. Both obvious alternatives are
wrong and both look like harmless simplifications later:
- the kept printing is chosen for identity and art, so it is usually a cheap
reprint — it underprices about a third of the legal pool
- the maximum is worse: premium and bundled listings put a $870 Sol Ring and a
$920 Counterspell in the data
The median lands where a player recognises the number (Forest $0.29, Sol Ring
$2.17, Mystical Tutor $19.07) and ignores the outliers. It is a **market
value, not a build cost** — the cheapest printing is well below it, and the
index does not answer that question.
`isMarketPrinting` decides what may be sampled, and the two non-obvious
decisions are both deliberate:
- **Reserved List printings are included.** Excluding them left Ancestral
Recall, Time Walk and Mox Jet with no price at all, because those cards are
only ever printed there. Reserved means "not reprinted", not "not traded".
- **`memorabilia` is excluded.** A Collectors' Edition *box* is priced as
sealed product; counting it as a single card is the largest single source of
nonsense in the data. It is also why Black Lotus has no price.
Two things to re-check before trusting a price column:
- A card with **no** price is stored as `null` and must render as "no price",
never `$0.00`. See `samplePrice` for why the naive conversion produces
exactly that, silently, for the most famous cards in the game.
- A banned card has no Commander-legal printing, so it falls back to sampling
its banned printings. Without that fallback every Power 9 reads as unpriced.
@@ -52,6 +52,15 @@ index" message, never a crash — pasted decklists outlive card databases.
quantities for the same card summed. Two regexes, not one — a quantity-first
pattern silently swallows the set code into the card name.
`parseDecklists` is the batch form: it splits one text into many decks on a
`-----` run **or** on a second `// name` header, and drops chunks with no cards
so a leading separator does not produce an empty deck. `parseBackup` reads back
the JSON Settings exports, accepting both the bare-array and `{decks: […]}`
shapes and keeping ids — that is what makes restoring the same backup twice
update rather than duplicate. Names that the index does not know keep their
entry with an empty id, everywhere: a silently short deck is worse than a
failed import.
`encodeDeck` packs a whole deck into a URL fragment, so the share slug must
be URL-safe and carry no `=` padding tail — both break chat apps. The payload
is **deflated** before it is base64'd (`CompressionStream('deflate-raw')`,
+10 -1
View File
@@ -42,12 +42,21 @@ were built to them.
- [x] Implement API (`/api/health`, `/api/search`, `/api/cards`, `/api/cards/:id`,
`/api/commanders`, `/api/lookup`).
- [x] Implement UI routes (deck builder, decks gallery, browse, analyze, settings).
- [x] Tests — 33 passing across the rules engine and decklist/share handling.
- [x] Tests — 58 passing across the rules engine, decklist/share handling, batch
import and the table reducer.
- [x] Project skills under `.claude/skills/`, templates under `templates/`.
- [x] Dockerfile + `deploy/k8s/magic.yaml` + `scripts/deploy.sh`.
- [x] Published at `https://magic.marcospaulo.dev.br` through Caddy on the Oracle VPS.
- [x] RAG decision.
- [x] Improvement pass — accessibility, modals, share-link size, then redeploy.
- [x] Batch import — many decklists in one paste or file picker, plus the JSON
backup restore that Settings' export was missing.
- [x] Table mode (`/table`) — life per seat, commander damage per card, poison
and free-form counters, turn and phase stepper, wake lock, full screen.
- [x] Mobile pass — the app shell is viewport-exact (the grid was sizing itself
to its widest child), stacked columns in reading order, 44px touch
targets, bottom-sheet modals, and a card detail that opens on a phone
instead of being display:none.
## The data problem, and what it cost
+40 -7
View File
@@ -16,7 +16,22 @@ Live at **https://magic.marcospaulo.dev.br**
and no account; a deck is a link.
- **Browse / Analyze** — browse the legal pool, and break a list down by mana
curve, colour identity and type line.
- **Batch import** — a folder of `.txt` decklists, several lists pasted
together, or the JSON backup Settings exports. Unresolved names are
reported by name rather than dropped.
- **Play** — a board for the phone lying face-up during a game. One card per
player holding only what is used mid-turn: a name, a life total, a minus and
a plus in opposite corners, and the counters on that player. All four seats
fit one screen with no scrolling. Commander damage, the ±5 and ±10 steps,
adding a counter, renaming and turn order are one tap away in a sheet,
because they are used between turns rather than during them. The state
survives a reload, the screen is kept awake, and it runs full screen.
- **Card art** — hot-linked from Scryfall's CDN.
- **Market prices** — a USD value per card and a total for the deck, so a list
can be costed before it is built.
- **Usable on a phone** — one column in the order a player reads it, touch
targets sized for a thumb, modals as bottom sheets, and a card detail that
opens rather than being hidden.
## Architecture
@@ -62,6 +77,21 @@ correct rather than a scaling compromise.
colour identity are read from Scryfall, so the Commander Committee changing the
ban list is a data refresh, not a code change.
**A card's price is a median, and the reason is not obvious.** The index keeps
one row per card, but a card has many printings at very different prices, so
the price has to be aggregated. Neither obvious choice works: the printing the
row keeps is chosen for identity and art, so it is usually a cheap reprint and
underprices about a third of the legal pool; and the maximum is far worse, since
premium and bundled listings put a $870 Sol Ring in the data. The median of the
ordinary printings lands where a player recognises the number — Forest $0.29,
Sol Ring $2.17, Mystical Tutor $19.07 — and is unmoved by those outliers.
It is a **market value, not a build cost**: the cheapest printing is well below
it, and the index does not answer that question. Cards with no open-market
price (Un-cards) and sealed product (Collectors' Edition boxes) are stored as
`null` and rendered as "no price", never as `$0.00`. `pnpm cards:verify` fails
the build if a card is priced at exactly zero.
### API
All endpoints are read-only `GET`.
@@ -79,18 +109,20 @@ All endpoints are read-only `GET`.
```
server/ node:http API + static serving; db.mjs is the card-index reader
src/lib/ commander.js is the rules engine; deckStore.js owns decks
src/components/ card browser, detail modal, commander picker, validator
src/routes/ Decks, DeckBuilder, Browse, Analyze, Settings
src/lib/ commander.js is the rules engine; deckStore.js owns decks;
table.js is the game-in-progress reducer
src/components/ card browser, detail modal, commander picker, validator, import
src/routes/ Decks, DeckBuilder, Browse, Analyze, Table, Settings
scripts/ build-cards.mjs, deploy.sh
deploy/k8s/ Namespace + Deployment + Service
templates/ project scaffolding
.claude/skills/ project-specific agent skills (see below)
```
`commander.js` and `deckStore.js` are plain JavaScript on purpose — `node --test`
exercises them directly, and the UI imports the same file. There is no second
copy of the rules.
`commander.js`, `deckStore.js` and `table.js` are plain JavaScript on purpose —
`node --test` exercises them directly, and the UI imports the same file. There
is no second copy of the rules, and the life total on the table is asserted by
the same reducer that draws it.
## Running it
@@ -101,8 +133,9 @@ pnpm dev # vite on :5173, api on :8099
```
```bash
pnpm test # 35 tests over the rules engine and deck/store handling
pnpm test # 71 tests: rules engine, deck/store handling, import, table, prices
pnpm typecheck
pnpm cards:verify # card-index invariants, including a $0.00 price check
pnpm build
pnpm api # serve the built SPA + API on one port
```
+5 -1
View File
@@ -2,7 +2,11 @@
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<meta name="theme-color" content="#080b0a" />
<title>MAGIK — Commander Deck Builder</title>
<meta
name="description"
+113 -9
View File
@@ -75,7 +75,8 @@ CREATE TABLE cards (
is_basic_land INTEGER NOT NULL DEFAULT 0,
legal_formats TEXT NOT NULL DEFAULT '', -- JSON map, formats where legal
image_small TEXT, image_normal TEXT, image_large TEXT,
image_png TEXT, image_art_crop TEXT, image_board_crop TEXT
image_png TEXT, image_art_crop TEXT, image_board_crop TEXT,
price_usd REAL -- median market price, see MARKET PRICE below
);
CREATE INDEX idx_cards_name ON cards(name);
@@ -108,9 +109,10 @@ async function main() {
color_identity, colors, rarity, power, toughness, loyalty, layout, faces,
released_at, artist, set_name, set_code, commander_legality,
commander_eligible, is_partner, is_basic_land, legal_formats,
image_small, image_normal, image_large, image_png, image_art_crop, image_board_crop
image_small, image_normal, image_large, image_png, image_art_crop, image_board_crop,
price_usd
) VALUES (
?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?
?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?
)
`)
@@ -123,12 +125,26 @@ async function main() {
// one. So printings are collapsed to one row per oracle card, keeping the
// best paper-legal one.
const best = new Map()
const prices = new Map()
const bannedPrices = new Map()
let seen = 0
for await (const card of readBulk(url, expectedSize)) {
seen++
const legality = card.legalities?.commander
if (legality === undefined) continue
if (legality === 'legal' || legality === 'banned') {
const usd = samplePrice(card)
if (usd !== null) {
const oracleId = card.oracle_id ?? card.id
const target = legality === 'legal' ? prices : bannedPrices
let bucket = target.get(oracleId)
if (!bucket) target.set(oracleId, (bucket = []))
bucket.push(usd)
}
}
// Reversible and split cards publish bare faces as their own objects with
// no type line. The front face is indexed under the full card, so these
// would only ever add empty duplicates.
@@ -147,8 +163,13 @@ async function main() {
}
let kept = 0
for (const { row } of best.values()) {
insert.run(...row)
for (const [oracleId, { row }] of best) {
// A banned card has no Commander-legal printing, so the legal sample is
// empty and it falls back to its banned printings. The card still has a
// market price, and the index keeps banned cards precisely so the validator
// can name them — pricing them as unknown would be a gap, not a truth.
const price = medianPrice(prices.get(oracleId)) ?? medianPrice(bannedPrices.get(oracleId))
insert.run(...row, price)
kept++
}
@@ -174,6 +195,85 @@ const SET_TYPE_RANK = {
token: 0,
}
/**
* MARKET PRICE
*
* A card has many printings at very different prices, but the index keeps one
* row per card, so a price has to be aggregated rather than copied off the
* kept printing. Neither obvious choice is correct:
*
* - the kept printing is chosen for identity and art (core set, English,
* recent), which is usually a cheap reprint; across the Commander-legal
* pool it underprices about a third of cards
* - the maximum is worse: premium and bundled listings put a $870 Sol Ring
* and a $920 Counterspell in the data, and Limited Edition Alpha forests
* price a basic land at $750
*
* The median of the ordinary printings lands where a player would recognise
* the number — Forest $0.32, Sol Ring $2.10, Mystical Tutor $18.30 — and is
* unmoved by those outliers, which is why it is the median and not the mean.
* It is a market value, not a build cost: the cheapest printing is usually
* well below it, and that is a different question this index does not answer.
*
* Prices are a property of the card, not of the app, so the runtime never
* fetches them; like every other fact here they are a build artifact and go
* stale together with the rest of the index.
*/
/**
* Whether a printing's price is a fair sample of the market.
*
* Excluded: promo and digital-only printings, which are not the card anyone is
* buying; token printings, which are not cards; non-English printings, whose
* USD price tracks a different regional market; and *memorabilia*, which is
* sealed product — a Collectors' Edition box priced as if it were a single
* card is the single largest source of nonsense in this data.
*
* Reserved List printings are deliberately included. It is tempting to exclude
* them as "not openly available", but that is exactly where Commander's banned
* Power 9 live: dropping them left Ancestral Recall, Time Walk and Mox Jet with
* no price at all, when they are among the most actively traded cards in the
* game. They are excluded from reprinting, not from the market.
*/
export function isMarketPrinting(card) {
return (
!card.promo &&
!card.digital &&
card.set_type !== 'token' &&
card.set_type !== 'memorabilia' &&
card.lang === 'en' &&
card.nonfoil !== false
)
}
/**
* A printing's USD price, or null when it contributes nothing to the sample.
*
* The missing-price guard is on the raw value, not the converted one, and that
* distinction is the whole function: Scryfall spells a missing price as null,
* `Number(null)` is 0, and 0 is finite. Converting first and testing the result
* lets a priceless printing into the median as a free card, which is how
* Ancestral Recall, Time Walk and Mox Jet all came out at $0.00 — every one of
* their priced printings surrounded by unpriced ones. A card priced at zero is
* worse than a card priced at nothing.
*/
export function samplePrice(card) {
const raw = card.prices?.usd
if (raw == null || raw === '') return null
const usd = Number(raw)
if (!Number.isFinite(usd)) return null
return isMarketPrinting(card) ? usd : null
}
/** Median of a price list, to the cent. Null when nothing was sampled. */
export function medianPrice(values) {
if (!values?.length) return null
const sorted = [...values].sort((a, b) => a - b)
const mid = sorted.length >> 1
const value = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
return Math.round(value * 100) / 100
}
/**
* Orders printings of the same card so the most useful one wins.
*
@@ -375,7 +475,11 @@ function project(card, commanderLegality) {
]
}
main().catch((err) => {
console.error(err)
process.exitCode = 1
})
// Only build when run as a script. The price helpers above are imported by the
// test suite, which must not kick off a 75 MB download as a side effect.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => {
console.error(err)
process.exitCode = 1
})
}
+3
View File
@@ -44,6 +44,9 @@ fi
pnpm test
pnpm typecheck
# The card index is a build artifact, so it is checked like one: a partial
# download or a poisoned price column would otherwise ship silently.
pnpm cards:verify
pnpm build
docker build -t "$registry/magic:$tag" "$repo_root"
+87
View File
@@ -0,0 +1,87 @@
/**
* Sanity-check data/cards.db.
*
* The build is the thing that can go quietly wrong: a partial download still
* produces a valid-looking index (see the skill's download note), and a price
* column is easy to poison with zeros. This asserts the invariants a correct
* build holds, so a bad one fails loudly instead of shipping a subtly wrong
* number to a deck total.
*
* pnpm cards:verify
*/
import { DatabaseSync } from 'node:sqlite'
import { existsSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const DB_PATH = process.env.CARDS_DB ?? resolve(ROOT, 'data/cards.db')
if (!existsSync(DB_PATH)) {
console.error(`card index missing at ${DB_PATH} — run \`pnpm cards:build\` first`)
process.exit(1)
}
const db = new DatabaseSync(DB_PATH, { readOnly: true })
const scalar = (sql, ...args) => db.prepare(sql).get(...args)
const failures = []
const check = (label, ok, detail) => {
console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`)
if (!ok) failures.push(label)
}
const total = scalar('SELECT COUNT(*) AS n FROM cards').n
check('index is populated', total > 30000, `${total} cards`)
check('every row has a name', scalar('SELECT COUNT(*) AS n FROM cards WHERE name IS NULL OR name = \'\'').n === 0)
const legal = scalar('SELECT COUNT(*) AS n FROM cards WHERE commander_legality = \'legal\'').n
check('legal cards were indexed', legal > 25000, `${legal} legal`)
const commanders = scalar('SELECT COUNT(*) AS n FROM cards WHERE commander_eligible = 1').n
check('commanders were identified', commanders > 500, `${commanders} commander-eligible`)
// A banned card must stay in the index: the validator names it and the
// commander picker explains why it is unavailable.
const banned = scalar('SELECT COUNT(*) AS n FROM cards WHERE commander_legality = \'banned\'').n
check('banned cards are kept for the validator', banned > 0, `${banned} banned`)
/**
* Prices.
*
* The zero check is the one that matters. Scryfall spells a missing price as
* null and `Number(null)` is 0, so a build that converts before testing writes
* a real $0.00 into the database — and a deck total then reports a famous card
* as free, which is the one price error that changes what a player does.
*/
const zeroPriced = scalar('SELECT COUNT(*) AS n FROM cards WHERE price_usd = 0').n
check('no card is priced at exactly zero', zeroPriced === 0, zeroPriced ? `${zeroPriced} at $0.00` : '')
const negative = scalar('SELECT COUNT(*) AS n FROM cards WHERE price_usd < 0').n
check('no card has a negative price', negative === 0)
const priced = scalar('SELECT COUNT(*) AS n FROM cards WHERE price_usd IS NOT NULL').n
const coverage = (priced / total) * 100
check('most cards carry a price', coverage > 95, `${priced} of ${total} (${coverage.toFixed(1)}%)`)
// An unpriced card is legitimate — Un-cards have no open-market price — but it
// must be the exception, not the rule. A bulk-download truncation or a filter
// mistake shows up here as a sudden collapse in coverage.
const unpriced = total - priced
check('unpriced cards are the exception', unpriced < total * 0.05, `${unpriced} unpriced`)
// A median over ordinary printings cannot be absurd. Anything past this is a
// premium/bundled listing leaking through the filter.
const outlier = db
.prepare('SELECT name, price_usd FROM cards WHERE price_usd > 100000')
.all()
check('no absurd outlier prices', outlier.length === 0, outlier.map((c) => c.name).join(', '))
db.close()
if (failures.length) {
console.error(`\n${failures.length} check(s) failed: ${failures.join('; ')}`)
process.exit(1)
}
console.log('\ncard index looks healthy')
+4 -1
View File
@@ -33,7 +33,7 @@ const CARD_COLUMNS = `
rarity, power, toughness, loyalty, layout, faces, released_at, artist,
set_name, set_code, commander_legality, commander_eligible, is_partner,
is_basic_land, legal_formats, image_small, image_normal, image_large,
image_png, image_art_crop, image_board_crop
image_png, image_art_crop, image_board_crop, price_usd
`
/** Shapes a database row into the JSON the client consumes. */
@@ -70,6 +70,9 @@ function hydrate(row) {
artCrop: row.image_art_crop,
boardCrop: row.image_board_crop,
},
// Null when the card has no market price in the index. Callers must render
// that as "no price", never as $0.00.
priceUsd: row.price_usd,
}
}
+26 -12
View File
@@ -8,23 +8,34 @@ import { DeckBuilder } from './routes/DeckBuilder'
import { Browse } from './routes/Browse'
import { Analyze } from './routes/Analyze'
import { Settings } from './routes/Settings'
import { Table } from './routes/Table'
export default function App() {
return (
<BrowserRouter>
<DeckProvider>
<TopBar />
<main>
<Routes>
<Route path="/" element={<Decks />} />
<Route path="/deck/:id" element={<DeckRoute />} />
<Route path="/deck/s/:slug" element={<ShareRoute />} />
<Route path="/browse" element={<Browse />} />
<Route path="/analyze/:id" element={<Analyze />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
{/* The shell is a fixed-height grid so the top bar never scrolls away
and each route owns exactly one scroll container. Without it the
body scrolls, and on a phone the builder's three columns collapse
into one long page under a header that has already left. */}
<div className="app">
<a className="skip" href="#main">
Skip to content
</a>
<TopBar />
<main id="main" className="main" tabIndex={-1}>
<Routes>
<Route path="/" element={<Decks />} />
<Route path="/deck/:id" element={<DeckRoute />} />
<Route path="/deck/s/:slug" element={<ShareRoute />} />
<Route path="/browse" element={<Browse />} />
<Route path="/analyze/:id" element={<Analyze />} />
<Route path="/table" element={<Table />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
</div>
</DeckProvider>
</BrowserRouter>
)
@@ -55,6 +66,9 @@ function TopBar() {
<NavLink to="/browse" className={({ isActive }) => (isActive ? 'active' : '')}>
Browse
</NavLink>
<NavLink to="/table" className={({ isActive }) => (isActive ? 'active' : '')}>
Table
</NavLink>
{hasDeck && (
<NavLink to={`/analyze/${deck!.id}`} className={({ isActive }) => (isActive ? 'active' : '')}>
Analyse
+189 -49
View File
@@ -1,72 +1,122 @@
import { useState } from 'react'
import { lookupCards } from '../lib/api'
import { useDecks } from '../lib/deckContext'
import { parseDecklist } from '../lib/deckStore.js'
import { parseBackup, parseDecklists } from '../lib/deckStore.js'
import { Modal } from './Modal'
/**
* Decklist import.
*
* Accepts the plain-text format every MTG tool exports. Names are resolved
* against the card index in one request, and anything unrecognised is
* reported rather than silently dropped — a silently short deck is worse than
* a failed import.
* Three ways in, because three sources are what people actually have: text
* pasted from a deck site, a folder of `.txt` files exported from one, and a
* MAGIK JSON backup of this browser's own decks.
*
* Names are resolved against the card index in batched requests, and anything
* unrecognised is reported rather than silently dropped — a silently short deck
* is worse than a failed import.
*/
export function ImportDialog({ onClose }: { onClose: () => void }) {
const { replaceDeck } = useDecks()
export function ImportDialog({
onClose,
target = 'gallery',
}: {
onClose: () => void
/** `open` loads the list into the deck being built; `gallery` files it. */
target?: 'open' | 'gallery'
}) {
const { replaceDeck, importDecks } = useDecks()
const [text, setText] = useState('')
const [files, setFiles] = useState<{ name: string; text: string }[]>([])
const [busy, setBusy] = useState(false)
const [missing, setMissing] = useState<string[]>([])
const [report, setReport] = useState<Report | null>(null)
const [error, setError] = useState<string | null>(null)
const readFiles = async (list: FileList | null) => {
if (!list) return
const read = await Promise.all(
[...list].map(async (file) => ({ name: file.name, text: await file.text() })),
)
// Appended rather than replaced: picking a second folder should not
// silently discard the first.
setFiles((current) => [...current, ...read])
setReport(null)
}
const run = async () => {
const parsed = parseDecklist(text)
if (parsed.cards.length === 0 && parsed.commanders.length === 0) {
setError('No cards found. Each line should look like "4 Lightning Bolt".')
// File boundaries are made explicit separators, so two files pasted
// together and two files chosen from disk take the same path.
const source = [text, ...files.map((f) => `----- ${f.name}\n${f.text}`)]
.filter((chunk) => chunk.trim() !== '')
.join('\n')
if (source.trim() === '') {
setError('Nothing to import. Paste a decklist or choose a file.')
return
}
setBusy(true)
setError(null)
try {
const names = [...parsed.commanders.map((c) => c.name), ...parsed.cards.map((c) => c.name)]
const { cards } = await lookupCards(names)
const idOf = new Map(cards.map((c) => [c.name, c]))
// A backup is JSON, and JSON is the one thing a decklist never is, so
// the first character decides which reader gets it.
const isJson = /^[[{]/.test(source.trim())
const parsed = isJson ? parseBackup(source) : parseDecklists(source)
if (parsed.length === 0) {
setError('No cards found. Each line should look like "4 Lightning Bolt".')
return
}
if (target === 'open' && parsed.length > 1) {
setError(
`${parsed.length} decklists found. This button loads one into the open deck — import from the Decks page to add several at once.`,
)
return
}
const names = [
...new Set(parsed.flatMap((d) => [...d.commanders.map((c) => c.name), ...d.cards.map((c) => c.name)])),
]
const idOf = new Map((await resolveNames(names)).map((c) => [c.name, c.id]))
// An unresolved name keeps its entry with an empty id, exactly as the
// share-link route does. The rules panel reports each one by name, so
// the import stays the size the pasted list described and the gap is
// visible rather than silently closed.
const deck = {
...parsed,
commanders: parsed.commanders.map((c) => {
const card = idOf.get(c.name)
return { id: card?.id ?? '', name: card?.name ?? c.name }
}),
cards: parsed.cards.map((c) => {
const card = idOf.get(c.name)
return { id: card?.id ?? '', name: card?.name ?? c.name, qty: c.qty }
}),
const resolved = parsed.map((deck) => ({
...deck,
commanders: deck.commanders.map((c) => ({ ...c, id: idOf.get(c.name) ?? '' })),
cards: deck.cards.map((c) => ({ ...c, id: idOf.get(c.name) ?? '' })),
}))
const missing = names.filter((n) => !idOf.has(n))
if (target === 'open') {
replaceDeck(resolved[0])
onClose()
return
}
setMissing(
names.filter((n) => !idOf.has(n)),
)
if (deck.cards.length > 0 || deck.commanders.length > 0) {
replaceDeck(deck)
onClose()
}
importDecks(resolved)
setReport({
decks: resolved.map((d) => ({
name: d.name,
cards: d.cards.reduce((n, c) => n + c.qty, 0) + d.commanders.length,
unresolved: [...d.commanders, ...d.cards].filter((c) => !idOf.has(c.name)).map((c) => c.name),
})),
missing,
backup: isJson,
})
} catch (err) {
setError(`Import failed: ${String(err)}`)
setError(`Import failed: ${err instanceof Error ? err.message : String(err)}`)
} finally {
setBusy(false)
}
}
const hasInput = text.trim() !== '' || files.length > 0
return (
<Modal onClose={onClose} label="Import a decklist" className="modal-narrow">
<Modal onClose={onClose} label="Import decklists" className="modal-narrow">
<div className="modal-head">
<h2 className="modal-title">Import a decklist</h2>
<h2 className="modal-title">{target === 'open' ? 'Import a decklist' : 'Import decklists'}</h2>
<span className="spacer" />
<button type="button" className="btn btn-ghost btn-icon" onClick={onClose} aria-label="Close">
✕
@@ -75,41 +125,131 @@ export function ImportDialog({ onClose }: { onClose: () => void }) {
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<p className="muted" style={{ fontSize: 12, margin: 0 }}>
Paste a decklist in the standard format: one <code>4 Card Name</code> per
line, with an optional <code>Commander: Name</code> line and a{' '}
<code>// name</code> header. <code>Sideboard</code> and{' '}
<code>*category*</code> lines are ignored.
Paste a decklist in the standard format: one <code>4 Card Name</code> per line,
with an optional <code>Commander: Name</code> line and a <code>// name</code> header.{' '}
<code>Sideboard</code> and <code>*category*</code> lines are ignored. Several lists
can be pasted together, separated by a <code>-----</code> line or by each one&apos;s
own <code>// name</code> header.
</p>
<textarea
className="io"
value={text}
onChange={(e) => setText(e.target.value)}
onChange={(e) => {
setText(e.target.value)
setReport(null)
}}
placeholder={'// My Commander Deck\nCommander: Atraxa, Grand Unifier\n1 Sol Ring\n1 Command Tower\n…'}
spellCheck={false}
aria-label="Decklist text"
/>
<div className="import-files">
<label className="btn">
Choose files
<input
type="file"
multiple
accept=".txt,.json,text/plain,application/json"
className="visually-hidden"
onChange={(e) => void readFiles(e.target.files)}
/>
</label>
<span className="dim" style={{ fontSize: 11.5 }}>
<code>.txt</code> decklists, or the <code>.json</code> backup from Settings.
Several at once is fine.
</span>
</div>
{files.length > 0 && (
<ul className="file-list">
{files.map((file) => (
<li key={file.name}>
<span className="file-name">{file.name}</span>
<button
type="button"
className="btn btn-ghost btn-icon"
aria-label={`Remove ${file.name}`}
onClick={() => setFiles((current) => current.filter((f) => f !== file))}
>
✕
</button>
</li>
))}
</ul>
)}
{/* The result of pressing Import is a status, not a page change, and
nothing moves focus here — so it is announced (WCAG 4.1.3) rather
than silently replacing text the user is no longer looking at. */}
<div role="status" aria-atomic="true">
{error && <p className="rule-issue">{error}</p>}
{missing.length > 0 && (
<p className="rule-issue">
Not found in the card index: {missing.slice(0, 8).join(', ')}
{missing.length > 8 ? ` +${missing.length - 8} more` : ''}
</p>
)}
{report && <ImportReport report={report} onDismiss={() => setReport(null)} />}
</div>
</div>
<div className="modal-foot">
<button type="button" className="btn" onClick={onClose}>
Cancel
</button>
<button type="button" className="btn btn-primary" onClick={run} disabled={busy || text.trim() === ''}>
{busy ? 'Importing…' : 'Import deck'}
{report ? 'Close' : 'Cancel'}
</button>
{!report && (
<button type="button" className="btn btn-primary" onClick={run} disabled={busy || !hasInput}>
{busy ? 'Importing…' : target === 'open' ? 'Import deck' : 'Import'}
</button>
)}
</div>
</Modal>
)
}
interface Report {
decks: { name: string; cards: number; unresolved: string[] }[]
missing: string[]
backup: boolean
}
function ImportReport({ report, onDismiss }: { report: Report; onDismiss: () => void }) {
return (
<div className="import-report">
<p className="import-report-head">
{report.decks.length === 1
? `Imported “${report.decks[0].name}”.`
: `Imported ${report.decks.length} decks.`}
{report.backup && ' A backup replaces decks that share an id with it.'}
</p>
{report.decks.length > 1 && (
<ul className="file-list">
{report.decks.map((deck) => (
<li key={deck.name}>
<span className="file-name">{deck.name}</span>
<span className="dim count">{deck.cards}</span>
</li>
))}
</ul>
)}
{report.missing.length > 0 && (
<p className="rule-issue">
Not found in the card index: {report.missing.slice(0, 8).join(', ')}
{report.missing.length > 8 ? ` +${report.missing.length - 8} more` : ''}
</p>
)}
<button type="button" className="btn" onClick={onDismiss}>
Import more
</button>
</div>
)
}
/**
* Name lookups in batches. A folder of decklists is a few hundred names, and
* one request carrying all of them would outgrow a URL; 150 keeps each
* request comfortably inside it without making the import feel sequential.
*/
async function resolveNames(names: string[]): Promise<{ name: string; id: string }[]> {
const chunks: string[][] = []
for (let i = 0; i < names.length; i += 150) chunks.push(names.slice(i, i + 150))
const results = await Promise.all(chunks.map((chunk) => lookupCards(chunk)))
return results.flatMap((r) => r.cards)
}
+2
View File
@@ -36,6 +36,8 @@ export interface Card {
isPartner: boolean
isBasicLand: boolean
legalFormats: string[]
/** Median market price in USD, or null when the index has no price. */
priceUsd: number | null
image: {
small: string | null
normal: string | null
+6
View File
@@ -17,6 +17,7 @@ export interface CardRecord {
isPartner: boolean
isBasicLand: boolean
bannedInCommander: boolean
priceUsd?: number | null
}
export interface DeckShape {
@@ -46,6 +47,11 @@ export declare function typeBreakdown(deck: DeckShape, byId: CardLookup): { type
export declare const TYPE_ORDER: string[]
export declare function primaryType(card: CardRecord | undefined): string
export declare function averageManaValue(deck: DeckShape, byId: CardLookup): number
export declare function deckValue(
deck: DeckShape,
byId: CardLookup,
): { total: number; unpriced: number }
export declare function highestCostCards(
deck: DeckShape,
byId: CardLookup,
+24
View File
@@ -113,6 +113,30 @@ export function typeBreakdown(deck, byId) {
return TYPE_ORDER.map((type) => ({ type, count: counts.get(type) }))
}
/**
* Market value of the deck in USD, Commander included.
*
* Cards the index has no price for are counted and skipped rather than
* treated as free, so a total is always labelled with what it left out. A
* silent zero would make a deck with unpriced cards look cheaper than it is,
* which is the one way a price total can mislead.
*/
export function deckValue(deck, byId) {
let total = 0
let unpriced = 0
const add = (card, qty) => {
if (!card) return
if (typeof card.priceUsd !== 'number') {
unpriced += qty
return
}
total += card.priceUsd * qty
}
for (const entry of deck.commanders) add(byId.get(entry.id), 1)
for (const entry of deck.cards) add(byId.get(entry.id), entry.qty)
return { total: Math.round(total * 100) / 100, unpriced }
}
/** Average mana value across the deck, Commander included. */
export function averageManaValue(deck, byId) {
let total = 0
+27 -1
View File
@@ -27,6 +27,7 @@ interface DeckState {
setCommander: (card: Card) => void
setCommanderFor: (deckId: string, card: Card) => void
replaceDeck: (deck: DeckShape) => void
importDecks: (decks: DeckShape[]) => void
removeDeck: (deckId: string) => void
quantityOf: (cardId: string) => number
}
@@ -161,8 +162,32 @@ export function DeckProvider({ children }: { children: ReactNode }) {
setActiveId(resolved.id)
}
const removeDeck: DeckState['removeDeck'] = (deckId) => {
/**
* A batch import lands in the gallery rather than in the open deck.
*
* Matching on id is what makes this safe to run twice: restoring the same
* backup again updates each deck in place instead of filling the gallery
* with copies, and a deck that was edited since the backup was written is
* replaced by it — which is what "restore" means, and why the import
* dialog says so before it does it.
*/
const importDecks: DeckState['importDecks'] = (incoming) => {
if (incoming.length === 0) return
setDecks((current) => {
const byId = new Map(current.map((d) => [d.id, d]))
for (const deck of incoming) {
// A deck parsed from text has no id yet, and a gallery entry without
// one cannot be opened, linked or deleted.
const id = deck.id || crypto.randomUUID()
byId.set(id, { ...deck, id, updatedAt: Date.now() })
}
const next = [...byId.values()]
saveDecks(next)
return next
})
}
const removeDeck: DeckState['removeDeck'] = (deckId) => { setDecks((current) => {
const next = current.filter((d) => d.id !== deckId)
saveDecks(next)
return next
@@ -183,6 +208,7 @@ export function DeckProvider({ children }: { children: ReactNode }) {
setCommander,
setCommanderFor,
replaceDeck,
importDecks,
removeDeck,
quantityOf: (cardId) => deck?.cards.find((c) => c.id === cardId)?.qty ?? 0,
}
+4
View File
@@ -23,6 +23,10 @@ export declare function encodeDeck(deck: DeckShape): Promise<string>
export declare function decodeDeck(slug: string): Promise<Deck>
export declare function parseDecklist(text: string): DeckShape
/** Every deck in one text: `-----` runs and repeated `// name` headers split them. */
export declare function parseDecklists(text: string): DeckShape[]
/** Reads back the JSON backup Settings writes. Throws on anything else. */
export declare function parseBackup(text: string): Deck[]
export declare function formatDecklist(deck: DeckShape): string
export interface DeckEntry {
id: string
+54
View File
@@ -181,6 +181,60 @@ export function parseDecklist(text) {
return deck
}
/**
* Several decklists in one text, for a batch import.
*
* Players who keep a folder of lists also paste the whole folder. A run of
* `-----` is the explicit separator, and a second `// name` header also
* starts a new deck — the header is how every one of these lists names
* itself, so treating it as a boundary is what a person pasting four lists
* expects, and a deck whose separator was lost in the clipboard still splits
* correctly.
*/
export function parseDecklists(text) {
const chunks = []
let current = []
const flush = () => {
if (current.length > 0) chunks.push(current.join('\n'))
current = []
}
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim()
if (/^-{3,}$/.test(line)) {
flush()
continue
}
if (line.startsWith('//') && current.some((l) => l.trim() !== '' && !l.trim().startsWith('//'))) flush()
current.push(raw)
}
flush()
return chunks
.map((chunk) => parseDecklist(chunk))
.filter((deck) => deck.cards.length > 0 || deck.commanders.length > 0)
}
/**
* A MAGIK JSON backup — the file Settings exports — read back.
*
* Restoring is the only way a deck crosses out of a browser, so this accepts
* both shapes the export has ever written (a bare array, and an object with a
* `decks` key) and re-normalises every entry. Ids are kept when they are
* present and real, because that is what makes a restore idempotent: the
* second import updates the first rather than duplicating it.
*/
export function parseBackup(text) {
const parsed = JSON.parse(text)
const list = Array.isArray(parsed) ? parsed : parsed?.decks
if (!Array.isArray(list)) {
throw new Error('That is JSON, but not a MAGIK backup — expected a list of decks.')
}
const decks = list.filter((deck) => deck && typeof deck === 'object').map(normaliseDeck)
if (decks.length === 0) throw new Error('That backup contains no decks.')
return decks
}
export function formatDecklist(deck) {
const lines = [`// ${deck.name}`]
for (const commander of deck.commanders) lines.push(`Commander: ${commander.name}`)
+12
View File
@@ -103,3 +103,15 @@ export function formatDate(iso: string) {
export function plural(n: number, one: string, many = `${one}s`) {
return `${n} ${n === 1 ? one : many}`
}
/**
* A card's market price, or null when the index has none.
*
* A sub-cent third decimal is only shown where the cents would otherwise hide
* the value entirely: a cent of Forest is a real number, and rounding every
* basic land to $0.00 would read as "free" rather than "cheap".
*/
export function formatPrice(usd: number | null | undefined) {
if (typeof usd !== 'number') return null
return usd < 0.01 ? `$${usd.toFixed(3)}` : `$${usd.toFixed(2)}`
}
+26
View File
@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
/**
* A live media query, as a hook.
*
* The card detail is a side panel on a wide screen and a modal on a narrow
* one, because the stacked layout has nowhere to put a third column: hiding
* it left a phone user tapping a card with nothing at all happening. The
* layout already changes at this breakpoint in CSS; this is the same question
* asked in JS so the two do not disagree.
*/
export function useMediaQuery(query: string) {
const [matches, setMatches] = useState(() =>
typeof window === 'undefined' ? false : window.matchMedia(query).matches,
)
useEffect(() => {
const list = window.matchMedia(query)
const onChange = () => setMatches(list.matches)
onChange()
list.addEventListener('change', onChange)
return () => list.removeEventListener('change', onChange)
}, [query])
return matches
}
+77
View File
@@ -0,0 +1,77 @@
/** Types for tabletop game tracking. */
export interface CommanderSlot {
id: string
name: string
damage: number
}
export interface Counter {
id: string
label: string
value: number
}
export interface TablePlayer {
id: string
name: string
life: number
commanders: CommanderSlot[]
counters: Counter[]
}
export interface Table {
startingLife: number
turn: number
active: number
phase: number
players: TablePlayer[]
}
export declare const STARTING_LIFE: 40
export declare const PHASES: string[]
export declare const QUICK_STEPS: number[]
export declare function newTable(playerCount?: number, startingLife?: number): Table
export declare function newPlayer(name?: string, life?: number): TablePlayer
export declare function addPlayer(state: Table): Table
export declare function removePlayer(state: Table, playerId: string): Table
export declare function renamePlayer(state: Table, playerId: string, name: string): Table
export declare function changeLife(state: Table, playerId: string, delta: number): Table
export declare function setLife(state: Table, playerId: string, life: number): Table
export declare function isOut(player: TablePlayer): boolean
export declare function isGameOver(state: Table): boolean
export declare function setActive(state: Table, index: number): Table
export declare function advancePhase(state: Table): Table
export declare function setPhase(state: Table, phase: number): Table
export declare function addCommander(
state: Table,
playerId: string,
card: { id?: string; name?: string },
): Table
export declare function removeCommander(state: Table, playerId: string, slotId: string): Table
export declare function damageCommander(
state: Table,
playerId: string,
slotId: string,
delta: number,
): Table
export declare function addCounter(state: Table, playerId: string, label: string): Table
export declare function removeCounter(state: Table, playerId: string, counterId: string): Table
export declare function changeCounter(
state: Table,
playerId: string,
counterId: string,
delta: number,
): Table
export declare function resetTable(state: Table): Table
export declare function setStartingLife(state: Table, startingLife: number): Table
export declare function loadTable(): Table | null
export declare function saveTable(state: Table): void
export declare function clearTable(): void
+308
View File
@@ -0,0 +1,308 @@
/**
* Tabletop tracking for a game in progress.
*
* This is a phone lying face-up on the table: it holds the number every player
* is actually reading — life, poison, and how much damage the Commander has
* taken — and nothing else. The deck lives in the builder; this is the part of
* the game that is not the deck.
*
* Two things are deliberately tracked apart:
*
* life how much damage a player has taken. Reset each game, and the
* number a rules-violation or combat-doubling effect cares about.
* commander damage
* how much damage a specific Commander card has taken, so that
* "commander damage" and "damage to your commander" effects — and
* any effect that looks at how many times it has been dealt — can
* be counted at the table.
*
* Every function is pure and returns a new state. That is not ceremony: the
* reducer is what the tests exercise, so the numbers a phone shows are checked
* by the same code the phone runs.
*
* Plain JavaScript on purpose, like `commander.js` and `deckStore.js`, so
* `node --test` can require this file directly.
*/
export const STARTING_LIFE = 40
/**
* The five steps worth tracking at a table. A Commander turn is untap, draw,
* combat, main and end; upkeep, the second combat and the second main are
* collapsed into their neighbours because nobody tracks them on a phone.
*/
export const PHASES = ['Untap', 'Draw', 'Combat', 'Main', 'End']
/** Damage steps offered as one tap each, ahead of the ±1 stepper. */
export const QUICK_STEPS = [-10, -5, -1, 1, 5, 10]
const STORAGE_KEY = 'magik.table.v1'
/* ── Construction ─────────────────────────────────────────────────────── */
export function newTable(playerCount = 4, startingLife = STARTING_LIFE) {
const count = Math.min(6, Math.max(1, Math.floor(playerCount) || 1))
return {
startingLife,
turn: 1,
active: 0,
phase: 0,
players: Array.from({ length: count }, (_, i) => newPlayer(`Player ${i + 1}`, startingLife)),
}
}
export function newPlayer(name = 'Player', life = STARTING_LIFE) {
return {
id: newId(),
name,
life,
commanders: [],
// Poison is on every seat from the start: it is the one counter a
// Commander game almost always needs, and adding it mid-game is a step
// someone forgets.
counters: [{ id: newId(), label: 'Poison', value: 0 }],
}
}
/**
* `crypto.randomUUID` is available in every browser this app targets and in
* Node 19+, but the id only has to be unique inside one saved table, so a
* counter-free fallback keeps this importable from a plain script context.
*/
function newId() {
return globalThis.crypto?.randomUUID?.() ?? `p${Math.random().toString(36).slice(2, 10)}`
}
/* ── Players ──────────────────────────────────────────────────────────── */
export function addPlayer(state) {
return {
...state,
players: [...state.players, newPlayer(`Player ${state.players.length + 1}`, state.startingLife)],
}
}
/**
* Removing a player also has to repair the two indices that point at the
* list: the active player and the current phase's owner. Leaving `active`
* past the end of the array would leave the table highlighting nobody.
*/
export function removePlayer(state, playerId) {
if (state.players.length <= 1) return state
const index = state.players.findIndex((p) => p.id === playerId)
if (index === -1) return state
const players = state.players.filter((p) => p.id !== playerId)
const active = state.active > index ? state.active - 1 : Math.min(state.active, players.length - 1)
return { ...state, players, active }
}
export function renamePlayer(state, playerId, name) {
return patchPlayer(state, playerId, (p) => ({ ...p, name }))
}
/** Life is allowed to go negative: a player at -3 has lost, and rounding up
* to zero on the way would hide the rules violation that did it. */
export function changeLife(state, playerId, delta) {
return patchPlayer(state, playerId, (p) => ({ ...p, life: p.life + delta }))
}
export function setLife(state, playerId, life) {
return patchPlayer(state, playerId, (p) => ({ ...p, life }))
}
/** A player is out once life is zero or less, and the game is over once
* every player but one is. */
export function isOut(player) {
return player.life <= 0
}
export function isGameOver(state) {
return state.players.filter(isOut).length >= state.players.length - 1
}
/* ── Turn and phase ───────────────────────────────────────────────────── */
export function setActive(state, index) {
if (index < 0 || index >= state.players.length || index === state.active) return state
return { ...state, active: index }
}
/**
* Advance one step. Past the last phase this rolls the turn over: the turn
* counter goes up and the active seat moves on, which is the whole of turn
* order — a Commander game always starts with the same player.
*/
export function advancePhase(state) {
const next = state.phase + 1
if (next < PHASES.length) return { ...state, phase: next }
return {
...state,
turn: state.turn + 1,
phase: 0,
active: (state.active + 1) % state.players.length,
}
}
export function setPhase(state, phase) {
if (phase < 0 || phase >= PHASES.length) return state
return { ...state, phase }
}
/* ── Commander damage ─────────────────────────────────────────────────── */
/** Each commander slot is one physical card, tracked separately from life. */
export function addCommander(state, playerId, { id = '', name = 'Commander' }) {
return patchPlayer(state, playerId, (p) => ({
...p,
commanders: [...p.commanders, { id, name, damage: 0 }],
}))
}
export function removeCommander(state, playerId, slotId) {
return patchPlayer(state, playerId, (p) => ({
...p,
// A card's uuid identifies the same Commander in two slots; a locally
// added one has none, so its own slot id stands in.
commanders: p.commanders.filter((c, i) => (c.id || `slot${i}`) !== slotId),
}))
}
export function damageCommander(state, playerId, slotId, delta) {
return patchPlayer(state, playerId, (p) => ({
...p,
commanders: p.commanders.map((c, i) =>
(c.id || `slot${i}`) === slotId ? { ...c, damage: c.damage + delta } : c,
),
}))
}
/* ── Counters ─────────────────────────────────────────────────────────── */
/** Poison is a counter like any other, seeded so it is on screen without
* being added; anything else a deck asks for is a labelled counter. */
export function addCounter(state, playerId, label) {
const clean = label.trim()
if (!clean) return state
return patchPlayer(state, playerId, (p) => {
const existing = p.counters.find((c) => c.label.toLowerCase() === clean.toLowerCase())
if (existing) return p
return { ...p, counters: [...p.counters, { id: newId(), label: clean, value: 0 }] }
})
}
export function removeCounter(state, playerId, counterId) {
return patchPlayer(state, playerId, (p) => ({
...p,
counters: p.counters.filter((c) => c.id !== counterId),
}))
}
/**
* Counters are clamped at zero from below and have no upper bound. Poison and
* experience never count down past nothing, and there is no such thing as
* negative charges.
*/
export function changeCounter(state, playerId, counterId, delta) {
return patchPlayer(state, playerId, (p) => ({
...p,
counters: p.counters.map((c) =>
c.id === counterId ? { ...c, value: Math.max(0, c.value + delta) } : c,
),
}))
}
/* ── Whole-table operations ───────────────────────────────────────────── */
/**
* A new game: everyone back to the starting life, every counter and every
* damage number cleared, the turn back to 1 with the same starting player.
* Player names and commanders are kept — a second game is usually the same
* four people with the same decks, and retyping is the part that is tedious
* on a phone.
*/
export function resetTable(state) {
return {
...state,
turn: 1,
phase: 0,
active: 0,
players: state.players.map((p) => ({
...p,
life: state.startingLife,
commanders: p.commanders.map((c) => ({ ...c, damage: 0 })),
// Poison is the one counter every game starts with, so it stays on the
// seat; anything else was a counter this game happened to need.
counters: p.counters
.filter((c) => c.label.toLowerCase() === 'poison')
.map((c) => ({ ...c, value: 0 })),
})),
}
}
export function setStartingLife(state, startingLife) {
const life = Math.max(1, Math.floor(startingLife) || STARTING_LIFE)
return { ...state, startingLife: life }
}
/* ── Persistence ──────────────────────────────────────────────────────── */
/**
* A saved table is restored leniently: every field is re-derived from a
* default so a table written by an older build, or one a user edited in
* devtools, still opens instead of throwing on a missing key.
*/
export function loadTable() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const saved = JSON.parse(raw)
if (!saved || !Array.isArray(saved.players) || saved.players.length === 0) return null
return {
startingLife: numberOr(saved.startingLife, STARTING_LIFE),
turn: numberOr(saved.turn, 1),
active: Math.max(0, Math.floor(saved.active ?? 0)),
phase: Math.max(0, Math.min(PHASES.length - 1, Math.floor(saved.phase ?? 0))),
players: saved.players.map((p) => ({
id: p.id || newId(),
name: p.name || 'Player',
life: numberOr(p.life, STARTING_LIFE),
commanders: Array.isArray(p.commanders)
? p.commanders.map((c) => ({ id: c.id ?? '', name: c.name || 'Commander', damage: numberOr(c.damage, 0) }))
: [],
counters: Array.isArray(p.counters)
? p.counters.map((c) => ({ id: c.id || newId(), label: c.label || 'Counter', value: numberOr(c.value, 0) }))
: [],
})),
}
} catch {
return null
}
}
export function saveTable(state) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
} catch {
// A full or disabled store must not break the game in progress; the table
// simply does not survive a reload.
}
}
export function clearTable() {
try {
localStorage.removeItem(STORAGE_KEY)
} catch {
/* nothing to do — the table is going away either way */
}
}
function numberOr(value, fallback) {
return Number.isFinite(value) ? value : fallback
}
function patchPlayer(state, playerId, fn) {
return {
...state,
players: state.players.map((p) => (p.id === playerId ? fn(p) : p)),
}
}
+6 -1
View File
@@ -8,6 +8,7 @@ import {
commanderIdentity,
colorName,
deckSize,
deckValue,
highestCostCards,
manaCurve,
offIdentityColors,
@@ -15,6 +16,7 @@ import {
validateDeck,
} from '../lib/commander.js'
import { formatDecklist } from '../lib/deckStore.js'
import { formatPrice } from '../lib/format'
import { Validator } from '../components/Validator'
import { CardDetail } from '../components/CardDetail'
import { Pips, PowerToughness } from '../components/primitives'
@@ -87,6 +89,7 @@ export function Analyze() {
const curveMax = Math.max(1, ...curve)
const typeTotal = Math.max(1, types.reduce((n, t) => n + t.count, 0))
const avg = averageManaValue(target, cards)
const value = deckValue(target, cards)
const expensive = highestCostCards(target, cards, 8)
const identity = [...commanderIdentity(target, cards)]
const offending = offIdentityColors(target, cards)
@@ -104,7 +107,9 @@ export function Analyze() {
<div>
<h1 className="page-title">{target.name || 'Untitled deck'}</h1>
<p className="page-sub">
{size} / 100 cards · average mana value {avg.toFixed(2)}
{size} / 100 cards · average mana value {avg.toFixed(2)} ·{' '}
{value.total > 0 ? formatPrice(value.total) : 'no priced cards'}
{value.unpriced > 0 && ` · ${value.unpriced} unpriced`}
</p>
</div>
<span className="spacer" />
+98 -45
View File
@@ -3,6 +3,8 @@ import type { Card } from '../lib/api'
import { CardBrowser } from '../components/CardBrowser'
import { useDecks } from '../lib/deckContext'
import { CardImage, Pips } from '../components/primitives'
import { Modal } from '../components/Modal'
import { useMediaQuery } from '../lib/media'
import { powerToughness } from '../lib/format'
/**
@@ -17,6 +19,7 @@ export function Browse() {
const [card, setCard] = useState<Card | null>(null)
const [tab, setTab] = useState<'oracle' | 'formats'>('oracle')
const [total, setTotal] = useState<number | null>(null)
const narrow = useMediaQuery('(max-width: 1180px)')
const quantities = new Map((deck?.cards ?? []).map((c) => [c.id, c.qty]))
@@ -78,51 +81,7 @@ export function Browse() {
<div className="card-art" style={{ borderBottom: '1px solid var(--rule-soft)' }}>
<CardImage card={card} />
</div>
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Pips identity={card.colorIdentity} large />
<span className="dim" style={{ fontSize: 11 }}>
{card.typeLine} {powerToughness(card) ? `· ${powerToughness(card)}` : ''}
</span>
</div>
<div style={{ display: 'flex', gap: 4 }}>
{(['oracle', 'formats'] as const).map((t) => (
<button key={t} type="button" className="chip" aria-pressed={tab === t} onClick={() => setTab(t)}>
{t === 'oracle' ? 'Rules text' : `Legal (${card.legalFormats.length})`}
</button>
))}
</div>
{tab === 'oracle' ? (
<p className="detail-text" style={{ margin: 0, fontSize: 12 }}>
{card.oracleText || 'This card has no rules text.'}
</p>
) : (
<div className="facet-body">
{card.legalFormats.map((f) => (
<span key={f} className="chip">
{f}
</span>
))}
</div>
)}
{card.bannedInCommander && <p className="rule-issue">Banned in Commander.</p>}
{deck && (
<div style={{ display: 'flex', gap: 6 }}>
<button type="button" className="btn btn-primary" style={{ flex: 1, justifyContent: 'center' }} onClick={() => addCard(card)}>
Add to {deck.name || 'deck'}
</button>
{(quantityOf(card.id) ?? 0) > 0 && (
<button type="button" className="btn" onClick={() => removeCard(card.id)}>
Remove
</button>
)}
</div>
)}
</div>
<CardFacts card={card} tab={tab} onTab={setTab} deck={deck} onAdd={addCard} onRemove={removeCard} quantity={quantityOf(card.id)} />
</section>
) : (
<div className="empty">
@@ -130,6 +89,100 @@ export function Browse() {
</div>
)}
</div>
{/* Below the stacking breakpoint that third column is display:none, so
a phone user tapping a card was met with nothing at all. The same
facts open as a modal there instead — the same component, so the two
layouts cannot drift apart. */}
{narrow && card && (
<Modal onClose={() => setCard(null)} label={`${card.name} details`} className="modal-narrow">
<div className="modal-head">
<h2 className="modal-title">{card.name}</h2>
<span className="spacer" />
<button type="button" className="btn btn-ghost btn-icon" onClick={() => setCard(null)} aria-label="Close">
✕
</button>
</div>
<div className="card-art" style={{ borderBottom: '1px solid var(--rule-soft)' }}>
<CardImage card={card} />
</div>
<div className="modal-body">
<CardFacts card={card} tab={tab} onTab={setTab} deck={deck} onAdd={addCard} onRemove={removeCard} quantity={quantityOf(card.id)} />
</div>
</Modal>
)}
</div>
)
}
/** Everything about a card below its artwork, in one place for both layouts. */
function CardFacts({
card,
tab,
onTab,
deck,
onAdd,
onRemove,
quantity,
}: {
card: Card
tab: 'oracle' | 'formats'
onTab: (t: 'oracle' | 'formats') => void
deck: ReturnType<typeof useDecks>['deck']
onAdd: (card: Card) => void
onRemove: (cardId: string) => void
quantity: number
}) {
return (
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Pips identity={card.colorIdentity} large />
<span className="dim" style={{ fontSize: 11 }}>
{card.typeLine} {powerToughness(card) ? `· ${powerToughness(card)}` : ''}
</span>
</div>
<div style={{ display: 'flex', gap: 4 }}>
{(['oracle', 'formats'] as const).map((t) => (
<button key={t} type="button" className="chip" aria-pressed={tab === t} onClick={() => onTab(t)}>
{t === 'oracle' ? 'Rules text' : `Legal (${card.legalFormats.length})`}
</button>
))}
</div>
{tab === 'oracle' ? (
<p className="detail-text" style={{ margin: 0, fontSize: 12 }}>
{card.oracleText || 'This card has no rules text.'}
</p>
) : (
<div className="facet-body">
{card.legalFormats.map((f) => (
<span key={f} className="chip">
{f}
</span>
))}
</div>
)}
{card.bannedInCommander && <p className="rule-issue">Banned in Commander.</p>}
{deck && (
<div style={{ display: 'flex', gap: 6 }}>
<button
type="button"
className="btn btn-primary"
style={{ flex: 1, justifyContent: 'center' }}
onClick={() => onAdd(card)}
>
Add to {deck.name || 'deck'}
</button>
{quantity > 0 && (
<button type="button" className="btn" onClick={() => onRemove(card.id)}>
Remove
</button>
)}
</div>
)}
</div>
)
}
+9 -3
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import type { Card } from '../lib/api'
import { useDecks } from '../lib/deckContext'
import {
@@ -16,7 +16,7 @@ import { Validator } from '../components/Validator'
import { CardImage, Pips } from '../components/primitives'
import { CommanderPicker } from '../components/CommanderPicker'
import { ImportDialog } from '../components/ImportDialog'
import { ManaCost } from '../lib/format'
import { ManaCost, formatPrice } from '../lib/format'
const GROUP_LABELS: Record<string, string> = {
Creature: 'Creatures',
@@ -255,6 +255,9 @@ export function DeckBuilder() {
</span>
<span className="deck-row-name">{card.name}</span>
<ManaCost cost={card.manaCost} />
{formatPrice(card.priceUsd) && (
<span className="deck-row-price">{formatPrice(card.priceUsd)}</span>
)}
</button>
<span className="deck-stepper">
@@ -326,6 +329,9 @@ export function DeckBuilder() {
<button type="button" className="btn" onClick={() => setImporting(true)}>
Import
</button>
<Link className="btn" to="/table">
Play
</Link>
<button type="button" className="btn" onClick={exportList}>
Export
</button>
@@ -373,7 +379,7 @@ export function DeckBuilder() {
/>
)}
{importing && <ImportDialog onClose={() => setImporting(false)} />}
{importing && <ImportDialog onClose={() => setImporting(false)} target="open" />}
{toast && <div className="toast">{toast}</div>}
</div>
+7
View File
@@ -7,6 +7,7 @@ import { deckSize, validateDeck } from '../lib/commander.js'
import { newDeck } from '../lib/deckStore.js'
import { CardImage, Pips } from '../components/primitives'
import { CommanderPicker } from '../components/CommanderPicker'
import { ImportDialog } from '../components/ImportDialog'
/**
* Deck gallery.
@@ -21,6 +22,7 @@ export function Decks() {
const { decks, open, replaceDeck, removeDeck, setCommanderFor } = useDecks()
const navigate = useNavigate()
const [picking, setPicking] = useState<{ deckId: string; partner: boolean } | null>(null)
const [importing, setImporting] = useState(false)
const [cards, setCards] = useState<Map<string, Card>>(new Map())
const wanted = useMemo(() => {
@@ -72,6 +74,9 @@ export function Decks() {
</p>
</div>
<span className="spacer" />
<button type="button" className="btn" onClick={() => setImporting(true)}>
Import
</button>
<button type="button" className="btn btn-primary" onClick={() => setPicking({ deckId: '', partner: false })}>
+ New deck
</button>
@@ -165,6 +170,8 @@ export function Decks() {
</div>
)}
{importing && <ImportDialog onClose={() => setImporting(false)} />}
{picking && (
<CommanderPicker
partnersOnly={picking.partner}
+39 -6
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useDecks } from '../lib/deckContext'
import { loadPrefs, savePrefs } from '../lib/deckStore.js'
import { loadPrefs, parseBackup, savePrefs } from '../lib/deckStore.js'
import type { Prefs } from '../lib/deckStore.js'
import { cardCount } from '../lib/api'
@@ -12,7 +12,7 @@ import { cardCount } from '../lib/api'
* the export/import pair is the only way data crosses between them.
*/
export function Settings() {
const { decks } = useDecks()
const { decks, importDecks } = useDecks()
const [prefs, setPrefs] = useState(loadPrefs)
const [pool, setPool] = useState<number | null>(null)
const [message, setMessage] = useState<string | null>(null)
@@ -40,6 +40,32 @@ export function Settings() {
setMessage(`Exported ${decks.length} deck${decks.length === 1 ? '' : 's'}.`)
}
/**
* The counterpart to the export above, and the only way a deck leaves and
* re-enters a browser. Restoring merges on id, so a deck in the backup that
* is not in this browser is added and one that is replaces it — which is
* what overwriting is for, and why it is confirmed before anything is read.
*/
const restore = async (list: FileList | null) => {
const file = list?.[0]
if (!file) return
try {
const decks = parseBackup(await file.text())
if (
!confirm(
`Restore ${decks.length} deck${decks.length === 1 ? '' : 's'} from ${file.name}? ` +
'Decks already here with the same id are replaced.',
)
) {
return
}
importDecks(decks)
setMessage(`Restored ${decks.length} deck${decks.length === 1 ? '' : 's'}.`)
} catch (err) {
setMessage(`That file could not be restored: ${err instanceof Error ? err.message : String(err)}`)
}
}
const clearAll = () => {
if (!confirm('Delete every deck stored in this browser? This cannot be undone — export first if you want a copy.')) return
localStorage.removeItem('magik.decks.v1')
@@ -96,15 +122,22 @@ export function Settings() {
<p className="muted" style={{ fontSize: 12, margin: '0 0 10px' }}>
{decks.length} deck{decks.length === 1 ? '' : 's'} in this browser&apos;s local storage.
Nothing is sent to the server. A share link carries a whole deck in the URL,
which is how anyone else can open one.
which is how anyone else can open one, and <strong>Import</strong> on the Decks
page takes a whole folder of decklists at once.
</p>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button type="button" className="btn" onClick={exportAll} disabled={decks.length === 0}>
Export all decks
</button>
<button type="button" className="btn" onClick={() => setMessage('Copy a decklist or a share link from any deck instead.')}>
How to share
</button>
<label className="btn">
Restore a backup
<input
type="file"
accept=".json,application/json"
className="visually-hidden"
onChange={(e) => void restore(e.target.files)}
/>
</label>
<button type="button" className="btn btn-danger" onClick={clearAll} disabled={decks.length === 0}>
Delete everything
</button>
+664
View File
@@ -0,0 +1,664 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import type { Card } from '../lib/api'
import { lookupCards } from '../lib/api'
import { CardImage } from '../components/primitives'
import { CommanderPicker } from '../components/CommanderPicker'
import { Modal } from '../components/Modal'
import {
PHASES,
QUICK_STEPS,
addCommander,
addCounter,
addPlayer,
advancePhase,
changeCounter,
changeLife,
damageCommander,
isGameOver,
isOut,
loadTable,
newTable,
removeCommander,
removeCounter,
removePlayer,
renamePlayer,
resetTable,
saveTable,
setActive,
setPhase,
setStartingLife,
type Table as TableState,
} from '../lib/table.js'
/**
* The phone face-up on the table.
*
* A board, not a control panel: one card per player, each holding a name, a
* life total the size of a palm, a minus and a plus, and the counters that are
* currently on that player. Nothing else. Four of those fit one screen without
* scrolling, which is the whole point — the game is played by people who are
* looking at the table, not at the phone.
*
* Everything a card does not have room for — the ±5 and ±10 steps, commander
* damage, adding a counter, renaming, removing a seat — is one tap away in that
* player's sheet, because it is used between turns rather than during them.
* The same reasoning puts game-wide settings behind the one menu button.
*
* The state is one plain object from `lib/table.js`, so the numbers on screen
* are the numbers the tests assert. Nothing is derived at render time.
*/
export function Table() {
const [state, setState] = useState<TableState>(() => loadTable() ?? newTable(4))
const [detail, setDetail] = useState<string | null>(null)
const [menu, setMenu] = useState(false)
const [announcement, setAnnouncement] = useState('')
// Read by the life handler below, which needs the total *after* the change
// to say it out loud. Reading `state` from the closure instead would
// announce the previous total on any two taps in the same tick — easy to do
// with a target this size.
const latest = useRef(state)
latest.current = state
useEffect(() => saveTable(state), [state])
const say = useCallback((message: string) => {
// Re-announcing the same life total is still a change worth hearing, so
// the message is cleared first; otherwise the region is unchanged and a
// screen reader stays silent.
setAnnouncement('')
window.setTimeout(() => setAnnouncement(message), 30)
}, [])
const damage = (playerId: string, name: string, delta: number) => {
const next = changeLife(latest.current, playerId, delta)
latest.current = next
setState(next)
const life = next.players.find((p) => p.id === playerId)?.life ?? 0
say(`${name} at ${life} life`)
}
const over = isGameOver(state)
const active = state.players[state.active]
return (
<div className="table">
<header className="table-bar">
<Link to="/" className="btn btn-ghost" aria-label="Back to decks">
‹ Decks
</Link>
<span className="turn-n">
Turn {state.turn} · {active?.name ?? '—'}
</span>
<span className="spacer" />
<Fullscreen />
<button
type="button"
className="btn btn-icon"
onClick={() => setMenu(true)}
aria-label="Game options"
title="Game options"
>
⋯
</button>
</header>
<div className="phase-bar">
<ol className="phases">
{PHASES.map((label, i) => (
<li key={label}>
<button
type="button"
className="phase"
data-on={i === state.phase}
aria-current={i === state.phase ? 'step' : undefined}
onClick={() => setState((s) => setPhase(s, i))}
>
{label}
</button>
</li>
))}
</ol>
<button
type="button"
className="btn btn-primary next-step"
onClick={() => setState((s) => advancePhase(s))}
>
Next ▸
</button>
</div>
{over && (
<p className="table-over" role="status">
Game over — {state.players.filter((p) => !isOut(p)).map((p) => p.name).join(' and ')} win
{state.players.filter((p) => !isOut(p)).length === 1 ? 's' : 'ners'}.
</p>
)}
<div className="seats">
{state.players.map((player, index) => (
<PlayerTile
key={player.id}
player={player}
active={index === state.active}
onOpen={() => setDetail(player.id)}
onLife={(delta) => damage(player.id, player.name, delta)}
onCounter={(counterId, delta) => setState((s) => changeCounter(s, player.id, counterId, delta))}
/>
))}
</div>
{/* Life changes are announced here rather than on each button, so the
screen reader hears "Ana at 35 life" once per tap instead of the
button's own label again. */}
<div className="visually-hidden" role="status" aria-atomic="true">
{announcement}
</div>
{detail && state.players.some((p) => p.id === detail) && (
<PlayerSheet
player={state.players.find((p) => p.id === detail)!}
setState={setState}
onClose={() => setDetail(null)}
/>
)}
{menu && (
<GameMenu
state={state}
onChange={setState}
onClose={() => setMenu(false)}
onOpen={(id) => {
setMenu(false)
setDetail(id)
}}
/>
)}
</div>
)
}
/**
* One player, on one card.
*
* The card answers the two questions asked mid-turn — how much life, and what is
* on them — and nothing else. The corner buttons are 56px because they are hit
* with a thumb, at a table, in a hurry, and never looked at directly.
*/
function PlayerTile({
player,
active,
onOpen,
onLife,
onCounter,
}: {
player: TableState['players'][number]
active: boolean
onOpen: () => void
onLife: (delta: number) => void
onCounter: (counterId: string, delta: number) => void
}) {
const out = isOut(player)
// Four is what fits in the corner grid. Counters past that are rare (poison
// plus a couple of tokens) and the sheet lists them all anyway.
const shown = player.counters.slice(0, 4)
const hidden = player.counters.length - shown.length
return (
<section className="tile" data-active={active} data-out={out} aria-label={`${player.name}, ${player.life} life`}>
<button
type="button"
className="tile-step tile-dec"
onClick={() => onLife(-1)}
aria-label={`One less life for ${player.name}`}
>
−
</button>
{active && !out && <span className="tile-turn">Turn</span>}
<button type="button" className="tile-mid" onClick={onOpen}>
<span className="tile-name">{player.name}</span>
<span className="tile-life" aria-hidden="true">
{player.life}
</span>
<span className="visually-hidden">
{player.name} has {player.life} life. Open the player sheet.
</span>
</button>
<div className="tile-counters">
{shown.map((counter) => (
<button
key={counter.id}
type="button"
className="tile-counter"
data-zero={counter.value === 0}
onClick={() => onCounter(counter.id, 1)}
aria-label={`Add one ${counter.label} to ${player.name}, now ${counter.value}`}
>
<span className="tile-counter-n">{counter.value}</span>
<span className="tile-counter-label">{counter.label}</span>
</button>
))}
{hidden > 0 && <span className="tile-counter tile-counter-more">+{hidden}</span>}
</div>
<button
type="button"
className="tile-step tile-inc"
onClick={() => onLife(1)}
aria-label={`One more life for ${player.name}`}
>
+
</button>
</section>
)
}
/**
* Everything a card has no room for.
*
* A sheet rather than a second layout: the same controls, once, reached by one
* tap, and dismissed by Escape or by tapping the scrim without a new set of
* rules for a phone-sized screen.
*/
function PlayerSheet({
player,
setState,
onClose,
}: {
player: TableState['players'][number]
setState: (fn: (s: TableState) => TableState) => void
onClose: () => void
}) {
const [counter, setCounter] = useState('')
const [picking, setPicking] = useState(false)
return (
<Modal onClose={onClose} label={`${player.name} controls`} className="modal-narrow">
<div className="modal-head">
<h2 className="modal-title">{player.name}</h2>
<span className="spacer" />
<button type="button" className="btn btn-ghost btn-icon" onClick={onClose} aria-label="Close">
✕
</button>
</div>
<div className="modal-body sheet">
<label className="setting">
<span>Name</span>
<input
value={player.name}
maxLength={24}
onChange={(e) => setState((s) => renamePlayer(s, player.id, e.target.value))}
/>
</label>
<div className="sheet-block">
<div className="eyebrow">Life</div>
<p className="sheet-life" data-out={isOut(player)}>
{player.life}
</p>
<div className="steps" role="group" aria-label={`Change ${player.name}'s life`}>
{QUICK_STEPS.map((step) => (
<button
key={step}
type="button"
className="step"
data-down={step < 0}
onClick={() => setState((s) => changeLife(s, player.id, step))}
aria-label={`${step > 0 ? 'Plus' : 'Minus'} ${Math.abs(step)} life for ${player.name}`}
>
{step > 0 ? `+${step}` : step}
</button>
))}
</div>
</div>
<div className="sheet-block">
<div className="eyebrow">Commanders</div>
{player.commanders.map((slot, i) => {
const slotId = slot.id || `slot${i}`
return (
<div className="cmd" key={slotId}>
<div className="cmd-card">
<CommanderThumb name={slot.name} />
<span className="cmd-name">{slot.name}</span>
<span className="cmd-damage" data-hot={slot.damage >= 21}>
{slot.damage}
</span>
</div>
<div className="cmd-steps">
<button
type="button"
className="step"
onClick={() => setState((s) => damageCommander(s, player.id, slotId, -1))}
aria-label={`One less damage on ${slot.name}`}
>
−1
</button>
<button
type="button"
className="step"
onClick={() => setState((s) => damageCommander(s, player.id, slotId, 1))}
aria-label={`One more damage on ${slot.name}`}
>
+1
</button>
<button
type="button"
className="step"
onClick={() => setState((s) => damageCommander(s, player.id, slotId, 21))}
aria-label={`Twenty-one damage on ${slot.name}`}
>
21
</button>
<button
type="button"
className="btn btn-ghost btn-icon"
onClick={() => setState((s) => removeCommander(s, player.id, slotId))}
aria-label={`Forget ${slot.name}`}
>
✕
</button>
</div>
</div>
)
})}
{picking ? (
<CommanderPicker
onClose={() => setPicking(false)}
onPick={(card) => {
setState((s) => addCommander(s, player.id, card))
setPicking(false)
}}
/>
) : (
<button type="button" className="btn" onClick={() => setPicking(true)}>
+ Commander
</button>
)}
</div>
<div className="sheet-block">
<div className="eyebrow">Counters</div>
{player.counters.map((counterRow) => (
<div className="counter" key={counterRow.id}>
<span className="counter-label">{counterRow.label}</span>
<span className="counter-n">{counterRow.value}</span>
<button
type="button"
className="step step-sm"
onClick={() => setState((s) => changeCounter(s, player.id, counterRow.id, -1))}
aria-label={`One less ${counterRow.label} for ${player.name}`}
>
−
</button>
<button
type="button"
className="step step-sm"
onClick={() => setState((s) => changeCounter(s, player.id, counterRow.id, 1))}
aria-label={`One more ${counterRow.label} for ${player.name}`}
>
+
</button>
<button
type="button"
className="btn btn-ghost btn-icon"
onClick={() => setState((s) => removeCounter(s, player.id, counterRow.id))}
aria-label={`Remove the ${counterRow.label} counter for ${player.name}`}
>
✕
</button>
</div>
))}
<form
className="counter-add"
onSubmit={(e) => {
e.preventDefault()
if (counter.trim() === '') return
setState((s) => addCounter(s, player.id, counter.trim()))
setCounter('')
}}
>
<input
value={counter}
onChange={(e) => setCounter(e.target.value)}
placeholder="Add counter (poison, experience…)"
aria-label={`Add a counter for ${player.name}`}
maxLength={20}
/>
<button type="submit" className="btn" disabled={counter.trim() === ''}>
Add
</button>
</form>
</div>
</div>
<div className="modal-foot">
<button type="button" className="btn btn-primary" onClick={onClose}>
Done
</button>
</div>
</Modal>
)
}
/**
* The whole game's settings, behind one button.
*
* Renaming, seats, starting life and the two screen controls are all things
* that get set once before the first turn and then never again. They are the
* first thing to get off the board.
*/
function GameMenu({
state,
onChange,
onClose,
onOpen,
}: {
state: TableState
onChange: (fn: (s: TableState) => TableState) => void
onClose: () => void
onOpen: (playerId: string) => void
}) {
return (
<Modal onClose={onClose} label="Game options" className="modal-narrow">
<div className="modal-head">
<h2 className="modal-title">Game</h2>
<span className="spacer" />
<button type="button" className="btn btn-ghost btn-icon" onClick={onClose} aria-label="Close">
✕
</button>
</div>
<div className="modal-body sheet">
<div className="setting">
<span>Starting life</span>
<input
type="number"
min={1}
max={99}
value={state.startingLife}
onChange={(e) => onChange((s) => setStartingLife(s, Number(e.target.value)))}
/>
</div>
<div className="sheet-block">
<div className="eyebrow">Seats</div>
<ul className="file-list">
{state.players.map((p) => (
<li key={p.id}>
<button type="button" className="file-name" onClick={() => onOpen(p.id)}>
{p.name}
</button>
<span className="dim">{p.life}</span>
{state.players.length > 1 && (
<button
type="button"
className="btn btn-ghost btn-icon"
onClick={() => onChange((s) => removePlayer(s, p.id))}
aria-label={`Remove ${p.name}`}
>
✕
</button>
)}
</li>
))}
</ul>
{state.players.length < 6 && (
<button type="button" className="btn" onClick={() => onChange((s) => addPlayer(s))}>
+ Add a seat
</button>
)}
</div>
<div className="sheet-block">
<div className="eyebrow">Turn order</div>
<div className="seat-order">
{state.players.map((p, i) => (
<button
key={p.id}
type="button"
className="btn"
aria-pressed={i === state.active}
onClick={() => onChange((s) => setActive(s, i))}
>
{p.name}
</button>
))}
</div>
</div>
</div>
<div className="modal-foot">
<WakeLock />
<span className="spacer" />
<button
type="button"
className="btn"
onClick={() => {
if (confirm('Start a new game? Life totals, counters and damage go back to the start.')) {
onChange((s) => resetTable(s))
onClose()
}
}}
>
Restart
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => {
onChange(() => newTable(state.players.length, state.startingLife))
onClose()
}}
>
New game
</button>
</div>
</Modal>
)
}
/**
* A commander's face, fetched by name.
*
* The table stores the name and the card uuid, but only the name is needed
* here and the uuid may be empty on a hand-typed slot, so this looks the card
* up rather than reading `getCards`, which takes ids.
*/
function CommanderThumb({ name }: { name: string }) {
const [card, setCard] = useState<Card | null>(null)
useEffect(() => {
if (!name) return
const controller = new AbortController()
lookupCards([name], controller.signal)
.then(({ cards }) => setCard(cards[0] ?? null))
.catch(() => setCard(null))
return () => controller.abort()
}, [name])
return (
<span className="cmd-thumb">
{card ? <CardImage card={card} /> : null}
<span className="visually-hidden">{name}</span>
</span>
)
}
/**
* Keeps the screen awake while the phone lies on the table. The browser drops
* the lock whenever the tab is hidden, so it is re-taken on the way back —
* otherwise the first time someone swipes the phone the lock is gone for the
* rest of the game.
*/
function WakeLock() {
const [on, setOn] = useState(false)
useEffect(() => {
if (!('wakeLock' in navigator)) return
let sentinel: WakeLockSentinel | null = null
let cancelled = false
const take = async () => {
try {
sentinel = await navigator.wakeLock.request('screen')
if (cancelled) {
sentinel.release()
return
}
setOn(true)
sentinel.addEventListener('release', () => setOn(false))
} catch {
// Denied, or no secure context. The table still works; the screen
// just dims.
setOn(false)
}
}
const onVisible = () => {
if (document.visibilityState === 'visible' && !sentinel?.released) void take()
}
void take()
document.addEventListener('visibilitychange', onVisible)
return () => {
cancelled = true
document.removeEventListener('visibilitychange', onVisible)
void sentinel?.release()
}
}, [])
if (!('wakeLock' in navigator)) return null
return (
<span className="dim keepawake" title="The screen is kept awake while this page is open">
{on ? 'Awake' : 'Screen may dim'}
</span>
)
}
function Fullscreen() {
const [on, setOn] = useState(false)
useEffect(() => {
const onChange = () => setOn(Boolean(document.fullscreenElement))
document.addEventListener('fullscreenchange', onChange)
return () => document.removeEventListener('fullscreenchange', onChange)
}, [])
return (
<button
type="button"
className="btn"
onClick={() => {
if (document.fullscreenElement) void document.exitFullscreen()
else void document.documentElement.requestFullscreen().catch(() => {})
}}
>
{on ? 'Exit full screen' : 'Full screen'}
</button>
)
}
+829 -3
View File
@@ -582,11 +582,40 @@ a {
.app {
display: grid;
grid-template-rows: var(--topbar-h) 1fr;
/* `auto` rather than var(--topbar-h): on a narrow screen the bar wraps to
two rows, and a fixed row height would clip the navigation off it. */
grid-template-rows: auto 1fr;
/* An `auto` column is sized by its content's min-content width, so one
element that refuses to shrink — the navigation, a card grid — made the
whole page wider than the screen and pushed the right-hand column out of
sight. minmax(0, 1fr) is what makes the shell exactly viewport-wide. */
grid-template-columns: minmax(0, 1fr);
height: 100%;
min-height: 0;
}
.app > * {
min-width: 0;
}
/* Visible only while focused — the first Tab on any page. */
.skip {
position: absolute;
left: 8px;
top: -60px;
z-index: 100;
padding: 8px 14px;
background: var(--ink-700);
border: var(--rule-strong);
border-radius: 3px;
font-size: 12px;
transition: top 0.14s;
}
.skip:focus {
top: 8px;
}
.topbar {
display: flex;
align-items: center;
@@ -633,6 +662,7 @@ a {
display: flex;
gap: 2px;
margin-left: 8px;
min-width: 0;
}
.nav a {
@@ -707,7 +737,7 @@ a {
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr) minmax(240px, 290px);
gap: var(--gutter);
padding: var(--gutter);
height: 100%;
flex: 1;
min-height: 0;
}
@@ -1003,6 +1033,16 @@ a {
color: var(--bad);
}
/* Pushed to the end of the open button, so a long card name truncates
against the price rather than pushing it out of the row. */
.deck-row-price {
margin-left: auto;
font-size: 11px;
color: var(--ash);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.deck-row-pt {
font-size: 10.5px;
color: var(--ash);
@@ -1411,7 +1451,7 @@ a {
grid-template-columns: 232px minmax(0, 1fr) 340px;
gap: var(--gutter);
padding: var(--gutter);
height: 100%;
flex: 1;
min-height: 0;
}
@@ -1628,6 +1668,568 @@ textarea.io:focus {
border-color: var(--gold-dim);
}
/* ── Import ────────────────────────────────────────────────────────────── */
.import-files {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
/* The file input is visually hidden, so the label is the button — and the
focus ring has to be drawn on the label or a keyboard user cannot see
where they are. */
.import-files .btn:focus-within,
.settings-grid .btn:focus-within {
outline: 2px solid var(--gold);
outline-offset: 2px;
}
.file-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
max-height: 132px;
overflow-y: auto;
border: var(--rule-soft);
border-radius: 2px;
}
.file-list li {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
background: var(--ink-850);
}
.file-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11.5px;
}
.import-report {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.import-report-head {
margin: 0;
color: var(--ok);
font-size: 12.5px;
}
.import-report .file-list {
width: 100%;
}
/* ── Table ────────────────────────────────────────────────────────────── */
/* The page a phone sits on for four hours.
It is a board, not a control panel: a fixed-height grid so all four players
are on one screen with no scrolling, and each cell holds a name, a life
total, a minus, a plus and the counters. Everything else is one tap away in
a sheet, because it is used between turns rather than during them. */
.table {
flex: 1;
min-height: 0;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 8px;
padding: 8px 10px calc(10px + env(safe-area-inset-bottom));
}
.table-bar {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.table-bar .turn-n {
font-family: var(--serif);
font-size: 16px;
color: var(--gold-bright);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* The phase stepper and the button that advances it share one row, so the
hand that taps "next" does not have to travel. */
.phase-bar {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.phases {
display: flex;
gap: 2px;
list-style: none;
margin: 0;
padding: 0;
flex: 1;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
.phases::-webkit-scrollbar {
display: none;
}
.phase {
padding: 8px 10px;
font-size: 11.5px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ash);
border: 1px solid transparent;
border-radius: 2px;
white-space: nowrap;
}
.phase:hover {
color: var(--ivory);
background: var(--ink-750);
}
.phase[data-on='true'] {
color: var(--ink-900);
background: var(--gold);
border-color: var(--gold-bright);
font-weight: 600;
}
.next-step {
flex: none;
min-height: 40px;
padding: 0 16px;
font-size: 12px;
}
.table-over {
grid-row: 2;
margin: 0;
padding: 8px 12px;
background: var(--gold-wash);
border: var(--rule-strong);
border-radius: 3px;
font-family: var(--serif);
font-size: 14px;
color: var(--gold-bright);
text-align: center;
}
/* Four cards, one screen. The 1fr row means they divide whatever height the
phone has rather than being sized by their contents — a player with three
counters and a player with none get the same card. */
.seats {
grid-row: 3;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
grid-auto-rows: minmax(0, 1fr);
gap: 8px;
min-height: 0;
overflow-y: auto;
}
.tile {
position: relative;
min-width: 0;
min-height: 0;
/* The number is sized from the card, not the viewport: a card in a 2×2 grid
on a phone and one of six on a tablet are different shapes, and the number
should fill whichever it is in. */
container-type: size;
/* Three rows and a three-column track for the corners. The counters stop
short of the last column, so a fourth counter grows the grid instead of
sliding under the plus button. */
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-rows: auto minmax(0, 1fr) auto;
align-items: center;
justify-items: center;
gap: 4px;
padding: 8px;
border: var(--rule-soft);
border-radius: 6px;
background: var(--ink-800);
}
.tile[data-active='true'] {
border-color: var(--gold);
background: var(--ink-750);
box-shadow: 0 0 0 1px var(--gold-dim);
}
.tile[data-out='true'] {
opacity: 0.5;
}
.tile[data-out='true'] .tile-life {
color: var(--bad);
text-decoration: line-through;
}
/* Marked by a word as well as a border, so the active player is not identified
by colour alone. */
.tile-turn {
grid-row: 1;
grid-column: 2;
justify-self: center;
font-size: 9.5px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--gold);
}
.tile-mid {
grid-row: 2;
grid-column: 1 / -1;
align-self: stretch;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 0;
border: none;
background: none;
}
.tile-name {
font-size: 10.5px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ash-dim);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tile-life {
font-family: var(--serif);
/* Roughly a third of the card's height, and never wider than the card
allows: at 100 life the extra digit still has to fit, so the width term
keeps a two-digit total inside a narrow card. */
font-size: min(30cqh, 42cqw);
line-height: 1;
color: var(--gold-bright);
font-variant-numeric: tabular-nums;
text-shadow: 0 0 24px rgba(201, 169, 97, 0.24);
}
/* In opposite corners and 56px across: hit with a thumb, at a table, in a
hurry, without being looked at. */
.tile-step {
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--rule-soft);
border-radius: 50%;
background: var(--ink-700);
color: var(--ivory);
font-size: 24px;
line-height: 1;
}
.tile-dec {
grid-row: 1;
grid-column: 1;
justify-self: start;
align-self: start;
}
.tile-inc {
grid-row: 3;
grid-column: 3;
justify-self: end;
align-self: end;
color: var(--gold-bright);
}
.tile-step:active {
background: var(--ink-600);
}
/* Counters live in a corner grid, like the tokens on a table. A tap adds one:
the common action mid-turn, and the sheet is where a token comes off. */
/* Counters live in a corner grid, like the tokens on a table. A tap adds one:
the common action mid-turn, and the sheet is where a token comes off. */
.tile-counters {
grid-row: 3;
grid-column: 1 / 3;
align-self: end;
justify-self: start;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
width: min(100%, 128px);
}
.tile-counter {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 34px;
padding: 2px 4px;
border: var(--rule-soft);
border-radius: 3px;
background: var(--ink-750);
color: var(--ash);
font-size: 9px;
letter-spacing: 0.04em;
text-transform: uppercase;
line-height: 1.1;
overflow: hidden;
}
.tile-counter-n {
font-family: var(--serif);
font-size: 17px;
color: var(--gold-bright);
font-variant-numeric: tabular-nums;
}
.tile-counter[data-zero='true'] .tile-counter-n {
color: var(--ash-dim);
}
.tile-counter-label {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tile-counter-more {
color: var(--ash-dim);
}
/* ── Player and game sheets ─────────────────────────────────────────────
The controls a card has no room for. They were on the card before, and four
seats of them is what made the page unusable on a phone. */
.sheet {
display: flex;
flex-direction: column;
gap: 14px;
}
.sheet .setting input {
width: 100%;
background: var(--ink-850);
border: var(--rule-soft);
border-radius: 2px;
padding: 10px;
font-size: 16px;
color: var(--ivory);
}
.sheet-block {
display: flex;
flex-direction: column;
gap: 6px;
border-top: var(--rule-soft);
padding-top: 10px;
}
.sheet-life {
margin: 0;
font-family: var(--serif);
font-size: 44px;
line-height: 1;
text-align: center;
color: var(--gold-bright);
font-variant-numeric: tabular-nums;
}
.sheet-life[data-out='true'] {
color: var(--bad);
text-decoration: line-through;
}
.seat-order {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.seat-order .btn[aria-pressed='true'] {
color: var(--ink-900);
background: var(--gold);
border-color: var(--gold);
font-weight: 600;
}
.keepawake {
font-size: 10.5px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.steps {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 4px;
}
.step {
min-height: 46px;
border: var(--rule-soft);
border-radius: 3px;
background: var(--ink-750);
color: var(--ivory);
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.step:hover {
background: var(--ink-600);
}
.step[data-down='true'] {
color: var(--ash);
}
.step-sm {
min-height: 40px;
font-size: 15px;
}
.cmd {
display: flex;
flex-direction: column;
gap: 4px;
}
.cmd-card {
display: grid;
grid-template-columns: 38px minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
text-align: left;
padding: 5px;
border: var(--rule-soft);
border-radius: 3px;
background: var(--ink-750);
}
.cmd-thumb {
position: relative;
width: 38px;
aspect-ratio: 488 / 680;
border-radius: 2px;
overflow: hidden;
background: var(--ink-900);
display: block;
}
.cmd-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cmd-name {
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cmd-damage {
font-family: var(--serif);
font-size: 26px;
line-height: 1;
color: var(--ash);
font-variant-numeric: tabular-nums;
min-width: 40px;
text-align: right;
}
/* 21 is the number Commander damage effects care about, so the counter says
so rather than making someone remember it at the table. */
.cmd-damage[data-hot='true'] {
color: var(--bad);
}
.cmd-steps {
display: flex;
gap: 4px;
}
.cmd-steps .step {
flex: 1;
min-height: 40px;
font-size: 12px;
}
.counter {
display: grid;
grid-template-columns: minmax(0, 1fr) auto 44px 44px auto;
align-items: center;
gap: 6px;
}
.counter-label {
font-size: 12px;
color: var(--ash);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.counter-n {
font-family: var(--serif);
font-size: 22px;
color: var(--gold-bright);
font-variant-numeric: tabular-nums;
min-width: 34px;
text-align: center;
}
.counter-add {
display: flex;
gap: 6px;
margin-top: 4px;
}
.counter-add input {
flex: 1;
min-width: 0;
background: var(--ink-850);
border: var(--rule-soft);
border-radius: 2px;
padding: 10px;
font-size: 16px;
color: var(--ivory);
}
.counter-add input:focus {
outline: none;
border-color: var(--gold-dim);
}
/* ── Modal ─────────────────────────────────────────────────────────────── */
/* The dialog element draws its own backdrop, so there is no scrim element and
@@ -1653,6 +2255,23 @@ textarea.io:focus {
backdrop-filter: blur(2px);
}
/* A card's art is 488×680, so at the width of a modal sheet it is taller
than the screen. It is shown whole and capped, not cropped: a cropped
card art crop is the one thing nobody can read a card from. */
.modal > .card-art {
aspect-ratio: auto;
max-height: 40dvh;
display: flex;
justify-content: center;
background: var(--ink-900);
}
.modal > .card-art img {
max-height: 40dvh;
width: auto;
object-fit: contain;
}
.modal-narrow {
width: min(680px, calc(100vw - 48px));
}
@@ -1762,6 +2381,13 @@ textarea.io:focus {
border: 0;
}
/* ── Responsive ───────────────────────────────────────────────────────────
* The app is used two ways: at a desk with a mouse, and on a phone held in
* one hand or lying on a table. The desktop layout is three columns; the
* narrow layout is one, and it puts the deck first — the deck is what the
* player came back to, and the card browser is a long scroll underneath it.
*/
@media (max-width: 1180px) {
.builder,
.browse {
@@ -1775,7 +2401,207 @@ textarea.io:focus {
overflow: visible;
min-height: 0;
}
/* Stacked, each column takes the height of its own content and the page
scrolls. Left to size itself to the grid row, the card library shrank to
the visible height and its results spilled over the panel underneath. */
.builder,
.browse {
grid-auto-rows: min-content;
align-content: start;
}
.col {
flex: 0 0 auto;
}
.browse > .col:last-child {
display: none;
}
/* Deck before browser before rules panel. */
.builder > .col:nth-child(1) {
order: 2;
}
.builder > .col:nth-child(2) {
order: 1;
}
.builder > .col:nth-child(3) {
order: 3;
}
.browse > .col:nth-child(1) {
order: 2;
}
.browse > .col:nth-child(2) {
order: 1;
}
}
@media (max-width: 860px) {
.topbar {
gap: 8px;
padding: 6px 10px;
align-items: center;
}
.brand-sub {
display: none;
}
.nav {
/* The bar cannot wrap into a second row without eating the viewport, so
the tabs scroll sideways instead — the active one is scrolled into
view by the browser on navigation. */
margin-left: 0;
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
flex: 1;
}
.nav::-webkit-scrollbar {
display: none;
}
.nav a {
white-space: nowrap;
}
.page {
padding: 16px 14px 28px;
}
.page-title {
font-size: 22px;
}
.card-grid {
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
}
.deck-body {
grid-template-columns: minmax(0, 1fr);
}
/* The three stat columns need 410px before they stop being readable; below
that the Commander takes the full width and the two stats share the rest,
rather than the whole panel being pushed off the right of the screen. */
.deck-head {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.deck-head .commander-slot {
grid-column: 1 / -1;
}
.deck-cards,
.settings-grid {
grid-template-columns: minmax(0, 1fr);
}
.modal.detail {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
max-width: 100vw;
max-height: 100dvh;
height: 100dvh;
border-radius: 0;
border: none;
}
.detail-art {
display: flex;
justify-content: center;
background: var(--ink-900);
border-right: none;
border-bottom: var(--rule-soft);
overflow: hidden;
}
.detail-art img {
/* The art is the first thing anyone opening a card on a phone wants to
see, and it is the only part that scales without reflowing. */
max-height: 34vh;
width: auto;
}
}
/* Below this the app is a phone held in one hand. The modals become sheets
from the bottom, the deck actions become a two-column grid of full-width
targets, and every text field goes to 16px so iOS does not zoom the page
on focus. */
@media (max-width: 620px) {
.modal,
.modal-narrow {
width: 100vw;
max-width: 100vw;
max-height: 92dvh;
margin: auto 0 0;
border-radius: 10px 10px 0 0;
border-bottom: none;
}
.modal-body {
padding: 14px;
/* The sheet sits over the home indicator; without this the last button
in it is under the gesture bar. */
padding-bottom: calc(14px + env(safe-area-inset-bottom));
}
.modal-foot {
padding: 10px 14px calc(10px + env(safe-area-inset-bottom));
}
.modal-foot .btn {
flex: 1;
min-height: 46px;
}
textarea.io {
/* 16px is the threshold below which iOS Safari zooms on focus. */
font-size: 16px;
}
.deck-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.deck-actions .btn {
justify-content: center;
min-height: 44px;
}
.deck-actions input {
grid-column: 1 / -1;
min-width: 0;
font-size: 16px;
}
.toast {
left: 10px;
right: 10px;
bottom: calc(14px + env(safe-area-inset-bottom));
transform: none;
text-align: center;
}
.builder,
.browse {
padding: 10px;
gap: 10px;
}
.empty {
padding: 40px 14px;
}
}
/* Touch input has no hover and no cursor, and a fingertip needs about twice
the target a mouse does. WCAG 2.2 Target Size (Minimum) AA (2.5.8) asks for
24px; these are the controls that get pressed mid-game, so they get 44. */
@media (pointer: coarse) {
.deck-stepper-btn,
.deck-row-remove,
.card-tile-add,
.btn,
.chip,
.pip-toggle {
min-width: 44px;
min-height: 44px;
}
.deck-stepper-btn {
width: 44px;
height: 44px;
}
.deck-row {
padding: 6px 4px;
}
.nav a {
padding: 10px 12px;
}
.field input,
.counter-add input,
.sheet .setting input {
font-size: 16px;
}
.pip-toggle,
.chip {
display: inline-flex;
align-items: center;
justify-content: center;
}
}
+62
View File
@@ -5,6 +5,7 @@ import {
averageManaValue,
commanderIdentity,
deckSize,
deckValue,
emptyDeck,
manaCurve,
offIdentityColors,
@@ -331,3 +332,64 @@ test('a card the index cannot resolve is reported by name, not skipped', () => {
assert.ok(!result.issues.some((i) => i.rule === 'singleton'))
assert.equal(result.valid, false)
})
/**
* Deck value.
*
* The engine sums prices it has and refuses to invent the ones it does not, so
* these cover both the arithmetic and the honesty of the total.
*/
test('a deck with no priced cards is worth nothing and says so', () => {
const deck = emptyDeck()
deck.commanders = [{ id: atraxa.id, name: atraxa.name }]
deck.cards = [{ id: solRing.id, name: solRing.name, qty: 1 }]
const byId = new Map([[atraxa.id, atraxa], [solRing.id, solRing]])
const { total, unpriced } = deckValue(deck, byId)
assert.equal(total, 0)
// A card with no price is not a card worth $0.00.
assert.equal(unpriced, 2)
})
test('deck value counts quantity and includes the commander', () => {
const pricedAtraxa = { ...atraxa, priceUsd: 30 }
const pricedRing = { ...solRing, priceUsd: 2.1 }
const pricedSwamp = { ...swamp, priceUsd: 0.32 }
const deck = emptyDeck()
deck.commanders = [{ id: pricedAtraxa.id, name: pricedAtraxa.name }]
deck.cards = [
{ id: pricedRing.id, name: pricedRing.name, qty: 4 },
{ id: pricedSwamp.id, name: pricedSwamp.name, qty: 36 },
]
const byId = new Map([
[pricedAtraxa.id, pricedAtraxa],
[pricedRing.id, pricedRing],
[pricedSwamp.id, pricedSwamp],
])
const { total, unpriced } = deckValue(deck, byId)
// 30 commander + (4 x 2.10) + (36 x 0.32)
assert.equal(total, 49.92)
assert.equal(unpriced, 0)
})
test('a card that cannot be resolved is not reported as unpriced', () => {
// An unknown card is a lookup failure, not a missing price. The deck list
// validator already reports it; double-counting it here would make the two
// panels disagree about how many cards are missing.
const deck = emptyDeck()
deck.cards = [{ id: 'nope', name: 'Orim, the Seraphic', qty: 2 }]
const { total, unpriced } = deckValue(deck, new Map())
assert.equal(total, 0)
assert.equal(unpriced, 0)
})
test('a null price is unpriced, not free', () => {
const nullPriced = { ...solRing, priceUsd: null }
const deck = emptyDeck()
deck.cards = [{ id: nullPriced.id, name: nullPriced.name, qty: 3 }]
const { total, unpriced } = deckValue(deck, new Map([[nullPriced.id, nullPriced]]))
assert.equal(total, 0)
assert.equal(unpriced, 3)
})
+78
View File
@@ -5,7 +5,9 @@ import {
decodeDeck,
encodeDeck,
formatDecklist,
parseBackup,
parseDecklist,
parseDecklists,
sortedEntries,
} from '../src/lib/deckStore.js'
@@ -210,3 +212,79 @@ test('a card name containing "x" does not confuse the quantity encoding', async
assert.equal(back.cards[0].name, 'Xenodoch Green')
assert.equal(back.cards[0].qty, 4)
})
/* ── Batch import ─────────────────────────────────────────────────────── */
test('a run of dashes splits a folder of decklists pasted as one text', () => {
const decks = parseDecklists(`// Atraxa Beatdown
Commander: Atraxa, Grand Unifier
1 Sol Ring
-----
//
// Marchesa
Commander: Marchesa, the Black Rose
2 Swamp
`)
assert.equal(decks.length, 2)
assert.equal(decks[0].name, 'Atraxa Beatdown')
assert.equal(decks[0].commanders[0].name, 'Atraxa, Grand Unifier')
assert.equal(decks[1].name, 'Marchesa')
assert.deepEqual(decks[1].cards, [{ id: '', name: 'Swamp', qty: 2 }])
})
test('a second name header also starts a new deck, so a lost separator is survivable', () => {
const decks = parseDecklists(`// First
1 Sol Ring
// Second
1 Counterspell
`)
assert.equal(decks.length, 2)
assert.equal(decks[0].cards.length, 1)
assert.equal(decks[1].cards[0].name, 'Counterspell')
})
test('a leading header does not produce an empty deck before the first list', () => {
const decks = parseDecklists(`// Only one
1 Sol Ring
`)
assert.equal(decks.length, 1)
assert.equal(decks[0].name, 'Only one')
})
test('a chunk with no cards is not offered as a deck', () => {
assert.equal(parseDecklists('-----\n-----\n1 Sol Ring\n').length, 1)
assert.deepEqual(parseDecklists(' \n\n'), [])
})
test('quantities accumulate across a list that repeats a card', () => {
const [deck] = parseDecklists('40 Mountain\n3 Mountain\n')
assert.deepEqual(deck.cards, [{ id: '', name: 'Mountain', qty: 43 }])
})
test('a MAGIK backup restores from both shapes it has been written in', () => {
const deck = {
id: 'keep-me',
name: 'Atraxa Beatdown',
commanders: [{ id: 'a', name: 'Atraxa, Grand Unifier' }],
cards: [{ id: 'b', name: 'Sol Ring', qty: 1 }],
createdAt: 1,
updatedAt: 2,
}
const fromArray = parseBackup(JSON.stringify([deck]))
const fromObject = parseBackup(JSON.stringify({ decks: [deck] }))
assert.equal(fromArray[0].id, 'keep-me', 'ids survive, so a restore is idempotent')
assert.equal(fromObject[0].cards.length, 1)
assert.equal(fromArray[0].name, deck.name)
})
test('a backup entry with no id is given one, so the gallery can open it', () => {
const [deck] = parseBackup(JSON.stringify([{ name: 'No id', cards: [], commanders: [] }]))
assert.ok(deck.id, 'a deck without an id cannot be opened, linked or deleted')
})
test('a file that is not a backup fails with a reason, not a stack trace', () => {
assert.throws(() => parseBackup('not json at all'), /JSON/)
assert.throws(() => parseBackup('{"decks": 3}'), /list of decks/)
assert.throws(() => parseBackup('[]'), /no decks/i)
})
+100
View File
@@ -0,0 +1,100 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { isMarketPrinting, medianPrice, samplePrice } from '../scripts/build-cards.mjs'
/**
* Price aggregation.
*
* The card index keeps one row per card, so a card's many printing prices have
* to be collapsed into one number. The two obvious choices are both wrong, and
* both are the kind of thing that looks like a harmless simplification later:
*
* - the maximum, which is dragged up by premium and bundled listings (a
* Sol Ring that reads as $870 is not a market price)
* - the cheapest, which understates what the card is actually worth and so
* makes a deck look cheaper than it is
*
* These pin the median and the printing filter that feeds it.
*/
/** A printing that would otherwise be sampled. */
const printing = (over = {}) => ({
lang: 'en',
set_type: 'expansion',
nonfoil: true,
promo: false,
digital: false,
reserved: false,
...over,
})
test('the median is unmoved by a single absurd printing', () => {
// Sol Ring: one premium listing at $869.99 and one cheap reprint. A mean or
// a max would carry the outlier; the median should not move at all.
const prices = [1.33, 2.1, 2.1, 869.99, 1.9]
assert.equal(medianPrice(prices), 2.1)
})
test('an even number of printings averages the middle pair', () => {
// (0.32 + 0.81) / 2 = 0.565, stored to the cent so a price never carries
// float noise into the database.
assert.equal(medianPrice([0.09, 0.32, 0.81, 42.42]), 0.57)
assert.equal(medianPrice([0.09, 0.32]), 0.21)
})
test('a single printing is its own median', () => {
assert.equal(medianPrice([4.98]), 4.98)
})
test('no sampled printing means no price, not zero', () => {
assert.equal(medianPrice([]), null)
assert.equal(medianPrice(undefined), null)
})
test('promo, digital, token and sealed-product printings are not market', () => {
assert.equal(isMarketPrinting(printing()), true)
assert.equal(isMarketPrinting(printing({ promo: true })), false)
assert.equal(isMarketPrinting(printing({ digital: true })), false)
assert.equal(isMarketPrinting(printing({ set_type: 'token' })), false)
// Sealed product is priced as the box it is, not as the card inside it.
assert.equal(isMarketPrinting(printing({ set_type: 'memorabilia' })), false)
// A non-English printing carries its own regional market's USD price.
assert.equal(isMarketPrinting(printing({ lang: 'ja' })), false)
// Foil-only printings have no non-foil price to read.
assert.equal(isMarketPrinting(printing({ nonfoil: false })), false)
})
test('a Reserved List printing is still the market', () => {
// Reserved excludes a card from reprinting, not from trading. Commander's
// banned Power 9 are only ever printed here, so excluding this set left
// Ancestral Recall and Time Walk with no price at all.
assert.equal(isMarketPrinting(printing({ reserved: true })), true)
assert.equal(isMarketPrinting(printing({ reserved: true, set_type: 'core' })), true)
})
test('a premium printing is still sampled when it is an ordinary release', () => {
// The filter keys off what a printing *is* (promo, digital, token, sealed
// product), not off how expensive it is. Expensive-but-normal printings must
// stay in the sample, or the median drifts toward the cheap reprints.
assert.equal(isMarketPrinting(printing({ set_type: 'masters' })), true)
})
test('a missing price is not a free card', () => {
// The bug this pins: Scryfall spells a missing price as null, and
// Number(null) is 0. Converting before testing let priceless printings into
// the median as $0.00, which zeroed out Ancestral Recall, Time Walk and
// Mox Jet — every one of their priced printings surrounded by unpriced ones.
assert.equal(samplePrice(printing({ prices: { usd: null } })), null)
assert.equal(samplePrice(printing({ prices: {} })), null)
assert.equal(samplePrice(printing({ prices: { usd: '' } })), null)
assert.equal(samplePrice(printing({ prices: { usd: 'not a number' } })), null)
assert.equal(samplePrice({ lang: 'en' }), null)
})
test('a real price is read from the string Scryfall sends', () => {
assert.equal(samplePrice(printing({ prices: { usd: '2.10' } })), 2.1)
assert.equal(samplePrice(printing({ prices: { usd: 0.05 } })), 0.05)
// A printing that is not market-contributing is not a price, even when
// Scryfall has one for it.
assert.equal(samplePrice(printing({ promo: true, prices: { usd: '1.00' } })), null)
})
+206
View File
@@ -0,0 +1,206 @@
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))
})