Implement Homeworld mechanics through Caladan reinforcement checkpoint

Connect typed Homeworld custody, transport, battle, income, revival and Caladan reinforcement flows with private multiplayer recovery and controls. Preserve the optimized test workflow and keep unfinished rules modes gated. Verified with 3682 offline tests, typecheck, lint, production build and 40 HTTP tests; existing saved games remain unchanged.
This commit is contained in:
Quintine committed 2026-09-10 08:12:26 +10:00
1 parent 3be49512f7
commit 28ba1618ba
266 files changed
+47680 -16851

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
export type TestKind = 'unit' | 'recovery' | 'integration';
export type TestSuite = TestKind | 'offline' | 'multiplayer' | 'all';
export type TestFile = { path: string; kind: TestKind };
// HTTP tests opt in explicitly. SQLite recovery tests use disposable memory DBs.
export function classifyTest(source: string): TestKind {
if (/^\/\/ @dune-suite integration\s*$/m.test(source)) return 'integration';
if (/^import\b[^\n]*from ['"]node:sqlite['"]/m.test(source))
return 'recovery';
return 'unit';
}
export function discoverTests(root: string, directory = 'tests'): TestFile[] {
return readdirSync(join(root, directory), { withFileTypes: true })
.sort((a, b) => a.name.localeCompare(b.name))
.flatMap((entry): TestFile[] => {
const path = `${directory}/${entry.name}`;
if (entry.isDirectory()) return discoverTests(root, path);
if (!entry.isFile() || !/\.test\.(?:ts|tsx|mts|js|mjs)$/.test(entry.name))
return [];
const source = readFileSync(join(root, path), 'utf8');
const kind = classifyTest(source);
if (
/process\.env\.DUNE_TEST_URL\b/.test(source) &&
kind !== 'integration'
)
throw new Error(`${path}: HTTP tests need // @dune-suite integration`);
return [{ path, kind }];
});
}
export function selectTests(
files: TestFile[],
suite: TestSuite,
filters: string[] = [],
): TestFile[] {
const selected = files.filter(({ path, kind }) => {
const included =
suite === 'all' ||
(suite === 'offline' && kind !== 'integration') ||
(suite === 'multiplayer' && kind !== 'unit') ||
suite === kind;
return (
included && (!filters.length || filters.some((f) => path.includes(f)))
);
});
// A typo should never produce a green run that executed no tests.
for (const filter of filters)
if (!selected.some(({ path }) => path.includes(filter)))
throw new Error(`No ${suite} test files match "${filter}".`);
if (!selected.length) throw new Error(`No test files in suite "${suite}".`);
return selected;
}
+114
View File
@@ -0,0 +1,114 @@
import { spawn } from 'node:child_process';
import { availableParallelism } from 'node:os';
import { fileURLToPath } from 'node:url';
import { parseArgs } from 'node:util';
import { discoverTests, selectTests, type TestSuite } from './test-discovery';
const root = fileURLToPath(new URL('../', import.meta.url));
async function main() {
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
suite: { type: 'string', default: 'offline' },
list: { type: 'boolean', default: false },
name: { type: 'string' },
concurrency: { type: 'string' },
help: { type: 'boolean', short: 'h' },
},
});
if (values.help) {
console.log(`Usage: npm test -- [filename-fragment ...] [options]
--suite offline|unit|recovery|integration|multiplayer|all
--list Print selected files without running tests
--name REGEX Run matching test names within selected files
--concurrency NUMBER Parallel test files (default: up to 4; HTTP: 1)
Examples:
npm test -- ecaz-collection ecaz-spice
npm run test:recovery -- guild-ambassador
npm run test:integration -- --list`);
return;
}
const suites = [
'offline',
'unit',
'recovery',
'integration',
'multiplayer',
'all',
];
if (!suites.includes(values.suite))
throw new Error('Unknown test suite. Use --help.');
const selected = selectTests(
discoverTests(root),
values.suite as TestSuite,
positionals,
);
if (values.list) {
for (const file of selected) console.log(`${file.kind}\t${file.path}`);
console.log(`${selected.length} test files`);
return;
}
if (values.name) new RegExp(values.name);
const network = selected.some(({ kind }) => kind === 'integration');
const concurrency =
values.concurrency === undefined
? network
? 1
: Math.min(4, availableParallelism())
: Number(values.concurrency);
if (!Number.isSafeInteger(concurrency) || concurrency < 1)
throw new Error('--concurrency must be a positive integer.');
if (network) {
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
try {
const response = await fetch(new URL('/', base), {
signal: AbortSignal.timeout(15_000),
});
await response.body?.cancel();
if (!response.ok) throw new Error(`HTTP ${response.status}`);
} catch {
throw new Error(
`Test server unavailable at ${base}. Run npm run db:local, then npm run dev, or set DUNE_TEST_URL to a test server.`,
);
}
}
console.log(
`Running ${selected.length} ${values.suite} test files (concurrency ${concurrency}).`,
);
const child = spawn(
process.execPath,
[
'--import',
'tsx',
'--test',
`--test-concurrency=${concurrency}`,
...(values.name ? [`--test-name-pattern=${values.name}`] : []),
...selected.map(({ path }) => path),
],
{ cwd: root, stdio: 'inherit' },
);
// Forward cancellation so a stopped check does not leave test workers behind.
const interrupt = () => child.kill('SIGINT');
const terminate = () => child.kill('SIGTERM');
process.on('SIGINT', interrupt);
process.on('SIGTERM', terminate);
try {
process.exitCode = await new Promise<number>((resolve, reject) => {
child.once('error', reject);
child.once('exit', (code, signal) =>
resolve(code ?? (signal === 'SIGINT' ? 130 : 1)),
);
});
} finally {
process.off('SIGINT', interrupt);
process.off('SIGTERM', terminate);
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});