feat: v0.12.0 — contracts → packages: load/fly/abandon gameplay (ATR 72-600 capacity 6, manual flight to any airport, time-scaled abandon penalty 15–60%), one-time DB migration preserving wallet/clock; new endpoints /api/packages/:id/load|abandon + /api/planes/:planeId/fly

This commit is contained in:
2026-09-18 11:02:56 +00:00
parent 0208673da7
commit ab2e9fa54a
49 changed files with 2424 additions and 1409 deletions
+46 -6
View File
@@ -27,12 +27,12 @@ CREATE TABLE IF NOT EXISTS planes (
fuel REAL NOT NULL,
max_fuel REAL NOT NULL,
location_icao TEXT NOT NULL,
contract_id TEXT,
dest_icao TEXT,
lat REAL NOT NULL,
lon REAL NOT NULL,
departure_sim_time INTEGER
);
CREATE TABLE IF NOT EXISTS contracts (
CREATE TABLE IF NOT EXISTS packages (
id TEXT PRIMARY KEY NOT NULL,
origin_icao TEXT NOT NULL,
dest_icao TEXT NOT NULL,
@@ -59,7 +59,7 @@ CREATE TABLE IF NOT EXISTS sim_state (
// v0.7.0: DBs created by earlier versions lack the new columns; CREATE TABLE IF
// NOT EXISTS won't add them, so ALTER (guarded by PRAGMA table_info). Fresh DBs
// already have everything from DDL above and this is a no-op for them.
function migrate(raw: RawSqlJsDatabase): void {
function migrateColumns(raw: RawSqlJsDatabase): void {
const cols = (table: string): Set<string> =>
new Set(raw.exec(`PRAGMA table_info(${table})`)[0].values.map((r) => String(r[1])));
if (!cols('sim_state').has('last_real_tick_ms')) {
@@ -68,6 +68,37 @@ function migrate(raw: RawSqlJsDatabase): void {
if (!cols('planes').has('departure_sim_time')) {
raw.exec('ALTER TABLE planes ADD COLUMN departure_sim_time INTEGER');
}
// v0.12.0: the flight destination column (set by fly, cleared on arrival).
if (!cols('planes').has('dest_icao')) {
raw.exec('ALTER TABLE planes ADD COLUMN dest_icao TEXT');
}
}
// v0.12.0 one-time migration: contracts world → packages world. Detected by the
// presence of the old `contracts` table (fresh and already-migrated DBs never
// have it, so this runs exactly once per file). Preserves account/sim_state rows
// and every plane row; planes are reset to GROUNDED at their locationIcao with
// departure_sim_time/dest_icao cleared — an in-flight plane from the old state
// simply lands back where it took off. Old contract data is wiped (the packages
// table starts empty and the engine's spawn rules reseed the market on the next
// tick). The orphaned pre-v0.12.0 `contract_id` column is dropped too (SQLite
// ≥3.35 DROP COLUMN; guarded so a failure can never block startup).
function migrateContractsToPackages(raw: RawSqlJsDatabase): void {
const cols = new Set(
raw.exec('PRAGMA table_info(planes)')[0].values.map((r) => String(r[1])),
);
raw.exec(
`UPDATE planes SET status = 'available', departure_sim_time = NULL, dest_icao = NULL` +
(cols.has('contract_id') ? ', contract_id = NULL' : ''),
);
if (cols.has('contract_id')) {
try {
raw.exec('ALTER TABLE planes DROP COLUMN contract_id');
} catch {
// Leaving the orphaned column is harmless — nothing references it anymore.
}
}
raw.exec('DROP TABLE contracts');
}
// FLIGHT_DB_PATH override exists so unit tests can point at a temp file instead
@@ -85,8 +116,17 @@ async function open(file: string): Promise<GameDb> {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database();
raw.exec(DDL);
migrate(raw);
raw.exec(DDL); // no-op for tables that already exist in an older file
migrateColumns(raw);
// v0.12.0: run BEFORE the seed check — a pre-v0.12.0 file has account rows, so
// without this it would keep the dead contracts table forever.
const tables = new Set(
raw.exec("SELECT name FROM sqlite_master WHERE type='table'")[0].values.map((r) => String(r[0])),
);
if (tables.has('contracts')) {
migrateContractsToPackages(raw);
}
const instance: GameDb = { db: drizzle(raw, { schema }), raw, file };
@@ -120,7 +160,7 @@ async function seedEmpty(instance: GameDb): Promise<void> {
const data = buildSeed({ airports, rng, nowMs: Date.now() });
await instance.db.insert(schema.planes).values(data.plane);
await instance.db.insert(schema.contracts).values(data.contracts);
await instance.db.insert(schema.packages).values(data.packages);
await instance.db.insert(schema.account).values(data.account);
await instance.db.insert(schema.simState).values(data.simState);
await persist(instance);