|
| 1 | +// SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | +// Contract test: Airborne Submarine Squadron JS↔WASM snapshot ABI (v1) |
| 3 | + |
| 4 | +import { existsSync, readFileSync } from "node:fs"; |
| 5 | +import { dirname, resolve } from "node:path"; |
| 6 | +import { fileURLToPath } from "node:url"; |
| 7 | + |
| 8 | +const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 9 | +const projectRoot = resolve(__dirname, "../.."); |
| 10 | +const contractPath = resolve( |
| 11 | + projectRoot, |
| 12 | + "src/contracts/airborne-submarine-squadron.snapshot-abi.v1.json", |
| 13 | +); |
| 14 | + |
| 15 | +let passed = 0; |
| 16 | +let failed = 0; |
| 17 | + |
| 18 | +function assert(condition, message) { |
| 19 | + if (condition) { |
| 20 | + passed += 1; |
| 21 | + console.log(` OK: ${message}`); |
| 22 | + return; |
| 23 | + } |
| 24 | + failed += 1; |
| 25 | + console.error(` FAIL: ${message}`); |
| 26 | +} |
| 27 | + |
| 28 | +function finish() { |
| 29 | + console.log(`\nResults: ${passed} passed, ${failed} failed`); |
| 30 | + if (failed > 0) process.exit(1); |
| 31 | +} |
| 32 | + |
| 33 | +function findFirstExisting(paths) { |
| 34 | + for (const path of paths) { |
| 35 | + if (existsSync(path)) return path; |
| 36 | + } |
| 37 | + return null; |
| 38 | +} |
| 39 | + |
| 40 | +console.log("=== Contract Test: Airborne step_state ABI v1 ===\n"); |
| 41 | + |
| 42 | +const contract = JSON.parse(readFileSync(contractPath, "utf-8")); |
| 43 | + |
| 44 | +// --------------------------------------------------------------------------- |
| 45 | +// 1) Static contract shape checks |
| 46 | +// --------------------------------------------------------------------------- |
| 47 | +assert(contract.id === "airborne-submarine-squadron.step_state.snapshot", "contract id is stable"); |
| 48 | +assert(contract.version === "1.0.0", "contract version is 1.0.0"); |
| 49 | + |
| 50 | +assert(contract.snapshot.state_field_count === 29, "snapshot field count is 29"); |
| 51 | +assert(contract.input.field_count === 5, "input field count is 5"); |
| 52 | +assert( |
| 53 | + contract.exports.step_state.params.length === |
| 54 | + contract.snapshot.state_field_count + contract.input.field_count, |
| 55 | + "step_state parameter count is snapshot+input (34)", |
| 56 | +); |
| 57 | + |
| 58 | +assert(contract.exports.init_state.params.length === 0, "init_state has no params"); |
| 59 | +assert(contract.exports.init_state.result === "ptr<i32>", "init_state result is ptr<i32>"); |
| 60 | +assert(contract.exports.step_state.result === "ptr<i32>", "step_state result is ptr<i32>"); |
| 61 | + |
| 62 | +assert( |
| 63 | + contract.snapshot.fields[contract.snapshot.indexes.tick] === "tick", |
| 64 | + "tick index maps to tick", |
| 65 | +); |
| 66 | +assert( |
| 67 | + contract.snapshot.fields[contract.snapshot.indexes.score] === "score", |
| 68 | + "score index maps to score", |
| 69 | +); |
| 70 | +assert( |
| 71 | + contract.snapshot.fields[contract.snapshot.indexes.kills] === "kills", |
| 72 | + "kills index maps to kills", |
| 73 | +); |
| 74 | + |
| 75 | +const stateParams = contract.exports.step_state.params.slice(0, contract.snapshot.state_field_count); |
| 76 | +const inputParams = contract.exports.step_state.params.slice(contract.snapshot.state_field_count); |
| 77 | +assert(stateParams.every((name) => name.startsWith("state_")), "first 29 step_state params are state_*"); |
| 78 | +assert(inputParams.every((name) => name.startsWith("input_")), "last 5 step_state params are input_*"); |
| 79 | +assert( |
| 80 | + inputParams.join(",") === |
| 81 | + "input_thrust_x,input_thrust_y,input_fire,input_fire_alt,input_toggle_env", |
| 82 | + "input params order is canonical", |
| 83 | +); |
| 84 | + |
| 85 | +// --------------------------------------------------------------------------- |
| 86 | +// 2) Optional dogfood check against airborne repo artifact/source |
| 87 | +// --------------------------------------------------------------------------- |
| 88 | +const airborneRoot = findFirstExisting([ |
| 89 | + resolve(projectRoot, "../../games-ecosystem/airborne-submarine-squadron"), |
| 90 | + "/var/mnt/eclipse/repos/games-ecosystem/airborne-submarine-squadron", |
| 91 | +]); |
| 92 | + |
| 93 | +if (airborneRoot) { |
| 94 | + console.log("\nDogfood check: validating against airborne repo"); |
| 95 | + const wasmPath = resolve(airborneRoot, "dist/airborne-submarine-squadron.wasm"); |
| 96 | + const appPath = resolve(airborneRoot, "gossamer/app_gossamer.js"); |
| 97 | + |
| 98 | + if (!existsSync(wasmPath)) { |
| 99 | + assert(false, `airborne wasm artifact exists (${wasmPath})`); |
| 100 | + } else { |
| 101 | + const bytes = readFileSync(wasmPath); |
| 102 | + const { instance } = await WebAssembly.instantiate(bytes, { |
| 103 | + wasi_snapshot_preview1: { fd_write: () => 0 }, |
| 104 | + }); |
| 105 | + const { init_state, step_state, memory } = instance.exports; |
| 106 | + assert(typeof init_state === "function", "wasm exports init_state"); |
| 107 | + assert(typeof step_state === "function", "wasm exports step_state"); |
| 108 | + assert(init_state.length === contract.exports.init_state.params.length, "init_state arity matches contract"); |
| 109 | + assert(step_state.length === contract.exports.step_state.params.length, "step_state arity matches contract"); |
| 110 | + |
| 111 | + const view = new DataView(memory.buffer); |
| 112 | + const readSnapshot = (ptr) => { |
| 113 | + const tag = view.getInt32(ptr, true); |
| 114 | + const len = view.getInt32(ptr + 4, true); |
| 115 | + const payload = []; |
| 116 | + for (let i = 0; i < len; i += 1) { |
| 117 | + payload.push(view.getInt32(ptr + 8 + i * 4, true)); |
| 118 | + } |
| 119 | + return { tag, len, payload }; |
| 120 | + }; |
| 121 | + |
| 122 | + const initSnap = readSnapshot(init_state()); |
| 123 | + assert(initSnap.len === contract.snapshot.state_field_count, "init_state payload length matches contract (29)"); |
| 124 | + assert(initSnap.payload.length === contract.snapshot.state_field_count, "init_state payload reads 29 fields"); |
| 125 | + |
| 126 | + const stepped = readSnapshot( |
| 127 | + step_state(...initSnap.payload, 0, 0, 0, 0, 0), |
| 128 | + ); |
| 129 | + assert(stepped.len === contract.snapshot.state_field_count, "step_state payload length matches contract (29)"); |
| 130 | + assert(stepped.payload.length === contract.snapshot.state_field_count, "step_state payload reads 29 fields"); |
| 131 | + assert(typeof stepped.payload[contract.snapshot.indexes.tick] === "number", "tick slot is numeric"); |
| 132 | + assert(typeof stepped.payload[contract.snapshot.indexes.score] === "number", "score slot is numeric"); |
| 133 | + assert(typeof stepped.payload[contract.snapshot.indexes.kills] === "number", "kills slot is numeric"); |
| 134 | + } |
| 135 | + |
| 136 | + if (!existsSync(appPath)) { |
| 137 | + assert(false, `airborne JS bridge source exists (${appPath})`); |
| 138 | + } else { |
| 139 | + const app = readFileSync(appPath, "utf-8"); |
| 140 | + assert(app.includes("world.wasmScore = world.wasmState[23]"), "JS bridge uses score index 23"); |
| 141 | + assert(app.includes("world.wasmKills = world.wasmState[24]"), "JS bridge uses kills index 24"); |
| 142 | + } |
| 143 | +} else { |
| 144 | + console.log("\nDogfood check skipped: airborne repo not found on this machine."); |
| 145 | +} |
| 146 | + |
| 147 | +finish(); |
0 commit comments