From e261e9ee71b516db357307f6ce8068e026a766d8 Mon Sep 17 00:00:00 2001 From: Matthew Chisolm <39521893+mchisolm0@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:54:46 -0500 Subject: [PATCH 1/3] refactor(systems): one definition per play system drives counters and formats Adds convex/lib/systems with one SystemDefinition per system (labels, counter rules, formats with capability flags, integration capabilities). playSystems, deckGames, and integrations now read from it. Brawl starts at 25 life instead of falling back to Magic's 20. Co-Authored-By: Claude Opus 5.5 --- convex/_generated/api.d.ts | 4 + convex/integrationManifest.ts | 7 +- convex/lib/cardImageFallback.ts | 36 ++- convex/lib/deckGames.ts | 155 ++----------- convex/lib/games/archidekt.ts | 3 +- convex/lib/integrations.ts | 128 ++--------- convex/lib/systems/definitions.ts | 301 ++++++++++++++++++++++++++ convex/lib/systems/index.ts | 31 +++ convex/lib/systems/systems.test.ts | 35 +++ src/features/game/playSystems.test.ts | 18 +- src/features/game/playSystems.ts | 133 +++--------- 11 files changed, 490 insertions(+), 361 deletions(-) create mode 100644 convex/lib/systems/definitions.ts create mode 100644 convex/lib/systems/index.ts create mode 100644 convex/lib/systems/systems.test.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 11ec9efb..94e36007 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -54,6 +54,8 @@ import type * as lib_policy from "../lib/policy.js"; import type * as lib_results from "../lib/results.js"; import type * as lib_revenueCat from "../lib/revenueCat.js"; import type * as lib_scryfall from "../lib/scryfall.js"; +import type * as lib_systems_definitions from "../lib/systems/definitions.js"; +import type * as lib_systems_index from "../lib/systems/index.js"; import type * as lib_usernameSuggestions from "../lib/usernameSuggestions.js"; import type * as matches from "../matches.js"; import type * as moderation from "../moderation.js"; @@ -115,6 +117,8 @@ declare const fullApi: ApiFromModules<{ "lib/results": typeof lib_results; "lib/revenueCat": typeof lib_revenueCat; "lib/scryfall": typeof lib_scryfall; + "lib/systems/definitions": typeof lib_systems_definitions; + "lib/systems/index": typeof lib_systems_index; "lib/usernameSuggestions": typeof lib_usernameSuggestions; matches: typeof matches; moderation: typeof moderation; diff --git a/convex/integrationManifest.ts b/convex/integrationManifest.ts index 11387e18..257db2aa 100644 --- a/convex/integrationManifest.ts +++ b/convex/integrationManifest.ts @@ -5,9 +5,10 @@ import { CAPABILITY_KEYS, capabilityState, GAME_SYSTEM_IDS, - INTEGRATIONS, + integrationFor, type CapabilityKey, } from "./lib/integrations" +import { isSystemId } from "./lib/systems" const capabilityKeyValidator = v.union( v.literal("integration"), @@ -30,7 +31,7 @@ export const list = query({ handler: async (ctx) => await Promise.all( GAME_SYSTEM_IDS.map(async (game) => ({ - ...INTEGRATIONS[game], + ...integrationFor(game), capabilities: Object.fromEntries( await Promise.all( CAPABILITY_KEYS.map(async (key) => [key, await capabilityState(ctx, game, key)]), @@ -53,7 +54,7 @@ export const setCapabilityOverride = internalMutation({ note: v.optional(v.string()), }, handler: async (ctx, args) => { - if (!GAME_SYSTEM_IDS.includes(args.game as (typeof GAME_SYSTEM_IDS)[number])) + if (!isSystemId(args.game)) throw new ConvexError({ code: "unknown_game", message: "Unknown game system" }) const existing = await ctx.db .query("integrationOverrides") diff --git a/convex/lib/cardImageFallback.ts b/convex/lib/cardImageFallback.ts index 708db709..55a662a6 100644 --- a/convex/lib/cardImageFallback.ts +++ b/convex/lib/cardImageFallback.ts @@ -5,6 +5,7 @@ import { objectRecord, stringValue } from "./games/cards" import { pokemonImageCandidates } from "./games/pokemon" import { cardsByYgoIds } from "./games/yugioh" import { SCRYFALL_RATE_LIMIT_BLOCK_MS, fetchScryfall, normalizeScryfallCard } from "./scryfall" +import { SYSTEMS, type CatalogProvider, type SystemId } from "./systems" function scryfallLookupError(status: number, cardId: string, lookup: "Card" | "Printing"): never { if (status === 429) @@ -19,17 +20,30 @@ function scryfallLookupError(status: number, cardId: string, lookup: "Card" | "P }) } -export async function cardImageCandidates(ctx: ActionCtx, game: string, cardId: string) { - if (game === "pokemon") return await pokemonImageCandidates(ctx, cardId) - if (game === "ygo") { - if (cardId.startsWith("rush:")) return [] - const result = await cardsByYgoIds(ctx, [cardId]) - return result.cards - .flatMap((card) => - card.printings.flatMap((printing) => printing.faces.flatMap((face) => face.imageUrl ?? [])), - ) - .slice(0, 8) - } +export async function cardImageCandidates(ctx: ActionCtx, game: SystemId, cardId: string) { + return await catalogImageCandidates[SYSTEMS[game].integration.capabilities.cardCatalog.provider]( + ctx, + cardId, + ) +} + +const catalogImageCandidates = { + tcgdex: pokemonImageCandidates, + ygoprodeck: ygoprodeckImageCandidates, + scryfall: scryfallImageCandidates, +} satisfies Record Promise> + +async function ygoprodeckImageCandidates(ctx: ActionCtx, cardId: string) { + if (cardId.startsWith("rush:")) return [] + const result = await cardsByYgoIds(ctx, [cardId]) + return result.cards + .flatMap((card) => + card.printings.flatMap((printing) => printing.faces.flatMap((face) => face.imageUrl ?? [])), + ) + .slice(0, 8) +} + +async function scryfallImageCandidates(ctx: ActionCtx, cardId: string) { const original = await fetchScryfall(ctx, `/cards/${encodeURIComponent(cardId)}`) if (!original.ok) scryfallLookupError(original.status, cardId, "Card") const card = objectRecord(await original.json()) diff --git a/convex/lib/deckGames.ts b/convex/lib/deckGames.ts index 78f58473..31e1d5a2 100644 --- a/convex/lib/deckGames.ts +++ b/convex/lib/deckGames.ts @@ -1,32 +1,17 @@ import { ConvexError } from "convex/values" -export type DeckSection = { id: string; label: string } - -export type DeckFormat = { - id: string - label: string - blurb?: string - sections: readonly DeckSection[] -} - -const MTG_COMMANDER_SECTIONS = [ - { id: "commander", label: "Commander" }, - { id: "main", label: "Main deck" }, - { id: "sideboard", label: "Sideboard" }, -] as const - -const MTG_CONSTRUCTED_SECTIONS = [ - { id: "main", label: "Main deck" }, - { id: "sideboard", label: "Sideboard" }, -] as const - -const YUGIOH_SECTIONS = [ - { id: "main", label: "Main Deck" }, - { id: "extra", label: "Extra Deck" }, - { id: "side", label: "Side Deck" }, -] as const - -const POKEMON_SECTIONS = [{ id: "main", label: "Deck" }] as const +import { + SYSTEM_IDS, + SYSTEMS, + isSystemId, + systemDefinition, + type DeckSection, + type FormatDefinition, + type SystemId, +} from "./systems" + +export type { DeckSection } +export type DeckFormat = FormatDefinition export type DeckGame = { id: string @@ -37,116 +22,23 @@ export type DeckGame = { formats: readonly DeckFormat[] } -const MTG_FORMATS = [ - { - id: "commander", - label: "Commander", - blurb: "100 cards, singleton", - sections: MTG_COMMANDER_SECTIONS, - }, - { - id: "standard", - label: "Standard", - blurb: "Recent sets", - sections: MTG_CONSTRUCTED_SECTIONS, - }, - { - id: "pioneer", - label: "Pioneer", - blurb: "Return to Ravnica forward", - sections: MTG_CONSTRUCTED_SECTIONS, - }, - { - id: "modern", - label: "Modern", - blurb: "8th Edition forward", - sections: MTG_CONSTRUCTED_SECTIONS, - }, - { - id: "legacy", - label: "Legacy", - blurb: "Nearly every set", - sections: MTG_CONSTRUCTED_SECTIONS, - }, - { - id: "vintage", - label: "Vintage", - blurb: "Restricted list", - sections: MTG_CONSTRUCTED_SECTIONS, - }, - { - id: "pauper", - label: "Pauper", - blurb: "Commons only", - sections: MTG_CONSTRUCTED_SECTIONS, - }, - { - id: "brawl", - label: "Brawl", - blurb: "60 cards, singleton", - sections: MTG_COMMANDER_SECTIONS, - }, - { - id: "limited", - label: "Limited", - blurb: "Draft and sealed", - sections: MTG_CONSTRUCTED_SECTIONS, - }, - { - id: "constructed", - label: "Constructed", - blurb: "Anything else", - sections: MTG_CONSTRUCTED_SECTIONS, - }, -] as const - -const YUGIOH_FORMATS = [ - { id: "advanced", label: "Advanced", sections: YUGIOH_SECTIONS }, - { id: "traditional", label: "Traditional", sections: YUGIOH_SECTIONS }, - { id: "rush", label: "Rush Duel", sections: YUGIOH_SECTIONS }, -] as const - -const POKEMON_FORMATS = [ - { id: "standard", label: "Standard", sections: POKEMON_SECTIONS }, - { id: "expanded", label: "Expanded", sections: POKEMON_SECTIONS }, - { id: "unlimited", label: "Unlimited", sections: POKEMON_SECTIONS }, -] as const - -export const DECK_GAMES: Record = { - mtg: { - id: "mtg", - label: "Magic: The Gathering", - shortLabel: "Magic", - available: true, - defaultFormat: "commander", - formats: MTG_FORMATS, - }, - ygo: { - id: "ygo", - label: "Yu-Gi-Oh!", - shortLabel: "Yu-Gi-Oh!", - available: true, - defaultFormat: "advanced", - formats: YUGIOH_FORMATS, - }, - pokemon: { - id: "pokemon", - label: "Pokémon TCG", - shortLabel: "Pokémon", - available: true, - defaultFormat: "standard", - formats: POKEMON_FORMATS, - }, +function deckGameFor(id: SystemId): DeckGame { + const { label, shortLabel, available, defaultFormat, formats } = SYSTEMS[id] + return { id, label, shortLabel, available, defaultFormat: defaultFormat.deck, formats } } +export const DECK_GAMES: Record = Object.fromEntries( + SYSTEM_IDS.map((id) => [id, deckGameFor(id)]), +) + export const DECK_GAME_LIST: readonly DeckGame[] = Object.values(DECK_GAMES) export type DeckGameId = string -export const DEFAULT_DECK_GAME = "mtg" +export const DEFAULT_DECK_GAME = "mtg" satisfies SystemId export function deckGame(game: string): DeckGame | undefined { - return Object.prototype.hasOwnProperty.call(DECK_GAMES, game) ? DECK_GAMES[game] : undefined + return isSystemId(game) ? DECK_GAMES[game] : undefined } export function assertDeckGame(game: string): DeckGameId { @@ -179,7 +71,7 @@ export function deckFormats(game: string): readonly DeckFormat[] { } export function defaultDeckFormat(game: string) { - return deckGame(game)?.defaultFormat ?? MTG_FORMATS[0].id + return deckGame(game)?.defaultFormat ?? SYSTEMS[DEFAULT_DECK_GAME].defaultFormat.deck } export function deckFormatLabel(game: string, format: string) { @@ -217,7 +109,8 @@ export function magicCatalogCardFields(card: { printingId?: string section: string }) { - if (card.game !== "mtg") return {} + if (systemDefinition(card.game)?.integration.capabilities.cardCatalog.provider !== "scryfall") + return {} const board = card.section === "commander" || card.section === "sideboard" ? card.section : "main" return { oracleId: card.cardId, scryfallId: card.printingId, board } as const } diff --git a/convex/lib/games/archidekt.ts b/convex/lib/games/archidekt.ts index 8d1ef624..f9baeefd 100644 --- a/convex/lib/games/archidekt.ts +++ b/convex/lib/games/archidekt.ts @@ -4,6 +4,7 @@ import type { ActionCtx } from "../../_generated/server" import { boundedText, deckSourceUrl, fetchDeckSource, invalidSourceDeck } from "../deckSources" import { MAX_DECK_CARDS } from "../policy" import { type CardReference, fetchScryfall, normalizeScryfallCard, objectRecord } from "../scryfall" +import { hasCommandZone } from "../systems" const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i const SCRYFALL_COLLECTION_SIZE = 75 @@ -144,7 +145,7 @@ export function parseArchidektDeck(payload: unknown, deckId: string) { ) continue const board = category.premier ? "commander" : primary === "Sideboard" ? "sideboard" : "main" - if (board === "commander" && !["commander", "brawl"].includes(format)) invalidDeck() + if (board === "commander" && !hasCommandZone("mtg", format)) invalidDeck() const key = `${board}:${card.uid.toLowerCase()}` const previous = entries.get(key) const quantity = (previous?.quantity ?? 0) + row.quantity diff --git a/convex/lib/integrations.ts b/convex/lib/integrations.ts index c6ef9b9d..60473048 100644 --- a/convex/lib/integrations.ts +++ b/convex/lib/integrations.ts @@ -1,126 +1,32 @@ import { ConvexError } from "convex/values" +import { + isSystemId, + SYSTEMS, + type CapabilityKey, + type IntegrationDefinition, + type SystemId, +} from "./systems" import type { MutationCtx, QueryCtx } from "../_generated/server" -export const GAME_SYSTEM_IDS = ["mtg", "ygo", "pokemon"] as const -export type GameSystemId = (typeof GAME_SYSTEM_IDS)[number] +export { CAPABILITY_KEYS, SYSTEM_IDS as GAME_SYSTEM_IDS } from "./systems" +export type { CapabilityKey, CapabilityRelease, SystemId as GameSystemId } from "./systems" -export const CAPABILITY_KEYS = [ - "integration", - "cardCatalog", - "deckImport", - "exampleDecks", - "images", - "playTracking", - "aggregateMetagameStats", -] as const -export type CapabilityKey = (typeof CAPABILITY_KEYS)[number] -export type CapabilityRelease = "enabled" | "permission_required" | "disabled" +export type Integration = IntegrationDefinition & { id: SystemId; displayName: string } -type Capability = { - technical: "available" | "unavailable" - release: CapabilityRelease - provider?: string - note?: string +export function integrationFor(id: SystemId): Integration { + const { label, integration } = SYSTEMS[id] + return { id, displayName: label, ...integration } } -type Integration = { - id: GameSystemId - displayName: string - identityNamespace: string - capabilities: Record - rights: { - review: "reviewed" | "permission_pending" | "blocked" - basis: "fan_policy" | "fair_use" | "explicit_license" | "publisher_api" | "unknown" - imageUse: "licensed" | "functional_card_context" | "text_only" | "none" - requiredNotices: readonly string[] - } -} - -const available = (provider?: string, note?: string): Capability => ({ - technical: "available", - release: "enabled", - ...(provider ? { provider } : {}), - ...(note ? { note } : {}), -}) - -export const INTEGRATIONS = { - mtg: { - id: "mtg", - displayName: "Magic: The Gathering", - identityNamespace: "scryfall-oracle", - capabilities: { - integration: available("scryve"), - cardCatalog: available("scryfall"), - deckImport: available("scryfall"), - exampleDecks: available("mtgjson"), - images: available("scryfall", "Functional card context only."), - playTracking: available("scryve"), - aggregateMetagameStats: available("scryve"), - }, - rights: { - review: "reviewed", - basis: "fan_policy", - imageUse: "functional_card_context", - requiredNotices: ["Magic: The Gathering is property of Wizards of the Coast."], - }, - }, - ygo: { - id: "ygo", - displayName: "Yu-Gi-Oh!", - identityNamespace: "ygoprodeck-card", - capabilities: { - integration: available("scryve"), - cardCatalog: available("ygoprodeck", "Card metadata is cached in Convex."), - deckImport: available("ygoprodeck"), - exampleDecks: available("ygoprodeck-decks", "Cleaned Top Decks only."), - images: available("cloudflare-r2", "Functional card context through Scryve's mirror."), - playTracking: available("scryve"), - aggregateMetagameStats: available("scryve"), - }, - rights: { - review: "reviewed", - basis: "fair_use", - imageUse: "functional_card_context", - requiredNotices: [ - "Yu-Gi-Oh! and related card content remain property of their respective owners.", - ], - }, - }, - pokemon: { - id: "pokemon", - displayName: "Pokémon TCG", - identityNamespace: "tcgdex-card", - capabilities: { - integration: available("scryve"), - cardCatalog: available("tcgdex"), - deckImport: available("tcgdex"), - exampleDecks: available("limitless", "Cleaned tournament deck data only."), - images: available("tcgdex", "Functional card context only."), - playTracking: available("scryve"), - aggregateMetagameStats: available("scryve"), - }, - rights: { - review: "reviewed", - basis: "fair_use", - imageUse: "functional_card_context", - requiredNotices: [ - "Pokémon, card artwork, and related marks remain property of their respective owners.", - ], - }, - }, -} as const satisfies Record - export function integration(game: string): Integration | undefined { - return GAME_SYSTEM_IDS.includes(game as GameSystemId) - ? (INTEGRATIONS[game as GameSystemId] as Integration) - : undefined + return isSystemId(game) ? integrationFor(game) : undefined } -export function assertGameSystem(game: string): GameSystemId { - if (!integration(game)) +export function assertGameSystem(game: string): SystemId { + if (!isSystemId(game)) throw new ConvexError({ code: "unknown_game", message: "Unknown game system" }) - return game as GameSystemId + return game } type DatabaseCtx = QueryCtx | MutationCtx diff --git a/convex/lib/systems/definitions.ts b/convex/lib/systems/definitions.ts new file mode 100644 index 00000000..fd409600 --- /dev/null +++ b/convex/lib/systems/definitions.ts @@ -0,0 +1,301 @@ +export type DeckSection = { readonly id: string; readonly label: string } + +export type CounterDefinition = { + readonly label: string + readonly heading: string + readonly singular: string + readonly plural: string + readonly defaultValue: number + readonly presets: readonly number[] + readonly tapStep: number + readonly longPressStep?: number + readonly quickAdjustments: readonly [number, number] + readonly scrubStep: number + readonly scrubSteps: number + /** why: "down" counters (Pokémon prizes) count toward zero, so reaching zero wins instead of loses. */ + readonly direction: "open" | "down" + readonly maxStartingValue: number +} + +export type FormatDefinition = { + readonly id: string + readonly label: string + readonly blurb?: string + readonly sections: readonly DeckSection[] + readonly startingValue?: number + readonly hasCommanderDamage?: boolean + readonly singleton?: boolean +} + +export const CAPABILITY_KEYS = [ + "integration", + "cardCatalog", + "deckImport", + "exampleDecks", + "images", + "playTracking", + "aggregateMetagameStats", +] as const +export type CapabilityKey = (typeof CAPABILITY_KEYS)[number] +export type CapabilityRelease = "enabled" | "permission_required" | "disabled" + +export type Capability = { + readonly technical: "available" | "unavailable" + readonly release: CapabilityRelease + readonly provider?: string + readonly note?: string +} + +export type IntegrationDefinition = { + readonly identityNamespace: string + readonly capabilities: Readonly> + readonly rights: { + readonly review: "reviewed" | "permission_pending" | "blocked" + readonly basis: "fan_policy" | "fair_use" | "explicit_license" | "publisher_api" | "unknown" + readonly imageUse: "licensed" | "functional_card_context" | "text_only" | "none" + readonly requiredNotices: readonly string[] + } +} + +export type SystemDefinition = { + readonly label: string + readonly shortLabel: string + readonly available: boolean + /** why: deck creation starts Magic in Commander while a new game starts in Standard. */ + readonly defaultFormat: { readonly deck: string; readonly play: string } + readonly counter: CounterDefinition + readonly formats: readonly FormatDefinition[] + readonly integration: IntegrationDefinition +} + +function available(provider: Provider, note?: string) { + return { + technical: "available", + release: "enabled", + provider, + ...(note ? { note } : {}), + } as const +} + +const MTG_COMMAND_ZONE_SECTIONS = [ + { id: "commander", label: "Commander" }, + { id: "main", label: "Main deck" }, + { id: "sideboard", label: "Sideboard" }, +] as const + +const MTG_CONSTRUCTED_SECTIONS = [ + { id: "main", label: "Main deck" }, + { id: "sideboard", label: "Sideboard" }, +] as const + +const YUGIOH_SECTIONS = [ + { id: "main", label: "Main Deck" }, + { id: "extra", label: "Extra Deck" }, + { id: "side", label: "Side Deck" }, +] as const + +const POKEMON_SECTIONS = [{ id: "main", label: "Deck" }] as const + +export const SYSTEM_IDS = ["mtg", "ygo", "pokemon"] as const +export type SystemId = (typeof SYSTEM_IDS)[number] + +export const SYSTEMS = { + mtg: { + label: "Magic: The Gathering", + shortLabel: "Magic", + available: true, + defaultFormat: { deck: "commander", play: "standard" }, + counter: { + label: "life", + heading: "Life", + singular: "life", + plural: "life", + defaultValue: 20, + presets: [20, 30, 40], + tapStep: 1, + quickAdjustments: [10, 5], + scrubStep: 1, + scrubSteps: 20, + direction: "open", + maxStartingValue: 999, + }, + formats: [ + { + id: "commander", + label: "Commander", + blurb: "100 cards, singleton", + sections: MTG_COMMAND_ZONE_SECTIONS, + startingValue: 40, + hasCommanderDamage: true, + singleton: true, + }, + { + id: "standard", + label: "Standard", + blurb: "Recent sets", + sections: MTG_CONSTRUCTED_SECTIONS, + }, + { + id: "pioneer", + label: "Pioneer", + blurb: "Return to Ravnica forward", + sections: MTG_CONSTRUCTED_SECTIONS, + }, + { + id: "modern", + label: "Modern", + blurb: "8th Edition forward", + sections: MTG_CONSTRUCTED_SECTIONS, + }, + { + id: "legacy", + label: "Legacy", + blurb: "Nearly every set", + sections: MTG_CONSTRUCTED_SECTIONS, + }, + { + id: "vintage", + label: "Vintage", + blurb: "Restricted list", + sections: MTG_CONSTRUCTED_SECTIONS, + }, + { id: "pauper", label: "Pauper", blurb: "Commons only", sections: MTG_CONSTRUCTED_SECTIONS }, + { + id: "brawl", + label: "Brawl", + blurb: "60 cards, singleton", + sections: MTG_COMMAND_ZONE_SECTIONS, + // why: Comprehensive Rules 903.12: two-player Brawl starts at 25 and skips the 21 commander damage loss. + startingValue: 25, + hasCommanderDamage: false, + singleton: true, + }, + { + id: "limited", + label: "Limited", + blurb: "Draft and sealed", + sections: MTG_CONSTRUCTED_SECTIONS, + }, + { + id: "constructed", + label: "Constructed", + blurb: "Anything else", + sections: MTG_CONSTRUCTED_SECTIONS, + }, + ], + integration: { + identityNamespace: "scryfall-oracle", + capabilities: { + integration: available("scryve"), + cardCatalog: available("scryfall"), + deckImport: available("scryfall"), + exampleDecks: available("mtgjson"), + images: available("scryfall", "Functional card context only."), + playTracking: available("scryve"), + aggregateMetagameStats: available("scryve"), + }, + rights: { + review: "reviewed", + basis: "fan_policy", + imageUse: "functional_card_context", + requiredNotices: ["Magic: The Gathering is property of Wizards of the Coast."], + }, + }, + }, + ygo: { + label: "Yu-Gi-Oh!", + shortLabel: "Yu-Gi-Oh!", + available: true, + defaultFormat: { deck: "advanced", play: "advanced" }, + counter: { + label: "Life Points", + heading: "Life Points", + singular: "Life Point", + plural: "Life Points", + defaultValue: 8000, + presets: [8000], + tapStep: 100, + quickAdjustments: [1000, 50], + scrubStep: 100, + scrubSteps: 80, + longPressStep: 1000, + direction: "open", + maxStartingValue: 999_999, + }, + formats: [ + { id: "advanced", label: "Advanced", sections: YUGIOH_SECTIONS }, + { id: "traditional", label: "Traditional", sections: YUGIOH_SECTIONS }, + { id: "rush", label: "Rush Duel", sections: YUGIOH_SECTIONS }, + ], + integration: { + identityNamespace: "ygoprodeck-card", + capabilities: { + integration: available("scryve"), + cardCatalog: available("ygoprodeck", "Card metadata is cached in Convex."), + deckImport: available("ygoprodeck"), + exampleDecks: available("ygoprodeck-decks", "Cleaned Top Decks only."), + images: available("cloudflare-r2", "Functional card context through Scryve's mirror."), + playTracking: available("scryve"), + aggregateMetagameStats: available("scryve"), + }, + rights: { + review: "reviewed", + basis: "fair_use", + imageUse: "functional_card_context", + requiredNotices: [ + "Yu-Gi-Oh! and related card content remain property of their respective owners.", + ], + }, + }, + }, + pokemon: { + label: "Pokémon TCG", + shortLabel: "Pokémon", + available: true, + defaultFormat: { deck: "standard", play: "standard" }, + counter: { + label: "Prize cards", + heading: "Prize cards", + singular: "Prize card", + plural: "Prize cards", + defaultValue: 6, + presets: [6], + tapStep: 1, + quickAdjustments: [2, 1], + scrubStep: 1, + scrubSteps: 20, + direction: "down", + maxStartingValue: 99, + }, + formats: [ + { id: "standard", label: "Standard", sections: POKEMON_SECTIONS }, + { id: "expanded", label: "Expanded", sections: POKEMON_SECTIONS }, + { id: "unlimited", label: "Unlimited", sections: POKEMON_SECTIONS }, + ], + integration: { + identityNamespace: "tcgdex-card", + capabilities: { + integration: available("scryve"), + cardCatalog: available("tcgdex"), + deckImport: available("tcgdex"), + exampleDecks: available("limitless", "Cleaned tournament deck data only."), + images: available("tcgdex", "Functional card context only."), + playTracking: available("scryve"), + aggregateMetagameStats: available("scryve"), + }, + rights: { + review: "reviewed", + basis: "fair_use", + imageUse: "functional_card_context", + requiredNotices: [ + "Pokémon, card artwork, and related marks remain property of their respective owners.", + ], + }, + }, + }, +} as const satisfies Record + +export type FormatId = + (typeof SYSTEMS)[System]["formats"][number]["id"] +export type CatalogProvider = + (typeof SYSTEMS)[SystemId]["integration"]["capabilities"]["cardCatalog"]["provider"] diff --git a/convex/lib/systems/index.ts b/convex/lib/systems/index.ts new file mode 100644 index 00000000..2ff7a644 --- /dev/null +++ b/convex/lib/systems/index.ts @@ -0,0 +1,31 @@ +import { + SYSTEM_IDS, + SYSTEMS, + type FormatDefinition, + type SystemDefinition, + type SystemId, +} from "./definitions" + +export * from "./definitions" + +export function isSystemId(value: unknown): value is SystemId { + return typeof value === "string" && SYSTEM_IDS.some((id) => id === value) +} + +export function systemDefinition(system: unknown): SystemDefinition | undefined { + return isSystemId(system) ? SYSTEMS[system] : undefined +} + +export function formatDefinition( + system: unknown, + format: string | undefined, +): FormatDefinition | undefined { + return systemDefinition(system)?.formats.find((candidate) => candidate.id === format) +} + +export function hasCommandZone(system: unknown, format: string | undefined): boolean { + return ( + formatDefinition(system, format)?.sections.some((section) => section.id === "commander") ?? + false + ) +} diff --git a/convex/lib/systems/systems.test.ts b/convex/lib/systems/systems.test.ts new file mode 100644 index 00000000..d2a2682f --- /dev/null +++ b/convex/lib/systems/systems.test.ts @@ -0,0 +1,35 @@ +import { formatDefinition, hasCommandZone, SYSTEM_IDS, SYSTEMS } from "." + +describe("system definitions", () => { + it("plays Brawl from the command zone without the 21 commander damage loss", () => { + expect(formatDefinition("mtg", "brawl")).toMatchObject({ + singleton: true, + hasCommanderDamage: false, + startingValue: 25, + }) + expect(hasCommandZone("mtg", "brawl")).toBe(true) + }) + + it("tracks commander damage only in Commander", () => { + const formats = SYSTEM_IDS.flatMap((system) => + SYSTEMS[system].formats.flatMap((format) => + "hasCommanderDamage" in format && format.hasCommanderDamage ? [format.id] : [], + ), + ) + expect(formats).toEqual(["commander"]) + }) + + it("counts Pokémon prizes down and other systems open", () => { + expect(SYSTEMS.pokemon.counter.direction).toBe("down") + expect(SYSTEMS.mtg.counter.direction).toBe("open") + expect(SYSTEMS.ygo.counter.direction).toBe("open") + }) + + it("keeps each default format inside its system", () => { + for (const system of SYSTEM_IDS) { + const { deck, play } = SYSTEMS[system].defaultFormat + expect(formatDefinition(system, deck)).toBeDefined() + expect(formatDefinition(system, play)).toBeDefined() + } + }) +}) diff --git a/src/features/game/playSystems.test.ts b/src/features/game/playSystems.test.ts index 9d221dae..88c3501f 100644 --- a/src/features/game/playSystems.test.ts +++ b/src/features/game/playSystems.test.ts @@ -1,4 +1,9 @@ -import { defaultStartingLife, playSystemRules } from "./playSystems" +import { + counterDeltaFromStartLabel, + defaultStartingLife, + playSystemRules, + supportsCommanderDamage, +} from "./playSystems" describe("play system defaults", () => { it("uses one point taps for Magic", () => { @@ -10,6 +15,17 @@ describe("play system defaults", () => { expect(defaultStartingLife("mtg", "standard")).toBe(20) }) + it("starts Brawl at 25 life without commander damage", () => { + expect(defaultStartingLife("mtg", "brawl")).toBe(25) + expect(supportsCommanderDamage("mtg", "brawl")).toBe(false) + expect(supportsCommanderDamage("mtg", "commander")).toBe(true) + }) + + it("reports prizes taken for down counters", () => { + expect(counterDeltaFromStartLabel("pokemon", 4, 6)).toBe("2 taken") + expect(counterDeltaFromStartLabel("mtg", 18, 20)).toBe("-2 from start") + }) + it("keeps other system defaults", () => { expect(defaultStartingLife("ygo")).toBe(8000) expect(defaultStartingLife("pokemon")).toBe(6) diff --git a/src/features/game/playSystems.ts b/src/features/game/playSystems.ts index 0b0ae533..c1b39d19 100644 --- a/src/features/game/playSystems.ts +++ b/src/features/game/playSystems.ts @@ -1,29 +1,18 @@ +import { deckFormatLabel, deckFormats } from "../../../convex/lib/deckGames" import { - deckFormatLabel, - deckFormats, - defaultDeckFormat, - DECK_GAME_LIST, -} from "../../../convex/lib/deckGames" - -export const PLAY_SYSTEM_IDS = ["mtg", "ygo", "pokemon"] as const -export type PlaySystemId = (typeof PLAY_SYSTEM_IDS)[number] + formatDefinition, + isSystemId, + SYSTEM_IDS, + SYSTEMS, + type CounterDefinition, + type SystemId, +} from "../../../convex/lib/systems" + +export const PLAY_SYSTEM_IDS = SYSTEM_IDS +export type PlaySystemId = SystemId export const NO_PLAY_SYSTEM = "none" -export type CounterRules = { - label: string - heading: string - singular: string - plural: string - defaultValue: number - presets: readonly number[] - tapStep: number - longPressStep?: number - quickAdjustments: readonly [number, number] - scrubStep: number - scrubSteps: number - direction: "open" | "down" - maxStartingValue: number -} +export type CounterRules = CounterDefinition export type PlaySystemRules = { id: PlaySystemId @@ -33,72 +22,16 @@ export type PlaySystemRules = { counter: CounterRules } -const SYSTEM_LABELS = new Map( - DECK_GAME_LIST.map((game) => [game.id, { label: game.label, shortLabel: game.shortLabel }]), -) +function playRulesFor(id: PlaySystemId): PlaySystemRules { + const { label, shortLabel, defaultFormat, counter } = SYSTEMS[id] + return { id, label, shortLabel, defaultFormat: defaultFormat.play, counter } +} +// why: rules feed render paths, so each system keeps one stable object. const PLAY_SYSTEMS: Record = { - mtg: { - id: "mtg", - label: SYSTEM_LABELS.get("mtg")?.label ?? "Magic: The Gathering", - shortLabel: SYSTEM_LABELS.get("mtg")?.shortLabel ?? "Magic", - defaultFormat: "standard", - counter: { - label: "life", - heading: "Life", - singular: "life", - plural: "life", - defaultValue: 20, - presets: [20, 30, 40], - tapStep: 1, - quickAdjustments: [10, 5], - scrubStep: 1, - scrubSteps: 20, - direction: "open", - maxStartingValue: 999, - }, - }, - ygo: { - id: "ygo", - label: SYSTEM_LABELS.get("ygo")?.label ?? "Yu-Gi-Oh!", - shortLabel: SYSTEM_LABELS.get("ygo")?.shortLabel ?? "Yu-Gi-Oh!", - defaultFormat: "advanced", - counter: { - label: "Life Points", - heading: "Life Points", - singular: "Life Point", - plural: "Life Points", - defaultValue: 8000, - presets: [8000], - tapStep: 100, - quickAdjustments: [1000, 50], - scrubStep: 100, - scrubSteps: 80, - longPressStep: 1000, - direction: "open", - maxStartingValue: 999_999, - }, - }, - pokemon: { - id: "pokemon", - label: SYSTEM_LABELS.get("pokemon")?.label ?? "Pokémon TCG", - shortLabel: SYSTEM_LABELS.get("pokemon")?.shortLabel ?? "Pokémon", - defaultFormat: "standard", - counter: { - label: "Prize cards", - heading: "Prize cards", - singular: "Prize card", - plural: "Prize cards", - defaultValue: 6, - presets: [6], - tapStep: 1, - quickAdjustments: [2, 1], - scrubStep: 1, - scrubSteps: 20, - direction: "down", - maxStartingValue: 99, - }, - }, + mtg: playRulesFor("mtg"), + ygo: playRulesFor("ygo"), + pokemon: playRulesFor("pokemon"), } const GENERIC_PLAY_RULES = { @@ -125,9 +58,7 @@ const GENERIC_PLAY_RULES = { export const PLAY_SYSTEM_LIST = PLAY_SYSTEM_IDS.map((id) => PLAY_SYSTEMS[id]) -export function isPlaySystemId(value: unknown): value is PlaySystemId { - return typeof value === "string" && PLAY_SYSTEM_IDS.some((system) => system === value) -} +export const isPlaySystemId = isSystemId export function playSystemId(value: unknown): PlaySystemId { return isPlaySystemId(value) ? value : "mtg" @@ -144,24 +75,21 @@ export function playSystemFormats(value?: unknown) { export function playSystemFormat(value?: unknown, format?: string): string { const system = playSystemId(value) if (format && deckFormats(system).some((candidate) => candidate.id === format)) return format - return PLAY_SYSTEMS[system].defaultFormat ?? defaultDeckFormat(system) + return SYSTEMS[system].defaultFormat.play } -export const COMMANDER_DAMAGE_FORMAT = "commander" +function playFormatDefinition(system: unknown, format?: string) { + return formatDefinition(system, playSystemFormat(system, format)) +} export function defaultStartingLife(system?: unknown, format?: string): number { - if ( - playSystemId(system) === "mtg" && - playSystemFormat(system, format) === COMMANDER_DAMAGE_FORMAT - ) - return 40 - return playSystemRules(system).counter.defaultValue + const counter = playSystemRules(system).counter + if (!isPlaySystemId(system)) return counter.defaultValue + return playFormatDefinition(system, format)?.startingValue ?? counter.defaultValue } export function supportsCommanderDamage(value: unknown, format?: string): boolean { - return ( - playSystemId(value) === "mtg" && playSystemFormat(value, format) === COMMANDER_DAMAGE_FORMAT - ) + return playFormatDefinition(playSystemId(value), format)?.hasCommanderDamage ?? false } export function playFormatLabel(value: unknown, format?: string): string { @@ -185,9 +113,8 @@ export function counterDeltaFromStartLabel( current: number, startingValue: number, ): string { - const system = playSystemId(value) const delta = current - startingValue - if (system === "pokemon") { + if (playSystemRules(value).counter.direction === "down") { const taken = startingValue - current return taken === 0 ? "none taken" : `${taken} taken` } From 7c9869cf5f158bb35a92641c553bf575583b1b52 Mon Sep 17 00:00:00 2001 From: Matthew Chisolm <39521893+mchisolm0@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:12:25 -0500 Subject: [PATCH 2/3] fix(systems): multiplayer Brawl starts at 30 and integration types stay narrow Brawl starts at 25 for two players and 30 for more (CR 903.12f). New Game and Settings recompute a default starting value when the player count changes, and a stored Brawl default of 20 migrates to the new default so later format changes still follow it. integrationFor infers its literal return type again, so integrationManifest.list keeps main's narrow shape. Co-Authored-By: Claude Opus 5.5 --- convex/lib/integrations.test.ts | 13 +++++- convex/lib/integrations.ts | 4 +- convex/lib/systems/definitions.ts | 4 +- src/devtools/seeds/gameSeed.ts | 2 +- src/features/game/localPersistence.test.ts | 21 ++++++++++ src/features/game/localPersistence.ts | 21 +++++++++- src/features/game/playSystems.test.ts | 3 ++ src/features/game/playSystems.ts | 9 ++++- src/screens/NewGameScreen.test.tsx | 16 ++++++++ src/screens/NewGameScreen.tsx | 8 ++-- src/screens/SettingsScreen.test.tsx | 46 +++++++++++++++++++++- src/screens/SettingsScreen.tsx | 31 ++++++++++++--- 12 files changed, 159 insertions(+), 19 deletions(-) diff --git a/convex/lib/integrations.test.ts b/convex/lib/integrations.test.ts index acb05356..5ed8250c 100644 --- a/convex/lib/integrations.test.ts +++ b/convex/lib/integrations.test.ts @@ -1,7 +1,8 @@ +import type { FunctionReturnType } from "convex/server" import { convexTest } from "convex-test" import { integration } from "./integrations" -import { internal } from "../_generated/api" +import { api, internal } from "../_generated/api" import schema from "../schema" const modules = { @@ -23,6 +24,16 @@ describe("integration capability state", () => { }) }) + it("lists integrations with their literal registry types", () => { + type Listed = FunctionReturnType[number] + const accepts = (value: T) => value + expect(accepts("scryfall-oracle")).toBe("scryfall-oracle") + // @ts-expect-error no system uses this namespace + accepts("bogus") + // @ts-expect-error every system ships functional card context images + accepts("none") + }) + it("retains the registry note when an override has no note", async () => { const t = convexTest(schema, modules) diff --git a/convex/lib/integrations.ts b/convex/lib/integrations.ts index 60473048..4e0fbb0e 100644 --- a/convex/lib/integrations.ts +++ b/convex/lib/integrations.ts @@ -14,9 +14,9 @@ export type { CapabilityKey, CapabilityRelease, SystemId as GameSystemId } from export type Integration = IntegrationDefinition & { id: SystemId; displayName: string } -export function integrationFor(id: SystemId): Integration { +export function integrationFor(id: Id) { const { label, integration } = SYSTEMS[id] - return { id, displayName: label, ...integration } + return { id, displayName: label, ...integration } satisfies Integration } export function integration(game: string): Integration | undefined { diff --git a/convex/lib/systems/definitions.ts b/convex/lib/systems/definitions.ts index fd409600..3f3a6d45 100644 --- a/convex/lib/systems/definitions.ts +++ b/convex/lib/systems/definitions.ts @@ -23,6 +23,7 @@ export type FormatDefinition = { readonly blurb?: string readonly sections: readonly DeckSection[] readonly startingValue?: number + readonly multiplayerStartingValue?: number readonly hasCommanderDamage?: boolean readonly singleton?: boolean } @@ -165,8 +166,9 @@ export const SYSTEMS = { label: "Brawl", blurb: "60 cards, singleton", sections: MTG_COMMAND_ZONE_SECTIONS, - // why: Comprehensive Rules 903.12: two-player Brawl starts at 25 and skips the 21 commander damage loss. + // why: Comprehensive Rules 903.12: Brawl starts at 25 (30 multiplayer) and skips the 21 commander damage loss. startingValue: 25, + multiplayerStartingValue: 30, hasCommanderDamage: false, singleton: true, }, diff --git a/src/devtools/seeds/gameSeed.ts b/src/devtools/seeds/gameSeed.ts index f26419e1..0b969f21 100644 --- a/src/devtools/seeds/gameSeed.ts +++ b/src/devtools/seeds/gameSeed.ts @@ -92,7 +92,7 @@ export function buildSeededGame(params: SeedParams, repository: LocalGameReposit startingLife: params.start !== undefined ? integer("start", params.start) - : defaultStartingLife(system, format), + : defaultStartingLife(system, format, playerCount), system, format, layout, diff --git a/src/features/game/localPersistence.test.ts b/src/features/game/localPersistence.test.ts index 75bcccc7..e203dc12 100644 --- a/src/features/game/localPersistence.test.ts +++ b/src/features/game/localPersistence.test.ts @@ -211,6 +211,27 @@ describe("LocalGameRepository", () => { }) }) + it.each([ + [2, 20, 25], + [4, 20, 30], + [2, 22, 22], + ])("migrates a %i-player Brawl default stored at %i life to %i", (players, stored, expected) => { + const storage = new MemoryStorage() + storage.set( + LOCAL_KEYS.settings, + JSON.stringify({ + schemaVersion: 1, + defaultPlayerCount: players, + defaultStartingLife: stored, + hapticsEnabled: true, + themePreference: "dark", + defaultSystem: "mtg", + defaultFormat: "brawl", + }), + ) + expect(new LocalGameRepository(storage).loadSettings().defaultStartingLife).toBe(expected) + }) + it("stores a default system and format only when both are known", () => { const storage = new MemoryStorage() const repository = new LocalGameRepository(storage) diff --git a/src/features/game/localPersistence.ts b/src/features/game/localPersistence.ts index 5d4a0a92..efd1b8bc 100644 --- a/src/features/game/localPersistence.ts +++ b/src/features/game/localPersistence.ts @@ -22,6 +22,7 @@ import { import { applyClaimDecisions, type ClaimDecision } from "./localGameClaims" import { playerGridLayoutForCount, type PlayerGridLayoutVariant } from "./playerLayouts" import { + defaultStartingLife, isPlaySystemId, playSystemFormats, playSystemId, @@ -363,20 +364,36 @@ function parseSettings(value: unknown): LocalSettings | null { ) { return null } + const systemPreference = parseSystemPreference(migrated.defaultSystem, migrated.defaultFormat) return { schemaVersion: 1, defaultPlayerCount: migrated.defaultPlayerCount, - defaultStartingLife: migrated.defaultStartingLife, + defaultStartingLife: migrateBrawlStartingLife( + systemPreference, + migrated.defaultStartingLife, + migrated.defaultPlayerCount, + ), hapticsEnabled: migrated.hapticsEnabled, themePreference: migrated.themePreference, menuButtonStyle: isMenuButtonStyle(migrated.menuButtonStyle) ? migrated.menuButtonStyle : DEFAULT_MENU_BUTTON_STYLE, launchDestination: migrated.launchDestination === "decks" ? "decks" : "play", - ...parseSystemPreference(migrated.defaultSystem, migrated.defaultFormat), + ...systemPreference, } } +// why: Brawl preferences saved before Brawl had its own default stored Magic's 20, which would otherwise read as a custom value and stop following format changes. +function migrateBrawlStartingLife( + { defaultSystem, defaultFormat }: ReturnType, + startingLife: number, + playerCount: number, +) { + return defaultSystem === "mtg" && defaultFormat === "brawl" && startingLife === 20 + ? defaultStartingLife(defaultSystem, defaultFormat, playerCount) + : startingLife +} + function parseSystemPreference( system: unknown, format: unknown, diff --git a/src/features/game/playSystems.test.ts b/src/features/game/playSystems.test.ts index 88c3501f..58e50628 100644 --- a/src/features/game/playSystems.test.ts +++ b/src/features/game/playSystems.test.ts @@ -17,6 +17,9 @@ describe("play system defaults", () => { it("starts Brawl at 25 life without commander damage", () => { expect(defaultStartingLife("mtg", "brawl")).toBe(25) + expect(defaultStartingLife("mtg", "brawl", 2)).toBe(25) + expect(defaultStartingLife("mtg", "brawl", 4)).toBe(30) + expect(defaultStartingLife("mtg", "commander", 4)).toBe(40) expect(supportsCommanderDamage("mtg", "brawl")).toBe(false) expect(supportsCommanderDamage("mtg", "commander")).toBe(true) }) diff --git a/src/features/game/playSystems.ts b/src/features/game/playSystems.ts index c1b39d19..0b03d3a8 100644 --- a/src/features/game/playSystems.ts +++ b/src/features/game/playSystems.ts @@ -82,10 +82,15 @@ function playFormatDefinition(system: unknown, format?: string) { return formatDefinition(system, playSystemFormat(system, format)) } -export function defaultStartingLife(system?: unknown, format?: string): number { +export function defaultStartingLife(system?: unknown, format?: string, playerCount = 2): number { const counter = playSystemRules(system).counter if (!isPlaySystemId(system)) return counter.defaultValue - return playFormatDefinition(system, format)?.startingValue ?? counter.defaultValue + const definition = playFormatDefinition(system, format) + return ( + (playerCount > 2 ? definition?.multiplayerStartingValue : undefined) ?? + definition?.startingValue ?? + counter.defaultValue + ) } export function supportsCommanderDamage(value: unknown, format?: string): boolean { diff --git a/src/screens/NewGameScreen.test.tsx b/src/screens/NewGameScreen.test.tsx index ee3e31ef..b27f5d12 100644 --- a/src/screens/NewGameScreen.test.tsx +++ b/src/screens/NewGameScreen.test.tsx @@ -537,6 +537,22 @@ describe("NewGameScreen", () => { ) }) + it("follows Brawl life from two players to multiplayer", () => { + const onStartLocal = jest.fn() + const view = setup({ onStartLocal }) + fireEvent.press(view.getByTestId("play-system-mtg")) + fireEvent.press(view.getByTestId("play-format")) + fireEvent.press(view.getByTestId("play-format-option-brawl")) + fireEvent.press(view.getByTestId("player-count-increment")) + fireEvent.press(view.getByTestId("player-count-increment")) + fireEvent.press(view.getByTestId("start-game-button")) + expect(onStartLocal).toHaveBeenCalledWith( + expect.any(Array), + 30, + expect.objectContaining({ format: "brawl" }), + ) + }) + it("keeps the start button pinned outside the scrollable form", () => { const view = setup() diff --git a/src/screens/NewGameScreen.tsx b/src/screens/NewGameScreen.tsx index fadbd6c7..9d0395bd 100644 --- a/src/screens/NewGameScreen.tsx +++ b/src/screens/NewGameScreen.tsx @@ -379,14 +379,14 @@ export function NewGameScreen({ const nextCounter = playSystemRules(next).counter setSystem(next) setFormat(next ? playSystemFormat(next) : undefined) - setStartingLife(defaultStartingLife(next)) + setStartingLife(defaultStartingLife(next, undefined, playerCount)) setLifeStep(nextCounter.tapStep) setDeck(undefined) } function chooseFormat(value: string | undefined) { - if (startingLife === defaultStartingLife(system, format)) - setStartingLife(defaultStartingLife(system, value)) + if (startingLife === defaultStartingLife(system, format, playerCount)) + setStartingLife(defaultStartingLife(system, value, playerCount)) setFormat(value) setDeck(undefined) } @@ -452,6 +452,8 @@ export function NewGameScreen({ }) return next }) + if (startingLife === defaultStartingLife(system, format, playerCount)) + setStartingLife(defaultStartingLife(system, format, value)) setPlayerCount(value) setLayout((current) => playerGridLayoutForCount(value, current)) } diff --git a/src/screens/SettingsScreen.test.tsx b/src/screens/SettingsScreen.test.tsx index c934d2e3..417bb4db 100644 --- a/src/screens/SettingsScreen.test.tsx +++ b/src/screens/SettingsScreen.test.tsx @@ -3,7 +3,11 @@ import * as Clipboard from "expo-clipboard" import * as Updates from "expo-updates" import { fireEvent, render, waitFor } from "@testing-library/react-native" -import { DEFAULT_LOCAL_SETTINGS } from "@/features/game/localPersistence" +import { + DEFAULT_LOCAL_SETTINGS, + LOCAL_KEYS, + LocalGameRepository, +} from "@/features/game/localPersistence" import { ThemeProvider } from "@/theme/context" import { analyticsId } from "@/utils/analytics" @@ -495,6 +499,46 @@ describe("SettingsScreen", () => { ) }) + it("moves a migrated Brawl default to Commander life and multiplayer Brawl life", () => { + const stored = new Map([ + [ + LOCAL_KEYS.settings, + JSON.stringify({ + ...DEFAULT_LOCAL_SETTINGS, + defaultStartingLife: 20, + defaultSystem: "mtg", + defaultFormat: "brawl", + }), + ], + ]) + const settings = new LocalGameRepository({ + getString: (key) => stored.get(key), + set: (key, value) => stored.set(key, value), + delete: (key) => stored.delete(key), + }).loadSettings() + const onSettingsChange = jest.fn() + const view = render( + + + , + ) + + fireEvent.press(view.getByTestId("default-player-count-increment")) + expect(onSettingsChange).toHaveBeenLastCalledWith( + expect.objectContaining({ defaultPlayerCount: 3, defaultStartingLife: 30 }), + ) + + fireEvent.press(view.getByTestId("default-format")) + fireEvent.press(view.getByTestId("default-format-option-commander")) + expect(onSettingsChange).toHaveBeenLastCalledWith( + expect.objectContaining({ defaultFormat: "commander", defaultStartingLife: 40 }), + ) + }) + it("keeps Back reachable and reveals the title after scrolling", () => { const onBack = jest.fn() const view = render( diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 40700c02..1500d2d5 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -129,7 +129,7 @@ export function SettingsScreen({ const { defaultSystem: _system, defaultFormat: _format, ...rest } = settings const next: LocalSettings = { ...rest, - defaultStartingLife: defaultStartingLife(system), + defaultStartingLife: defaultStartingLife(system, undefined, settings.defaultPlayerCount), ...(system ? { defaultSystem: system, @@ -141,18 +141,35 @@ export function SettingsScreen({ } const selectFormat = (format?: string) => { if (!settings.defaultSystem) return - const previousDefaultLife = defaultStartingLife(settings.defaultSystem, settings.defaultFormat) const { defaultFormat: _format, ...rest } = settings const next: LocalSettings = { ...rest, ...(format ? { defaultFormat: format } : {}), - ...(settings.defaultStartingLife === previousDefaultLife - ? { defaultStartingLife: defaultStartingLife(settings.defaultSystem, format) } - : {}), + ...followDefaultLife({ defaultFormat: format }), } setSettings(next) onSettingsChange(next) } + // why: a starting value still at its default follows the new default; a custom one stays put. + const followDefaultLife = ( + changes: Partial>, + ) => { + const { defaultSystem, defaultFormat, defaultPlayerCount } = { ...settings, ...changes } + const previous = defaultStartingLife( + settings.defaultSystem, + settings.defaultFormat, + settings.defaultPlayerCount, + ) + return settings.defaultStartingLife === previous + ? { + defaultStartingLife: defaultStartingLife( + defaultSystem, + defaultFormat, + defaultPlayerCount, + ), + } + : {} + } const themes: ThemePreference[] = ["system", "light", "dark"] const formats = settings.defaultSystem ? playSystemFormats(settings.defaultSystem) : [] const counter = playSystemRules(settings.defaultSystem).counter @@ -175,7 +192,9 @@ export function SettingsScreen({ value={settings.defaultPlayerCount} min={MIN_PLAYERS} max={MAX_PLAYERS} - onChange={(defaultPlayerCount) => update({ defaultPlayerCount })} + onChange={(defaultPlayerCount) => + update({ defaultPlayerCount, ...followDefaultLife({ defaultPlayerCount }) }) + } /> From 694e937d4bca6b6ebfa2bd7e4334e8fca220ca5b Mon Sep 17 00:00:00 2001 From: Matthew Chisolm <39521893+mchisolm0@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:23:02 -0500 Subject: [PATCH 3/3] fix(settings): the Brawl starting life fix runs once, so a chosen 20 sticks Co-Authored-By: Claude Opus 5.5 --- src/features/game/localPersistence.test.ts | 12 +++++++ src/features/game/localPersistence.ts | 41 ++++++++++++---------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/features/game/localPersistence.test.ts b/src/features/game/localPersistence.test.ts index e203dc12..d6108705 100644 --- a/src/features/game/localPersistence.test.ts +++ b/src/features/game/localPersistence.test.ts @@ -232,6 +232,18 @@ describe("LocalGameRepository", () => { expect(new LocalGameRepository(storage).loadSettings().defaultStartingLife).toBe(expected) }) + it("keeps a Brawl starting life of 20 chosen after the migration", () => { + const storage = new MemoryStorage() + const repository = new LocalGameRepository(storage) + repository.saveSettings({ + ...repository.loadSettings(), + defaultSystem: "mtg", + defaultFormat: "brawl", + defaultStartingLife: 20, + }) + expect(new LocalGameRepository(storage).loadSettings().defaultStartingLife).toBe(20) + }) + it("stores a default system and format only when both are known", () => { const storage = new MemoryStorage() const repository = new LocalGameRepository(storage) diff --git a/src/features/game/localPersistence.ts b/src/features/game/localPersistence.ts index efd1b8bc..9fa5ad67 100644 --- a/src/features/game/localPersistence.ts +++ b/src/features/game/localPersistence.ts @@ -368,11 +368,7 @@ function parseSettings(value: unknown): LocalSettings | null { return { schemaVersion: 1, defaultPlayerCount: migrated.defaultPlayerCount, - defaultStartingLife: migrateBrawlStartingLife( - systemPreference, - migrated.defaultStartingLife, - migrated.defaultPlayerCount, - ), + defaultStartingLife: migrated.defaultStartingLife, hapticsEnabled: migrated.hapticsEnabled, themePreference: migrated.themePreference, menuButtonStyle: isMenuButtonStyle(migrated.menuButtonStyle) @@ -384,14 +380,18 @@ function parseSettings(value: unknown): LocalSettings | null { } // why: Brawl preferences saved before Brawl had its own default stored Magic's 20, which would otherwise read as a custom value and stop following format changes. -function migrateBrawlStartingLife( - { defaultSystem, defaultFormat }: ReturnType, - startingLife: number, - playerCount: number, -) { +function migrateBrawlStartingLife(settings: LocalSettings): LocalSettings { + const { defaultSystem, defaultFormat, defaultStartingLife: startingLife } = settings return defaultSystem === "mtg" && defaultFormat === "brawl" && startingLife === 20 - ? defaultStartingLife(defaultSystem, defaultFormat, playerCount) - : startingLife + ? { + ...settings, + defaultStartingLife: defaultStartingLife( + defaultSystem, + defaultFormat, + settings.defaultPlayerCount, + ), + } + : settings } function parseSystemPreference( @@ -524,18 +524,23 @@ export class LocalGameRepository { } loadSettings(): LocalSettings { - const current = parseSettings(parseJson(this.storage.getString(LOCAL_KEYS.settings))) - if (current) return current - const legacy = parseSettings(parseJson(this.storage.getString(LOCAL_KEYS.legacySettings))) - const settings = legacy ?? DEFAULT_LOCAL_SETTINGS + const stored = parseJson(this.storage.getString(LOCAL_KEYS.settings)) + const current = parseSettings(stored) + if (current && isRecord(stored) && stored.brawlLifeMigrated === true) return current + const legacy = current + ? null + : parseSettings(parseJson(this.storage.getString(LOCAL_KEYS.legacySettings))) + const found = current ?? legacy + const settings = found ? migrateBrawlStartingLife(found) : DEFAULT_LOCAL_SETTINGS this.saveSettings(settings) if (legacy) this.storage.delete(LOCAL_KEYS.legacySettings) return settings } saveSettings(settings: LocalSettings): void { - const valid = parseSettings(settings) - this.storage.set(LOCAL_KEYS.settings, JSON.stringify(valid ?? DEFAULT_LOCAL_SETTINGS)) + const valid = parseSettings(settings) ?? DEFAULT_LOCAL_SETTINGS + // why: anything this build saves is past the one-time Brawl fix, so a 20 chosen afterward is never migrated again. + this.storage.set(LOCAL_KEYS.settings, JSON.stringify({ ...valid, brawlLifeMigrated: true })) } loadLayoutPreference(playerCount: number): PlayerGridLayoutVariant {