Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 14daed4c54 | |||
| f1226b294e | |||
| c0fe10e664 |
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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'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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+6
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
Vendored
+4
@@ -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
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+77
@@ -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
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,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
@@ -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'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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
Reference in New Issue
Block a user