Step 10C.1: map art manifest and content sprite keys

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 19:02:57 -04:00
parent 23b40a4324
commit 102c5bba91
12 changed files with 103 additions and 1 deletions

View File

@ -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. - **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. - **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.

View File

@ -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" }
}
}

View File

@ -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<string, { kind: ArtKind; file: string | null }>;
}
export const artManifest = manifestJson as ArtManifest;

View File

@ -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);
}
});
});

View File

@ -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/App.tsx` | Clock sync; loading / start screen / routed layout |
| `src/layout/Layout.tsx` | Header (credits, game clock), screen outlet, bottom tab bar | | `src/layout/Layout.tsx` | Header (credits, game clock), screen outlet, bottom tab bar |
| `src/routes.ts` | Screen list shared by router and navigation | | `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/screens/*` | HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
| `src/components/*` | `AwaySummary` dialog, `Countdown` | | `src/components/*` | `AwaySummary` dialog, `Countdown` |
| `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) | | `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) |

View File

@ -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. 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.2 — Asset optimization script
- [ ] Step 10C.3 — Map style config - [ ] Step 10C.3 — Map style config
- [ ] Step 10C.4 — Asset loading and fallback - [ ] Step 10C.4 — Asset loading and fallback

View File

@ -12,6 +12,7 @@ export const locations: Location[] = [
type: "atmospheric-world", type: "atmospheric-world",
landingRequirement: "atmospheric", landingRequirement: "atmospheric",
body: "earth", body: "earth",
sprite: "body-earth",
launchCostMultiplier: 3, launchCostMultiplier: 3,
startsDiscovered: true, startsDiscovered: true,
bayLeasePrice: 1000, bayLeasePrice: 1000,
@ -24,6 +25,7 @@ export const locations: Location[] = [
type: "airless-body", type: "airless-body",
landingRequirement: "vacuum", landingRequirement: "vacuum",
body: "moon", body: "moon",
sprite: "body-moon",
launchCostMultiplier: 0.5, launchCostMultiplier: 0.5,
startsDiscovered: true, startsDiscovered: true,
bayLeasePrice: 800, bayLeasePrice: 800,
@ -36,6 +38,7 @@ export const locations: Location[] = [
type: "atmospheric-world", type: "atmospheric-world",
landingRequirement: "atmospheric", landingRequirement: "atmospheric",
body: "mars", body: "mars",
sprite: "body-mars",
launchCostMultiplier: 1.5, launchCostMultiplier: 1.5,
startsDiscovered: false, startsDiscovered: false,
bayLeasePrice: 20000, bayLeasePrice: 20000,
@ -48,6 +51,7 @@ export const locations: Location[] = [
type: "belt-station", type: "belt-station",
landingRequirement: "vacuum", landingRequirement: "vacuum",
body: "ceres", body: "ceres",
sprite: "body-ceres",
launchCostMultiplier: 0.3, launchCostMultiplier: 0.3,
startsDiscovered: false, startsDiscovered: false,
bayLeasePrice: 40000, bayLeasePrice: 40000,
@ -60,6 +64,8 @@ export const locations: Location[] = [
type: "cloud-platform", type: "cloud-platform",
landingRequirement: "atmospheric", landingRequirement: "atmospheric",
body: "jupiter", body: "jupiter",
sprite: "body-jupiter",
badge: "badge-cloud-platform",
launchCostMultiplier: 2, launchCostMultiplier: 2,
startsDiscovered: false, startsDiscovered: false,
bayLeasePrice: 80000, bayLeasePrice: 80000,

View File

@ -8,6 +8,7 @@ import type { ShipModel } from "../schemas";
export const shipModels: ShipModel[] = [ export const shipModels: ShipModel[] = [
{ {
id: "courier", id: "courier",
sprite: "ship-courier",
name: "Courier", name: "Courier",
archetype: "courier", archetype: "courier",
price: 4000, price: 4000,
@ -23,6 +24,7 @@ export const shipModels: ShipModel[] = [
}, },
{ {
id: "freighter", id: "freighter",
sprite: "ship-freighter",
name: "Freighter", name: "Freighter",
archetype: "freighter", archetype: "freighter",
price: 100000, price: 100000,
@ -38,6 +40,7 @@ export const shipModels: ShipModel[] = [
}, },
{ {
id: "bulk-hauler", id: "bulk-hauler",
sprite: "ship-bulk-hauler",
name: "Bulk Hauler", name: "Bulk Hauler",
archetype: "bulk-hauler", archetype: "bulk-hauler",
price: 400000, price: 400000,
@ -55,6 +58,7 @@ export const shipModels: ShipModel[] = [
// The company's tutorial loaner (decision 0.5.29): not for sale. Faster with // 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. // less overhead than the Courier, so a full Earth–Moon run is under a real minute.
id: "company-courier", id: "company-courier",
sprite: "ship-company-courier",
name: "Company Courier", name: "Company Courier",
archetype: "courier", archetype: "courier",
price: 4000, price: 4000,

View File

@ -45,6 +45,10 @@ export const locationSchema = z.object({
produces: z.array(idSchema), produces: z.array(idSchema),
/** Cargo type ids. */ /** Cargo type ids. */
demands: z.array(idSchema), 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<typeof locationSchema>; export type Location = z.infer<typeof locationSchema>;
@ -69,6 +73,8 @@ export const shipModelSchema = z.object({
fuelCostPerMillionKm: z.number().nonnegative(), fuelCostPerMillionKm: z.number().nonnegative(),
/** False for company-only ships such as the tutorial loaner (decision 0.5.29). */ /** False for company-only ships such as the tutorial loaner (decision 0.5.29). */
purchasable: z.boolean(), purchasable: z.boolean(),
/** Map art asset id (CR-003), e.g. `ship-freighter`. */
sprite: idSchema,
}); });
export type ShipModel = z.infer<typeof shipModelSchema>; export type ShipModel = z.infer<typeof shipModelSchema>;

View File

@ -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);
});
}); });

View File

@ -19,6 +19,7 @@ const validLocation = {
launchCostMultiplier: 3, launchCostMultiplier: 3,
startsDiscovered: true, startsDiscovered: true,
bayLeasePrice: 5000, bayLeasePrice: 5000,
sprite: "body-mars",
produces: ["standard"], produces: ["standard"],
demands: ["heavy"], demands: ["heavy"],
}; };
@ -36,6 +37,7 @@ const validShipModel = {
overheadGameHours: 2, overheadGameHours: 2,
fuelCostPerMillionKm: 100, fuelCostPerMillionKm: 100,
purchasable: true, purchasable: true,
sprite: "ship-courier",
}; };
const validCargoType = { const validCargoType = {

View File

@ -44,4 +44,10 @@ describe("ship models", () => {
expect(ship("courier").price).toBeLessThan(ship("freighter").price); expect(ship("courier").price).toBeLessThan(ship("freighter").price);
expect(ship("freighter").price).toBeLessThan(ship("bulk-hauler").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);
});
}); });