a07522a189
React 19 SPA + node:http/node:sqlite API in one container. Card data is baked into the image from a Scryfall bulk build, so the runtime never calls Scryfall. No server-side user state: decks live in the browser and travel in the share URL, which is why there is no PVC and replicas: 1 is correct. Adds a README covering the architecture and the reasoning behind it, and publishes the project skills under .claude/skills/. Co-Authored-By: Claude Code <noreply@anthropic.com>
213 lines
7.3 KiB
JavaScript
213 lines
7.3 KiB
JavaScript
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { DECK_SIZE } from '../src/lib/commander.js'
|
|
import {
|
|
decodeDeck,
|
|
encodeDeck,
|
|
formatDecklist,
|
|
parseDecklist,
|
|
sortedEntries,
|
|
} from '../src/lib/deckStore.js'
|
|
|
|
/**
|
|
* Decklists arrive as text pasted from other tools and leave as links, so the
|
|
* parser has to survive the formats players actually paste, and the share
|
|
* encoding has to round-trip a full deck.
|
|
*/
|
|
|
|
const SAMPLE = `// Atraxa Beatdown
|
|
Commander: Atraxa, Grand Unifier
|
|
1 Sol Ring
|
|
1 Command Tower
|
|
|
|
*Lands*
|
|
37 Plains
|
|
37 Island
|
|
5 Swamp
|
|
|
|
Sideboard
|
|
2 Negate
|
|
`
|
|
|
|
test('a decklist is split into commander, cards and quantity', () => {
|
|
const deck = parseDecklist(SAMPLE)
|
|
assert.equal(deck.name, 'Atraxa Beatdown')
|
|
assert.deepEqual(deck.commanders, [{ id: '', name: 'Atraxa, Grand Unifier' }])
|
|
const wanted = Object.fromEntries(deck.cards.map((c) => [c.name, c.qty]))
|
|
assert.equal(wanted['Sol Ring'], 1)
|
|
assert.equal(wanted.Plains, 37)
|
|
assert.equal(wanted.Swamp, 5)
|
|
assert.equal(wanted.Negate, undefined, 'sideboard cards are not part of the deck')
|
|
})
|
|
|
|
test('quantities for the same card are summed', () => {
|
|
const deck = parseDecklist('3 Forest\n2 Forest\n1 Forest')
|
|
assert.equal(deck.cards.length, 1)
|
|
assert.equal(deck.cards[0].qty, 6)
|
|
})
|
|
|
|
test('a set code in brackets is not part of the card name', () => {
|
|
const deck = parseDecklist('1 Lightning Bolt (M10)')
|
|
assert.equal(deck.cards[0].name, 'Lightning Bolt')
|
|
})
|
|
|
|
test('a list with no commander line still parses as a main deck', () => {
|
|
const deck = parseDecklist('1 Sol Ring\n2 Llanowar Elves')
|
|
assert.equal(deck.commanders.length, 0)
|
|
assert.equal(deck.cards.length, 2)
|
|
})
|
|
|
|
test('blank lines, category headers and stray text are ignored', () => {
|
|
const deck = parseDecklist('// Name\n\n*Creatures*\n1 Elvish Mystic\nnot a card line\n1 Sol Ring')
|
|
assert.equal(deck.cards.length, 2)
|
|
})
|
|
|
|
test('a decklist formats back to the standard layout', () => {
|
|
const deck = parseDecklist(SAMPLE)
|
|
const text = formatDecklist(deck)
|
|
assert.match(text, /^\/\/ Atraxa Beatdown/)
|
|
assert.match(text, /^Commander: Atraxa, Grand Unifier$/m)
|
|
assert.match(text, /^37 Plains$/m)
|
|
const round = parseDecklist(text)
|
|
assert.equal(round.name, deck.name)
|
|
assert.equal(round.cards.length, deck.cards.length)
|
|
})
|
|
|
|
test('entries are ordered by type, then mana value, then name', () => {
|
|
const deck = {
|
|
id: 'd',
|
|
name: 'Order',
|
|
commanders: [],
|
|
createdAt: 0,
|
|
updatedAt: 0,
|
|
cards: [
|
|
{ id: '1', name: 'Forest', qty: 10 },
|
|
{ id: '2', name: 'Giant Growth', qty: 1 },
|
|
{ id: '3', name: 'Llanowar Elves', qty: 1 },
|
|
{ id: '4', name: 'Sol Ring', qty: 1 },
|
|
{ id: '5', name: 'Teferi, Time Raveler', qty: 1 },
|
|
],
|
|
}
|
|
const typeLine = {
|
|
Forest: 'Basic Land — Forest',
|
|
'Giant Growth': 'Instant',
|
|
'Llanowar Elves': 'Creature — Elf Druid',
|
|
'Sol Ring': 'Artifact',
|
|
'Teferi, Time Raveler': 'Legendary Planeswalker',
|
|
}
|
|
const cost = { 'Giant Growth': 1, 'Llanowar Elves': 1, 'Sol Ring': 1, Forest: 0, 'Teferi, Time Raveler': 2 }
|
|
const order = sortedEntries(deck, {
|
|
cmcOf: (e) => cost[e.name] ?? 0,
|
|
typeOf: (e) => typeLine[e.name] ?? '',
|
|
}).map((e) => e.name)
|
|
// Creature, Artifact, Instant, Planeswalker, then Land — the order a
|
|
// player reads a decklist in, regardless of mana value. The Planeswalker is
|
|
// here because this list used to be a second copy of TYPE_ORDER that had
|
|
// drifted from it, and the exported list then disagreed with the deck on
|
|
// screen. A test without a planeswalker in it cannot catch that.
|
|
assert.deepEqual(order, [
|
|
'Llanowar Elves',
|
|
'Sol Ring',
|
|
'Giant Growth',
|
|
'Teferi, Time Raveler',
|
|
'Forest',
|
|
])
|
|
})
|
|
|
|
test('a full deck survives a share link round trip', async () => {
|
|
const deck = {
|
|
id: 'local-id',
|
|
name: "Kaer'shir, the Golden Wake",
|
|
commanders: [{ id: '', name: "Ka'erishir, the Golden Wake" }],
|
|
cards: Array.from({ length: DECK_SIZE - 1 }, (_, i) => ({
|
|
id: '',
|
|
name: i === 0 ? 'Sol Ring' : `Card ${i}`,
|
|
qty: i === 0 ? 1 : 1,
|
|
})),
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
}
|
|
const url = await encodeDeck(deck)
|
|
// A share link has to survive being pasted into a chat app, so it must be
|
|
// URL-safe and must not carry a base64 padding tail.
|
|
assert.match(url, /^z\.[A-Za-z0-9_-]+$/)
|
|
|
|
const back = await decodeDeck(url)
|
|
assert.equal(back.name, deck.name)
|
|
assert.deepEqual(back.commanders, deck.commanders)
|
|
assert.equal(back.cards.length, deck.cards.length)
|
|
assert.equal(back.cards[0].name, 'Sol Ring')
|
|
assert.notEqual(back.id, deck.id, 'a shared deck is copied, not shared by reference')
|
|
})
|
|
|
|
test('a share link is short enough to paste into a chat message', async () => {
|
|
// Discord rejects a message `content` field over 2,000 characters, so that
|
|
// is the budget a share link has to fit inside. A real 100-card deck did
|
|
// not: uncompressed it was 3,519 characters, which no chat app would carry.
|
|
const deck = {
|
|
id: 'x',
|
|
name: "Ka'erishir, the Golden Wake",
|
|
commanders: [{ id: '', name: "Ka'erishir, the Golden Wake" }],
|
|
cards: [
|
|
...Array.from({ length: 36 }, () => ({ id: '', name: 'Command Tower', qty: 1 })),
|
|
...Array.from({ length: 36 }, () => ({ id: '', name: 'Fabled Passage', qty: 1 })),
|
|
...Array.from({ length: 27 }, () => ({ id: '', name: 'Bonto, the Giant Pump', qty: 1 })),
|
|
],
|
|
createdAt: 0,
|
|
updatedAt: 0,
|
|
}
|
|
const slug = await encodeDeck(deck)
|
|
assert.ok(slug.length < 2000, `share slug is ${slug.length} characters, over the chat budget`)
|
|
})
|
|
|
|
test('a share link written before compression still opens', async () => {
|
|
// Links are already in the wild. The prefix is what tells the two formats
|
|
// apart, so an unprefixed slug must keep decoding as plain base64 JSON.
|
|
const payload = {
|
|
n: 'Legacy',
|
|
c: ['Atraxa, Grand Unifier'],
|
|
d: ['1xSol Ring', '2xForest'],
|
|
}
|
|
const bytes = new TextEncoder().encode(JSON.stringify(payload))
|
|
let binary = ''
|
|
for (const byte of bytes) binary += String.fromCharCode(byte)
|
|
const legacy = btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
|
|
assert.equal(legacy.startsWith('z.'), false)
|
|
|
|
const deck = await decodeDeck(legacy)
|
|
assert.equal(deck.name, 'Legacy')
|
|
assert.deepEqual(deck.commanders, [{ id: '', name: 'Atraxa, Grand Unifier' }])
|
|
assert.equal(deck.cards.length, 2)
|
|
assert.equal(deck.cards[0].name, 'Sol Ring')
|
|
})
|
|
|
|
test('a deck full of basic lands survives a share link round trip', async () => {
|
|
for (const count of [1, 2, 3, 30, 98, 99]) {
|
|
const deck = {
|
|
id: 'x',
|
|
name: 'Lands'.padEnd((count % 5) + 1, 'a'),
|
|
commanders: [],
|
|
cards: Array.from({ length: count }, (_, i) => ({ id: '', name: `Land ${i}`, qty: 1 })),
|
|
createdAt: 0,
|
|
updatedAt: 0,
|
|
}
|
|
const back = await decodeDeck(await encodeDeck(deck))
|
|
assert.equal(back.cards.length, count, `lost cards at count ${count}`)
|
|
assert.equal(back.name, deck.name)
|
|
}
|
|
})
|
|
|
|
test('a card name containing "x" does not confuse the quantity encoding', async () => {
|
|
const deck = {
|
|
id: 'x',
|
|
name: 'Awkward',
|
|
commanders: [],
|
|
cards: [{ id: '', name: 'Xenodoch Green', qty: 4 }],
|
|
createdAt: 0,
|
|
updatedAt: 0,
|
|
}
|
|
const back = await decodeDeck(await encodeDeck(deck))
|
|
assert.equal(back.cards[0].name, 'Xenodoch Green')
|
|
assert.equal(back.cards[0].qty, 4)
|
|
})
|