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:
+115
-6
@@ -42,6 +42,46 @@ CREATE TABLE sim_state (
|
||||
);
|
||||
`;
|
||||
|
||||
// The v0.11.0 DDL — the last schema before the packages pivot; used to build a
|
||||
// "pre-v0.12.0" DB file for the migration test (has departure_sim_time +
|
||||
// contract_id, has contracts, no packages/dest_icao).
|
||||
const PRE_V12_DDL = `
|
||||
CREATE TABLE planes (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
fuel REAL NOT NULL,
|
||||
max_fuel REAL NOT NULL,
|
||||
location_icao TEXT NOT NULL,
|
||||
contract_id TEXT,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL,
|
||||
departure_sim_time INTEGER
|
||||
);
|
||||
CREATE TABLE contracts (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
origin_icao TEXT NOT NULL,
|
||||
dest_icao TEXT NOT NULL,
|
||||
reward REAL NOT NULL,
|
||||
cargo_type TEXT NOT NULL,
|
||||
cargo_count INTEGER NOT NULL,
|
||||
deadline_sim_time INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
plane_id TEXT,
|
||||
created_sim_time INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE account (
|
||||
cash REAL NOT NULL,
|
||||
reputation REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE sim_state (
|
||||
clock_ms INTEGER NOT NULL,
|
||||
time_scale REAL NOT NULL,
|
||||
paused INTEGER NOT NULL,
|
||||
last_real_tick_ms INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`;
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -64,9 +104,9 @@ describe('openGameDb', () => {
|
||||
expect(planes[0].locationIcao).toBe('EDDF'); // Frankfurt hub from the CSV
|
||||
expect(planes[0].fuel).toBe(planes[0].maxFuel);
|
||||
|
||||
const contracts = await inst.db.select().from(schema.contracts);
|
||||
expect(contracts.length).toBeGreaterThanOrEqual(8);
|
||||
expect(contracts.every((c) => c.status === 'open')).toBe(true);
|
||||
const packages = await inst.db.select().from(schema.packages);
|
||||
expect(packages.length).toBeGreaterThanOrEqual(8);
|
||||
expect(packages.every((p) => p.status === 'open')).toBe(true);
|
||||
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
expect(account?.cash).toBeGreaterThan(0);
|
||||
@@ -82,13 +122,13 @@ describe('openGameDb', () => {
|
||||
it('does not re-seed an existing DB — state survives reopen', async () => {
|
||||
const file = path.join(dir, 'reopen.db');
|
||||
const first = await openGameDb(file);
|
||||
const contracts1 = (await first.db.select().from(schema.contracts)).map((c) => c.id).sort();
|
||||
const packages1 = (await first.db.select().from(schema.packages)).map((p) => p.id).sort();
|
||||
const account1 = (await first.db.select().from(schema.account))[0];
|
||||
|
||||
// Simulate a process restart: brand-new instance over the same file.
|
||||
const second = await openGameDb(file);
|
||||
const contracts2 = (await second.db.select().from(schema.contracts)).map((c) => c.id).sort();
|
||||
expect(contracts2).toEqual(contracts1);
|
||||
const packages2 = (await second.db.select().from(schema.packages)).map((p) => p.id).sort();
|
||||
expect(packages2).toEqual(packages1);
|
||||
expect((await second.db.select().from(schema.account))[0]).toEqual(account1);
|
||||
});
|
||||
|
||||
@@ -128,4 +168,73 @@ describe('openGameDb', () => {
|
||||
// tick-on-read resumes cleanly instead of taking one clamped catch-up jump.
|
||||
expect(sim?.lastRealTickMs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('migrates a pre-v0.12.0 database: wipes contracts, grounds planes, preserves account + clock', async () => {
|
||||
// Build a v0.11.0 file with an IN-FLIGHT plane (old dispatch state) and live contracts.
|
||||
const SQL = await initSqlJs();
|
||||
const raw = new SQL.Database();
|
||||
raw.exec(PRE_V12_DDL);
|
||||
raw.exec(
|
||||
`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'flying', 4000, 6300, 'EDDF', 'c-1', 50.03, 8.57, 1789000000000)`,
|
||||
);
|
||||
raw.exec(
|
||||
`INSERT INTO contracts VALUES ('c-1', 'EDDF', 'LEMD', 5000, 'Mail', 4, 1789100000000, 'accepted', 'plane-starter', 1788900000000)`,
|
||||
);
|
||||
raw.exec(`INSERT INTO account VALUES (54020.34, 62.57)`);
|
||||
raw.exec('INSERT INTO sim_state VALUES (1789706016605, 1, 0, 1789718713288)');
|
||||
const file = path.join(dir, 'pre-v12.db');
|
||||
await fs.writeFile(file, Buffer.from(raw.export()));
|
||||
|
||||
const inst = await openGameDb(file);
|
||||
|
||||
// Wallet + sim clock survive byte-identical…
|
||||
expect((await inst.db.select().from(schema.account))[0]).toEqual({ cash: 54020.34, reputation: 62.57 });
|
||||
expect((await inst.db.select().from(schema.simState))[0]?.clockMs).toBe(1_789_706_016_605);
|
||||
|
||||
// …the in-flight plane is reset to GROUNDED at its locationIcao (it "lands" back where it took off)…
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('available');
|
||||
expect(plane.locationIcao).toBe('EDDF'); // unchanged — grounded AT the origin
|
||||
expect(plane.departureSimTime ?? null).toBeNull();
|
||||
expect(plane.destIcao ?? null).toBeNull();
|
||||
|
||||
// …old contract data is wiped and the packages table exists (empty until spawn reseeds)…
|
||||
let contractsGone = false;
|
||||
try {
|
||||
inst.raw.exec('SELECT COUNT(*) FROM contracts');
|
||||
} catch {
|
||||
contractsGone = true;
|
||||
}
|
||||
expect(contractsGone).toBe(true);
|
||||
const packages = await inst.db.select().from(schema.packages);
|
||||
expect(packages).toHaveLength(0);
|
||||
|
||||
// …and the orphaned contract_id column is dropped from planes.
|
||||
const cols = new Set(inst.raw.exec('PRAGMA table_info(planes)')[0].values.map((r) => String(r[1])));
|
||||
expect(cols.has('contract_id')).toBe(false);
|
||||
expect(cols.has('dest_icao')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not re-run the v0.12 plane reset on later opens (migration is one-shot)', async () => {
|
||||
const file = path.join(dir, 'pre-v12-reopen.db');
|
||||
const SQL = await initSqlJs();
|
||||
const raw = new SQL.Database();
|
||||
raw.exec(PRE_V12_DDL);
|
||||
raw.exec(
|
||||
`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57, NULL)`,
|
||||
);
|
||||
raw.exec(`INSERT INTO account VALUES (1000, 1)`);
|
||||
raw.exec('INSERT INTO sim_state VALUES (1789000000000, 1, 0, 1789000000000)');
|
||||
await fs.writeFile(file, Buffer.from(raw.export()));
|
||||
|
||||
const first = await openGameDb(file); // migration runs here
|
||||
// Simulate post-migration play: the plane flies again.
|
||||
await first.db.update(schema.planes).set({ status: 'flying', destIcao: 'LEMD', departureSimTime: 1789000001000 });
|
||||
await persist(first);
|
||||
|
||||
const second = await openGameDb(file); // must NOT ground the plane again
|
||||
const plane = (await second.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('flying');
|
||||
expect(plane.destIcao).toBe('LEMD');
|
||||
});
|
||||
});
|
||||
|
||||
+46
-6
@@ -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);
|
||||
|
||||
+12
-6
@@ -11,7 +11,9 @@ export const planes = sqliteTable('planes', {
|
||||
fuel: real('fuel').notNull(),
|
||||
maxFuel: real('max_fuel').notNull(),
|
||||
locationIcao: text('location_icao').notNull(),
|
||||
contractId: text('contract_id'),
|
||||
// Current flight destination (v0.12.0) — set by fly, cleared on arrival. The
|
||||
// plane's origin is always its locationIcao while flying.
|
||||
destIcao: text('dest_icao'),
|
||||
lat: real('lat').notNull(),
|
||||
lon: real('lon').notNull(),
|
||||
// Sim-clock departure time of the current flight (null when available). While
|
||||
@@ -20,7 +22,11 @@ export const planes = sqliteTable('planes', {
|
||||
departureSimTime: integer('departure_sim_time'),
|
||||
});
|
||||
|
||||
export const contracts = sqliteTable('contracts', {
|
||||
// Cargo packages (v0.12.0) replace the old contracts table end-to-end: a package
|
||||
// is one cargo unit origin → dest with a reward and deadline. 'loaded' = bound to
|
||||
// a plane's slot; it stays bound ('expired') or pays out ('delivered') when the
|
||||
// plane arrives at its destination airport.
|
||||
export const packages = sqliteTable('packages', {
|
||||
id: text('id').primaryKey().notNull(),
|
||||
originIcao: text('origin_icao').notNull(),
|
||||
destIcao: text('dest_icao').notNull(),
|
||||
@@ -28,9 +34,9 @@ export const contracts = sqliteTable('contracts', {
|
||||
cargoType: text('cargo_type').notNull(),
|
||||
cargoCount: integer('cargo_count').notNull(),
|
||||
deadlineSimTime: integer('deadline_sim_time').notNull(),
|
||||
// 'expired' = an open contract whose deadline passed (v0.7.0). Accepted/
|
||||
// in-flight contracts never expire — they complete.
|
||||
status: text('status', { enum: ['open', 'accepted', 'completed', 'expired'] }).notNull(),
|
||||
// 'expired' = an open OR loaded package whose deadline passed on a tick. Loaded
|
||||
// expired packages keep occupying their slot, deliver no reward, and abandon free.
|
||||
status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] }).notNull(),
|
||||
planeId: text('plane_id'),
|
||||
createdSimTime: integer('created_sim_time').notNull(),
|
||||
});
|
||||
@@ -50,6 +56,6 @@ export const simState = sqliteTable('sim_state', {
|
||||
});
|
||||
|
||||
export type PlaneRow = typeof planes.$inferSelect;
|
||||
export type ContractRow = typeof contracts.$inferSelect;
|
||||
export type PackageRow = typeof packages.$inferSelect;
|
||||
export type AccountRow = typeof account.$inferSelect;
|
||||
export type SimStateRow = typeof simState.$inferSelect;
|
||||
|
||||
Reference in New Issue
Block a user