diff --git a/app/games/[id]/page.test.ts b/app/games/[id]/page.test.ts
index 003fd641..935c8953 100644
--- a/app/games/[id]/page.test.ts
+++ b/app/games/[id]/page.test.ts
@@ -1,13 +1,18 @@
-import { type ReactElement, Suspense } from "react";
+import { createElement, Fragment, type ReactElement, Suspense } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vitest";
import { GameDetailSkeleton } from "@/components/game/game-detail-skeleton";
+import { GameFacts } from "@/components/game/game-facts";
import { GameHero } from "@/components/game/game-hero";
+import { GameMoreLikeThisSection } from "@/components/game/game-more-like-this";
+import { GameOverview } from "@/components/game/game-overview";
+import { ScreenshotGallery } from "@/components/game/screenshot-gallery";
import { GameTrackingSlot } from "@/components/tracking/game-tracking-slot";
/**
- * covers: spec 0025, AC-1, AC-6, AC-7, AC-8, AC-9, AC-10
+ * covers: spec 0025, AC-1, AC-6, AC-7, AC-8, AC-9, AC-10; spec 0031, AC-1,
+ * AC-11
*
* Until G10 every game page outcome is `noindex`, with no canonical or share
* card. `loadGame` is replaced, so the metadata and the streamed body branch
@@ -139,13 +144,30 @@ describe("the streamed body", () => {
platforms: [{ id: 6, name: "PC (Microsoft Windows)" }],
communityRating: 9.3,
communityRatingCount: 5501,
+ storyline: null,
+ artUrl: "https://images.igdb.com/igdb/image/upload/t_1080p/ar1.jpg",
+ screenshotUrls: [],
+ trailer: { key: "dQw4w9WgXcQ", name: "Launch Trailer" },
+ similarGameIds: [1887],
+ developers: ["CD Projekt RED"],
+ publishers: [],
+ gameModes: [],
+ playerPerspectives: [],
+ themes: [],
+ franchises: [],
+ series: [],
+ gameEngines: [],
+ platformReleases: [],
};
+ /** The article's children: the hero first, then the sections in order. */
+ const sectionsOf = (article: Element) => article.props.children as Element[];
+
it("hands the found game's facts to the hero and nothing it does not show (AC-1)", async () => {
loadGame.mockResolvedValue({ kind: "found", game: FOUND });
const article = await bodyFor("1942");
- const hero = article.props.children as Element;
+ const hero = sectionsOf(article)[0];
expect(loadGame).toHaveBeenCalledWith(1942);
expect(hero.type).toBe(GameHero);
@@ -159,14 +181,49 @@ describe("the streamed body", () => {
platforms: FOUND.platforms,
communityRating: FOUND.communityRating,
communityRatingCount: FOUND.communityRatingCount,
+ artUrl: FOUND.artUrl,
+ trailer: FOUND.trailer,
});
});
+ it("orders the sections under the hero as spec 0031 lists them (AC-1)", async () => {
+ loadGame.mockResolvedValue({ kind: "found", game: FOUND });
+
+ const types = sectionsOf(await bodyFor("1942")).map((child) => child.type);
+
+ expect(types).toEqual([
+ GameHero,
+ GameOverview,
+ ScreenshotGallery,
+ GameMoreLikeThisSection,
+ GameFacts,
+ ]);
+ });
+
+ it("renders the hero alone for a game IGDB has nothing more on (AC-1, AC-11)", async () => {
+ const BARE = {
+ ...FOUND,
+ summary: null,
+ artUrl: null,
+ trailer: null,
+ similarGameIds: [],
+ developers: [],
+ };
+ loadGame.mockResolvedValue({ kind: "found", game: BARE });
+
+ const [hero, ...sections] = sectionsOf(await bodyFor("94975"));
+
+ expect(hero.props).toMatchObject({ artUrl: null, trailer: null });
+ expect(
+ renderToStaticMarkup(createElement(Fragment, null, ...sections)),
+ ).toBe("");
+ });
+
it("puts the status control in the hero's tracking slot, in its own null fallback boundary (spec 0026, AC-1)", async () => {
loadGame.mockResolvedValue({ kind: "found", game: FOUND });
const article = await bodyFor("1942");
- const hero = article.props.children as Element;
+ const hero = sectionsOf(article)[0];
const tracking = hero.props.tracking as Element;
const slot = tracking.props.children as Element;
diff --git a/app/games/[id]/page.tsx b/app/games/[id]/page.tsx
index 92c9319c..8ec5b784 100644
--- a/app/games/[id]/page.tsx
+++ b/app/games/[id]/page.tsx
@@ -3,7 +3,11 @@ import { notFound } from "next/navigation";
import { Suspense } from "react";
import { GameDetailSkeleton } from "@/components/game/game-detail-skeleton";
+import { GameFacts } from "@/components/game/game-facts";
import { GameHero } from "@/components/game/game-hero";
+import { GameMoreLikeThisSection } from "@/components/game/game-more-like-this";
+import { GameOverview } from "@/components/game/game-overview";
+import { ScreenshotGallery } from "@/components/game/screenshot-gallery";
import { RetryLink } from "@/components/retry-link";
import { StatePanel } from "@/components/state-panel";
import { GameTrackingSlot } from "@/components/tracking/game-tracking-slot";
@@ -63,8 +67,12 @@ export async function generateMetadata({
*
* Nothing here reads a cookie, a header, the session or the visitor's region
* (AC-9). The status control reads the session in its own Suspense boundary
- * through the hero's `tracking` slot (spec 0026, AC-1), and G9 adds the
- * sections under the hero.
+ * through the hero's `tracking` slot (spec 0026, AC-1).
+ *
+ * Spec 0031 adds the backdrop and Trailer button to the hero, then Overview,
+ * Screenshots, More like this and Facts, in that order (AC-1). All but More
+ * like this come from the one `getGame` read; that section streams its own
+ * batch read in its own boundary (AC-8).
*/
export default function GamePage({ params }: PageProps<"/games/[id]">) {
return (
@@ -113,6 +121,8 @@ async function GameDetail({
platforms={game.platforms}
communityRating={game.communityRating}
communityRatingCount={game.communityRatingCount}
+ artUrl={game.artUrl}
+ trailer={game.trailer}
tracking={
}
/>
+
+
+
+
);
}
diff --git a/app/showcase/page.tsx b/app/showcase/page.tsx
index 5f06c7c9..d8a07590 100644
--- a/app/showcase/page.tsx
+++ b/app/showcase/page.tsx
@@ -25,6 +25,9 @@ import {
ListEmptyPanel,
ListErrorPanel,
} from "@/components/catalog/list-panels";
+import { GameFacts } from "@/components/game/game-facts";
+import { GameOverview } from "@/components/game/game-overview";
+import { ScreenshotGallery } from "@/components/game/screenshot-gallery";
import { GlassPill } from "@/components/glass-pill";
import { IconKey } from "@/components/layout/icon-key";
import { LibraryNavView } from "@/components/layout/library-nav";
@@ -102,6 +105,56 @@ const LIGHT_POSTER =
'',
);
+/**
+ * Flat 16:9 stand ins for IGDB screenshots, so the showcase needs no network.
+ * Shades of SVG's default fill, so no colour value lives outside globals.css.
+ */
+const SCREENSHOTS = [0.2, 0.4, 0.6, 0.8, 1].map(
+ (opacity) =>
+ "data:image/svg+xml;utf8," +
+ encodeURIComponent(
+ ``,
+ ),
+);
+
+/** Every Facts entry, with a quarter date and a TBA, for the showcase (AC-16). */
+const GAME_FACTS: React.ComponentProps = {
+ gameModes: [
+ { id: 1, name: "Single player" },
+ { id: 2, name: "Multiplayer" },
+ ],
+ playerPerspectives: [{ id: 2, name: "Third person" }],
+ themes: [{ id: 1, name: "Action" }],
+ franchises: [{ id: 452, name: "The Witcher" }],
+ series: [{ id: 62, name: "The Witcher" }],
+ gameEngines: [
+ { id: 24, name: "REDengine" },
+ { id: 25, name: "REDengine 3" },
+ ],
+ platformReleases: [
+ {
+ platformId: 6,
+ platform: "PC (Microsoft Windows)",
+ release: { precision: "day", date: "2015-05-19" },
+ },
+ {
+ platformId: 508,
+ platform: "Nintendo Switch 2",
+ release: { precision: "quarter", year: 2027, quarter: 1 },
+ },
+ {
+ platformId: 169,
+ platform: "Xbox Series X|S",
+ release: { precision: "month", date: "2027-06-01" },
+ },
+ {
+ platformId: 900,
+ platform: "PlayStation 6",
+ release: { precision: "tba" },
+ },
+ ],
+};
+
function Section({
title,
note,
@@ -465,6 +518,33 @@ export default function ShowcasePage() {
+
+
+
+
+
+
+
+
+
}) {
const shown = facts.filter((fact): fact is Fact => fact !== null);
@@ -182,4 +184,14 @@ function NetworkList({ networks }: { networks: Company[] }) {
);
}
-export { AgeRatingChip, MovieCrew, MovieFacts, ShowCrew, ShowFacts };
+export {
+ AgeRatingChip,
+ CrewLines,
+ type Fact,
+ FactsPanel,
+ MovieCrew,
+ MovieFacts,
+ ShowCrew,
+ ShowFacts,
+ textFact,
+};
diff --git a/components/catalog/title-extras.tsx b/components/catalog/title-extras.tsx
index 8d204509..26a3c550 100644
--- a/components/catalog/title-extras.tsx
+++ b/components/catalog/title-extras.tsx
@@ -10,6 +10,7 @@ import {
import { CarouselScroller } from "@/components/catalog/carousel-scroller";
import { CatalogListCards } from "@/components/catalog/catalog-list-cards";
import { type MediaType, titleHref } from "@/lib/catalog/media-type";
+import type { TaggedCatalogPage } from "@/lib/catalog-list-read";
import { getVisitorRegion } from "@/lib/region";
import {
getMovieExtras,
@@ -198,12 +199,32 @@ async function MoreLikeThisContent(props: TitleRef) {
}
if (extras.recommendations.paged.results.length === 0) return null;
+ return (
+
+ );
+}
+
+/**
+ * The filled row, shared with the game page (spec 0031, AC-8) so a game's
+ * similar games and a title's recommendations keep one card size, cap and
+ * eager count.
+ */
+function MoreLikeThisRow({
+ result,
+ returnPath,
+}: {
+ result: TaggedCatalogPage;
+ returnPath: string;
+}) {
return (
- Couldn't load {what} from TMDB.
+ Couldn't load {what} from {source}.
diff --git a/components/game/game-detail-skeleton.test.tsx b/components/game/game-detail-skeleton.test.tsx
index 44f49bd1..d106cb3e 100644
--- a/components/game/game-detail-skeleton.test.tsx
+++ b/components/game/game-detail-skeleton.test.tsx
@@ -5,11 +5,10 @@ import { DetailHeroSkeleton } from "@/components/catalog/detail-skeletons";
import { GameDetailSkeleton } from "@/components/game/game-detail-skeleton";
/**
- * covers: spec 0025, AC-9
+ * covers: spec 0025, AC-9; spec 0031, AC-12
*
- * A game has no backdrop and no tagline, so its skeleton reserves neither and
- * nothing jumps when the hero streams in. The movie and show skeletons keep
- * both, since the new props default on.
+ * A game has no tagline, so its skeleton reserves none. Since spec 0031 most
+ * games have art, so it reserves the backdrop and the Overview, as movies do.
*/
const skeletons = (container: HTMLElement) =>
container.querySelectorAll('[data-slot="skeleton"]');
@@ -27,12 +26,13 @@ describe("GameDetailSkeleton", () => {
);
});
- it("draws no backdrop band and no tagline line", () => {
+ it("draws the backdrop band and no tagline line", () => {
const game = render().container;
+ const hero = game.querySelector(":scope > div > div") as HTMLElement;
const withBoth = render().container;
- expect(backdrop(game)).toBeNull();
- expect(skeletons(game)).toHaveLength(skeletons(withBoth).length - 2);
+ expect(backdrop(game)).not.toBeNull();
+ expect(skeletons(hero)).toHaveLength(skeletons(withBoth).length - 1);
});
it("hides every placeholder from assistive technology", () => {
diff --git a/components/game/game-detail-skeleton.tsx b/components/game/game-detail-skeleton.tsx
index cd2afae8..95e7afb5 100644
--- a/components/game/game-detail-skeleton.tsx
+++ b/components/game/game-detail-skeleton.tsx
@@ -1,10 +1,14 @@
-import { DetailHeroSkeleton } from "@/components/catalog/detail-skeletons";
+import {
+ DetailHeroSkeleton,
+ OverviewSkeleton,
+} from "@/components/catalog/detail-skeletons";
/**
- * The game page while IGDB answers (spec 0025, AC-9): the hero skeleton with
- * no backdrop and no tagline, the layout `GameHero` always renders, so nothing
- * jumps when the game streams in. Two meta rows, for the chips and the
- * platforms line.
+ * The game page while IGDB answers (spec 0025, AC-9; spec 0031, AC-12): the
+ * hero skeleton with a backdrop, since most games have art, and no tagline,
+ * which games never have, then the Overview. A game with no art moves up
+ * when it streams in, the tradeoff movies accept. Two meta rows, for the
+ * chips and the platforms line.
*/
function GameDetailSkeleton() {
return (
@@ -16,7 +20,8 @@ function GameDetailSkeleton() {
Loading game details
-
+
+
);
}
diff --git a/components/game/game-facts.test.tsx b/components/game/game-facts.test.tsx
new file mode 100644
index 00000000..21291ffe
--- /dev/null
+++ b/components/game/game-facts.test.tsx
@@ -0,0 +1,105 @@
+import { render, screen } from "@testing-library/react";
+import type * as React from "react";
+import { describe, expect, it } from "vitest";
+
+import { GameFacts } from "./game-facts";
+
+/**
+ * covers: spec 0031, AC-9, AC-10, AC-11
+ *
+ * Facts lists only what IGDB has, pluralises a label by its count, and prints
+ * each platform's release at IGDB's precision with TBA last.
+ */
+const NONE = {
+ gameModes: [],
+ playerPerspectives: [],
+ themes: [],
+ franchises: [],
+ series: [],
+ gameEngines: [],
+ platformReleases: [],
+};
+
+const value = (label: string) => screen.getByText(label).nextSibling;
+
+const ALL: React.ComponentProps = {
+ gameModes: [
+ { id: 1, name: "Single player" },
+ { id: 2, name: "Multiplayer" },
+ ],
+ playerPerspectives: [{ id: 2, name: "Third person" }],
+ themes: [{ id: 1, name: "Action" }],
+ franchises: [{ id: 452, name: "The Witcher" }],
+ series: [{ id: 62, name: "The Witcher" }],
+ gameEngines: [{ id: 24, name: "REDengine" }],
+ platformReleases: [
+ {
+ platformId: 6,
+ platform: "PC",
+ release: { precision: "day", date: "2015-05-19" },
+ },
+ {
+ platformId: 508,
+ platform: "Switch 2",
+ release: { precision: "quarter", year: 2027, quarter: 1 },
+ },
+ {
+ platformId: 900,
+ platform: "PlayStation 6",
+ release: { precision: "tba" },
+ },
+ ],
+};
+
+describe("GameFacts", () => {
+ it("lists every fact in order, joined in IGDB's order", () => {
+ render();
+
+ const labels = [...document.querySelectorAll("dt")].map(
+ (dt) => dt.textContent,
+ );
+ expect(labels).toEqual([
+ "Game modes",
+ "Player perspectives",
+ "Themes",
+ "Franchise",
+ "Series",
+ "Game engine",
+ "Release dates",
+ ]);
+ expect(value("Game modes")).toHaveTextContent("Single player, Multiplayer");
+ const lines = [...document.querySelectorAll("dd li")].map(
+ (li) => li.textContent,
+ );
+ expect(lines).toEqual([
+ "PC: May 19, 2015",
+ "Switch 2: Q1 2027",
+ "PlayStation 6: TBA",
+ ]);
+ });
+
+ it("uses the plural label for more than one franchise or engine", () => {
+ render(
+ ,
+ );
+
+ expect(value("Franchises")).toHaveTextContent("A, B");
+ expect(value("Game engines")).toHaveTextContent("X, Y");
+ });
+
+ it("leaves the panel out with no facts", () => {
+ const { container } = render();
+
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/components/game/game-facts.tsx b/components/game/game-facts.tsx
new file mode 100644
index 00000000..4f7cbec2
--- /dev/null
+++ b/components/game/game-facts.tsx
@@ -0,0 +1,85 @@
+import {
+ type Fact,
+ FactsPanel,
+ textFact,
+} from "@/components/catalog/title-details";
+import { formatGameReleaseDate } from "@/lib/format";
+import type { Game, GameRef } from "@/lib/igdb";
+
+/** Names joined in IGDB's order, or null with none (spec 0031, AC-9). */
+function names(refs: GameRef[]): string | null {
+ return refs.length === 0 ? null : refs.map((ref) => ref.name).join(", ");
+}
+
+/** `singular` for one entry, `plural` for more (AC-9). */
+function label(refs: GameRef[], singular: string, plural: string): string {
+ return refs.length > 1 ? plural : singular;
+}
+
+/**
+ * One line per platform, `{platform}: {date}`, already sorted by the module
+ * with TBA last (AC-10). No cap: a game lists a handful of platforms.
+ */
+function releaseDatesFact(
+ platformReleases: Game["platformReleases"],
+): Fact | null {
+ if (platformReleases.length === 0) return null;
+ return {
+ label: "Release dates",
+ value: (
+
+ ),
+ };
+}
+
+/**
+ * A game's Facts (spec 0031, AC-9), all from `getGame`, through the shared
+ * `FactsPanel`. Each entry only when IGDB has a value, and the panel is left
+ * out with none (AC-11).
+ */
+function GameFacts({
+ gameModes,
+ playerPerspectives,
+ themes,
+ franchises,
+ series,
+ gameEngines,
+ platformReleases,
+}: Pick<
+ Game,
+ | "gameModes"
+ | "playerPerspectives"
+ | "themes"
+ | "franchises"
+ | "series"
+ | "gameEngines"
+ | "platformReleases"
+>) {
+ return (
+
+ );
+}
+
+export { GameFacts };
diff --git a/components/game/game-hero.test.tsx b/components/game/game-hero.test.tsx
index ed990aef..357c228c 100644
--- a/components/game/game-hero.test.tsx
+++ b/components/game/game-hero.test.tsx
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
import { GameHero } from "@/components/game/game-hero";
/**
- * covers: spec 0025, AC-1 to AC-5
+ * covers: spec 0025, AC-1 to AC-5; spec 0031, AC-3, AC-4
*
* The first page built from IGDB data, so every missing value is asserted:
* absent means absent or stated, never `TBA`, a zero or today's date.
@@ -26,6 +26,8 @@ const FULL = {
],
communityRating: 7.946,
communityRatingCount: 1234,
+ artUrl: null,
+ trailer: null,
};
const EMPTY = {
@@ -37,6 +39,8 @@ const EMPTY = {
platforms: [],
communityRating: null,
communityRatingCount: null,
+ artUrl: null,
+ trailer: null,
};
describe("GameHero", () => {
@@ -149,4 +153,29 @@ describe("GameHero", () => {
expect(images).toHaveLength(1);
expect(images[0]).toHaveAttribute("alt", "");
});
+
+ it("draws the game's art as the backdrop when it has some (spec 0031, AC-3)", () => {
+ const art = "https://images.igdb.com/igdb/image/upload/t_1080p/ar1.jpg";
+ const { container } = render();
+
+ const backdrop = container.querySelector('[data-slot="detail-backdrop"]');
+ expect(backdrop).not.toBeNull();
+ expect(backdrop?.querySelector("img")?.getAttribute("src")).toContain(
+ "images.igdb.com",
+ );
+ });
+
+ it("offers the Trailer button only when the game has a trailer (spec 0031, AC-4)", () => {
+ const { rerender } = render();
+ expect(screen.queryByRole("button", { name: "Trailer" })).toBeNull();
+
+ rerender(
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Trailer" })).toBeInTheDocument();
+ expect(document.querySelector("iframe")).toBeNull();
+ });
});
diff --git a/components/game/game-hero.tsx b/components/game/game-hero.tsx
index 2d2052e7..c44517a3 100644
--- a/components/game/game-hero.tsx
+++ b/components/game/game-hero.tsx
@@ -3,6 +3,7 @@ import type * as React from "react";
import { DetailHero, IgdbRatingBlock } from "@/components/catalog/detail-hero";
import { GenreChips, MetaLine } from "@/components/catalog/detail-hero-meta";
+import { TrailerButton } from "@/components/catalog/trailer-button";
import { formatAirDate } from "@/lib/format";
import type { Game, GameType } from "@/lib/igdb";
@@ -22,6 +23,8 @@ type GameHeroProps = {
| "platforms"
| "communityRating"
| "communityRatingCount"
+ | "artUrl"
+ | "trailer"
>;
/**
@@ -37,9 +40,10 @@ const RELEASE_KIND_LABELS: Partial> = {
};
/**
- * The top of a game page (spec 0025, AC-1): the shared `DetailHero` in its no
- * backdrop layout, because G3 reads no IGDB artwork, and with no tagline,
- * because games have none.
+ * The top of a game page (spec 0025, AC-1): the shared `DetailHero` with no
+ * tagline, because games have none. The backdrop is the game's wide art, and
+ * a game with none keeps the no backdrop layout; the Trailer button shows
+ * only when IGDB has a usable trailer (spec 0031, AC-3, AC-4).
*
* The meta row and the platforms line both go in the `meta` slot, so they sit
* above the IGDB rating as the AC orders them. Every missing value is left
@@ -54,6 +58,8 @@ function GameHero({
platforms,
communityRating,
communityRatingCount,
+ artUrl,
+ trailer,
tracking,
}: GameHeroProps) {
const meta = [
@@ -66,13 +72,22 @@ function GameHero({
title={name}
tagline={null}
posterUrl={coverUrl}
- backdropUrl={null}
+ backdropUrl={artUrl}
fallbackIcon={Gamepad2Icon}
meta={}
rating={
}
tracking={tracking}
+ actions={
+ trailer === null ? undefined : (
+
+ )
+ }
/>
);
}
diff --git a/components/game/game-more-like-this.test.tsx b/components/game/game-more-like-this.test.tsx
new file mode 100644
index 00000000..fdc26dd7
--- /dev/null
+++ b/components/game/game-more-like-this.test.tsx
@@ -0,0 +1,133 @@
+import { render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * covers: spec 0031, AC-8
+ *
+ * The batch read and the cards are replaced; `IgdbError` is the real one.
+ * IGDB's similar games order wins over the batch's, missing and hidden games
+ * are dropped before the cap of 12, and only an `IgdbError` becomes the error
+ * line.
+ */
+const readBatch = vi.fn();
+
+vi.mock("@/lib/igdb", async () => ({
+ IgdbError: (await import("@/lib/igdb/errors")).IgdbError,
+ getGamesByIds: (...args: unknown[]) => readBatch(...args),
+}));
+// The shared frame lives beside the TMDB extras, which are never read here.
+vi.mock("@/lib/tmdb", () => ({ TmdbError: class {} }));
+vi.mock("@/lib/region", () => ({ getVisitorRegion: vi.fn() }));
+vi.mock("@/components/catalog/list-cards", () => ({
+ GameListCard: ({
+ game,
+ returnPath,
+ gridGameIds,
+ }: {
+ game: { name: string };
+ returnPath: string;
+ gridGameIds: number[];
+ }) => (
+
+ {game.name}
+
+ ),
+}));
+
+const { IgdbError } = await import("@/lib/igdb/errors");
+const { GameMoreLikeThisContent } = await import("./game-more-like-this");
+
+const game = (id: number, hidden = false) => ({
+ id,
+ name: `Game ${id}`,
+ hidden,
+});
+
+beforeEach(() => {
+ // The row's scroller measures itself; jsdom has no ResizeObserver.
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+ disconnect() {}
+ },
+ );
+});
+
+afterEach(() => readBatch.mockReset());
+
+async function renderFor(similarGameIds: number[]) {
+ render(
+ <>{await GameMoreLikeThisContent({ gameId: 1942, similarGameIds })}>,
+ );
+}
+
+const names = () =>
+ [...document.querySelectorAll("li span")].map((span) => span.textContent);
+
+describe("GameMoreLikeThisContent", () => {
+ it("keeps IGDB's order although the batch comes back shuffled", async () => {
+ readBatch.mockResolvedValue({
+ found: [game(3), game(1), game(2)],
+ missingIds: [],
+ });
+
+ await renderFor([2, 3, 1]);
+
+ expect(readBatch).toHaveBeenCalledTimes(1);
+ expect(readBatch).toHaveBeenCalledWith([2, 3, 1]);
+ expect(
+ screen.getByRole("heading", { name: "More like this" }),
+ ).toBeVisible();
+ expect(names()).toEqual(["Game 2", "Game 3", "Game 1"]);
+ expect(screen.getByText("Game 2")).toHaveAttribute(
+ "data-return",
+ "/games/1942",
+ );
+ });
+
+ it("drops missing and hidden games before keeping 12", async () => {
+ const ids = Array.from({ length: 16 }, (_, index) => index + 1);
+ readBatch.mockResolvedValue({
+ found: ids.filter((id) => id !== 2).map((id) => game(id, id === 1)),
+ missingIds: [2],
+ });
+
+ await renderFor(ids);
+
+ expect(names()).toEqual(ids.slice(2, 14).map((id) => `Game ${id}`));
+ });
+
+ it("leaves the section out when every game is hidden or missing", async () => {
+ readBatch.mockResolvedValue({ found: [game(5, true)], missingIds: [6] });
+
+ expect(
+ await GameMoreLikeThisContent({ gameId: 1942, similarGameIds: [5, 6] }),
+ ).toBeNull();
+ });
+
+ it("keeps its heading with an IGDB error line and Retry on an IgdbError", async () => {
+ readBatch.mockRejectedValue(new IgdbError("upstream", "games", "x"));
+
+ await renderFor([5]);
+
+ expect(
+ screen.getByRole("heading", { name: "More like this" }),
+ ).toBeVisible();
+ expect(
+ screen.getByText("Couldn't load similar games from IGDB."),
+ ).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Try again" })).toHaveAttribute(
+ "href",
+ "/games/1942",
+ );
+ });
+
+ it("rethrows any other error", async () => {
+ readBatch.mockRejectedValue(new TypeError("bug"));
+
+ await expect(
+ GameMoreLikeThisContent({ gameId: 1942, similarGameIds: [5] }),
+ ).rejects.toBeInstanceOf(TypeError);
+ });
+});
diff --git a/components/game/game-more-like-this.tsx b/components/game/game-more-like-this.tsx
new file mode 100644
index 00000000..8b14d22d
--- /dev/null
+++ b/components/game/game-more-like-this.tsx
@@ -0,0 +1,91 @@
+import { Suspense } from "react";
+
+import {
+ CarouselRowSkeleton,
+ ROW_SIZE,
+} from "@/components/catalog/carousel-row-states";
+import {
+ MoreLikeThisFrame,
+ MoreLikeThisRow,
+} from "@/components/catalog/title-extras";
+import { SectionErrorLine } from "@/components/catalog/title-section";
+import { CATALOG_SOURCES, gameHref } from "@/lib/catalog/media-type";
+import { type GameSummary, getGamesByIds, IgdbError } from "@/lib/igdb";
+
+type MoreLikeThisProps = { gameId: number; similarGameIds: number[] };
+
+/**
+ * A game's More like this (spec 0031, AC-8): IGDB's similar games as home row
+ * cards, through the same `CatalogListCards` as a games landing row, so each
+ * card carries the G8 status badge and Want to play bookmark. It streams in
+ * its own boundary, since it is the one section `getGame` cannot feed. With
+ * no ids the section is left out without a skeleton.
+ */
+function GameMoreLikeThisSection(props: MoreLikeThisProps) {
+ if (props.similarGameIds.length === 0) return null;
+ return (
+
+
+
+ }
+ >
+
+
+ );
+}
+
+/**
+ * The section's body, exported apart from its boundary so tests can await it.
+ * One cached batch read for every id; `found` has no promised order, so the
+ * cards follow `similarGameIds`. Missing and hidden games are dropped before
+ * the cap of 12, so a hidden game never costs a slot. Only an `IgdbError`
+ * becomes the error line; anything else is a bug and is rethrown. The empty
+ * check lives once, in the boundary: with no ids the batch makes no request
+ * and this renders nothing anyway.
+ */
+async function GameMoreLikeThisContent({
+ gameId,
+ similarGameIds,
+}: MoreLikeThisProps) {
+ let found: GameSummary[];
+ try {
+ ({ found } = await getGamesByIds(similarGameIds));
+ } catch (error) {
+ if (!(error instanceof IgdbError)) throw error;
+ return (
+
+
+
+ );
+ }
+
+ const byId = new Map(found.map((game) => [game.id, game]));
+ const results = similarGameIds
+ .map((id) => byId.get(id))
+ .filter((game): game is GameSummary => game !== undefined && !game.hidden)
+ .slice(0, ROW_SIZE);
+ if (results.length === 0) return null;
+
+ return (
+
+ );
+}
+
+export { GameMoreLikeThisContent, GameMoreLikeThisSection };
diff --git a/components/game/game-overview.test.tsx b/components/game/game-overview.test.tsx
new file mode 100644
index 00000000..430b6101
--- /dev/null
+++ b/components/game/game-overview.test.tsx
@@ -0,0 +1,62 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { GameOverview } from "./game-overview";
+
+/**
+ * covers: spec 0031, AC-5, AC-11
+ *
+ * Each part of the Overview shows only when IGDB has it, with no "No overview
+ * available" line, and the section goes only when every part is missing.
+ */
+const NONE = {
+ summary: null,
+ storyline: null,
+ developers: [],
+ publishers: [],
+};
+
+describe("GameOverview", () => {
+ it("shows the summary alone, with no Story heading or company lines", () => {
+ render();
+
+ expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
+ "Overview",
+ );
+ expect(screen.getByText(/Line one\./)).toHaveClass("whitespace-pre-line");
+ expect(screen.queryByRole("heading", { name: "Story" })).toBeNull();
+ expect(screen.queryByText("Developed by")).toBeNull();
+ });
+
+ it("shows the storyline under its own Story heading", () => {
+ render();
+
+ expect(
+ screen.getByRole("heading", { level: 3, name: "Story" }),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Geralt searches for Ciri.")).toBeInTheDocument();
+ });
+
+ it("shows the company lines alone, a company in both lines when IGDB says so", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Developed by").nextSibling).toHaveTextContent(
+ "CD Projekt RED",
+ );
+ expect(screen.getByText("Published by").nextSibling).toHaveTextContent(
+ "CD Projekt RED, WB Games",
+ );
+ });
+
+ it("renders nothing at all with nothing to show", () => {
+ const { container } = render();
+
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/components/game/game-overview.tsx b/components/game/game-overview.tsx
new file mode 100644
index 00000000..4d2e68e6
--- /dev/null
+++ b/components/game/game-overview.tsx
@@ -0,0 +1,55 @@
+import { CrewLines } from "@/components/catalog/title-details";
+import { SECTION_HEADING_CLASS } from "@/components/catalog/title-section";
+import type { Game } from "@/lib/igdb";
+
+/** The movie overview's paragraph look, keeping IGDB's line breaks (AC-5). */
+const PARAGRAPH_CLASS =
+ "text-base leading-relaxed whitespace-pre-line text-text-secondary";
+
+/**
+ * A game's Overview (spec 0031, AC-5): the summary, then IGDB's storyline
+ * under its own `Story` heading, then who made and published it, in the
+ * movie page's crew line look. Each part is left out when IGDB has nothing,
+ * with no "No overview available" line, and the whole section only when
+ * every part is missing (AC-11).
+ */
+function GameOverview({
+ summary,
+ storyline,
+ developers,
+ publishers,
+}: Pick) {
+ if (
+ summary === null &&
+ storyline === null &&
+ developers.length === 0 &&
+ publishers.length === 0
+ ) {
+ return null;
+ }
+
+ return (
+
+
+ Overview
+
+ {summary === null ? null : (
+
{summary}
+ )}
+ {storyline === null ? null : (
+
+
Story
+
{storyline}
+
+ )}
+
+
+ );
+}
+
+export { GameOverview };
diff --git a/components/game/screenshot-gallery.test.tsx b/components/game/screenshot-gallery.test.tsx
new file mode 100644
index 00000000..9f2a8a48
--- /dev/null
+++ b/components/game/screenshot-gallery.test.tsx
@@ -0,0 +1,128 @@
+import { render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { ScreenshotGallery } from "./screenshot-gallery";
+
+/**
+ * covers: spec 0031, AC-6, AC-7, AC-13, AC-15
+ *
+ * Against the real Base UI dialog: one controlled dialog serves every
+ * thumbnail, moves without wrapping, answers the arrow keys and Escape, and
+ * returns focus to the thumbnail of the image on screen when it closes. Only
+ * that one image is in the dialog.
+ */
+const URLS = Array.from(
+ { length: 4 },
+ (_, index) =>
+ `https://images.igdb.com/igdb/image/upload/t_1080p/sc${index}.jpg`,
+);
+
+function renderGallery(urls = URLS) {
+ return render(
+ ,
+ );
+}
+
+const thumb = (n: number) =>
+ screen.getByRole("button", { name: `Open screenshot ${n} of 4` });
+
+async function openAt(n: number) {
+ const user = userEvent.setup();
+ renderGallery();
+ await user.click(thumb(n));
+ const dialog = await screen.findByRole("dialog", {
+ name: "The Witcher 3 screenshots",
+ });
+ return { user, dialog };
+}
+
+beforeEach(() => {
+ // The row's scroller measures itself; jsdom has no ResizeObserver.
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+ disconnect() {}
+ },
+ );
+});
+
+describe("ScreenshotGallery", () => {
+ it("renders a labelled, decorative thumbnail per screenshot from IGDB", () => {
+ renderGallery();
+
+ expect(
+ screen.getByRole("heading", { level: 2, name: "Screenshots" }),
+ ).toBeInTheDocument();
+ for (const n of [1, 2, 3, 4]) {
+ const image = within(thumb(n)).getByRole("presentation", {
+ hidden: true,
+ });
+ expect(image).toHaveAttribute("alt", "");
+ expect(image.getAttribute("src")).toContain("images.igdb.com");
+ expect(image).toHaveAttribute("loading", "lazy");
+ }
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ it("renders nothing with no screenshots", () => {
+ const { container } = renderGallery([]);
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("opens on the chosen image, focus on Close, with one image mounted", async () => {
+ const { dialog } = await openAt(3);
+
+ expect(within(dialog).getByText("3 of 4")).toHaveAttribute(
+ "aria-live",
+ "polite",
+ );
+ const images = dialog.querySelectorAll("img");
+ expect(images).toHaveLength(1);
+ expect(images[0].getAttribute("src")).toContain("sc2");
+ await waitFor(() =>
+ expect(
+ within(dialog).getByRole("button", { name: "Close" }),
+ ).toHaveFocus(),
+ );
+ });
+
+ it("moves with Next and Previous, disabled at the ends, never wrapping", async () => {
+ const { user, dialog } = await openAt(1);
+ const previous = within(dialog).getByRole("button", {
+ name: "Previous screenshot",
+ });
+ const next = within(dialog).getByRole("button", {
+ name: "Next screenshot",
+ });
+
+ expect(previous).toBeDisabled();
+ await user.click(next);
+ await user.click(next);
+ await user.click(next);
+ expect(within(dialog).getByText("4 of 4")).toBeInTheDocument();
+ expect(next).toBeDisabled();
+ await user.click(previous);
+ expect(within(dialog).getByText("3 of 4")).toBeInTheDocument();
+ });
+
+ it("answers the arrow keys, and closes on Escape with focus on the image's thumbnail", async () => {
+ const { user, dialog } = await openAt(2);
+ await waitFor(() =>
+ expect(
+ within(dialog).getByRole("button", { name: "Close" }),
+ ).toHaveFocus(),
+ );
+
+ await user.keyboard("{ArrowRight}{ArrowRight}{ArrowRight}");
+ expect(screen.getByText("4 of 4")).toBeInTheDocument();
+ await user.keyboard("{ArrowLeft}");
+ expect(screen.getByText("3 of 4")).toBeInTheDocument();
+
+ await user.keyboard("{Escape}");
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+ expect(thumb(3)).toHaveFocus();
+ });
+});
diff --git a/components/game/screenshot-gallery.tsx b/components/game/screenshot-gallery.tsx
new file mode 100644
index 00000000..89019b49
--- /dev/null
+++ b/components/game/screenshot-gallery.tsx
@@ -0,0 +1,191 @@
+"use client";
+
+import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react";
+import Image from "next/image";
+import { useRef, useState } from "react";
+
+import { CarouselScroller } from "@/components/catalog/carousel-scroller";
+import { SECTION_HEADING_CLASS } from "@/components/catalog/title-section";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogTitle,
+} from "@/components/ui/dialog";
+
+/** A thumbnail's width, mirrored by `THUMB_SIZES` (spec 0031, AC-6). */
+const THUMB_CLASS = "relative aspect-video w-64 shrink-0 snap-start sm:w-72";
+const THUMB_SIZES = "(min-width: 640px) 288px, 256px";
+
+/** The dialog image's width at each breakpoint, the trailer panel's (AC-7). */
+const DIALOG_IMAGE_SIZES = "(min-width: 768px) min(90vw, 1100px), 100vw";
+
+/**
+ * A game's Screenshots row and the dialog that opens one larger (spec 0031,
+ * AC-6, AC-7).
+ *
+ * One controlled dialog serves every thumbnail, so it is not a
+ * `Dialog.Trigger`: focus return is `finalFocus`, pointed at the thumbnail of
+ * the image showing when the dialog closes, which is scrolled into view
+ * first. Only the image on screen is mounted (`key={index}`), so nothing
+ * beside it is requested. Focus opens on Close, as in the trailer dialog.
+ * Every image loads from IGDB through the image loader (AC-13).
+ */
+function ScreenshotGallery({
+ name,
+ screenshotUrls,
+}: {
+ name: string;
+ screenshotUrls: string[];
+}) {
+ const [open, setOpen] = useState(false);
+ const [index, setIndex] = useState(0);
+ const thumbRefs = useRef>([]);
+ const closeRef = useRef(null);
+ const previousRef = useRef(null);
+ const nextRef = useRef(null);
+
+ const total = screenshotUrls.length;
+ if (total === 0) return null;
+
+ const isFirst = index === 0;
+ const isLast = index === total - 1;
+
+ /**
+ * Moves by one image, never wrapping. When the move disables the button
+ * that made it, focus goes to the other one rather than being lost.
+ */
+ function move(step: -1 | 1) {
+ const target = index + step;
+ if (target < 0 || target >= total) return;
+ setIndex(target);
+ if (step === 1 && target === total - 1 && total > 1) {
+ if (document.activeElement === nextRef.current) {
+ previousRef.current?.focus();
+ }
+ }
+ if (step === -1 && target === 0 && total > 1) {
+ if (document.activeElement === previousRef.current) {
+ nextRef.current?.focus();
+ }
+ }
+ }
+
+ function returnFocus() {
+ const thumb = thumbRefs.current[index];
+ if (!thumb) return true;
+ thumb.scrollIntoView?.({ inline: "nearest", block: "nearest" });
+ return thumb;
+ }
+
+ return (
+
+
+ Screenshots
+
+ }
+ seeAll={null}
+ >
+
+ {screenshotUrls.map((url, position) => (
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+export { ScreenshotGallery };
diff --git a/docs/scope/games.md b/docs/scope/games.md
index 754bd2b2..9dd3ce71 100644
--- a/docs/scope/games.md
+++ b/docs/scope/games.md
@@ -32,7 +32,7 @@ _This is the second scope for BeStats. The movie and TV scope lives in [scope.md
| G6 | Games home and list pages | Slice G3 | done |
| G7 | Game search and filters | Slice G4 | done |
| G8 | Game cards everywhere | Slice G4 | done |
-| G9 | Richer game page | Slice G5 | planned |
+| G9 | Richer game page | Slice G5 | done |
| G10 | Games launch | Slice G6 | planned |
## Foundations
@@ -162,10 +162,19 @@ spec [0030](../specs/0030-game-cards-everywhere/index.md) · code in `components
## Slice G5: Richer game page
-### G9. Richer game page · needs a decision
-The game page grows its sections in this order: hero, summary, developer and publisher, screenshots, trailer, similar games, Facts.
+### G9. Richer game page · done
+The game page grows its sections in this order: hero (with an art backdrop and the Trailer button), summary, developer and publisher, screenshots, similar games, Facts.
**Done when:** every section renders from cached IGDB data and is omitted when IGDB has nothing, never invented; the trailer uses the existing `youtube-nocookie` dialog that loads only after a click; similar games use the home row card; Facts lists game modes, themes, player perspectives, franchise and the release date for each platform; the shell stays prerendered.
-- [ ] Design it (spec): `/architect richer game page`
+- [x] Design it (spec): `/architect richer game page`
+- [x] Build it: `/develop richer game page`
+ - [x] Thin thread and screenshots: art backdrop, the screenshot row and its dialog, AC-1, AC-3, AC-6, AC-7, AC-13, AC-15
+ - [x] Overview and trailer: storyline, developer and publisher lines, the hero Trailer button, AC-2, AC-4, AC-5, AC-11
+ - [x] More like this: one cached batch read through the G8 cards, with its own error line, AC-8
+ - [x] Facts and release dates: the lists, per platform dates at IGDB's precision, the live facts check, AC-9, AC-10, AC-14
+ - [x] Types, fixtures, skeleton, showcase and checks, AC-2, AC-12, AC-16
+- [x] Verify it: `/check verify richer game page`
+- [x] Test it: `/test richer game page`
+spec [0031](../specs/0031-richer-game-page/index.md) · code in `components/game/`, `lib/igdb/`, `app/games/[id]/`
## Slice G6: Launch
@@ -179,7 +188,6 @@ Out of scope for the current build pass, kept so the plan stays honest.
- **Platform you play on, hours played, 100% completion** (from the 2026-10-17 grilling): skipped for the first pass
- **Dropped or Paused status**: left out, matching the removal of TV holds on 2026-10-10
- **Where to buy a game**: no store links in this pass
-- **Artwork backdrop on the game hero** (from spec 0025): G3 ships without one; G9 or a later spec decides
## Legend
diff --git a/docs/specs/0031-richer-game-page/index.md b/docs/specs/0031-richer-game-page/index.md
new file mode 100644
index 00000000..2a8e123d
--- /dev/null
+++ b/docs/specs/0031-richer-game-page/index.md
@@ -0,0 +1,183 @@
+# 0031. Richer game page
+
+**Date**: 2026-10-26
+**Status**: Accepted
+
+Scope feature: [G9. Richer game page](../../scope/games.md#g9-richer-game-page) · Beta tier
+
+## Summary
+
+This spec fills out `/games/{id}` below the hero, the way movie pages are filled out. The hero gets the game's art as a backdrop and a Trailer button. Under it come an Overview (summary, story, who made it and who published it), a row of screenshots you can open larger, More like this, and Facts. All of it except similar games comes from the same cached `getGame` request the hero already waits on, so the page still makes one IGDB call. Similar games need their covers and ratings, so they take one more cached batch read in their own loading boundary. Whatever IGDB does not have is left out, never filled in.
+
+## Requirements
+
+**User stories**:
+- As a visitor, I want a game page that tells me what the game is about, who made it, and what it looks like, so I can decide whether I want to play it.
+- As a visitor, I want to watch the trailer and look through screenshots without leaving the page.
+- As a visitor, I want to see similar games and add them to Want to play straight from their cards.
+- As a visitor, I want plain facts (modes, perspectives, franchise, release dates per platform) without guessed or invented values.
+
+**Acceptance criteria** (the contract):
+
+- **AC-1**: A found game's page renders, in this order: the hero, `Overview`, `Screenshots`, `More like this`, `Facts`. Each section is left out when IGDB has nothing for it (AC-5, AC-6, AC-8, AC-9). The one exception is More like this, which still shows its heading and an error line when its batch read fails (AC-8). A game with none of the new data renders the hero alone, as it does today.
+- **AC-2**: Everything except the similar games' cards comes from the single `getGame` request (one IGDB call, cached `hours`, as now). `IGDB_GAME_FIELDS` gains: `storyline`, `artworks.image_id`, `screenshots.image_id`, `videos.id`, `videos.name`, `videos.video_id`, `similar_games`, `involved_companies.company.id`, `involved_companies.company.name`, `involved_companies.developer`, `involved_companies.publisher`, `game_modes.name`, `player_perspectives.name`, `franchise.name`, `franchises.name`, `collections.name`, `game_engines.name`, `release_dates.date`, `release_dates.date_format`, `release_dates.platform.id`, `release_dates.platform.name`. Every new nested list is parsed loosely (`looseArray`), and each entry is checked on its own in `normalize.ts` the way `refs()` does, so one odd entry is dropped instead of failing `getGame`. Nothing is written to Postgres (no catalog cache, spec 0008).
+- **AC-3**: The hero shows a backdrop from `Game.artUrl`, the same rule as spec 0028 AC-6: the artwork with the lowest `image_id`, else the screenshot with the lowest, built at `t_1080p` and sized down `IGDB_ART_LADDER` by the image loader. With neither, the hero keeps the no backdrop layout of spec 0025. `tagline` stays `null`.
+- **AC-4**: The hero's `actions` slot shows the existing `TrailerButton` (the `youtube-nocookie.com` dialog whose iframe exists only while open) when the game has a trailer. The trailer is the video with the lowest `id` (IGDB promises no array order) among the videos whose `name` contains `trailer` and does not contain `gameplay`, both in any case, and whose `video_id` matches `^[A-Za-z0-9_-]{6,20}$`. With no such video (gameplay videos only, a "Gameplay Trailer", or none), there is no button. The dialog is labelled `{name} trailer`, and the iframe title uses the video's IGDB name.
+- **AC-5**: `Overview` (an `h2`) holds, in order: the summary as a paragraph; when IGDB also has a storyline, an `h3` `Story` and the storyline paragraph; then the company lines `Developed by` and `Published by` (the `CrewLines` look from the movie page). The `h2` uses `SECTION_HEADING_CLASS`, and the text reuses the movie overview's classes (`max-w-[65ch]`, `text-base leading-relaxed text-text-secondary`). Both paragraphs keep IGDB's line breaks (`whitespace-pre-line`). Company names follow IGDB's `involved_companies` order. Within each line they are deduplicated by `company.id`, falling back to the name ignoring case, and an entry with no name is dropped. A company flagged as both developer and publisher appears in both lines. A missing part is left out: there is no "No overview available" text. The section is left out only when the game has no summary, no storyline, no developer, and no publisher.
+- **AC-6**: `Screenshots` (an `h2`) is a `CarouselScroller` row of up to 12 screenshots. They are deduplicated by `image_id`, then ordered by `image_id` ascending, then capped. That order is stable but not hand picked, so the first thumbnail is not IGDB's featured shot; it matches `artUrl`'s rule. Each is a 16:9 thumbnail (`object-cover`, `fill`) in a fixed width wrapper (`w-64 sm:w-72 aspect-video`, `sizes="(min-width: 640px) 288px, 256px"`), loaded lazily through the IGDB loader. Each thumbnail is a button with the accessible name `Open screenshot {n} of {total}` and an image with `alt=""`. With no screenshots, the section is left out.
+- **AC-7**: Activating a thumbnail opens a screenshot dialog on that image. It is full screen below `md` and a panel from `md`, the same frame as the trailer dialog. It shows the dialog title `{name} screenshots`, the image (`object-contain`, at most the viewport), the position `{n} of {total}` (announced politely), Previous and Next buttons (44px targets, `disabled` at the first and last image, no wraparound), and the Close button. ArrowLeft and ArrowRight move between images and Escape closes. Focus starts on Close. On close, focus returns to the thumbnail of the image showing at that moment, scrolled into view (`scrollIntoView({ inline: "nearest", block: "nearest" })`). The dialog is controlled (`open` and `currentIndex` state), with `finalFocus` pointing at that thumbnail's ref, because one dialog serves 12 triggers. The arrow keys are handled on the popup. The image is one `next/image` with `fill`, `key={index}` and `sizes="(min-width: 768px) min(90vw, 1100px), 100vw"`, inside a `relative aspect-video` box capped at `max-h-[calc(100dvh-8rem)]`. Only the image on screen is requested, with no preloading of the images next to it.
+- **AC-8**: `More like this` sits in its own Suspense boundary, with the `CarouselRowSkeleton` in the section frame as its fallback. Its ids are `Game.similarGameIds`: IGDB's `similar_games` in order, deduplicated, without the game's own id. It makes one `getGamesByIds` call with all of them (well under the 500 limit). Because `found` comes back in no promised order, the content maps `found` by id and walks `similarGameIds` in order. It drops missing and `hidden` games first, then keeps the first 12, so a hidden game never costs a slot. These become home row cards. These are `GameListCard`s through `CatalogListCards`, given `{ mediaType: "game", paged: { page: 1, results, hasNextPage: false, totalResults: results.length } }` with `eagerCount={0}`, `limit={ROW_SIZE}`, `sizes={ROW_POSTER_SIZES}`, `itemClassName={ROW_ITEM_CLASS}` and no `releaseDay` (the G8 controls still show their own release pill). They carry the G8 badge, bookmark and chip slots and `returnPath` set to the game's page. There is no See all. With no ids, or none left after dropping, the section is left out. On an `IgdbError`, the heading stays and the line `Couldn't load similar games from IGDB.` shows with a Retry link to the game page. Any other error is rethrown.
+- **AC-9**: `Facts` uses the shared `FactsPanel`. Each entry appears only when it has a value, in this order:
+ - `Game modes`, `Player perspectives`, `Themes`: names joined with `, ` in IGDB's order.
+ - `Franchise`: the main `franchise` first, then the other `franchises`, deduplicated. The label becomes `Franchises` when there is more than one.
+ - `Series`: from `collections`.
+ - `Game engine`: the label becomes `Game engines` when there is more than one.
+ - `Release dates` (AC-10).
+
+ With none of these, the panel is left out.
+- **AC-10**: `Release dates` lists one line per platform, `{platform}: {date}`, as a plain `
` with no cap.
+ - Entries are grouped by `platform.id`. An entry with no platform, or with a platform that has no name, is skipped.
+ - An entry whose `date_format` is TBD counts as undated, even when IGDB sends a placeholder `date`. Any other entry with no `date` is skipped. An entry whose `date_format` is missing or not in `IGDB_DATE_FORMAT` is skipped, never read as a full day.
+ - Each platform shows its dated entry with the lowest timestamp, across all regions, at the precision IGDB states: day `Mar 20, 2020`, month `Mar 2020`, quarter `Q1 2027`, year `2027`.
+ - A platform with TBD entries and no dated one reads `TBA`, because IGDB itself says TBD. A platform with no usable entry gets no line (the hero's platforms line still names it).
+ - Lines are sorted by timestamp ascending, with `TBA` last, and ties broken by platform name (English, case insensitive).
+ - The year and month come from the UTC timestamp, and the quarter from `date_format`.
+- **AC-11**: Nothing is invented. Every empty list or blank value is left out. No section shows `Unknown`, `0`, `TBA` (except AC-10's IGDB stated TBD), or today's date. A hidden game still never reaches these sections (spec 0025, AC-7).
+- **AC-12**: The shell stays prerendered. `pnpm build` lists `/games/[id]` as partially prerendered and makes no IGDB call. `GameDetailSkeleton` becomes `DetailHeroSkeleton` with the backdrop on and the tagline off, plus `OverviewSkeleton` and its `role="status"` line. Nothing new reads a cookie, header, session or region outside the existing card and tracking slots. `generateMetadata` is unchanged: `noindex` until G10.
+- **AC-13**: The backdrop, thumbnails and dialog image all load from `images.igdb.com` through `lib/images/loader.ts` and `IGDB_ART_LADDER`, never through `/_next/image`. No `remotePatterns` and no new image host are added.
+- **AC-14**: `pnpm igdb:live` checks the new fields against a real game: each field in AC-2 parses, `involved_companies` carries the `developer` and `publisher` booleans, `videos.video_id` matches the YouTube id pattern, the pinned `date_format` ids (0 day, 1 month, 2 year, 3 to 6 Q1 to Q4, 7 TBD) match IGDB's `date_formats` endpoint, and on a game with a quarter entry, its UTC timestamp falls inside the stated quarter and year.
+- **AC-15**: Every new section and the dialog hold at 375px and 1280px wide: no horizontal page scroll, 44px touch targets, visible focus, and a dialog you can use with the keyboard alone.
+- **AC-16**: `/showcase` shows the screenshot row and dialog, the Overview with and without a storyline, and a Facts panel with a TBA and a quarter release date.
+
+## Decision
+
+**Chosen option**: Option 1: widen `getGame`, and read similar game cards through one cached batch.
+
+One IGDB request feeds the hero and every new section except More like this. That section streams in its own boundary from `getGamesByIds`, reusing the G8 cards. The Trailer button joins the hero, as on movie and show pages.
+
+**Implementation skills**: `next-cache-components-optimizer` (`vercel/next.js`, `.agents/skills/next-cache-components-optimizer/`) · `next-dev-loop` (`vercel/next.js`, `.agents/skills/next-dev-loop/`)
+
+## Rationale
+
+Reasoning and options: see [rationale.md](rationale.md).
+
+## Feature design
+
+**Data model sketch**: no schema change. The `Game` type (in `lib/igdb/types.ts`) gains these fields. Every list is `[]` when IGDB has none, and every text is `null`.
+
+| Field | Type | From |
+|---|---|---|
+| `storyline` | `string \| null` | `storyline` |
+| `artUrl` | `string \| null` | `artUrl(record)` (existing function) |
+| `screenshotUrls` | `string[]` (at most 12, `t_1080p`) | `screenshots`, `image_id` ascending |
+| `trailer` | `GameVideo \| null`, `{ key: string; name: string }` | `videos` (AC-4) |
+| `similarGameIds` | `number[]` | `similar_games` (AC-8) |
+| `developers` | `string[]` | `involved_companies` with `developer: true` |
+| `publishers` | `string[]` | `involved_companies` with `publisher: true` |
+| `gameModes`, `playerPerspectives`, `series`, `gameEngines` | `GameRef[]` | `game_modes`, `player_perspectives`, `collections`, `game_engines` |
+| `franchises` | `GameRef[]` | `franchise` then `franchises`, deduplicated by id |
+| `platformReleases` | `PlatformRelease[]`, already sorted (AC-10) | `release_dates` |
+
+`PlatformRelease` is `{ platformId: number; platform: string; release: GameReleaseDate }` (`platformId` is the grouping key and the list key, so two platforms IGDB names alike stay apart), and `GameReleaseDate` is one of:
+- `{ precision: "day" | "month" | "year"; date: string }` (a `YYYY-MM-DD` UTC day)
+- `{ precision: "quarter"; year: number; quarter: 1 | 2 | 3 | 4 }`
+- `{ precision: "tba" }`
+
+The `date_format` ids are pinned in `constants.ts` as `IGDB_DATE_FORMAT` (0 day, 1 month, 2 year, 3 to 6 Q1 to Q4, 7 TBD) and parsed with `optionalId`, so a bare id or an expanded `{ id }` both read. A null or unknown id drops the entry (AC-10). The live check confirms the ids. `similarGameIds` goes through the existing `ids()`, is deduplicated, and loses the game's own id. `artUrl` is computed from the full artwork and screenshot lists, never the capped 12. `imageListSchema` is exported from `schemas.ts` for `gameDetailSchema`.
+
+`formatGameReleaseDate` builds its text from fixed English month names and the UTC parts of the `YYYY-MM-DD` string, with no `Intl`, so CI (Node 22) and local (Node 24) print the same thing.
+
+**State transitions**: none new. The page keeps its four outcomes (spec 0025). More like this has its own: loading → cards | left out | error line.
+
+**API surface**:
+| Surface | Kind | Key inputs | Key outputs | Auth | Key errors |
+|---|---|---|---|---|---|
+| `getGame(id)` (widened) | cached read, `hours` | id | `Game` with the fields above | none | unchanged |
+| `getGamesByIds(ids)` | existing cached read, `hours` | `similarGameIds` | `BatchResult` | none | `IgdbError` → error line |
+| `GameHero` | server component | `"artUrl"` and `"trailer"` added to its `Pick` | `backdropUrl={artUrl}`, and `actions={}` when `trailer` is set | none | none |
+| `GameOverview` (`components/game/game-overview.tsx`) | server component | `summary`, `storyline`, `developers`, `publishers` | the section, or null | none | none |
+| `ScreenshotGallery` (`components/game/screenshot-gallery.tsx`) | client component | `name`, `screenshotUrls` | the row and the dialog | none | none |
+| `GameMoreLikeThisSection` and `GameMoreLikeThisContent` (`components/game/game-more-like-this.tsx`) | async server component in Suspense | `gameId`, `similarGameIds` | the row, nothing, or the error line | the G8 card slots read the session in their own boundaries | catches `IgdbError` (from `@/lib/igdb`) only; rethrows anything else, `IgdbValidationError` included |
+| `GameFacts` (`components/game/game-facts.tsx`) | server component | `Game` | `FactsPanel`, with `Fact \| null` entries built through `textFact`, and the release lines as a `
` value (`textFact` exported from `title-details.tsx`) | none | none |
+| `formatGameReleaseDate(release)` (`lib/format.ts`) | pure | `GameReleaseDate` | `Mar 20, 2020`, `Mar 2020`, `Q1 2027`, `2027`, `TBA` | none | none |
+| `SectionErrorLine` | existing | adds `source?: "TMDB" \| "IGDB"` (default `TMDB`) | `Couldn't load {what} from {source}.` | none | none |
+| `MoreLikeThisFrame`, `CrewLines` | existing, now exported | unchanged | unchanged | none | none |
+
+**Value sourcing**:
+| Action | Value | Source |
+|---|---|---|
+| Hero | backdrop | `Game.artUrl` → `IGDB_ART_LADDER` in the loader |
+| Hero | Trailer button key and name | `Game.trailer`, picked in `normalize.ts` (AC-4) |
+| Hero | trailer dialog title | `Game.name` |
+| Overview | summary, storyline | `Game.summary`, `Game.storyline` |
+| Overview | Developed by, Published by | `Game.developers`, `Game.publishers` |
+| Screenshots | images, `{n}`, `{total}` | `Game.screenshotUrls`, the index, `.length` |
+| Screenshot dialog | title | `Game.name` |
+| More like this | ids, order | `Game.similarGameIds` |
+| More like this | cards, badge, bookmark | `getGamesByIds` → `GameSummary`; G8 slots (`getGameGridStates`) |
+| More like this | Retry href | `gameHref(id)` |
+| Facts | each list | the matching `Game` field |
+| Facts | release lines | `Game.platformReleases` → `formatGameReleaseDate` |
+| Facts | quarter | `date_format` id via `IGDB_DATE_FORMAT` |
+| Facts | label plural | the list length |
+
+**Key invariants**:
+- One IGDB request per game page render for everything but the similar cards, and both reads are cached (AC-2, AC-8).
+- No section renders a value IGDB did not send (AC-11).
+- The YouTube iframe and every screenshot beyond the one on screen load only after a click (AC-4, AC-7).
+- Similar cards never show a hidden game (AC-8).
+- The page shell reads no request state (AC-12).
+
+**Security model**: public and read only. No new credentials or hosts. YouTube is already a listed processor, and the privacy text already says a trailer loads only after you press Trailer, so it covers game pages with no change. `video_id` is validated before it reaches the embed path. The similar cards read the session only inside the G8 slots, which are already private and never cached in shared caches.
+
+**Configuration required**: none new.
+
+**Critical test scenarios**:
+- `normalize.test.ts`: a full fixture yields every new field. The trailer pick skips a gameplay video and a bad id. The franchise dedupe works. Release dates pick the earliest per platform. A TBD entry with a placeholder date reads TBA, an entry with an unknown `date_format` or no platform is skipped, quarter is kept, and TBA sorts last. Companies are deduplicated by id. Screenshots are deduplicated before the cap. Verifies **AC-2**, **AC-4**, **AC-9**, **AC-10**
+- Empty fixture: every list is `[]` and every text is `null`. The page renders the hero only. Verifies **AC-1**, **AC-11**
+- `game-overview.test.tsx`: summary only, storyline only, companies only, a company in both lines, nothing at all. Verifies **AC-5**
+- `screenshot-gallery.test.tsx`: open on the third image, Next and Previous, disabled at the ends, the arrow keys, Escape, focus returns to the right thumbnail, and only one dialog image is in the DOM. Verifies **AC-6**, **AC-7**
+- `game-more-like-this.test.tsx`: IGDB order is kept even though the batch returns shuffled results, hidden and missing games are dropped, 12 is the cap, all hidden leaves the section out, `IgdbError` shows the error line, and a `TypeError` is rethrown. Verifies **AC-8**
+- `format.test.ts`: each precision formats correctly. Verifies **AC-10**
+- Build: `/games/[id]` is still partially prerendered. Verifies **AC-12**
+- Live: `pnpm igdb:live` passes the new field and `date_format` checks. Verifies **AC-14**
+
+## Build plan
+
+The build is ordered as a Tracer Bullet. The first task alone puts real new IGDB data on the real page from one end to the other, and each later task thickens one strand.
+
+1. [x] Thin thread: add `artworks.image_id` and `screenshots.image_id` to `IGDB_GAME_FIELDS`, `gameDetailSchema`, `Game` and `normalizeGame` (`artUrl`, `screenshotUrls`). Pass `backdropUrl` through `GameHero` and render a `Screenshots` row of plain, non interactive images on the page (no dead buttons before step 2). Check it in the running app against a real id, satisfies **AC-1**, **AC-3**, **AC-6**, **AC-13**
+2. [x] The screenshot dialog: `ScreenshotGallery` with the thumbnail buttons, the dialog, position text, Previous and Next, arrow keys and focus return, with tests, satisfies **AC-7**, **AC-15**
+3. [x] Overview: add `storyline` and the `involved_companies` fields, plus `developers` and `publishers` in normalize. Export `CrewLines`. Write `GameOverview` with tests, satisfies **AC-2**, **AC-5**, **AC-11**
+4. [x] Trailer: add the `videos` fields, the trailer pick, and the id check in normalize. `GameHero` passes `actions={}`, satisfies **AC-4**
+5. [x] More like this: add `similar_games` and `similarGameIds`. Export `MoreLikeThisFrame`. Add `source` to `SectionErrorLine`. Write `GameMoreLikeThisSection` through `CatalogListCards` with a game `TaggedCatalogPage`, with tests, satisfies **AC-8**
+6. [x] Facts lists: add the modes, perspectives, franchise(s), collections and engines fields and normalization, then `GameFacts` with the label plural rule, satisfies **AC-9**, **AC-11**
+7. [x] Release dates: pin `IGDB_DATE_FORMAT` in `constants.ts`, add the `release_dates` fields, `platformReleases` in normalize, and `formatGameReleaseDate` in `lib/format.ts`, with tests. Extend `igdb.live.ts` for every new field and the `date_format` ids, then run `pnpm igdb:live`, satisfies **AC-10**, **AC-14**
+8. [x] Types and fixtures: export `GameVideo`, `PlatformRelease` and `GameReleaseDate` from `index.ts` and update `surface.test.ts`. Capture a fresh full `getGame` fixture and update `games.test.ts` and `normalize.test.ts`, satisfies **AC-2**
+9. [x] States and checks: update `GameDetailSkeleton` (`DetailHeroSkeleton` with its existing `backdrop` prop on and `tagline={false}`, plus `OverviewSkeleton`; no skeleton component changes). Check every section at 375px and 1280px in the running app, including a game with no new data. Run `pnpm typecheck`, `pnpm lint`, `pnpm test` and `pnpm build`, and confirm `/games/[id]` is partially prerendered, satisfies **AC-12**, **AC-15**
+10. [x] Showcase: add the screenshot gallery, the Overview variants, and a Facts panel with TBA and quarter dates, satisfies **AC-16**
+
+## Consequences
+
+**Positive**:
+- Game pages reach parity with movie pages while still making one IGDB call per game.
+- The backdrop closes the deferred item from spec 0025.
+- Similar cards inherit G8's personal state and Want to play bookmark for free.
+
+**Negative / tradeoffs**:
+- The `getGame` response grows several times over (nested companies, release dates, videos). The hero now waits on a bigger payload, which is softened by the `hours` cache.
+- A failure in `getGame` takes every section down together. Only More like this fails on its own.
+- Comparing partial dates for "earliest" uses IGDB's timestamp for the period, so a `Q1 2027` entry can sort before a precise date in the same quarter. This is accepted.
+- The skeleton assumes a backdrop, so a game with no art shifts up slightly when it streams in. This is the same tradeoff movies accept.
+- Screenshots and the backdrop can show the same image when a game has no artwork.
+
+**Neutral**:
+- The `Game` type gains about a dozen fields, so every fixture that builds a `Game` needs the new empty defaults.
+- `SectionErrorLine` gains a `source` prop, with TMDB callers unchanged.
+- No privacy or legal change: `images.igdb.com` and YouTube are already disclosed.
+
+## Follow-up
+
+- [x] `/scope`: G9's row says "screenshots, trailer, similar games". Note that the trailer is a hero button, and that the backdrop deferred item is now resolved by this spec. (Done on the G9 branch: `docs/scope/games.md` names the backdrop and Trailer button in the hero and drops the deferred item.)
+- [ ] `/sync`: `lib/igdb/AGENTS.md`, the "Adding a field" convention and the `IGDB_DATE_FORMAT` pin. In the root `AGENTS.md`, the game page facts.
+- [ ] G10: `VideoGame` JSON-LD can use the new developer, publisher, screenshot and trailer fields.
diff --git a/docs/specs/0031-richer-game-page/rationale.md b/docs/specs/0031-richer-game-page/rationale.md
new file mode 100644
index 00000000..2dd24780
--- /dev/null
+++ b/docs/specs/0031-richer-game-page/rationale.md
@@ -0,0 +1,57 @@
+# 0031. Richer game page: rationale
+
+## Context
+
+The game page from spec 0025 shows only a hero: cover, name, date, release kind, genres, platforms and the IGDB rating. Movie and show pages go further, with a backdrop, a Trailer button, an overview with credits, More like this, and Facts. On a game page there is nothing to read, watch or compare. G9 closes that gap in the order the scope set: hero, summary, developer and publisher, screenshots, trailer, similar games, Facts.
+
+Forces that shape the choice:
+
+- **IGDB is rate limited** (4 requests a second, shared by every page render on an instance) and reached through one limiter. Each extra request per page view costs capacity that search and the landing also need.
+- **IGDB can expand nested fields in one query** (`involved_companies.company.name`, `release_dates.platform.name`), unlike TMDB, where the movie page needed a separate extras read for videos, providers and recommendations.
+- **Similar games need card data** (cover, rating, hidden flag, release date for the G8 slots). `similar_games` alone gives only ids.
+- **The shell must stay prerendered** (spec 0025, AC-9), and nothing may be invented when IGDB is silent.
+- **Release dates are messy**: one entry per platform and region, often partial (a year, a quarter) or TBD.
+
+## Options considered
+
+### Option 1: widen `getGame`, plus one batch for similar cards (chosen)
+
+Add every new field to the existing single `getGame` request. Similar cards come from the existing cached `getGamesByIds`, in their own Suspense boundary.
+
+**Pros**: one IGDB request for the hero and four sections. Reuses the summary normalization, the hidden filter and the G8 cards as they are. Only the section that can fail alone fails alone.
+**Cons**: a bigger payload on the hero's critical path. A `getGame` failure takes every section down together.
+
+### Option 2: a separate `getGameExtras` read
+
+Mirror TMDB: keep `getGame` lean and add a second cached read for the new data, with each section streaming in its own boundary.
+
+**Pros**: the hero renders sooner, and the sections fail on their own.
+**Cons**: two or three IGDB requests per page against a 4 a second limit. More code (a new read pair, schema, cache wrapper and surface entry). The extras would fail in exactly the conditions `getGame` fails, so the isolation buys little.
+
+### Option 3: one request including the similar games' card fields
+
+Expand `similar_games.cover.image_id`, `similar_games.rating` and the filter fields inside `getGame`.
+
+**Pros**: truly one request.
+**Cons**: it duplicates `normalizeSummary` and the catalog filter inside the detail read, and it makes the hero wait on the similar cards' data too. IGDB's nested expansion of catalog filter fields is also unproven here.
+
+## Rationale
+
+IGDB's nested expansion removes the reason TMDB needed an extras read, and the rate limit makes every extra request per page view a real cost. That makes widening `getGame` the boring choice: one cache entry and one failure mode, which is the one the page already handles. Similar cards are the exception, because they need full summary normalization and the hidden filter, which `getGamesByIds` already does and caches. Isolating that one read also matches the movie page, where More like this fails alone.
+
+Smaller calls made in this spec, each with its runner up:
+
+- **Trailer as a hero button**, not a section. It matches movies and shows, and IGDB gives no thumbnail, so a section would either be empty or add YouTube's image host before any click. Runner up: a section with a play tile over the art.
+- **Trailer pick**: the first video named like a trailer, else no button, so the button never opens gameplay footage under a "Trailer" label. Runner up: fall back to any video.
+- **Screenshots: a row plus a dialog**, capped at 12 and ordered by `image_id`. Thumbnails are small on phones, the dialog reuses the trailer dialog's frame, and the cap bounds the payload. No wraparound, because `3 of 12` with disabled ends is clearer about position. Runner up: wraparound.
+- **Backdrop from artwork, else screenshot**: this reuses spec 0028's `artUrl`, so the home hero and the game hero always pick the same image.
+- **Overview layout**: the summary, then the storyline under `Story`, then `Developed by` and `Published by` lines, mirroring `Directed by` on movies. The no text placeholder is dropped, because the scope says to leave a section out rather than say "No overview available".
+- **Facts**: the scope's five, plus Series and Game engine, which the engineer chose. Plural labels switch on count only where a single value is the norm (Franchise, Game engine).
+- **Release dates**: one line per platform, the earliest across regions (the same global rule as the hero's first release date), at IGDB's stated precision, built from `date_format` rather than IGDB's `human` string so the format matches the rest of the app. TBD reads `TBA`, because IGDB states it; this is not an invented value.
+- **Skeleton with a backdrop**: most games with a page worth visiting have art, so a backdrop footprint shifts the layout least on average.
+
+Added during the build and kept, though no criterion asked for them:
+
+- **The dialog image's alt is `Screenshot {n} of {total}`**, while the thumbnails stay `alt=""` behind their named buttons. The dialog holds one image and nothing else names it, so a screen reader landing on it hears what it is, not only the live region's count.
+- **Focus moves to the other arrow when Previous or Next turns disabled**, so a keyboard user at either end is never left on a disabled button, where focus would drop to the dialog.
+- **`IgdbField` admits `date` and `date_format`**, which only `igdb.live.ts` writes into a body, so the live check can ask IGDB for real quarter and TBD entries (AC-14).
diff --git a/docs/specs/0031-richer-game-page/verify.md b/docs/specs/0031-richer-game-page/verify.md
new file mode 100644
index 00000000..4eababba
--- /dev/null
+++ b/docs/specs/0031-richer-game-page/verify.md
@@ -0,0 +1,34 @@
+# Verify: Richer game page · spec 0031 · updated 2026-10-26
+_Steps derived from spec 0031 acceptance criteria. `/check verify` runs these; `/test` locks the durable ones._
+
+## UI / manual
+- [x] Open `/games/1942` (The Witcher 3) at 1280px → the hero, then `Overview`, `Screenshots`, `More like this`, `Facts`, in that order → AC-1
+- [x] Open `/games/94975` (Wubble Bubbles, no extra IGDB data) → the hero alone: no backdrop, no Trailer button, no `h2` at all → AC-1, AC-3, AC-11
+- [x] On `/games/1942`, the hero shows a wide art backdrop whose request goes to `images.igdb.com` (no `/_next/image` request in the network panel) → AC-3, AC-13
+- [x] On `/games/1942`, press Trailer → a dialog labelled `The Witcher 3: Wild Hunt trailer` opens with a `youtube-nocookie.com` iframe for `5nLipy-Z4yo` (the lowest id trailer, not a gameplay one); before pressing, no iframe exists → AC-4
+- [x] Overview shows the summary with its blank line kept, an `h3` `Story` with the storyline, `Developed by CD Projekt RED`, and `Published by` in IGDB's order → AC-5
+- [x] Screenshots shows 12 thumbnails, each a button named `Open screenshot {n} of 12`, images with `alt=""`, loaded lazily → AC-6
+- [x] Open thumbnail 3 → dialog `The Witcher 3: Wild Hunt screenshots`, `3 of 12`, focus on Close, one image in the dialog; ArrowRight shows `4 of 12`; Escape closes and focus lands on `Open screenshot 4 of 12`, scrolled into view → AC-7
+- [x] In the dialog, Previous is disabled on image 1 and Next on image 12; no wraparound → AC-7
+- [x] More like this shows up to 12 G8 cards in IGDB's `similar_games` order (Elden Ring first on 2026-10-26), with no See all; a card's bookmark works signed in → AC-8
+- [x] Block `api.igdb.com` for the batch only (or force an `IgdbError`) → the More like this heading stays with `Couldn't load similar games from IGDB.` and a Try again link to `/games/1942` → AC-8 (2026-10-26: an `IgdbError` forced after the batch in a local edit, then reverted)
+- [x] Facts lists Game modes, Player perspectives, Themes, Franchise, Series, Game engine, Release dates; Release dates starts `PC (Microsoft Windows): May 19, 2015` and ends `Nintendo Switch 2: Sep 29, 2026` → AC-9, AC-10
+- [x] Find a game with a quarter and a TBD release (IGDB `release_dates` with `date_format` 3 to 7) → its Facts show `Q1 2027` style and `TBA` last → AC-10
+- [x] At 375px and 1280px, `/games/1942` and the open dialog: no horizontal page scroll, 44px Previous, Next and Close, visible focus, the dialog usable by keyboard alone → AC-15
+- [x] `/showcase` (dev) shows "Game page sections": the Overview with and without a storyline, the screenshot row and dialog, and Facts with a quarter date and TBA → AC-16
+
+## Value sourcing
+- [x] Backdrop: a game with artworks uses the lowest artwork `image_id`; a game with only screenshots uses the lowest screenshot → AC-3 (2026-10-26: `/games/1942` loads `ar3lzb`, the lowest of 16 artworks; `/games/19258` Sonic Rush, no artworks, loads `sc8hmp`, the lowest screenshot)
+- [x] Trailer dialog title is the game's name, iframe title uses IGDB's video name → AC-4
+- [x] `{total}` in the thumbnail names equals the number of thumbnails (12 cap) → AC-6
+- [x] More like this ids are exactly `similar_games` without the game's own id; a hidden game never takes a slot → AC-8 (2026-10-26: `/games/1942` links the 10 `similar_games` ids in IGDB's order, none hidden that day; the hidden case is held by `game-more-like-this.test.tsx`)
+- [x] Release date quarter comes from `date_format`, the year from the UTC timestamp → AC-10
+- [x] Franchise label turns `Franchises`, engine label `Game engines`, only when there is more than one → AC-9 (2026-10-26: `/games/500` reads `Franchises` and `Game engine`, `/games/20` `Franchise` and `Game engines`)
+
+## Commands
+- [x] `pnpm build` → `/games/[id]` listed as `◐` (partially prerendered) and no IGDB call during the build → AC-12
+- [x] `pnpm igdb:live` → passes the new field, `date_formats` and quarter checks → AC-14
+- [x] `pnpm test`, `pnpm typecheck`, `pnpm lint:ci` → green → AC-2, AC-12
+
+## Acceptance-criteria coverage
+- AC-1 first two UI steps · AC-2 commands + `games.test.ts` · AC-3 backdrop steps · AC-4 trailer step · AC-5 overview step · AC-6 thumbnails step · AC-7 dialog steps · AC-8 More like this steps · AC-9 and AC-10 Facts steps · AC-11 bare game step · AC-12 build · AC-13 network step · AC-14 live check · AC-15 375 and 1280 step · AC-16 showcase step
diff --git a/lib/catalog/media-type.ts b/lib/catalog/media-type.ts
index fa734935..eb2e2cda 100644
--- a/lib/catalog/media-type.ts
+++ b/lib/catalog/media-type.ts
@@ -38,6 +38,9 @@ export const CATALOG_SOURCES = {
game: "IGDB",
} as const satisfies Record;
+/** A catalog name as `CATALOG_SOURCES` spells it, for props that blame one. */
+export type CatalogSource = (typeof CATALOG_SOURCES)[LibraryType];
+
/**
* The three library pages, one per `LibraryList`, and the only typed pages a
* game tab exists on (spec 0027, AC-2).
diff --git a/lib/format.test.ts b/lib/format.test.ts
index 9379f362..01d0475f 100644
--- a/lib/format.test.ts
+++ b/lib/format.test.ts
@@ -4,6 +4,7 @@ import {
formatAirDate,
formatAirSpan,
formatEpisodeCount,
+ formatGameReleaseDate,
formatMonthYear,
formatPersonalScore,
formatReleaseDate,
@@ -310,3 +311,19 @@ describe("formatMonthYear", () => {
expect(formatMonthYear("not a date")).toBeNull();
});
});
+
+describe("formatGameReleaseDate · covers spec 0031 AC-10", () => {
+ it.each([
+ [{ precision: "day", date: "2020-03-20" }, "Mar 20, 2020"],
+ [{ precision: "day", date: "2015-05-01" }, "May 1, 2015"],
+ [{ precision: "month", date: "2020-03-31" }, "Mar 2020"],
+ [{ precision: "year", date: "2027-12-31" }, "2027"],
+ [{ precision: "quarter", year: 2027, quarter: 1 }, "Q1 2027"],
+ [{ precision: "day", date: "2026-12-31" }, "Dec 31, 2026"],
+ [{ precision: "month", date: "1998-01-15" }, "Jan 1998"],
+ [{ precision: "quarter", year: 2026, quarter: 4 }, "Q4 2026"],
+ [{ precision: "tba" }, "TBA"],
+ ] as const)("formats %o as %s", (release, text) => {
+ expect(formatGameReleaseDate(release)).toBe(text);
+ });
+});
diff --git a/lib/format.ts b/lib/format.ts
index 0b2d6ad1..f5698061 100644
--- a/lib/format.ts
+++ b/lib/format.ts
@@ -7,6 +7,8 @@
* Pure and dependency free, so both Server Components and tests can use them.
*/
+import type { GameReleaseDate } from "@/lib/igdb/types";
+
/**
* A runtime as `2h 19m`, `45m` under an hour, or `2h` on the hour
* (spec 0006, AC-3).
@@ -295,3 +297,41 @@ export function parseTmdbDate(date: string | null): Date | null {
if (utc.getUTCMonth() !== month - 1 || utc.getUTCDate() !== day) return null;
return utc;
}
+
+/** Fixed English month names, so no `Intl` output differs by Node version. */
+const SHORT_MONTHS = [
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+] as const;
+
+/**
+ * A game's release on one platform at the precision IGDB states (spec 0031,
+ * AC-10): `Mar 20, 2020`, `Mar 2020`, `Q1 2027`, `2027`, or `TBA` when IGDB
+ * itself says TBD. Built from the `YYYY-MM-DD` parts by hand, with no `Intl`,
+ * so CI (Node 22) and local (Node 24) print the same text.
+ */
+export function formatGameReleaseDate(release: GameReleaseDate): string {
+ switch (release.precision) {
+ case "tba":
+ return "TBA";
+ case "quarter":
+ return `Q${release.quarter} ${release.year}`;
+ default: {
+ const [year, month, day] = release.date.split("-").map(Number);
+ const monthName = SHORT_MONTHS[month - 1];
+ if (release.precision === "year") return String(year);
+ if (release.precision === "month") return `${monthName} ${year}`;
+ return `${monthName} ${day}, ${year}`;
+ }
+ }
+}
diff --git a/lib/igdb/AGENTS.md b/lib/igdb/AGENTS.md
index ca1537ca..b4af8e0b 100644
--- a/lib/igdb/AGENTS.md
+++ b/lib/igdb/AGENTS.md
@@ -30,9 +30,9 @@ scope feature G1. It mirrors `lib/tmdb/` in shape but shares no code with it.
(Trending and Popular: the `popularity_primitives` ranks, then the batch
read, spec 0028) and `reference.ts`.
- Pure code outside this folder (`lib/catalog/game-lists.ts`,
- `lib/catalog/media-type.ts`, `lib/catalog-list-read.ts`, `lib/images/`
- and `lib/search/`) may import `constants.ts` and `types.ts` directly,
- since `index.ts` is server only; a page or component never does, and
+ `lib/catalog/media-type.ts`, `lib/catalog-list-read.ts`, `lib/format.ts`,
+ `lib/images/` and `lib/search/`) may import `constants.ts` and `types.ts`
+ directly, since `index.ts` is server only; a page or component never does, and
reads search's IGDB limits through `SEARCH_CATALOGS`
(`lib/search/catalogs.ts`). Nothing outside imports `query.ts`; a rule it
shares, like the control character pattern, moves to `constants.ts`.
diff --git a/lib/igdb/__fixtures__/game-1942.json b/lib/igdb/__fixtures__/game-1942.json
index c0a25124..256b3905 100644
--- a/lib/igdb/__fixtures__/game-1942.json
+++ b/lib/igdb/__fixtures__/game-1942.json
@@ -1,11 +1,95 @@
[
{
"id": 1942,
+ "artworks": [
+ {
+ "id": 303975,
+ "image_id": "ar6ijr"
+ },
+ {
+ "id": 168458,
+ "image_id": "ar3lze"
+ },
+ {
+ "id": 168459,
+ "image_id": "ar3lzf"
+ },
+ {
+ "id": 168463,
+ "image_id": "ar3lzj"
+ },
+ {
+ "id": 168460,
+ "image_id": "ar3lzg"
+ },
+ {
+ "id": 168465,
+ "image_id": "ar3lzl"
+ },
+ {
+ "id": 168461,
+ "image_id": "ar3lzh"
+ },
+ {
+ "id": 168464,
+ "image_id": "ar3lzk"
+ },
+ {
+ "id": 168457,
+ "image_id": "ar3lzd"
+ },
+ {
+ "id": 168455,
+ "image_id": "ar3lzb"
+ },
+ {
+ "id": 168466,
+ "image_id": "ar3lzm"
+ },
+ {
+ "id": 168456,
+ "image_id": "ar3lzc"
+ },
+ {
+ "id": 168467,
+ "image_id": "ar3lzn"
+ },
+ {
+ "id": 168462,
+ "image_id": "ar3lzi"
+ },
+ {
+ "id": 168533,
+ "image_id": "ar3m1h"
+ },
+ {
+ "id": 168532,
+ "image_id": "ar3m1g"
+ }
+ ],
"cover": {
"id": 480513,
"image_id": "coaarl"
},
"first_release_date": 1431993600,
+ "franchises": [
+ {
+ "id": 452,
+ "name": "The Witcher"
+ }
+ ],
+ "game_engines": [
+ {
+ "id": 24,
+ "name": "REDengine"
+ }
+ ],
+ "game_modes": [
+ {
+ "id": 1,
+ "name": "Single player"
+ }
+ ],
"genres": [
{
"id": 12,
@@ -16,6 +100,71 @@
"name": "Adventure"
}
],
+ "involved_companies": [
+ {
+ "id": 17436,
+ "company": {
+ "id": 50,
+ "name": "WB Games"
+ },
+ "developer": false,
+ "publisher": true
+ },
+ {
+ "id": 17769,
+ "company": {
+ "id": 3119,
+ "name": "cdp.pl"
+ },
+ "developer": false,
+ "publisher": true
+ },
+ {
+ "id": 17771,
+ "company": {
+ "id": 1217,
+ "name": "Spike Chunsoft"
+ },
+ "developer": false,
+ "publisher": true
+ },
+ {
+ "id": 17768,
+ "company": {
+ "id": 248,
+ "name": "Bandai Namco Entertainment"
+ },
+ "developer": false,
+ "publisher": true
+ },
+ {
+ "id": 64293,
+ "company": {
+ "id": 5696,
+ "name": "D3T Limited"
+ },
+ "developer": false,
+ "publisher": false
+ },
+ {
+ "id": 196544,
+ "company": {
+ "id": 946,
+ "name": "Saber Interactive"
+ },
+ "developer": false,
+ "publisher": false
+ },
+ {
+ "id": 418547,
+ "company": {
+ "id": 908,
+ "name": "CD Projekt RED"
+ },
+ "developer": true,
+ "publisher": false
+ }
+ ],
"name": "The Witcher 3: Wild Hunt",
"platforms": [
{
@@ -47,9 +196,134 @@
"name": "Nintendo Switch"
}
],
- "rating": 93.77371445859636,
- "rating_count": 5498,
+ "player_perspectives": [
+ {
+ "id": 2,
+ "name": "Third person"
+ }
+ ],
+ "rating": 93.77367293830875,
+ "rating_count": 5504,
+ "release_dates": [
+ {
+ "id": 974208,
+ "date": 1431993600,
+ "platform": {
+ "id": 48,
+ "name": "PlayStation 4"
+ },
+ "date_format": 0
+ },
+ {
+ "id": 974209,
+ "date": 1670976000,
+ "platform": {
+ "id": 167,
+ "name": "PlayStation 5"
+ },
+ "date_format": 0
+ },
+ {
+ "id": 974210,
+ "date": 1431993600,
+ "platform": {
+ "id": 49,
+ "name": "Xbox One"
+ },
+ "date_format": 0
+ },
+ {
+ "id": 974211,
+ "date": 1670976000,
+ "platform": {
+ "id": 169,
+ "name": "Xbox Series X|S"
+ },
+ "date_format": 0
+ },
+ {
+ "id": 974206,
+ "date": 1611792000,
+ "platform": {
+ "id": 130,
+ "name": "Nintendo Switch"
+ },
+ "date_format": 0
+ },
+ {
+ "id": 974207,
+ "date": 1431993600,
+ "platform": {
+ "id": 6,
+ "name": "PC (Microsoft Windows)"
+ },
+ "date_format": 0
+ },
+ {
+ "id": 974212,
+ "date": 1790640000,
+ "platform": {
+ "id": 508,
+ "name": "Nintendo Switch 2"
+ },
+ "date_format": 0
+ }
+ ],
+ "screenshots": [
+ {
+ "id": 1377966,
+ "image_id": "sctj8u"
+ },
+ {
+ "id": 21107,
+ "image_id": "farvemmmxav0bgt6wx7t"
+ },
+ {
+ "id": 9743,
+ "image_id": "em1y2ugcwy2myuhvb9db"
+ },
+ {
+ "id": 9744,
+ "image_id": "usxccsncekxg0wd1v6ee"
+ },
+ {
+ "id": 853179,
+ "image_id": "sciabf"
+ },
+ {
+ "id": 1377965,
+ "image_id": "sctj8t"
+ },
+ {
+ "id": 853177,
+ "image_id": "sciabd"
+ },
+ {
+ "id": 853181,
+ "image_id": "sciabh"
+ },
+ {
+ "id": 853178,
+ "image_id": "sciabe"
+ },
+ {
+ "id": 9745,
+ "image_id": "z5t0yuhyiiui1ickwhgj"
+ },
+ {
+ "id": 9742,
+ "image_id": "mnljdjtrh44x4snmierh"
+ },
+ {
+ "id": 853180,
+ "image_id": "sciabg"
+ }
+ ],
+ "similar_games": [
+ 119133, 1887, 3025, 9112, 27421, 6142, 5503, 25076, 27384, 7346
+ ],
"slug": "the-witcher-3-wild-hunt",
+ "storyline": "The Witcher 3: Wild Hunt concludes the story of the witcher Geralt of Rivia, the series' protagonist, whose story to date has been covered in the previous installments. Geralt's new mission comes in dark times as the mysterious and otherworldly army known as the Wild Hunt invades the Northern Kingdoms, leaving only blood soaked earth and fiery ruin in its wake; and it seems the Witcher is the key to stopping their cataclysmic rampage.",
"summary": "The Witcher 3: Wild Hunt is an open-world action role-playing game developed by CD Projekt Red.\n\nSet in a dark fantasy world, the game follows Geralt of Rivia, a monster hunter searching for his adopted daughter, Ciri, while navigating political conflicts and supernatural threats. Gameplay features exploration, combat, character progression, and branching narratives shaped by player choices. Widely acclaimed for its writing, world-building, and depth, it is considered one of the most influential RPGs of its generation.",
"themes": [
{
@@ -65,6 +339,99 @@
"name": "Open world"
}
],
+ "videos": [
+ {
+ "id": 9648,
+ "name": "Developer Diary: Creating the Sound",
+ "video_id": "yowv6_rspoM"
+ },
+ {
+ "id": 5993,
+ "name": "TV Spot",
+ "video_id": "xQGam9OHSUo"
+ },
+ {
+ "id": 5990,
+ "name": "Opening Cinematic: The Trail",
+ "video_id": "QrwGXAcE6ZA"
+ },
+ {
+ "id": 5992,
+ "name": "Recap",
+ "video_id": "bcEAsOC_8L0"
+ },
+ {
+ "id": 5987,
+ "name": "The Begining trailer",
+ "video_id": "5nLipy-Z4yo"
+ },
+ {
+ "id": 5995,
+ "name": "A Night to Remember Trailer",
+ "video_id": "8ZLfJjlZKvc"
+ },
+ {
+ "id": 5994,
+ "name": "Rage & Steel Trailer",
+ "video_id": "p14dHAwLOmo"
+ },
+ {
+ "id": 5991,
+ "name": "Elder Blood Trailer",
+ "video_id": "6f8TbvsZ5Mk"
+ },
+ {
+ "id": 5989,
+ "name": "Downwarren Gameplay Video",
+ "video_id": "_IBAovRNCuA"
+ },
+ {
+ "id": 5988,
+ "name": "Killing Monsters Cinematic Trailer",
+ "video_id": "FP7no968jVU"
+ },
+ {
+ "id": 5996,
+ "name": "Debut Gameplay Trailer",
+ "video_id": "sb81f-ejNSI"
+ },
+ {
+ "id": 150458,
+ "name": "Trailer",
+ "video_id": "TWOkT7l0yWQ"
+ },
+ {
+ "id": 15748,
+ "name": "10th Anniversary Video",
+ "video_id": "ZpiczsigQto"
+ },
+ {
+ "id": 120257,
+ "name": "Gameplay Demo",
+ "video_id": "gRmKKjQu9Tw"
+ },
+ {
+ "id": 141704,
+ "name": "VGX 2013 Trailer",
+ "video_id": "MqQ3LeBgGDM"
+ },
+ {
+ "id": 141210,
+ "name": "Cinematic Trailer",
+ "video_id": "c0i88t0Kacs"
+ },
+ {
+ "id": 141211,
+ "name": "Launch Trailer",
+ "video_id": "1-l29HlKkXU"
+ }
+ ],
+ "collections": [
+ {
+ "id": 62,
+ "name": "The Witcher"
+ }
+ ],
"game_type": 0
}
]
diff --git a/lib/igdb/constants.ts b/lib/igdb/constants.ts
index 3e8faff9..26ce4c6a 100644
--- a/lib/igdb/constants.ts
+++ b/lib/igdb/constants.ts
@@ -177,8 +177,49 @@ export const IGDB_GAME_FIELDS = [
"version_parent",
"rating",
"rating_count",
+ "storyline",
+ "artworks.image_id",
+ "screenshots.image_id",
+ "videos.id",
+ "videos.name",
+ "videos.video_id",
+ "similar_games",
+ "involved_companies.company.id",
+ "involved_companies.company.name",
+ "involved_companies.developer",
+ "involved_companies.publisher",
+ "game_modes.name",
+ "player_perspectives.name",
+ "franchise.name",
+ "franchises.name",
+ "collections.name",
+ "game_engines.name",
+ "release_dates.date",
+ "release_dates.date_format",
+ "release_dates.platform.id",
+ "release_dates.platform.name",
] as const;
+/**
+ * IGDB's `date_format` ids on a release date, by id (spec 0031, AC-10). An id
+ * outside this map, or none, drops the entry rather than reading it as a full
+ * day. `pnpm igdb:live` checks them against IGDB's `date_formats` endpoint
+ * (AC-14).
+ */
+export const IGDB_DATE_FORMAT = {
+ 0: "day",
+ 1: "month",
+ 2: "year",
+ 3: "q1",
+ 4: "q2",
+ 5: "q3",
+ 6: "q4",
+ 7: "tbd",
+} as const;
+
+/** The most screenshots a game page shows (spec 0031, AC-6). */
+export const IGDB_MAX_SCREENSHOTS = 12;
+
/**
* The `fields` clause of a batch, search or discover read. Genres, platforms
* and themes stay ids; their names come from the reference lists. Artworks
diff --git a/lib/igdb/games.test.ts b/lib/igdb/games.test.ts
index f96d4e16..e46407fd 100644
--- a/lib/igdb/games.test.ts
+++ b/lib/igdb/games.test.ts
@@ -56,6 +56,27 @@ describe("fetchGame", () => {
name: expect.any(String),
});
expect(game?.summary).toBeTruthy();
+ // The fields spec 0031 added, from the same one request (AC-2).
+ expect(game?.storyline).toBeTruthy();
+ expect(game?.artUrl).toMatch(/\/t_1080p\/.+\.jpg$/);
+ expect(game?.screenshotUrls).toHaveLength(12);
+ expect(game?.trailer).toEqual({
+ key: "5nLipy-Z4yo",
+ name: "The Begining trailer",
+ });
+ expect(game?.similarGameIds).toEqual(witcher.similar_games);
+ expect(game?.developers.length).toBeGreaterThan(0);
+ expect(game?.publishers).toContain("WB Games");
+ expect(game?.gameModes).toEqual([{ id: 1, name: "Single player" }]);
+ expect(game?.franchises).toEqual([{ id: 452, name: "The Witcher" }]);
+ expect(game?.series).toEqual([{ id: 62, name: "The Witcher" }]);
+ expect(game?.gameEngines).toEqual([{ id: 24, name: "REDengine" }]);
+ expect(game?.platformReleases[0]).toEqual({
+ platformId: 6,
+ platform: "PC (Microsoft Windows)",
+ release: { precision: "day", date: "2015-05-19" },
+ });
+ expect(game?.platformReleases.at(-1)?.platform).toBe("Nintendo Switch 2");
expect(Object.keys(game ?? {}).some((key) => key.includes("_"))).toBe(
false,
);
@@ -146,6 +167,49 @@ describe("missing stays missing", () => {
communityRating: null,
communityRatingCount: null,
hidden: false,
+ storyline: null,
+ artUrl: null,
+ screenshotUrls: [],
+ trailer: null,
+ similarGameIds: [],
+ developers: [],
+ publishers: [],
+ gameModes: [],
+ playerPerspectives: [],
+ franchises: [],
+ series: [],
+ gameEngines: [],
+ platformReleases: [],
+ });
+ });
+
+ it("keeps the read when every new list holds odd entries (spec 0031, AC-2)", async () => {
+ const { value, error } = await readWith({
+ id: 7,
+ name: "Odd",
+ storyline: 12,
+ artworks: "junk",
+ screenshots: [null, { image_id: 4 }, { image_id: "sc1" }],
+ videos: [{ id: "x" }, "junk"],
+ similar_games: ["a", -1, 9],
+ involved_companies: [{ company: null }, { developer: true }],
+ game_modes: [{ id: 1 }],
+ franchise: "junk",
+ franchises: { id: 1 },
+ release_dates: [{ date_format: "day", platform: 6 }],
+ });
+ expect(error).toBeUndefined();
+ expect(value).toMatchObject({
+ storyline: null,
+ screenshotUrls: [
+ "https://images.igdb.com/igdb/image/upload/t_1080p/sc1.jpg",
+ ],
+ trailer: null,
+ similarGameIds: [9],
+ developers: [],
+ gameModes: [],
+ franchises: [],
+ platformReleases: [],
});
});
diff --git a/lib/igdb/igdb.live.ts b/lib/igdb/igdb.live.ts
index 14c6835e..7049b8ff 100644
--- a/lib/igdb/igdb.live.ts
+++ b/lib/igdb/igdb.live.ts
@@ -4,7 +4,9 @@ import { igdbRequest } from "./client";
import {
IGDB_ART_LADDER,
IGDB_COVER_LADDER,
+ IGDB_DATE_FORMAT,
IGDB_EROTIC_THEME_ID,
+ IGDB_GAME_FIELDS,
IGDB_IMAGE_BASE,
IGDB_POPULARITY_TYPES,
IGDB_SUMMARY_FIELDS,
@@ -13,10 +15,11 @@ import {
import { fetchDiscoverGames as fetchValidDiscoverGames } from "./discover";
import { getIgdbCredentials } from "./env";
import { fetchGame as fetchValidGame } from "./games";
+import { platformReleases } from "./normalize";
import { fetchPopularGames as fetchValidPopularGames } from "./popularity";
import { buildQuery, type Condition, cond } from "./query";
import { fetchGameGenres, fetchGamePlatforms } from "./reference";
-import { recordsSchema } from "./schemas";
+import { gameDetailSchema, recordsSchema } from "./schemas";
import { fetchSearchGames as fetchValidSearchGames } from "./search";
import { getIgdbToken } from "./token";
import type {
@@ -161,6 +164,119 @@ describe.skipIf(!hasCredentials)("IGDB live check (real network)", () => {
expect(game.genres.length).toBeGreaterThan(0);
});
+ it("reads every field spec 0031 added, in the shapes it parses (AC-14)", async () => {
+ const { data } = await games(
+ buildQuery({
+ fields: IGDB_GAME_FIELDS,
+ where: [cond.is("id", KNOWN_GAME_ID)],
+ limit: 1,
+ }),
+ );
+ const record = gameDetailSchema.parse(data[0]);
+ expect(record.storyline).toBeTruthy();
+ for (const list of [
+ record.artworks,
+ record.screenshots,
+ record.videos,
+ record.similar_games,
+ record.involved_companies,
+ record.game_modes,
+ record.player_perspectives,
+ record.franchises,
+ record.collections,
+ record.game_engines,
+ record.release_dates,
+ ]) {
+ expect(list?.length).toBeGreaterThan(0);
+ }
+ for (const entry of record.involved_companies as {
+ company: { name: string };
+ developer: unknown;
+ publisher: unknown;
+ }[]) {
+ expect(typeof entry.developer).toBe("boolean");
+ expect(typeof entry.publisher).toBe("boolean");
+ expect(entry.company.name).toBeTruthy();
+ }
+ for (const video of record.videos as { video_id: string }[]) {
+ expect(video.video_id).toMatch(/^[A-Za-z0-9_-]{6,20}$/);
+ }
+ for (const date of record.release_dates as {
+ date_format: number;
+ platform: { id: number; name: string };
+ }[]) {
+ expect(Object.keys(IGDB_DATE_FORMAT).map(Number)).toContain(
+ date.date_format,
+ );
+ expect(date.platform.name).toBeTruthy();
+ }
+
+ const game = await fetchGame(KNOWN_GAME_ID);
+ expect(game.trailer).not.toBeNull();
+ expect(game.developers).toContain("CD Projekt RED");
+ expect(game.platformReleases.length).toBeGreaterThan(0);
+ });
+
+ it("matches the pinned date_format ids to IGDB's date_formats (AC-14)", async () => {
+ const { data } = await igdbRequest(
+ "date_formats",
+ buildQuery({
+ fields: ["format"],
+ sort: { field: "id", direction: "asc" },
+ limit: 50,
+ }),
+ recordsSchema,
+ );
+ const formats = Object.fromEntries(
+ (data as { id: number; format: string }[]).map((row) => [
+ row.id,
+ row.format,
+ ]),
+ );
+ expect(formats).toMatchObject({
+ 0: "YYYYMMDD",
+ 1: "YYYYMM",
+ 2: "YYYY",
+ 3: "YYYYQ1",
+ 4: "YYYYQ2",
+ 5: "YYYYQ3",
+ 6: "YYYYQ4",
+ 7: "TBD",
+ });
+ expect(Object.keys(IGDB_DATE_FORMAT).map(Number)).toEqual([
+ 0, 1, 2, 3, 4, 5, 6, 7,
+ ]);
+ });
+
+ it("puts a quarter entry's UTC timestamp inside its stated quarter (AC-14)", async () => {
+ for (const formatId of [3, 4, 5, 6]) {
+ const { data } = await igdbRequest(
+ "release_dates",
+ buildQuery({
+ fields: ["date", "date_format", "y", "platform.name"],
+ where: [cond.is("date_format", formatId), cond.notNull("date")],
+ sort: { field: "id", direction: "desc" },
+ limit: 1,
+ }),
+ recordsSchema,
+ );
+ const row = data[0] as { date: number; y: number } | undefined;
+ if (row === undefined) continue;
+ const day = new Date(row.date * 1000);
+ const quarter = formatId - 2;
+ expect(day.getUTCFullYear()).toBe(row.y);
+ expect(Math.floor(day.getUTCMonth() / 3) + 1).toBe(quarter);
+ const [release] = platformReleases([
+ { ...row, platform: { id: 1, name: "Live" } },
+ ]);
+ expect(release?.release).toEqual({
+ precision: "quarter",
+ year: row.y,
+ quarter,
+ });
+ }
+ });
+
it("matches the pinned game type ids to their names, and reports new ones", async () => {
const { data } = await igdbRequest(
"game_types",
diff --git a/lib/igdb/index.ts b/lib/igdb/index.ts
index db6eeeea..d28ef4e0 100644
--- a/lib/igdb/index.ts
+++ b/lib/igdb/index.ts
@@ -35,9 +35,12 @@ export type {
DiscoverSort,
Game,
GameRef,
+ GameReleaseDate,
GameSummary,
GameType,
+ GameVideo,
Paged,
+ PlatformRelease,
PopularGamesKind,
SearchGamesOptions,
} from "./types";
diff --git a/lib/igdb/normalize.test.ts b/lib/igdb/normalize.test.ts
index 073e4dd1..a93bae3f 100644
--- a/lib/igdb/normalize.test.ts
+++ b/lib/igdb/normalize.test.ts
@@ -1,10 +1,14 @@
import { describe, expect, it } from "vitest";
import {
artUrl,
+ companyNames,
coverUrl,
normalizeGame,
normalizeSummary,
+ pickGameTrailer,
+ platformReleases,
releaseDay,
+ screenshotUrls,
} from "./normalize";
/**
@@ -175,3 +179,345 @@ describe("normalizeSummary", () => {
).toBeNull();
});
});
+
+describe("screenshotUrls (spec 0031, AC-6)", () => {
+ it("dedupes by image id before sorting and capping at 12", () => {
+ const ids = Array.from(
+ { length: 14 },
+ (_, i) => `s${String(i).padStart(2, "0")}`,
+ );
+ const urls = screenshotUrls(
+ [...ids.reverse(), "s00", "s00"].map((image_id) => ({ image_id })),
+ );
+ expect(urls).toHaveLength(12);
+ expect(urls[0]).toBe(
+ "https://images.igdb.com/igdb/image/upload/t_1080p/s00.jpg",
+ );
+ expect(urls[1]).toMatch(/\/s01\.jpg$/);
+ expect(urls[11]).toMatch(/\/s11\.jpg$/);
+ });
+
+ it("drops blank ids and answers an empty list with no screenshots", () => {
+ expect(
+ screenshotUrls([{ image_id: " " }, { image_id: " sc9 " }, null]),
+ ).toEqual(["https://images.igdb.com/igdb/image/upload/t_1080p/sc9.jpg"]);
+ expect(screenshotUrls(null)).toEqual([]);
+ expect(screenshotUrls(undefined)).toEqual([]);
+ });
+});
+
+describe("a game's hero backdrop (spec 0031, AC-3)", () => {
+ it("takes the lowest artwork over any screenshot", () => {
+ const game = normalizeGame({
+ id: 1,
+ name: "G",
+ artworks: [{ image_id: "ar9" }, { image_id: "ar2" }],
+ screenshots: [{ image_id: "aa1" }],
+ });
+ expect(game.artUrl).toBe(
+ "https://images.igdb.com/igdb/image/upload/t_1080p/ar2.jpg",
+ );
+ });
+
+ it("falls back to the lowest screenshot when there is no artwork", () => {
+ const game = normalizeGame({
+ id: 1,
+ name: "G",
+ screenshots: [{ image_id: "sc7" }, { image_id: "sc3" }],
+ });
+ expect(game.artUrl).toBe(
+ "https://images.igdb.com/igdb/image/upload/t_1080p/sc3.jpg",
+ );
+ });
+
+ it("is null with neither, so the hero keeps its no backdrop layout", () => {
+ expect(normalizeGame({ id: 1, name: "G" }).artUrl).toBeNull();
+ });
+});
+
+describe("pickGameTrailer (spec 0031, AC-4)", () => {
+ const video = (id: number, name: string, video_id = "abcdefghijk") => ({
+ id,
+ name,
+ video_id,
+ });
+
+ it("takes the lowest id among trailers, in any case", () => {
+ expect(
+ pickGameTrailer([
+ video(30, "Launch Trailer", "launch12345"),
+ video(20, "REVEAL TRAILER", "reveal12345"),
+ video(10, "Developer Diary"),
+ ]),
+ ).toEqual({ key: "reveal12345", name: "REVEAL TRAILER" });
+ });
+
+ it("skips a gameplay trailer and a bad id, so neither hides a good one", () => {
+ expect(
+ pickGameTrailer([
+ video(1, "Gameplay Trailer"),
+ video(2, "Trailer", "bad id!"),
+ video(3, "Story Trailer", "story123456"),
+ ]),
+ ).toEqual({ key: "story123456", name: "Story Trailer" });
+ });
+
+ it("answers null with only gameplay videos or none", () => {
+ expect(pickGameTrailer([video(1, "Gameplay Demo")])).toBeNull();
+ expect(pickGameTrailer([video(1, "Gameplay Trailer")])).toBeNull();
+ expect(pickGameTrailer(null)).toBeNull();
+ });
+
+ it("accepts a YouTube id of 6 to 20 safe characters and nothing outside", () => {
+ expect(pickGameTrailer([video(1, "Trailer", "abcde")])).toBeNull();
+ expect(pickGameTrailer([video(1, "Trailer", "a".repeat(21))])).toBeNull();
+ expect(pickGameTrailer([video(1, "Trailer", "a_b-c6")])?.key).toBe(
+ "a_b-c6",
+ );
+ expect(
+ pickGameTrailer([video(1, "Trailer", "a".repeat(20))])?.key,
+ ).toHaveLength(20);
+ });
+
+ it("skips a video with no name, and trims the name it keeps", () => {
+ expect(
+ pickGameTrailer([
+ { id: 1, name: null, video_id: "noname12345" },
+ video(2, " Launch Trailer ", "launch12345"),
+ ]),
+ ).toEqual({ key: "launch12345", name: "Launch Trailer" });
+ });
+});
+
+describe("companyNames (spec 0031, AC-5)", () => {
+ const entries = [
+ { company: { id: 1, name: "CD Projekt RED" }, developer: true },
+ { company: { id: 2, name: "WB Games" }, publisher: true },
+ {
+ company: { id: 1, name: "CD Projekt RED" },
+ developer: true,
+ publisher: true,
+ },
+ { company: { name: "Studio" }, developer: true },
+ { company: { name: "studio" }, developer: true },
+ { company: { id: 3, name: " " }, developer: true },
+ ];
+
+ it("keeps IGDB's order and dedupes by id, else by name ignoring case", () => {
+ expect(companyNames(entries, "developer")).toEqual([
+ "CD Projekt RED",
+ "Studio",
+ ]);
+ });
+
+ it("puts a company flagged both ways in both lists", () => {
+ expect(companyNames(entries, "publisher")).toEqual([
+ "WB Games",
+ "CD Projekt RED",
+ ]);
+ });
+});
+
+describe("franchises and similar games (spec 0031, AC-8, AC-9)", () => {
+ it("puts the main franchise first and dedupes the rest by id", () => {
+ const game = normalizeGame({
+ id: 5,
+ name: "G",
+ franchise: { id: 2, name: "Main" },
+ franchises: [
+ { id: 1, name: "Other" },
+ { id: 2, name: "Main" },
+ ],
+ similar_games: [9, 5, 8, 9],
+ });
+ expect(game.franchises).toEqual([
+ { id: 2, name: "Main" },
+ { id: 1, name: "Other" },
+ ]);
+ expect(game.similarGameIds).toEqual([9, 8]);
+ });
+});
+
+describe("platformReleases (spec 0031, AC-10)", () => {
+ const at = (iso: string) => Date.parse(`${iso}T00:00:00Z`) / 1000;
+ const pc = { id: 6, name: "PC" };
+ const ps5 = { id: 167, name: "PlayStation 5" };
+
+ it("takes each platform's earliest dated entry across regions", () => {
+ expect(
+ platformReleases([
+ { date: at("2020-05-01"), date_format: 0, platform: pc },
+ { date: at("2020-03-20"), date_format: 0, platform: pc },
+ ]),
+ ).toEqual([
+ {
+ platformId: 6,
+ platform: "PC",
+ release: { precision: "day", date: "2020-03-20" },
+ },
+ ]);
+ });
+
+ it("keeps IGDB's precision, the quarter from date_format", () => {
+ expect(
+ platformReleases([
+ { date: at("2027-03-31"), date_format: 3, platform: pc },
+ { date: at("2027-12-31"), date_format: 2, platform: ps5 },
+ {
+ date: at("2028-06-30"),
+ date_format: { id: 1 },
+ platform: { id: 7, name: "Switch" },
+ },
+ ]),
+ ).toEqual([
+ {
+ platformId: 6,
+ platform: "PC",
+ release: { precision: "quarter", year: 2027, quarter: 1 },
+ },
+ {
+ platformId: 167,
+ platform: "PlayStation 5",
+ release: { precision: "year", date: "2027-12-31" },
+ },
+ {
+ platformId: 7,
+ platform: "Switch",
+ release: { precision: "month", date: "2028-06-30" },
+ },
+ ]);
+ });
+
+ it("reads TBD as TBA even with a placeholder date, and sorts it last", () => {
+ expect(
+ platformReleases([
+ { date: at("2025-12-31"), date_format: 7, platform: ps5 },
+ { date: at("2030-01-01"), date_format: 0, platform: pc },
+ ]),
+ ).toEqual([
+ {
+ platformId: 6,
+ platform: "PC",
+ release: { precision: "day", date: "2030-01-01" },
+ },
+ {
+ platformId: 167,
+ platform: "PlayStation 5",
+ release: { precision: "tba" },
+ },
+ ]);
+ });
+
+ it("prefers a dated entry over a TBD one on the same platform", () => {
+ expect(
+ platformReleases([
+ { date_format: 7, platform: pc },
+ { date: at("2021-01-01"), date_format: 0, platform: pc },
+ ]),
+ ).toEqual([
+ {
+ platformId: 6,
+ platform: "PC",
+ release: { precision: "day", date: "2021-01-01" },
+ },
+ ]);
+ });
+
+ it("skips no platform, a nameless one, an unknown or missing format, and no date", () => {
+ expect(
+ platformReleases([
+ { date: at("2021-01-01"), date_format: 0 },
+ { date: at("2021-01-01"), date_format: 0, platform: { id: 9 } },
+ { date: at("2021-01-01"), date_format: 99, platform: pc },
+ { date: at("2021-01-01"), platform: pc },
+ { date_format: 0, platform: pc },
+ "junk",
+ ]),
+ ).toEqual([]);
+ });
+
+ it("keeps two platforms IGDB names alike apart by id", () => {
+ const day = at("2020-01-01");
+ expect(
+ platformReleases([
+ { date: day, date_format: 0, platform: { id: 1, name: "Arcade" } },
+ { date: day, date_format: 0, platform: { id: 2, name: "Arcade" } },
+ ]).map((entry) => entry.platformId),
+ ).toEqual([1, 2]);
+ });
+
+ it("breaks a tie by platform name ignoring case", () => {
+ const day = at("2015-05-19");
+ expect(
+ platformReleases([
+ { date: day, date_format: 0, platform: { id: 1, name: "xbox One" } },
+ { date: day, date_format: 0, platform: { id: 2, name: "PC" } },
+ ]).map((entry) => entry.platform),
+ ).toEqual(["PC", "xbox One"]);
+ });
+
+ it("maps date_format 4 to 6 to Q2 to Q4", () => {
+ expect(
+ platformReleases([
+ {
+ date: at("2027-06-30"),
+ date_format: 4,
+ platform: { id: 1, name: "A" },
+ },
+ {
+ date: at("2027-09-30"),
+ date_format: 5,
+ platform: { id: 2, name: "B" },
+ },
+ {
+ date: at("2027-12-31"),
+ date_format: 6,
+ platform: { id: 3, name: "C" },
+ },
+ ]).map((entry) => entry.release),
+ ).toEqual([
+ { precision: "quarter", year: 2027, quarter: 2 },
+ { precision: "quarter", year: 2027, quarter: 3 },
+ { precision: "quarter", year: 2027, quarter: 4 },
+ ]);
+ });
+
+ it("reads the year from the UTC timestamp, late on 31 December", () => {
+ const lateNewYearsEve = Date.parse("2026-12-31T23:30:00Z") / 1000;
+ expect(
+ platformReleases([
+ { date: lateNewYearsEve, date_format: 6, platform: pc },
+ { date: lateNewYearsEve, date_format: 2, platform: ps5 },
+ ]).map((entry) => entry.release),
+ ).toEqual([
+ { precision: "quarter", year: 2026, quarter: 4 },
+ { precision: "year", date: "2026-12-31" },
+ ]);
+ });
+
+ it("orders several TBA platforms by name after every dated one", () => {
+ expect(
+ platformReleases([
+ { date_format: 7, platform: { id: 3, name: "xbox" } },
+ { date_format: 7, platform: { id: 4, name: "Switch" } },
+ { date: at("2040-01-01"), date_format: 0, platform: pc },
+ ]).map((entry) => entry.platform),
+ ).toEqual(["PC", "Switch", "xbox"]);
+ });
+
+ it("groups by platform id, keeping one line per platform", () => {
+ expect(
+ platformReleases([
+ { date: at("2020-01-01"), date_format: 0, platform: pc },
+ { date_format: 7, platform: pc },
+ { date: at("2019-06-01"), date_format: 1, platform: pc },
+ ]),
+ ).toEqual([
+ {
+ platformId: 6,
+ platform: "PC",
+ release: { precision: "month", date: "2019-06-01" },
+ },
+ ]);
+ });
+});
diff --git a/lib/igdb/normalize.ts b/lib/igdb/normalize.ts
index 6afd473d..36ce105f 100644
--- a/lib/igdb/normalize.ts
+++ b/lib/igdb/normalize.ts
@@ -3,15 +3,28 @@ import { isCatalogVisible } from "./catalog-filter";
import {
IGDB_ART_SIZE,
IGDB_COVER_SIZE,
+ IGDB_DATE_FORMAT,
IGDB_IMAGE_BASE,
+ IGDB_MAX_SCREENSHOTS,
IGDB_VISIBLE_GAME_TYPES,
} from "./constants";
import {
type GameDetailRecord,
type GameSummaryRecord,
+ involvedCompanySchema,
refSchema,
+ releaseDateSchema,
+ videoSchema,
} from "./schemas";
-import type { Game, GameRef, GameSummary, GameType } from "./types";
+import type {
+ Game,
+ GameRef,
+ GameReleaseDate,
+ GameSummary,
+ GameType,
+ GameVideo,
+ PlatformRelease,
+} from "./types";
/**
* IGDB's shape to the app's shape (spec 0023, AC-15, AC-16).
@@ -48,7 +61,40 @@ export function artUrl(record: {
}): string | null {
const id =
lowestImageId(record.artworks) ?? lowestImageId(record.screenshots);
- return id === null ? null : `${IGDB_IMAGE_BASE}/${IGDB_ART_SIZE}/${id}.jpg`;
+ return id === null ? null : artImageUrl(id);
+}
+
+/**
+ * A game page's screenshots (spec 0031, AC-6): deduplicated by `image_id`,
+ * ordered by it ascending, then capped at 12, each built at `t_1080p`. The
+ * order is stable rather than hand picked, the rule `artUrl` follows.
+ */
+export function screenshotUrls(images: ImageList): string[] {
+ const unique = new Set();
+ for (const image of images ?? []) {
+ const id = image?.image_id?.trim();
+ if (id) unique.add(id);
+ }
+ return [...unique]
+ .sort(compareStrings)
+ .slice(0, IGDB_MAX_SCREENSHOTS)
+ .map(artImageUrl);
+}
+
+/**
+ * One wide image at `t_1080p`, the ceiling the loader steps down from, so the
+ * backdrop and the screenshots can never drift to different sizes.
+ */
+function artImageUrl(imageId: string): string {
+ return `${IGDB_IMAGE_BASE}/${IGDB_ART_SIZE}/${imageId}.jpg`;
+}
+
+/**
+ * Plain code unit order, not `localeCompare`: the screenshot ids and the
+ * release date tie break must sort the same on every server, whatever its ICU.
+ */
+function compareStrings(a: string, b: string): number {
+ return a < b ? -1 : a > b ? 1 : 0;
}
function lowestImageId(images: ImageList): string | null {
@@ -129,6 +175,185 @@ function ids(values: readonly unknown[] | null | undefined): number[] {
return found;
}
+/** A YouTube id the embed URL can carry safely (spec 0031, AC-4). */
+const YOUTUBE_ID = /^[A-Za-z0-9_-]{6,20}$/;
+
+/**
+ * The one trailer a game page offers (spec 0031, AC-4): among the videos
+ * whose name says `trailer` and not `gameplay` (in any case) and whose id is
+ * a safe YouTube id, the one with the lowest IGDB id, since IGDB promises no
+ * array order. A gameplay trailer is left out on purpose: it shows play, not
+ * the game's pitch. With none left there is no trailer.
+ */
+export function pickGameTrailer(
+ values: readonly unknown[] | null | undefined,
+): GameVideo | null {
+ let chosen: { id: number; key: string; name: string } | null = null;
+ for (const value of values ?? []) {
+ const parsed = videoSchema.safeParse(value);
+ if (!parsed.success) continue;
+ const name = parsed.data.name?.trim() ?? "";
+ const lower = name.toLowerCase();
+ if (!lower.includes("trailer") || lower.includes("gameplay")) continue;
+ const key = parsed.data.video_id.trim();
+ if (!YOUTUBE_ID.test(key)) continue;
+ if (chosen === null || parsed.data.id < chosen.id) {
+ chosen = { id: parsed.data.id, key, name };
+ }
+ }
+ return chosen === null ? null : { key: chosen.key, name: chosen.name };
+}
+
+/**
+ * The companies in one role, in IGDB's `involved_companies` order (spec
+ * 0031, AC-5), deduplicated by company id, else by name ignoring case. A
+ * company with no name is dropped; one flagged both ways is in both lists.
+ */
+export function companyNames(
+ values: readonly unknown[] | null | undefined,
+ role: "developer" | "publisher",
+): string[] {
+ const seen = new Set();
+ const names: string[] = [];
+ for (const value of values ?? []) {
+ const parsed = involvedCompanySchema.safeParse(value);
+ if (!parsed.success || parsed.data[role] !== true) continue;
+ const name = parsed.data.company.name?.trim();
+ if (!name) continue;
+ const id = parsed.data.company.id;
+ const key =
+ typeof id === "number" ? `id:${id}` : `name:${name.toLowerCase()}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ names.push(name);
+ }
+ return names;
+}
+
+/** `GameRef`s deduplicated by id, the first entry winning (AC-9). */
+function uniqueRefs(values: GameRef[]): GameRef[] {
+ const seen = new Set();
+ return values.filter((ref) => {
+ if (seen.has(ref.id)) return false;
+ seen.add(ref.id);
+ return true;
+ });
+}
+
+/** `similar_games` in IGDB's order, deduplicated, without the game itself. */
+function similarIds(
+ values: readonly unknown[] | null | undefined,
+ gameId: number,
+): number[] {
+ return [...new Set(ids(values))].filter((id) => id !== gameId);
+}
+
+type DatedRelease = { timestamp: number; release: GameReleaseDate };
+
+type DateFormat = (typeof IGDB_DATE_FORMAT)[keyof typeof IGDB_DATE_FORMAT];
+
+/** IGDB's quarter formats as the quarter they name (AC-10). */
+const QUARTER = { q1: 1, q2: 2, q3: 3, q4: 4 } as const;
+
+/** The format an id names, or undefined for an id IGDB may add later. */
+function dateFormat(formatId: number): DateFormat | undefined {
+ return Object.hasOwn(IGDB_DATE_FORMAT, formatId)
+ ? IGDB_DATE_FORMAT[formatId as keyof typeof IGDB_DATE_FORMAT]
+ : undefined;
+}
+
+/**
+ * One release date entry at IGDB's stated precision, or null when it cannot
+ * be read (spec 0031, AC-10). An unknown or missing `date_format` drops the
+ * entry rather than reading it as a full day; TBD is undated even with a
+ * placeholder `date`. The year and month come from the UTC timestamp, the
+ * quarter from `date_format`.
+ */
+function readRelease(
+ formatId: number | null | undefined,
+ date: number | null | undefined,
+): DatedRelease | "tba" | null {
+ if (formatId === null || formatId === undefined) return null;
+ const format = dateFormat(formatId);
+ if (format === undefined) return null;
+ if (format === "tbd") return "tba";
+ const { releaseDate, releaseYear } = releaseDay(date);
+ if (
+ releaseDate === null ||
+ releaseYear === null ||
+ typeof date !== "number"
+ ) {
+ return null;
+ }
+ if (format === "day" || format === "month" || format === "year") {
+ return {
+ timestamp: date,
+ release: { precision: format, date: releaseDate },
+ };
+ }
+ return {
+ timestamp: date,
+ release: {
+ precision: "quarter",
+ year: releaseYear,
+ quarter: QUARTER[format],
+ },
+ };
+}
+
+/**
+ * The earliest release per platform (spec 0031, AC-10), grouped by platform
+ * id across every region. A platform with only TBD entries reads TBA, one
+ * with no usable entry is left out. Sorted by timestamp, TBA last, ties by
+ * platform name ignoring case.
+ */
+export function platformReleases(
+ values: readonly unknown[] | null | undefined,
+): PlatformRelease[] {
+ const byPlatform = new Map<
+ number,
+ { platform: string; dated: DatedRelease | null; tba: boolean }
+ >();
+ for (const value of values ?? []) {
+ const parsed = releaseDateSchema.safeParse(value);
+ if (!parsed.success) continue;
+ const platform = parsed.data.platform;
+ const name = platform?.name?.trim();
+ if (!platform || !name) continue;
+ const read = readRelease(parsed.data.date_format, parsed.data.date);
+ if (read === null) continue;
+ const entry = byPlatform.get(platform.id) ?? {
+ platform: name,
+ dated: null,
+ tba: false,
+ };
+ if (read === "tba") entry.tba = true;
+ else if (entry.dated === null || read.timestamp < entry.dated.timestamp) {
+ entry.dated = read;
+ }
+ byPlatform.set(platform.id, entry);
+ }
+
+ return [...byPlatform.entries()]
+ .map(([platformId, entry]) => ({
+ platformId,
+ platform: entry.platform,
+ timestamp: entry.dated?.timestamp ?? Number.POSITIVE_INFINITY,
+ release: entry.dated?.release ?? ({ precision: "tba" } as const),
+ }))
+ .sort((a, b) => {
+ if (a.timestamp !== b.timestamp) {
+ return a.timestamp < b.timestamp ? -1 : 1;
+ }
+ return compareStrings(a.platform.toLowerCase(), b.platform.toLowerCase());
+ })
+ .map(({ platformId, platform, release }) => ({
+ platformId,
+ platform,
+ release,
+ }));
+}
+
/** The catalog filter's verdict on a record read by id (AC-10). */
function isHidden(record: GameDetailRecord | GameSummaryRecord): boolean {
return !isCatalogVisible({
@@ -159,6 +384,23 @@ export function normalizeGame(record: GameDetailRecord): Game {
communityRating: communityRating(record.rating),
communityRatingCount: positiveOrNull(record.rating_count),
hidden: isHidden(record),
+ storyline: textOrNull(record.storyline),
+ // From the full lists, never the capped 12 (spec 0031, AC-3).
+ artUrl: artUrl(record),
+ screenshotUrls: screenshotUrls(record.screenshots),
+ trailer: pickGameTrailer(record.videos),
+ similarGameIds: similarIds(record.similar_games, record.id),
+ developers: companyNames(record.involved_companies, "developer"),
+ publishers: companyNames(record.involved_companies, "publisher"),
+ gameModes: refs(record.game_modes),
+ playerPerspectives: refs(record.player_perspectives),
+ franchises: uniqueRefs([
+ ...refs(record.franchise === undefined ? [] : [record.franchise]),
+ ...refs(record.franchises),
+ ]),
+ series: refs(record.collections),
+ gameEngines: refs(record.game_engines),
+ platformReleases: platformReleases(record.release_dates),
};
}
diff --git a/lib/igdb/query.ts b/lib/igdb/query.ts
index f9a171c8..0775b99a 100644
--- a/lib/igdb/query.ts
+++ b/lib/igdb/query.ts
@@ -25,7 +25,9 @@ export type IgdbField =
| "rating_count"
| "hypes"
| "popularity_type"
- | "value";
+ | "value"
+ | "date"
+ | "date_format";
declare const conditionBrand: unique symbol;
diff --git a/lib/igdb/schemas.ts b/lib/igdb/schemas.ts
index 484abfa8..0bf7480b 100644
--- a/lib/igdb/schemas.ts
+++ b/lib/igdb/schemas.ts
@@ -67,13 +67,72 @@ const baseGameSchema = z.object({
version_parent: versionParent,
});
-/** `getGame`: platforms, genres and themes expanded to `{ id, name }`. */
+/**
+ * `getGame`: platforms, genres and themes expanded to `{ id, name }`, plus
+ * the richer page's fields (spec 0031, AC-2). Every new list is loose, and
+ * `normalize.ts` checks each entry on its own, so one odd video, company or
+ * release date is dropped instead of failing the read. `franchise` is one
+ * reference, so it is loose too, and checked as a `GameRef`.
+ */
export const gameDetailSchema = baseGameSchema.extend({
slug: optionalText,
summary: optionalText,
+ storyline: optionalText,
platforms: looseArray,
genres: looseArray,
themes: looseArray,
+ artworks: imageListSchema,
+ screenshots: imageListSchema,
+ videos: looseArray,
+ similar_games: looseArray,
+ involved_companies: looseArray,
+ game_modes: looseArray,
+ player_perspectives: looseArray,
+ franchise: z.unknown().optional(),
+ franchises: looseArray,
+ collections: looseArray,
+ game_engines: looseArray,
+ release_dates: looseArray,
+});
+
+/** One of `videos`: a YouTube id and IGDB's name for it (AC-4). */
+export const videoSchema = z.object({
+ id: z.number().int().positive(),
+ name: z.string().nullish(),
+ video_id: z.string(),
+});
+
+/**
+ * One of `involved_companies` (AC-5). A company with no name is dropped by
+ * `normalize.ts`; the flags read false when absent.
+ */
+export const involvedCompanySchema = z.object({
+ company: z.object({
+ id: z.number().int().positive().nullish(),
+ name: z.string().nullish(),
+ }),
+ developer: z.boolean().nullish(),
+ publisher: z.boolean().nullish(),
+});
+
+/**
+ * One of `release_dates` (AC-10). `date_format` reads as a bare id or an
+ * expanded `{ id }`, and `optionalId` turns an odd one into null, which drops
+ * the entry.
+ */
+export const releaseDateSchema = z.object({
+ date: optionalNumber,
+ date_format: z
+ .union([z.number().int(), z.object({ id: z.number().int() })])
+ .nullish()
+ .catch(null)
+ .transform((value) =>
+ typeof value === "object" && value !== null ? value.id : value,
+ ),
+ platform: z
+ .object({ id: z.number().int().positive(), name: z.string().nullish() })
+ .nullish()
+ .catch(null),
});
/**
diff --git a/lib/igdb/surface.test.ts b/lib/igdb/surface.test.ts
index ed35bee5..73f0ff34 100644
--- a/lib/igdb/surface.test.ts
+++ b/lib/igdb/surface.test.ts
@@ -48,7 +48,7 @@ function exportedTypeNames(source: string): string[] {
}
describe("lib/igdb/index.ts types", () => {
- it("exports exactly the types in spec 0023's design section, plus spec 0028's", () => {
+ it("exports exactly the types in spec 0023's design section, plus spec 0028's and 0031's", () => {
const source = readFileSync(join("lib", "igdb", "index.ts"), "utf8");
expect(exportedTypeNames(source)).toEqual(
[
@@ -57,10 +57,13 @@ describe("lib/igdb/index.ts types", () => {
"DiscoverSort",
"Game",
"GameRef",
+ "GameReleaseDate",
"GameSummary",
"GameType",
+ "GameVideo",
"IgdbErrorKind",
"Paged",
+ "PlatformRelease",
"PopularGamesKind",
"SearchGamesOptions",
].sort(),
diff --git a/lib/igdb/types.ts b/lib/igdb/types.ts
index be57ef1e..ee700208 100644
--- a/lib/igdb/types.ts
+++ b/lib/igdb/types.ts
@@ -39,6 +39,49 @@ export type Game = {
communityRatingCount: number | null;
/** True when the game fails the catalog filter (AC-10). */
hidden: boolean;
+ /** The richer game page's fields (spec 0031). A list with none is `[]`. */
+ storyline: string | null;
+ /** Wide 16:9 art for the hero backdrop, the rule `GameSummary` uses. */
+ artUrl: string | null;
+ /** At most 12, built at `t_1080p`, by `image_id` ascending (AC-6). */
+ screenshotUrls: string[];
+ trailer: GameVideo | null;
+ /** IGDB's `similar_games` in order, deduplicated, without this game. */
+ similarGameIds: number[];
+ developers: string[];
+ publishers: string[];
+ gameModes: GameRef[];
+ playerPerspectives: GameRef[];
+ /** The main `franchise` first, then the other `franchises`. */
+ franchises: GameRef[];
+ /** IGDB's `collections`, shown as Series. */
+ series: GameRef[];
+ gameEngines: GameRef[];
+ /** One per platform, already sorted, TBA last (AC-10). */
+ platformReleases: PlatformRelease[];
+};
+
+/** The trailer a game page offers: a YouTube id and IGDB's name (AC-4). */
+export type GameVideo = { key: string; name: string };
+
+/**
+ * A release date at the precision IGDB states (AC-10). A day, month or year
+ * carries the UTC `YYYY-MM-DD` of IGDB's timestamp; a quarter carries its
+ * year and number; `tba` is IGDB's own TBD.
+ */
+export type GameReleaseDate =
+ | { precision: "day" | "month" | "year"; date: string }
+ | { precision: "quarter"; year: number; quarter: 1 | 2 | 3 | 4 }
+ | { precision: "tba" };
+
+/**
+ * One platform's earliest release (AC-10). `platformId` is the grouping key,
+ * kept so two platforms IGDB names alike still render as two entries.
+ */
+export type PlatformRelease = {
+ platformId: number;
+ platform: string;
+ release: GameReleaseDate;
};
/** What a card, a batch, a search and a discover read carry. */