Step 16.5: assets: sprites and belt rocks
CI / check (push) Has been cancelled Details

The art script slices belt-asteroids.png into its six separate rocks
(connected regions of visible pixels, each cut out on its own) and
exports them at 64 and 128 px; an optional belt-haze.png strip is
supported. The source images are only read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-10 13:46:55 -04:00
parent 50d23436aa
commit 89b2e2689c
21 changed files with 275 additions and 25 deletions

View File

@ -1520,3 +1520,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
## Step 16.4 ## Step 16.4
- **No save migration for new locations**: CR-016 §9 asks for one that adds the three places as undiscovered, but the save (since CR-014) stores only discovered places, so there is nothing to add. Bumping the save version for no data change would only make older builds refuse newer saves for no reason; the test shows an older save opens correctly instead. - **No save migration for new locations**: CR-016 §9 asks for one that adds the three places as undiscovered, but the save (since CR-014) stores only discovered places, so there is nothing to add. Bumping the save version for no data change would only make older builds refuse newer saves for no reason; the test shows an older save opens correctly instead.
## Step 16.5
- **The manifest declares how many rocks the sheet holds** (`pieces: 6`), and the script checks it: the loader needs to know the file names without reading the folder, and a changed sheet then fails loudly instead of silently loading the wrong set.
- **Slicing uses alpha ≥ 16 and drops everything else around a rock**: the user's rocks have a soft glow that reaches toward their neighbours; below 16/255 it is invisible on the map, and keeping it would put slivers of other rocks' glow in each sprite.
- **Rocks are exported at 64 and 128 px** (the CR's sizes), named `belt-rocks-<n>@1x/@2x`, in reading order of the sheet.

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@ -8,13 +8,22 @@
import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { sliceSheet } from "./slice.mjs";
const webRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const webRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const manifest = JSON.parse(readFileSync(join(webRoot, "src/map/art/manifest.json"), "utf8")); const manifest = JSON.parse(readFileSync(join(webRoot, "src/map/art/manifest.json"), "utf8"));
/** Long-side sizes in px for the 1x and 2x variants of each sprite kind. */ /** Long-side sizes in px for the 1x and 2x variants of each sprite kind. */
export const SPRITE_SIZES = { body: [256, 512], badge: [256, 512], ship: [128, 256] }; export const SPRITE_SIZES = {
body: [256, 512],
badge: [256, 512],
ship: [128, 256],
// Each rock sliced from a sheet (CR-016 §5).
rocks: [64, 128],
};
export const BACKGROUND_MAX = 2048; export const BACKGROUND_MAX = 2048;
/** A seamless strip (the belt's haze, CR-016 §5) is kept within this size. */
export const STRIP_MAX = { width: 4096, height: 256 };
export const DEFAULT_OUT_DIR = join(webRoot, "public/assets/map-gen"); export const DEFAULT_OUT_DIR = join(webRoot, "public/assets/map-gen");
/** Every file the script writes for the manifest, relative to the output folder. */ /** Every file the script writes for the manifest, relative to the output folder. */
@ -23,7 +32,14 @@ export function expectedOutputs() {
for (const [id, asset] of Object.entries(manifest.assets)) { for (const [id, asset] of Object.entries(manifest.assets)) {
if (asset.file === null) continue; if (asset.file === null) continue;
if (asset.kind === "background") outputs.push({ id, file: `${id}.webp`, kind: asset.kind }); if (asset.kind === "background") outputs.push({ id, file: `${id}.webp`, kind: asset.kind });
else { else if (asset.kind === "strip") outputs.push({ id, file: `${id}.png`, kind: asset.kind });
else if (asset.kind === "rocks") {
const [one, two] = SPRITE_SIZES.rocks;
for (let i = 1; i <= asset.pieces; i++) {
outputs.push({ id, file: `${id}-${i}@1x.png`, kind: asset.kind, longSide: one });
outputs.push({ id, file: `${id}-${i}@2x.png`, kind: asset.kind, longSide: two });
}
} else {
const [one, two] = SPRITE_SIZES[asset.kind]; const [one, two] = SPRITE_SIZES[asset.kind];
outputs.push({ id, file: `${id}@1x.png`, kind: asset.kind, longSide: one }); outputs.push({ id, file: `${id}@1x.png`, kind: asset.kind, longSide: one });
outputs.push({ id, file: `${id}@2x.png`, kind: asset.kind, longSide: two }); outputs.push({ id, file: `${id}@2x.png`, kind: asset.kind, longSide: two });
@ -54,18 +70,42 @@ async function generate(sharp, outDir) {
.toFile(join(outDir, `${id}.webp`)); .toFile(join(outDir, `${id}.webp`));
continue; continue;
} }
if (asset.kind === "strip") {
await sharp(source)
.resize({ ...STRIP_MAX, fit: "inside", withoutEnlargement: true })
.png(png)
.toFile(join(outDir, `${id}.png`));
continue;
}
if (asset.kind === "rocks") {
// A sheet of separate rocks: one sprite per rock (CR-016 §5).
const pieces = await sliceSheet(sharp, source);
if (pieces.length !== asset.pieces) {
throw new Error(
`map-art: ${asset.file} has ${pieces.length} separate sprites; the manifest says ${asset.pieces}`,
);
}
for (const [i, piece] of pieces.entries()) {
await writeSizes(sharp, piece, SPRITE_SIZES.rocks, join(outDir, `${id}-${i + 1}`));
}
continue;
}
// Trim transparent padding once, then export each size from the trimmed image. // Trim transparent padding once, then export each size from the trimmed image.
const trimmed = await sharp(source).trim().png().toBuffer(); const trimmed = await sharp(source).trim().png().toBuffer();
const [one, two] = SPRITE_SIZES[asset.kind]; await writeSizes(sharp, trimmed, SPRITE_SIZES[asset.kind], join(outDir, id));
for (const [suffix, size] of [ }
["1x", one], }
["2x", two],
]) { /** Writes `<base>@1x.png` and `<base>@2x.png` with the given long sides. */
await sharp(trimmed) async function writeSizes(sharp, image, [one, two], base) {
.resize({ width: size, height: size, fit: "inside" }) for (const [suffix, size] of [
.png(png) ["1x", one],
.toFile(join(outDir, `${id}@${suffix}.png`)); ["2x", two],
} ]) {
await sharp(image)
.resize({ width: size, height: size, fit: "inside" })
.png(png)
.toFile(`${base}@${suffix}.png`);
} }
} }

View File

@ -0,0 +1,88 @@
// Splits a sheet of separate sprites (e.g. the belt's rocks, CR-016) into one
// image per sprite, by connected regions of visible pixels. Read-only on the source.
/** Pixels at least this opaque belong to a sprite; fainter glow is dropped. */
export const MIN_ALPHA = 16;
/** Regions smaller than this share of the sheet are stray specks, not sprites. */
export const MIN_AREA_SHARE = 0.002;
/**
* Labels the connected regions (4-neighbour) of pixels with alpha ≥ MIN_ALPHA.
* Returns the label per pixel (0 = none) and each kept region's bounding box,
* in reading order (by row of their top edge, then left to right).
*/
export function findRegions(alpha, width, height) {
const labels = new Int32Array(width * height);
const minArea = Math.max(1, Math.floor(width * height * MIN_AREA_SHARE));
const regions = [];
let next = 1;
for (let start = 0; start < width * height; start++) {
if (labels[start] !== 0 || alpha[start] < MIN_ALPHA) continue;
const label = next++;
let area = 0;
let [left, top, right, bottom] = [width, height, -1, -1];
const stack = [start];
labels[start] = label;
while (stack.length > 0) {
const p = stack.pop();
area++;
const x = p % width;
const y = (p - x) / width;
if (x < left) left = x;
if (x > right) right = x;
if (y < top) top = y;
if (y > bottom) bottom = y;
for (const q of [p - 1, p + 1, p - width, p + width]) {
if (q < 0 || q >= width * height) continue;
if ((q === p - 1 || q === p + 1) && Math.floor(q / width) !== y) continue;
if (labels[q] === 0 && alpha[q] >= MIN_ALPHA) {
labels[q] = label;
stack.push(q);
}
}
}
if (area >= minArea) {
regions.push({ label, left, top, width: right - left + 1, height: bottom - top + 1, area });
}
}
// Reading order: rows of sprites (tops within half the smallest sprite's height), then x.
const rowTolerance = Math.max(1, Math.min(...regions.map((r) => r.height)) / 2);
regions.sort((a, b) =>
Math.abs(a.top - b.top) <= rowTolerance ? a.left - b.left : a.top - b.top,
);
return { labels, regions };
}
/**
* Cuts each sprite out of the sheet as its own trimmed PNG buffer (RGBA): the
* region's bounding box, with every pixel that isn't part of that region made
* fully transparent (so a neighbour's glow never comes along).
*/
export async function sliceSheet(sharp, source) {
const { data, info } = await sharp(source)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const { width, height } = info;
const alpha = new Uint8Array(width * height);
for (let i = 0; i < width * height; i++) alpha[i] = data[i * 4 + 3];
const { labels, regions } = findRegions(alpha, width, height);
const pieces = [];
for (const region of regions) {
const out = Buffer.alloc(region.width * region.height * 4);
for (let y = 0; y < region.height; y++) {
for (let x = 0; x < region.width; x++) {
const src = (region.top + y) * width + region.left + x;
const dst = (y * region.width + x) * 4;
if (labels[src] !== region.label) continue;
data.copy(out, dst, src * 4, src * 4 + 4);
}
}
pieces.push(
await sharp(out, { raw: { width: region.width, height: region.height, channels: 4 } })
.png()
.toBuffer(),
);
}
return pieces;
}

View File

@ -1,4 +1,4 @@
import { artManifest } from "./manifest"; import { artManifest, pieceIds } from "./manifest";
/** Where `scripts/map-art.mjs` output is served from (`public/assets/map-gen`). */ /** Where `scripts/map-art.mjs` output is served from (`public/assets/map-gen`). */
export const ART_BASE_URL = "/assets/map-gen"; export const ART_BASE_URL = "/assets/map-gen";
@ -10,10 +10,18 @@ export function artBundle(devicePixelRatio: number): Record<string, string> {
const bundle: Record<string, string> = {}; const bundle: Record<string, string> = {};
for (const [id, asset] of Object.entries(artManifest.assets)) { for (const [id, asset] of Object.entries(artManifest.assets)) {
if (asset.file === null) continue; if (asset.file === null) continue;
if (asset.kind === "rocks") {
for (const piece of pieceIds(id, asset)) {
bundle[piece] = `${ART_BASE_URL}/${piece}@${density}.png`;
}
continue;
}
bundle[id] = bundle[id] =
asset.kind === "background" asset.kind === "background"
? `${ART_BASE_URL}/${id}.webp` ? `${ART_BASE_URL}/${id}.webp`
: `${ART_BASE_URL}/${id}@${density}.png`; : asset.kind === "strip"
? `${ART_BASE_URL}/${id}.png`
: `${ART_BASE_URL}/${id}@${density}.png`;
} }
return bundle; return bundle;
} }

View File

@ -68,6 +68,15 @@
"ship-clipper": { "ship-clipper": {
"kind": "ship", "kind": "ship",
"file": "ship-clipper.png" "file": "ship-clipper.png"
},
"belt-rocks": {
"kind": "rocks",
"file": "belt-asteroids.png",
"pieces": 6
},
"belt-haze": {
"kind": "strip",
"file": null
} }
} }
} }

View File

@ -5,12 +5,27 @@ import manifestJson from "./manifest.json";
* user's map assets folder. A `null` file means the asset is expected but * 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). * missing, so the map draws its placeholder (decision 0.5.41).
*/ */
export type ArtKind = "background" | "body" | "badge" | "ship"; export type ArtKind = "background" | "body" | "badge" | "ship" | "rocks" | "strip";
export interface ArtAsset {
kind: ArtKind;
file: string | null;
/**
* `rocks` only (CR-016): how many separate sprites the sheet holds. The
* script slices it into `<id>-1` … `<id>-<pieces>` and checks the count.
*/
pieces?: number;
}
export interface ArtManifest { export interface ArtManifest {
/** Source folder, relative to `apps/web`. Never written to. */ /** Source folder, relative to `apps/web`. Never written to. */
sourceDir: string; sourceDir: string;
assets: Record<string, { kind: ArtKind; file: string | null }>; assets: Record<string, ArtAsset>;
}
/** The texture ids a sliced sheet loads as (`belt-rocks-1` …). */
export function pieceIds(id: string, asset: ArtAsset): string[] {
return Array.from({ length: asset.pieces ?? 0 }, (_, i) => `${id}-${i + 1}`);
} }
export const artManifest = manifestJson as ArtManifest; export const artManifest = manifestJson as ArtManifest;

View File

@ -6,14 +6,21 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import sharp from "sharp"; import sharp from "sharp";
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { artManifest } from "../src/map/art/manifest"; import { artManifest, pieceIds } from "../src/map/art/manifest";
// @ts-expect-error: a plain .mjs build script, tested directly.
import { MIN_ALPHA, sliceSheet } from "../scripts/slice.mjs";
const webRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const webRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const script = join(webRoot, "scripts/map-art.mjs"); const script = join(webRoot, "scripts/map-art.mjs");
sharp.cache(false); sharp.cache(false);
/** Reads through a buffer so sharp never holds the temp files open (Windows). */ /** Reads through a buffer so sharp never holds the temp files open (Windows). */
const image = (path: string) => sharp(readFileSync(path)); const image = (path: string) => sharp(readFileSync(path));
const SIZES = { body: [256, 512], badge: [256, 512], ship: [128, 256] } as const; const SIZES = {
body: [256, 512],
badge: [256, 512],
ship: [128, 256],
rocks: [64, 128],
} as const;
const run = (outDir: string) => const run = (outDir: string) =>
execFileSync(process.execPath, [script, outDir], { cwd: webRoot, stdio: "pipe" }); execFileSync(process.execPath, [script, outDir], { cwd: webRoot, stdio: "pipe" });
@ -42,13 +49,23 @@ describe("map art optimization script (CR-003)", () => {
expect(Math.max(meta.width!, meta.height!)).toBeLessThanOrEqual(2048); expect(Math.max(meta.width!, meta.height!)).toBeLessThanOrEqual(2048);
continue; continue;
} }
if (asset.kind === "strip") {
const meta = await image(join(first, `${id}.png`)).metadata();
expect(meta.width!).toBeLessThanOrEqual(4096);
expect(meta.height!).toBeLessThanOrEqual(256);
continue;
}
// A sliced sheet writes one sprite per piece (CR-016).
const bases = asset.kind === "rocks" ? pieceIds(id, asset) : [id];
const [one, two] = SIZES[asset.kind]; const [one, two] = SIZES[asset.kind];
for (const [suffix, longSide] of [ for (const base of bases) {
["1x", one], for (const [suffix, longSide] of [
["2x", two], ["1x", one],
] as const) { ["2x", two],
const meta = await image(join(first, `${id}@${suffix}.png`)).metadata(); ] as const) {
expect(Math.max(meta.width!, meta.height!), `${id}@${suffix}`).toBe(longSide); const meta = await image(join(first, `${base}@${suffix}.png`)).metadata();
expect(Math.max(meta.width!, meta.height!), `${base}@${suffix}`).toBe(longSide);
}
} }
} }
}); });
@ -101,3 +118,70 @@ describe("map art optimization script (CR-003)", () => {
} }
}); });
}); });
/** A transparent sheet with an opaque square at each `[x, y, size]`, plus faint glow and specks. */
async function sheet(squares: [number, number, number][]): Promise<Buffer> {
const width = 200;
const height = 120;
const data = Buffer.alloc(width * height * 4);
const paint = (x: number, y: number, a: number) => {
const i = (y * width + x) * 4;
data[i] = 180;
data[i + 1] = 160;
data[i + 2] = 140;
data[i + 3] = Math.max(data[i + 3]!, a);
};
for (const [left, top, size] of squares) {
// A faint halo bridging toward the neighbours, below the slicing threshold.
for (let y = top - 6; y < top + size + 6; y++) {
for (let x = left - 6; x < left + size + 6; x++) {
if (x >= 0 && y >= 0 && x < width && y < height) paint(x, y, MIN_ALPHA - 6);
}
}
for (let y = top; y < top + size; y++)
for (let x = left; x < left + size; x++) paint(x, y, 255);
}
// A stray speck: far too small to be a sprite.
paint(195, 115, 255);
return sharp(data, { raw: { width, height, channels: 4 } })
.png()
.toBuffer();
}
describe("slicing a sprite sheet (Step 16.5)", () => {
it("cuts three separate rocks into exactly three trimmed sprites", async () => {
const pieces: Buffer[] = await sliceSheet(
sharp,
await sheet([
[10, 10, 30],
[80, 20, 20],
[140, 60, 40],
]),
);
expect(pieces).toHaveLength(3);
const sizes = await Promise.all(pieces.map((piece) => sharp(piece).metadata()));
// Reading order (top row left to right), each trimmed to its own square.
expect(sizes.map((m) => [m.width, m.height])).toEqual([
[30, 30],
[20, 20],
[40, 40],
]);
});
it("keeps a single rock as one sprite", async () => {
expect(await sliceSheet(sharp, await sheet([[60, 30, 50]]))).toHaveLength(1);
});
it("drops a neighbour's glow from each sprite", async () => {
const [piece] = await sliceSheet(
sharp,
await sheet([
[10, 10, 30],
[44, 10, 30],
]),
);
const { data } = await sharp(piece).raw().toBuffer({ resolveWithObject: true });
// Every pixel is either the rock (opaque) or cleared, never the faint halo.
for (let i = 3; i < data.length; i += 4) expect([0, 255]).toContain(data[i]);
});
});

View File

@ -397,7 +397,7 @@ Spec: `memory-bank/change-requests/CR-016-venus-io-europa-belt.md` (received as
- [x] **Step 16.2 — Content: goods.** New goods: Atmospheric Data Cores (High-Value tier 2, Venus → Earth), Refined Sulfur (Heavy, Io → Earth, Mars, Venus), Volcanic Isotopes (High-Value tier 3, Io → Earth), Subsurface Ocean Samples (High-Value tier 3, Priority, Europa → Earth); Cloud-Grown Botanicals came in 16.1. Water Ice and Liquid Oxygen are also made at Europa, Industrial Chemicals at Venus; Water Ice goes to Io and Venus, Liquid Oxygen to Io, Habitat Prefabs to Io and Europa, and Packaged Food, Consumer Goods, Mail & Parcels, Medical Supplies, Electronics and Machine Parts to all three new places. Balance medians: first purchase 92.3, discovery 48.9, loan 160.5, four ships 147 vs 156. Tests (`packages/content/test/goods.test.ts`): the five new goods with their classes, tiers and routes (Sulfur has no tier of its own); every location makes a Standard good and wants at least one; each new place is a destination for at least five goods; the new producers on existing goods. - [x] **Step 16.2 — Content: goods.** New goods: Atmospheric Data Cores (High-Value tier 2, Venus → Earth), Refined Sulfur (Heavy, Io → Earth, Mars, Venus), Volcanic Isotopes (High-Value tier 3, Io → Earth), Subsurface Ocean Samples (High-Value tier 3, Priority, Europa → Earth); Cloud-Grown Botanicals came in 16.1. Water Ice and Liquid Oxygen are also made at Europa, Industrial Chemicals at Venus; Water Ice goes to Io and Venus, Liquid Oxygen to Io, Habitat Prefabs to Io and Europa, and Packaged Food, Consumer Goods, Mail & Parcels, Medical Supplies, Electronics and Machine Parts to all three new places. Balance medians: first purchase 92.3, discovery 48.9, loan 160.5, four ships 147 vs 156. Tests (`packages/content/test/goods.test.ts`): the five new goods with their classes, tiers and routes (Sulfur has no tier of its own); every location makes a Standard good and wants at least one; each new place is a destination for at least five goods; the new producers on existing goods.
- [x] **Step 16.3 — Content tuning: reach.** No tuning needed: with Phase 15's tanks every cell of CR-015 §4 and of CR-016 §3 already holds (Clipper ≈ 252M km reaches Venus at ≈ 200 but not Mars from Venus at ≈ 332; Freighter ≈ 460 reaches Mars from Venus and Io/Europa from Ceres at ≈ 393 but not Ceres from Venus at ≈ 520, nor Io from Mars at ≈ 592 × 1.15 short; Bulk Hauler ≈ 1,251 covers Venus–Jupiter at ≈ 885). Tests (`reach-table.test.ts`, CR-016 §3): one test per new cell (the Jupiter-system loop for Courier, Lighter, Clipper and Freighter; Earth↔Venus and Moon→Venus for Clipper and Freighter, not Courier or Lighter; Venus→Mars only from the Freighter up; Ceres↔Io/Europa for the Freighter; nothing from the inner system to the Jupiter system short of the Bulk Hauler, which flies every leg between all eight); the Bulk Hauler's full-tank reach ≥ 900M km; a Courier at the platform flies to Io and Europa and back without stranding. The no-strand test already covers every leg between all eight places. - [x] **Step 16.3 — Content tuning: reach.** No tuning needed: with Phase 15's tanks every cell of CR-015 §4 and of CR-016 §3 already holds (Clipper ≈ 252M km reaches Venus at ≈ 200 but not Mars from Venus at ≈ 332; Freighter ≈ 460 reaches Mars from Venus and Io/Europa from Ceres at ≈ 393 but not Ceres from Venus at ≈ 520, nor Io from Mars at ≈ 592 × 1.15 short; Bulk Hauler ≈ 1,251 covers Venus–Jupiter at ≈ 885). Tests (`reach-table.test.ts`, CR-016 §3): one test per new cell (the Jupiter-system loop for Courier, Lighter, Clipper and Freighter; Earth↔Venus and Moon→Venus for Clipper and Freighter, not Courier or Lighter; Venus→Mars only from the Freighter up; Ceres↔Io/Europa for the Freighter; nothing from the inner system to the Jupiter system short of the Bulk Hauler, which flies every leg between all eight); the Bulk Hauler's full-tank reach ≥ 900M km; a Courier at the platform flies to Io and Europa and back without stranding. The no-strand test already covers every leg between all eight places.
- [x] **Step 16.4 — Engine: save migration and boards.** No save migration and no save-version change: the save lists only discovered places, so a place that is in content but not in that list is undiscovered, and a save from before CR-016 opens with the three new places undiscovered and nothing else changed. Boards needed no change either: first contacts and fit slots already draw from undiscovered places in reach. Tests (`packages/engine/test/new-places.test.ts`): a pre-CR-016 save imported through the engine shows eight places with Venus, Io and Europa undiscovered, and its save version, discoveries and company are unchanged; 200 Earth boards for the deal's Clipper and veteran include first contacts to Venus; a Clipper delivering a first contact to Venus discovers it (the logbook says so) and Venus then has its own board, every contract on it a good made at Venus. - [x] **Step 16.4 — Engine: save migration and boards.** No save migration and no save-version change: the save lists only discovered places, so a place that is in content but not in that list is undiscovered, and a save from before CR-016 opens with the three new places undiscovered and nothing else changed. Boards needed no change either: first contacts and fit slots already draw from undiscovered places in reach. Tests (`packages/engine/test/new-places.test.ts`): a pre-CR-016 save imported through the engine shows eight places with Venus, Io and Europa undiscovered, and its save version, discoveries and company are unchanged; 200 Earth boards for the deal's Clipper and veteran include first contacts to Venus; a Clipper delivering a first contact to Venus discovers it (the logbook says so) and Venus then has its own board, every contract on it a good made at Venus.
- [ ] Step 16.5 — Assets: sprites and belt rocks - [x] **Step 16.5 — Assets: sprites and belt rocks.** (The Venus, Io and Europa sprites came in 16.1.) Two new art kinds: `rocks` (a sheet of separate sprites; the manifest's `belt-rocks` entry names `belt-asteroids.png` and says it holds `pieces: 6`) and `strip` (`belt-haze`, file `null` until the user provides one). `scripts/slice.mjs`: `findRegions` labels connected regions of pixels with alpha ≥ 16 (fainter glow and specks under 0.2 % of the sheet are dropped) in reading order; `sliceSheet` cuts each region out with every other pixel cleared, so a neighbour's glow never comes along. `map-art.mjs` writes `belt-rocks-1` … `belt-rocks-6` at 64 and 128 px (long side) and refuses a sheet whose piece count doesn't match the manifest; a haze strip would be kept within 4096 × 256. The loader registers each rock piece and the strip. The source image is only read. Tests: `apps/web/test/map-art.test.ts` (a fixture sheet with three separate squares and faint bridging glow slices into exactly three sprites, trimmed, in reading order; one square gives one; no faint glow survives in a piece; the real sheet's six pieces are written at the right sizes, deterministically and matching the committed output; the transparency check covers the belt image).
- [ ] Step 16.6 — Map: belt haze - [ ] Step 16.6 — Map: belt haze
- [ ] Step 16.7 — Map: belt rocks - [ ] Step 16.7 — Map: belt rocks
- [ ] Step 16.8 — Map: new bodies and Jupiter-system layout - [ ] Step 16.8 — Map: new bodies and Jupiter-system layout