From 102c5bba919a1644d4ca2e126a38f8fc383c5179 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 19:02:57 -0400 Subject: [PATCH] Step 10C.1: map art manifest and content sprite keys Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 8 +++++++ apps/web/src/map/art/manifest.json | 17 ++++++++++++++ apps/web/src/map/art/manifest.ts | 16 +++++++++++++ apps/web/test/art-manifest.test.ts | 30 +++++++++++++++++++++++++ memory-bank/architecture.md | 1 + memory-bank/progress.md | 2 +- packages/content/src/data/locations.ts | 6 +++++ packages/content/src/data/ships.ts | 4 ++++ packages/content/src/schemas.ts | 6 +++++ packages/content/test/locations.test.ts | 6 +++++ packages/content/test/schemas.test.ts | 2 ++ packages/content/test/ships.test.ts | 6 +++++ 12 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/map/art/manifest.json create mode 100644 apps/web/src/map/art/manifest.ts create mode 100644 apps/web/test/art-manifest.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index c9544ca..0e3f932 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -678,3 +678,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Change request CR-003 is stored in `memory-bank/change-requests/`** and inserted as Phase 10C, after Phase 10B and before Phase 11. - **Decision numbering:** the CR's 0.5.29–0.5.33 are recorded as **0.5.37 Map art pipeline, 0.5.38 Constant screen size, 0.5.39 Child bodies, 0.5.40 Background, 0.5.41 Fallback**, since 29–36 were already in use. + +## Step 10C.1 + +- **Inventory:** `bg.png`, `body-sun.png`, `body-earth.png`, `body-moon.png`, `body-mars.png`, `body-ceres.png`, `body-jupiter.png`, `ship-courier.png`, `ship-freighter.png`, `ship-bulk-hauler.png`, `ship-company-courier.png`. Every name matched exactly one asset id; nothing was ambiguous. +- **The Sun's file is `body-sun.png`**; its asset id stays `sun` as in the CR. +- **Missing: `badge-cloud-platform`** (the CR marks it optional). It is in the manifest with `file: null`, Jupiter Cloud Platform keeps `badge: "badge-cloud-platform"` in content, and the map simply draws no badge (fallback, decision 0.5.41). +- **The manifest is JSON** (`apps/web/src/map/art/manifest.json`) with a `kind` per asset (`background`, `body`, `badge`, `ship`), so both the app (typed wrapper `manifest.ts`) and the Node optimization script read the same file. +- **The Sun is not a location**, so its sprite key lives only in the manifest; the map style will refer to `sun` directly. diff --git a/apps/web/src/map/art/manifest.json b/apps/web/src/map/art/manifest.json new file mode 100644 index 0000000..74deb2b --- /dev/null +++ b/apps/web/src/map/art/manifest.json @@ -0,0 +1,17 @@ +{ + "sourceDir": "public/assets/map", + "assets": { + "bg": { "kind": "background", "file": "bg.png" }, + "sun": { "kind": "body", "file": "body-sun.png" }, + "body-earth": { "kind": "body", "file": "body-earth.png" }, + "body-moon": { "kind": "body", "file": "body-moon.png" }, + "body-mars": { "kind": "body", "file": "body-mars.png" }, + "body-ceres": { "kind": "body", "file": "body-ceres.png" }, + "body-jupiter": { "kind": "body", "file": "body-jupiter.png" }, + "badge-cloud-platform": { "kind": "badge", "file": null }, + "ship-courier": { "kind": "ship", "file": "ship-courier.png" }, + "ship-freighter": { "kind": "ship", "file": "ship-freighter.png" }, + "ship-bulk-hauler": { "kind": "ship", "file": "ship-bulk-hauler.png" }, + "ship-company-courier": { "kind": "ship", "file": "ship-company-courier.png" } + } +} diff --git a/apps/web/src/map/art/manifest.ts b/apps/web/src/map/art/manifest.ts new file mode 100644 index 0000000..d1fa599 --- /dev/null +++ b/apps/web/src/map/art/manifest.ts @@ -0,0 +1,16 @@ +import manifestJson from "./manifest.json"; + +/** + * Map art (CR-003, decision 0.5.37): logical asset id → source file in the + * user's map assets folder. A `null` file means the asset is expected but + * missing, so the map draws its placeholder (decision 0.5.41). + */ +export type ArtKind = "background" | "body" | "badge" | "ship"; + +export interface ArtManifest { + /** Source folder, relative to `apps/web`. Never written to. */ + sourceDir: string; + assets: Record; +} + +export const artManifest = manifestJson as ArtManifest; diff --git a/apps/web/test/art-manifest.test.ts b/apps/web/test/art-manifest.test.ts new file mode 100644 index 0000000..4e20aef --- /dev/null +++ b/apps/web/test/art-manifest.test.ts @@ -0,0 +1,30 @@ +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { content } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { artManifest } from "../src/map/art/manifest"; + +const webRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); + +describe("map art manifest (CR-003)", () => { + it("has an entry for every location's and ship model's sprite and badge", () => { + const keys = [ + ...content.locations.flatMap((l) => [l.sprite, ...(l.badge ? [l.badge] : [])]), + ...content.shipModels.map((m) => m.sprite), + ]; + for (const key of keys) expect(artManifest.assets, key).toHaveProperty([key]); + expect(artManifest.assets).toHaveProperty(["bg"]); + expect(artManifest.assets).toHaveProperty(["sun"]); + }); + + it("names only files that exist in the source folder", () => { + for (const [id, asset] of Object.entries(artManifest.assets)) { + if (asset.file === null) continue; + expect( + existsSync(join(webRoot, artManifest.sourceDir, asset.file)), + `${id}: ${asset.file}`, + ).toBe(true); + } + }); +}); diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 352f423..560129c 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -450,6 +450,7 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen | `src/App.tsx` | Clock sync; loading / start screen / routed layout | | `src/layout/Layout.tsx` | Header (credits, game clock), screen outlet, bottom tab bar | | `src/routes.ts` | Screen list shared by router and navigation | +| `src/map/art/manifest.json` | Map art manifest (CR-003): asset id → `{ kind, file }` in `public/assets/map/` (never edited); `null` file = missing, placeholder used | | `src/screens/*` | HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen | | `src/components/*` | `AwaySummary` dialog, `Countdown` | | `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) | diff --git a/memory-bank/progress.md b/memory-bank/progress.md index ba2cb5d..600dcb8 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -149,7 +149,7 @@ Spec: `memory-bank/change-requests/CR-002-open-markets.md`. New decisions record Spec: `memory-bank/change-requests/CR-003-map-art.md`. New decisions recorded as plan §0.5.37–0.5.41. -- [ ] Step 10C.1 — Inventory and asset manifest +- [x] **Step 10C.1 — Inventory and asset manifest.** `apps/web/src/map/art/manifest.json` maps 12 asset ids to the 11 files in `apps/web/public/assets/map/` (all 1254×1254; sprites RGBA). `badge-cloud-platform` is listed with `file: null` (not provided). Content: `sprite` on every location and ship model, `badge` on Jupiter Cloud Platform. Tests: `art-manifest.test.ts`, content sprite-key tests. - [ ] Step 10C.2 — Asset optimization script - [ ] Step 10C.3 — Map style config - [ ] Step 10C.4 — Asset loading and fallback diff --git a/packages/content/src/data/locations.ts b/packages/content/src/data/locations.ts index 0c040e4..d6dbc15 100644 --- a/packages/content/src/data/locations.ts +++ b/packages/content/src/data/locations.ts @@ -12,6 +12,7 @@ export const locations: Location[] = [ type: "atmospheric-world", landingRequirement: "atmospheric", body: "earth", + sprite: "body-earth", launchCostMultiplier: 3, startsDiscovered: true, bayLeasePrice: 1000, @@ -24,6 +25,7 @@ export const locations: Location[] = [ type: "airless-body", landingRequirement: "vacuum", body: "moon", + sprite: "body-moon", launchCostMultiplier: 0.5, startsDiscovered: true, bayLeasePrice: 800, @@ -36,6 +38,7 @@ export const locations: Location[] = [ type: "atmospheric-world", landingRequirement: "atmospheric", body: "mars", + sprite: "body-mars", launchCostMultiplier: 1.5, startsDiscovered: false, bayLeasePrice: 20000, @@ -48,6 +51,7 @@ export const locations: Location[] = [ type: "belt-station", landingRequirement: "vacuum", body: "ceres", + sprite: "body-ceres", launchCostMultiplier: 0.3, startsDiscovered: false, bayLeasePrice: 40000, @@ -60,6 +64,8 @@ export const locations: Location[] = [ type: "cloud-platform", landingRequirement: "atmospheric", body: "jupiter", + sprite: "body-jupiter", + badge: "badge-cloud-platform", launchCostMultiplier: 2, startsDiscovered: false, bayLeasePrice: 80000, diff --git a/packages/content/src/data/ships.ts b/packages/content/src/data/ships.ts index 5a1bc99..86c5730 100644 --- a/packages/content/src/data/ships.ts +++ b/packages/content/src/data/ships.ts @@ -8,6 +8,7 @@ import type { ShipModel } from "../schemas"; export const shipModels: ShipModel[] = [ { id: "courier", + sprite: "ship-courier", name: "Courier", archetype: "courier", price: 4000, @@ -23,6 +24,7 @@ export const shipModels: ShipModel[] = [ }, { id: "freighter", + sprite: "ship-freighter", name: "Freighter", archetype: "freighter", price: 100000, @@ -38,6 +40,7 @@ export const shipModels: ShipModel[] = [ }, { id: "bulk-hauler", + sprite: "ship-bulk-hauler", name: "Bulk Hauler", archetype: "bulk-hauler", price: 400000, @@ -55,6 +58,7 @@ export const shipModels: ShipModel[] = [ // The company's tutorial loaner (decision 0.5.29): not for sale. Faster with // less overhead than the Courier, so a full Earth–Moon run is under a real minute. id: "company-courier", + sprite: "ship-company-courier", name: "Company Courier", archetype: "courier", price: 4000, diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts index e898c4b..22d5cb3 100644 --- a/packages/content/src/schemas.ts +++ b/packages/content/src/schemas.ts @@ -45,6 +45,10 @@ export const locationSchema = z.object({ produces: z.array(idSchema), /** Cargo type ids. */ demands: z.array(idSchema), + /** Map art asset id (CR-003), e.g. `body-earth`; see the web app's art manifest. */ + sprite: idSchema, + /** Optional map badge asset id drawn on the body's edge (CR-003). */ + badge: idSchema.optional(), }); export type Location = z.infer; @@ -69,6 +73,8 @@ export const shipModelSchema = z.object({ fuelCostPerMillionKm: z.number().nonnegative(), /** False for company-only ships such as the tutorial loaner (decision 0.5.29). */ purchasable: z.boolean(), + /** Map art asset id (CR-003), e.g. `ship-freighter`. */ + sprite: idSchema, }); export type ShipModel = z.infer; diff --git a/packages/content/test/locations.test.ts b/packages/content/test/locations.test.ts index 075700d..332b23c 100644 --- a/packages/content/test/locations.test.ts +++ b/packages/content/test/locations.test.ts @@ -36,4 +36,10 @@ describe("locations", () => { } } }); + + it("gives every location its own map sprite key (CR-003)", () => { + const sprites = locations.map((x) => x.sprite); + for (const sprite of sprites) expect(sprite).toMatch(/^[a-z][a-z-]*$/); + expect(new Set(sprites).size).toBe(sprites.length); + }); }); diff --git a/packages/content/test/schemas.test.ts b/packages/content/test/schemas.test.ts index a5683f3..4c5afbe 100644 --- a/packages/content/test/schemas.test.ts +++ b/packages/content/test/schemas.test.ts @@ -19,6 +19,7 @@ const validLocation = { launchCostMultiplier: 3, startsDiscovered: true, bayLeasePrice: 5000, + sprite: "body-mars", produces: ["standard"], demands: ["heavy"], }; @@ -36,6 +37,7 @@ const validShipModel = { overheadGameHours: 2, fuelCostPerMillionKm: 100, purchasable: true, + sprite: "ship-courier", }; const validCargoType = { diff --git a/packages/content/test/ships.test.ts b/packages/content/test/ships.test.ts index 8be9fa6..619cef2 100644 --- a/packages/content/test/ships.test.ts +++ b/packages/content/test/ships.test.ts @@ -44,4 +44,10 @@ describe("ship models", () => { expect(ship("courier").price).toBeLessThan(ship("freighter").price); expect(ship("freighter").price).toBeLessThan(ship("bulk-hauler").price); }); + + it("gives every ship model its own map sprite key (CR-003)", () => { + const sprites = shipModels.map((x) => x.sprite); + for (const sprite of sprites) expect(sprite).toMatch(/^[a-z][a-z-]*$/); + expect(new Set(sprites).size).toBe(sprites.length); + }); });