Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 4 additions & 3 deletions convex/integrationManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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)]),
Expand All @@ -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")
Expand Down
36 changes: 25 additions & 11 deletions convex/lib/cardImageFallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<CatalogProvider, (ctx: ActionCtx, cardId: string) => Promise<string[]>>

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())
Expand Down
155 changes: 24 additions & 131 deletions convex/lib/deckGames.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string, DeckGame> = {
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<string, DeckGame> = 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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
3 changes: 2 additions & 1 deletion convex/lib/games/archidekt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion convex/lib/integrations.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -23,6 +24,16 @@ describe("integration capability state", () => {
})
})

it("lists integrations with their literal registry types", () => {
type Listed = FunctionReturnType<typeof api.integrationManifest.list>[number]
const accepts = <T>(value: T) => value
expect(accepts<Listed["identityNamespace"]>("scryfall-oracle")).toBe("scryfall-oracle")
// @ts-expect-error no system uses this namespace
accepts<Listed["identityNamespace"]>("bogus")
// @ts-expect-error every system ships functional card context images
accepts<Listed["rights"]["imageUse"]>("none")
})

it("retains the registry note when an override has no note", async () => {
const t = convexTest(schema, modules)

Expand Down
Loading
Loading