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.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 c6ef9b9d..4e0fbb0e 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: Id) { + const { label, integration } = SYSTEMS[id] + return { id, displayName: label, ...integration } satisfies 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..3f3a6d45 --- /dev/null +++ b/convex/lib/systems/definitions.ts @@ -0,0 +1,303 @@ +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 multiplayerStartingValue?: 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: Brawl starts at 25 (30 multiplayer) and skips the 21 commander damage loss. + startingValue: 25, + multiplayerStartingValue: 30, + 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/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..d6108705 100644 --- a/src/features/game/localPersistence.test.ts +++ b/src/features/game/localPersistence.test.ts @@ -211,6 +211,39 @@ 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("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 5d4a0a92..9fa5ad67 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,6 +364,7 @@ function parseSettings(value: unknown): LocalSettings | null { ) { return null } + const systemPreference = parseSystemPreference(migrated.defaultSystem, migrated.defaultFormat) return { schemaVersion: 1, defaultPlayerCount: migrated.defaultPlayerCount, @@ -373,10 +375,25 @@ function parseSettings(value: unknown): LocalSettings | null { ? 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(settings: LocalSettings): LocalSettings { + const { defaultSystem, defaultFormat, defaultStartingLife: startingLife } = settings + return defaultSystem === "mtg" && defaultFormat === "brawl" && startingLife === 20 + ? { + ...settings, + defaultStartingLife: defaultStartingLife( + defaultSystem, + defaultFormat, + settings.defaultPlayerCount, + ), + } + : settings +} + function parseSystemPreference( system: unknown, format: unknown, @@ -507,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 { diff --git a/src/features/game/playSystems.test.ts b/src/features/game/playSystems.test.ts index 9d221dae..58e50628 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,20 @@ 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(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) + }) + + 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..0b03d3a8 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,26 @@ 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 +export function defaultStartingLife(system?: unknown, format?: string, playerCount = 2): number { + const counter = playSystemRules(system).counter + if (!isPlaySystemId(system)) return counter.defaultValue + const definition = playFormatDefinition(system, format) + return ( + (playerCount > 2 ? definition?.multiplayerStartingValue : undefined) ?? + definition?.startingValue ?? + counter.defaultValue ) - return 40 - return playSystemRules(system).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 +118,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` } 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 }) }) + } />