diff --git a/docs/modkit/test.md b/docs/modkit/test.md index 8652342..4c7ff52 100755 --- a/docs/modkit/test.md +++ b/docs/modkit/test.md @@ -95,6 +95,12 @@ Tests that write the same world (for example player position) must run in order. | ------------------------------------ | --------------------------------------------------------------------------------------------- | | `game.evaluate(fn, ...args)` | Run `fn` in the renderer. Arguments must be JSON values. Closures do not capture Node locals. | | `game.waitFor(read, match, options)` | Poll `read` in the page until `match` is true in Node. | +| `game.buildStructures(placements)` | Build several structures in one renderer turn and wait for every anchor. | +| `game.buildLayout(layout)` | Expand a visual fixture diagram into phased structure placements. | +| `game.setSimulationPaused(paused)` | Pause or resume the simulation without opening the in-game pause menu. | +| `game.pauseSimulation()` | Pause the simulation. | +| `game.resumeSimulation()` | Resume the simulation. | +| `game.runSimulation(durationMs)` | Run live simulation for a wall-clock duration, then restore the prior pause state. | | `game.orderedModIds()` | Return live `manifest.id` values from the ordered mod list. | | `game.screenshot(options)` | Capture a PNG of the compositor (WebGL plus DOM). Returns a `Buffer`. | | `game.withModMain(id, fn)` | Edit the test-host `main.js`, then restore the original bytes. | @@ -104,6 +110,68 @@ Tests that write the same world (for example player position) must run in order. Return values from `evaluate` must be JSON-serializable. +### Structure fixtures + +`game.buildStructures(placements)` builds a batch of structures and waits for +their anchors to appear. Each placement uses cell coordinates and can include +`options` or seeded `data`. Seeded data is applied after the structure anchor +exists, which makes it reliable for custom structure initialization. + +The helper resumes the simulation while building, then restores its previous +pause state. An empty placement list is a no-op. + +For fixtures that are easier to understand as a diagram, use +`game.buildLayout()`. Each character represents one structure on a 4-cell +grid; `.` leaves a cell empty: + +```ts +await game.buildLayout({ + origin: { x: 2400, y: 1612 }, + cells: ["fff", "fsf", "fff"], + legend: { + f: { type: "foundation" }, + s: { type: "mySource", data: { mode: "sand" } }, + }, +}); +``` + +Use `phases` when placement order matters. Every phase is expanded and built +before the next phase begins: + +```ts +await game.buildLayout({ + origin: { x: 2400, y: 1612 }, + phases: [ + { + cells: ["fff", "f.f", "fff"], + legend: { f: { type: "foundation" } }, + }, + { + cells: ["...", ".s.", "..."], + legend: { s: { type: "mySource" } }, + }, + ], +}); +``` + +The top-left character is placed at `origin`; columns and rows add four cells +per step. Use either top-level `cells` and `legend`, or `phases`, but not both. + +### Simulation control + +The integration host starts with the simulation paused. Use +`runSimulation()` for a bounded behavior check; it resumes the simulation for +the requested wall-clock duration and restores the state afterward: + +```ts +await game.runSimulation(1000); +``` + +For longer workflows, use `resumeSimulation()` and `pauseSimulation()` +explicitly. `setSimulationPaused(value)` is useful when a test needs to +restore or assert a specific state. These helpers change the engine session +state directly and do not open the game’s pause menu. + ## Screenshots `expect(game).toHaveScreenshot(name)` captures, then compares against a PNG next to the test file. `expect(png).toMatchSnapshot(name)` compares a buffer you already captured. Value checks stay on `node:assert`. diff --git a/modkit/test/chrome.ts b/modkit/test/chrome.ts index aabb28d..07b5d95 100755 --- a/modkit/test/chrome.ts +++ b/modkit/test/chrome.ts @@ -22,7 +22,7 @@ export function hostWindowMode(input?: { const visible = input?.visible === true; const platform = input?.platform ?? process.platform; const display = input && "display" in input ? input.display : process.env.DISPLAY; - if (visible && (platform === "win32" || display)) return "window"; + if (visible && (platform === "win32" || platform === "darwin" || display)) return "window"; return "headless"; } diff --git a/modkit/test/host.test.ts b/modkit/test/host.test.ts index 7c1fb9e..68717b8 100755 --- a/modkit/test/host.test.ts +++ b/modkit/test/host.test.ts @@ -13,8 +13,9 @@ test("hostWindowMode is headless when not visible", () => { assert.equal(hostWindowMode({ platform: "darwin", display: undefined }), "headless"); }); -test("hostWindowMode uses a window only when visible and a display exists", () => { +test("hostWindowMode uses a window when visible on desktop platforms", () => { assert.equal(hostWindowMode({ visible: true, platform: "win32" }), "window"); + assert.equal(hostWindowMode({ visible: true, platform: "darwin" }), "window"); assert.equal(hostWindowMode({ visible: true, platform: "linux", display: ":1" }), "window"); }); @@ -49,7 +50,6 @@ test("chromeLaunchArgs pins ozone screen size in headless", () => { assert.ok(!window.some((arg) => arg.startsWith("--ozone-override-screen-size="))); }); - test("resolveHostStaticFile prefers live /mods// then vanilla dist/mods", () => { const root = mkdtempSync(join(tmpdir(), "sandustry-host-")); const distMods = join(root, "dist", "mods"); diff --git a/modkit/test/host.ts b/modkit/test/host.ts index dc01fb9..52940a8 100755 --- a/modkit/test/host.ts +++ b/modkit/test/host.ts @@ -414,7 +414,7 @@ export async function startSandustryTestHost(options?: { if (!extractedDistDir()) { return { ok: false, reason: "No sandustry/-/dist. Run npm run setup." }; } - if (visible && process.platform !== "win32" && !process.env.DISPLAY) { + if (visible && process.platform === "linux" && !process.env.DISPLAY) { return { ok: false, reason: "DISPLAY is missing" }; } diff --git a/modkit/test/index.ts b/modkit/test/index.ts index b8f9d8d..087ef7a 100755 --- a/modkit/test/index.ts +++ b/modkit/test/index.ts @@ -20,6 +20,14 @@ export { } from "./paths.ts"; export { setupGame } from "./setup-game.ts"; export { SandustrySession } from "./session.ts"; +export { + buildLayout, + buildStructures, + pauseSimulation, + resumeSimulation, + runSimulation, + setSimulationPaused, +} from "../../test/helpers/world.ts"; export { expect } from "./helpers/expect.ts"; export { toPageExpression } from "./serialize.ts"; export { waitFor } from "./helpers/wait.ts"; @@ -28,6 +36,11 @@ export type { ScreenshotClip, ScreenshotOptions, SessionWaitForOptions, + StructureLayout, + ElementSeed, + StructureLayoutPhase, + StructureLayoutSymbol, + StructurePlacement, } from "./session.ts"; export type { BufferExpect, SessionExpect, ToHaveScreenshotOptions } from "./helpers/expect.ts"; export type { ImageMatchOptions } from "./helpers/screenshot.ts"; diff --git a/modkit/test/session.ts b/modkit/test/session.ts index 97a2287..ce1a753 100755 --- a/modkit/test/session.ts +++ b/modkit/test/session.ts @@ -12,6 +12,17 @@ import { } from "./readiness.ts"; import { toPageExpression } from "./serialize.ts"; import { waitFor, type WaitForOptions } from "./helpers/wait.ts"; +import { + buildLayout, + buildStructures, + runSimulation, + setSimulationPaused, + type ElementSeed, + type StructureLayout, + type StructureLayoutPhase, + type StructureLayoutSymbol, + type StructurePlacement, +} from "../../test/helpers/world.ts"; export type ModMainFile = { path: string; @@ -25,6 +36,14 @@ export type SessionWaitForOptions = WaitFor args?: TArgs; }; +export type { + ElementSeed, + StructureLayout, + StructureLayoutPhase, + StructureLayoutSymbol, + StructurePlacement, +} from "../../test/helpers/world.ts"; + export type { ScreenshotClip }; export type ScreenshotOptions = { @@ -105,6 +124,34 @@ export class SandustrySession { return waitFor(() => this.evaluate(read, ...pageArgs), match, options); } + /** Build several structures in one renderer turn and wait for their anchors. */ + async buildStructures(placements: readonly StructurePlacement[]): Promise { + await buildStructures(this, placements); + } + + /** Build a readable 4-cell-grid fixture, optionally in explicit phases. */ + async buildLayout(layout: StructureLayout): Promise { + await buildLayout(this, layout); + } + + /** Pause or resume the simulation without opening the in-game pause UI. */ + async setSimulationPaused(paused: boolean): Promise { + await setSimulationPaused(this, paused); + } + + async pauseSimulation(): Promise { + await this.setSimulationPaused(true); + } + + async resumeSimulation(): Promise { + await this.setSimulationPaused(false); + } + + /** Run the simulation for a wall-clock interval, then restore its prior state. */ + async runSimulation(durationMs: number): Promise { + await runSimulation(this, durationMs); + } + /** Return `manifest.id` values from the live ordered mod list. */ async orderedModIds(): Promise { return this.evaluate(() => { diff --git a/test/helpers/world.ts b/test/helpers/world.ts new file mode 100644 index 0000000..cb71b64 --- /dev/null +++ b/test/helpers/world.ts @@ -0,0 +1,257 @@ +export type StructurePlacement = { + type: string | number; + x: number; + y: number; + options?: Record; + data?: Record; +}; + +export type StructureLayoutSymbol = Omit; + +export type StructureLayoutPhase = { + cells: readonly string[]; + legend: Readonly>; +}; + +export type ElementSeed = { + x: number; + y: number; + element: string | number; + count?: number; +}; + +export type StructureLayout = { + origin: { x: number; y: number }; + cells?: readonly string[]; + legend?: Readonly>; + phases?: readonly StructureLayoutPhase[]; + seeds?: readonly ElementSeed[]; +}; + +type WorldSession = { + evaluate( + fn: (...args: TArgs) => TResult | Promise, + ...args: TArgs + ): Promise; + waitFor( + read: (...args: TArgs) => T | Promise, + match: (value: T) => boolean, + options?: { args?: TArgs; message?: string }, + ): Promise; + setSimulationPaused(paused: boolean): Promise; + resumeSimulation(): Promise; +}; + +/** Build several structures in one renderer turn and wait for their anchors. */ +export async function buildStructures( + session: WorldSession, + placements: readonly StructurePlacement[], +): Promise { + if (placements.length === 0) return; + const priorPaused = await session.evaluate(() => { + const state = ( + globalThis as typeof globalThis & { + sandkit?: { engine?: { state?: { session?: { paused?: boolean } } } }; + } + ).sandkit?.engine?.state?.session; + if (!state) throw new Error("Sandustry session state is unavailable"); + return Boolean(state.paused); + }); + try { + await session.resumeSimulation(); + await session.evaluate((items: readonly StructurePlacement[]) => { + const api = ( + globalThis as typeof globalThis & { + sandkit?: { + api?: { + structures?: { + buildAtCell?: ( + x: number, + y: number, + type: string | number, + options?: Record, + ) => void; + }; + }; + }; + } + ).sandkit?.api; + if (typeof api?.structures?.buildAtCell !== "function") { + throw new Error("Sandustry structures.buildAtCell is unavailable"); + } + for (const item of items) { + api.structures.buildAtCell(item.x, item.y, item.type, { + ...item.options, + ...(item.data ? { data: item.data } : {}), + }); + } + }, placements); + + await session.waitFor( + (items: readonly StructurePlacement[]) => { + const api = ( + globalThis as typeof globalThis & { + sandkit?: { api?: { structures?: { getAtCell?: (x: number, y: number) => unknown } } }; + } + ).sandkit?.api; + return items.map((item) => { + const structure = api?.structures?.getAtCell?.(item.x, item.y) as + | { x: number; y: number; type: string | number } + | null + | undefined; + if (!structure || structure.type !== item.type) return null; + return { x: structure.x, y: structure.y, type: structure.type }; + }); + }, + (structures) => structures.every((structure) => structure !== null), + { args: [placements], message: "Structures were not built at every requested anchor" }, + ); + + const withData = placements.filter((item) => item.data); + if (withData.length > 0) { + await session.evaluate((items: readonly StructurePlacement[]) => { + const structures = ( + globalThis as typeof globalThis & { + sandkit?: { + api?: { + structures?: { + getAtCell?: (x: number, y: number) => unknown; + setData?: (structure: unknown, data: Record) => void; + }; + }; + }; + } + ).sandkit?.api?.structures; + if (typeof structures?.getAtCell !== "function") { + throw new Error("Sandustry structures.getAtCell is unavailable"); + } + if (typeof structures.setData !== "function") { + throw new Error("Sandustry structures.setData is unavailable"); + } + for (const item of items) { + const structure = structures.getAtCell(item.x, item.y); + if (!structure || !item.data) { + throw new Error(`Sandustry could not initialize structure data at ${item.x},${item.y}`); + } + structures.setData(structure, item.data); + } + }, withData); + } + } finally { + await session.setSimulationPaused(priorPaused); + } +} + +/** Build a readable 4-cell-grid fixture, optionally in explicit phases. */ +export async function buildLayout(session: WorldSession, layout: StructureLayout): Promise { + const phases = + layout.phases ?? + (layout.cells && layout.legend ? [{ cells: layout.cells, legend: layout.legend }] : []); + if (phases.length === 0) { + throw new Error("A structure layout needs cells and legend, or at least one phase"); + } + if (layout.phases && (layout.cells || layout.legend)) { + throw new Error("A structure layout cannot mix top-level cells/legend with phases"); + } + for (const [index, phase] of phases.entries()) { + const placements: StructurePlacement[] = []; + const width = phase.cells[0]?.length ?? 0; + if (width === 0 || phase.cells.some((row) => row.length !== width)) { + throw new Error(`Structure layout phase ${index} must contain a non-empty rectangle`); + } + for (let row = 0; row < phase.cells.length; row += 1) { + for (let column = 0; column < width; column += 1) { + const symbol = phase.cells[row]?.[column]; + if (!symbol || symbol === ".") continue; + const definition = phase.legend[symbol]; + const isSeed = layout.seeds?.some((seed) => seed.x === column && seed.y === row); + if (!definition && isSeed) continue; + if (!definition) { + throw new Error(`Structure layout phase ${index} has no legend entry for "${symbol}"`); + } + placements.push({ + ...definition, + x: layout.origin.x + column * 4, + y: layout.origin.y + row * 4, + }); + } + } + await buildStructures(session, placements); + } + if (layout.seeds?.length) { + await session.evaluate( + (origin, seeds) => { + for (const seed of seeds) { + const elementType = + typeof seed.element === "number" + ? seed.element + : sandkit.api.elements.getTypeById(seed.element); + if (typeof elementType !== "number") { + throw new Error(`Unknown seeded element: ${String(seed.element)}`); + } + const count = seed.count ?? 1; + if (!Number.isInteger(count) || count < 1 || count > 16) { + throw new Error(`Element seed count must be an integer from 1 to 16: ${count}`); + } + const cellX = origin.x + seed.x * 4; + const cellY = origin.y + seed.y * 4; + for (let index = 0; index < count; index += 1) { + sandkit.api.elements.createAtCell( + cellX + (index % 4), + cellY + Math.floor(index / 4), + elementType, + ); + } + } + }, + layout.origin, + layout.seeds, + ); + } +} + +/** Pause or resume the simulation without opening the in-game pause UI. */ +export async function setSimulationPaused(session: WorldSession, paused: boolean): Promise { + await session.evaluate((nextPaused: boolean) => { + const state = ( + globalThis as typeof globalThis & { + sandkit?: { engine?: { state?: { session?: { paused?: boolean } } } }; + } + ).sandkit?.engine?.state?.session; + if (!state) throw new Error("Sandustry session state is unavailable"); + state.paused = nextPaused; + }, paused); +} + +export async function pauseSimulation(session: WorldSession): Promise { + await setSimulationPaused(session, true); +} + +export async function resumeSimulation(session: WorldSession): Promise { + await setSimulationPaused(session, false); +} + +/** Run the simulation for a wall-clock interval, then restore its prior state. */ +export async function runSimulation(session: WorldSession, durationMs: number): Promise { + if (!Number.isFinite(durationMs) || durationMs < 0) { + throw new Error(`Simulation duration must be a non-negative finite number: ${durationMs}`); + } + const priorPaused = await session.evaluate(() => { + const state = ( + globalThis as typeof globalThis & { + sandkit?: { engine?: { state?: { session?: { paused?: boolean } } } }; + } + ).sandkit?.engine?.state?.session; + if (!state) throw new Error("Sandustry session state is unavailable"); + return Boolean(state.paused); + }); + try { + await session.resumeSimulation(); + await session.evaluate( + (duration: number) => new Promise((resolve) => setTimeout(resolve, duration)), + durationMs, + ); + } finally { + await session.setSimulationPaused(priorPaused); + } +}