diff --git a/CHANGELOG.md b/CHANGELOG.md index 27d4ef0..5bba0fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v1.0.0 — 2026-09-24 14:20 +- **Multiplayer step 1/12 (ISS-105): `players` table replaces the singleton `account`.** New `players` table (`id TEXT PK`, `provider`, `username`, `cash REAL`, `reputation REAL`) in the raw DDL (`lib/db/client.ts`) and a matching Drizzle entry (`lib/db/schema.ts`; `AccountRow` → `PlayerRow`). **One-time migration**: on every `open()`, if the legacy `account` table still exists (sqlite_master check, same guard style as the v0.12.0 contracts migration) AND `players` is empty, its single row is copied into a seeded local player — id `local`, provider `local`, username `kamilkrzywda` (so a later step can join players↔users on username) with cash/reputation carried over — and `account` is dropped. Idempotent: fresh DBs never have `account`, already-migrated files no longer do either, so it runs exactly once per legacy file. **Fresh worlds**: the DDL no longer creates `account`; first-run seeding (empty-check flipped from `COUNT(*) FROM account` to `players`) inserts the local player row directly with the same starting cash/reputation as before. +- **No behavior change — temporary single-player helper.** Every pre-multiplayer read/write of the singleton economy now goes through ONE small module, `lib/db/localPlayer.ts`: `getLocalPlayer(inst)` (resolves the `players` row where id = 'local', throws if missing) and `updateLocalPlayerEconomy(inst, {cash?, reputation?})`. Call sites converted: snapshot (`lib/game/state.ts`), all five actions (`flyPlane` refuel, abandon penalty, buy/sell plane, repair — `lib/game/actions.ts`), the engine's delivery awards + chain top-up + spawn reputation read (`lib/sim/engine.ts`), and the three test hooks (add-cash / set-cash / create-package). The `/api/state` snapshot keeps its exact `account: {cash, reputation}` field name and values, so OpenAPI is untouched — 17 paths, same schemas. +- **Tests.** Unit: fresh DB now asserts the seeded local player row (`local`/`local`/`kamilkrzywda`, starting cash/reputation) AND that no `account` table exists; new dedicated migration test builds a minimal legacy file containing only an `account` table and verifies open() copies its row into the local player, drops `account`, and is a no-op on second open. All existing suites (actions, engine, client, buy/sell/abandon routes) now read/write through the helper; the admin rows CRUD test's PK-less-table example moves from `account` to `sim_state` (the only remaining PK-less table), and the admin tables-list expectation swaps `account` → `players`. No new e2e spec — behavior is byte-identical and CI's existing suite covers the UI paths; a fresh-stack boot exercises the new seed path. + ## v0.73.0 — 2026-09-24 12:24 - **New-package toasts removed.** The client-side snapshot diff in `lib/deriveEvents.ts` no longer emits "New package available" info toasts when a contract appears (the user was spammed by them); the first-snapshot guard and every other event type — delivered, expired, landed, bought, sold — are unchanged. diff --git a/app/api/admin/tables/[name]/rows/route.test.ts b/app/api/admin/tables/[name]/rows/route.test.ts index c6d3c63..1268ae1 100644 --- a/app/api/admin/tables/[name]/rows/route.test.ts +++ b/app/api/admin/tables/[name]/rows/route.test.ts @@ -62,43 +62,43 @@ describe('admin rows CRUD', () => { adminSession = await createSession(await getGameDb(), 'admin-kamilkrzywda'); }); - it('GET returns columns + rows with __rowid for the PK-less account table', async () => { - const res = await getRows('account'); + it('GET returns columns + rows with __rowid for the PK-less sim_state table', async () => { + const res = await getRows('sim_state'); expect(res.status).toBe(200); const body = (await res.json()) as { columns: string[]; rows: Array> }; expect(body.columns[0]).toBe('__rowid'); - for (const col of ['cash', 'reputation']) expect(body.columns).toContain(col); - expect(body.rows).toHaveLength(1); // the single seeded account row + for (const col of ['clock_ms', 'time_scale']) expect(body.columns).toContain(col); + expect(body.rows).toHaveLength(1); // the single seeded sim_state row expect(typeof body.rows[0].__rowid).toBe('number'); - expect(typeof body.rows[0].cash).toBe('number'); + expect(typeof body.rows[0].clock_ms).toBe('number'); }); it('PUT updates a cell and the change survives a re-fetch', async () => { - const before = await (await getRows('account')).json(); - const originalCash = (before as { rows: Array> }).rows[0].cash; + const before = await (await getRows('sim_state')).json(); + const originalClockMs = (before as { rows: Array> }).rows[0].clock_ms; - const res = await putRow('account', { rowid: 1, values: { cash: 424_242 } }); + const res = await putRow('sim_state', { rowid: 1, values: { clock_ms: 424_242 } }); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true }); // Re-fetch through the same route — the persisted value is back. - const body = await (await getRows('account')).json(); - expect((body as { rows: Array> }).rows[0].cash).toBe(424_242); + const body = await (await getRows('sim_state')).json(); + expect((body as { rows: Array> }).rows[0].clock_ms).toBe(424_242); - // Restore the original cash so later tests see a sane world. - const restore = await putRow('account', { rowid: 1, values: { cash: originalCash } }); + // Restore the original clock so later tests see a sane world. + const restore = await putRow('sim_state', { rowid: 1, values: { clock_ms: originalClockMs } }); expect(restore.status).toBe(200); }); it('PUT rejects unknown columns and empty values with 400', async () => { - const badCol = await putRow('account', { rowid: 1, values: { no_such_column: 1 } }); + const badCol = await putRow('sim_state', { rowid: 1, values: { no_such_column: 1 } }); expect(badCol.status).toBe(400); expect(((await badCol.json()) as { error: string }).error).toMatch(/unknown column/i); - const empty = await putRow('account', { rowid: 1, values: {} }); + const empty = await putRow('sim_state', { rowid: 1, values: {} }); expect(empty.status).toBe(400); - const noRowid = await putRow('account', { values: { cash: 1 } }); + const noRowid = await putRow('sim_state', { values: { clock_ms: 1 } }); expect(noRowid.status).toBe(400); }); diff --git a/app/api/admin/tables/[name]/rows/route.ts b/app/api/admin/tables/[name]/rows/route.ts index 90ff439..9be6323 100644 --- a/app/api/admin/tables/[name]/rows/route.ts +++ b/app/api/admin/tables/[name]/rows/route.ts @@ -74,8 +74,8 @@ function sqlError(err: unknown): Response { } // GET /api/admin/tables/{name}/rows?limit=500 — column headers + up to `limit` rows (default -// 500, cap 1000). rowid is exposed as __rowid so the PK-less tables (account, sim_state) are -// editable like any other. Unknown table → 404; bad limit → 400. +// 500, cap 1000). rowid is exposed as __rowid so the PK-less tables (sim_state) are editable +// like any other. Unknown table → 404; bad limit → 400. export async function GET(req: Request, ctx: { params: Promise<{ name: string }> }) { const guard = await requireAdmin(req); if (!guard.ok) return guard.response; diff --git a/app/api/admin/tables/route.test.ts b/app/api/admin/tables/route.test.ts index ddc4a2b..c4738fc 100644 --- a/app/api/admin/tables/route.test.ts +++ b/app/api/admin/tables/route.test.ts @@ -70,7 +70,7 @@ describe('GET /api/admin/tables', () => { expect(res.status).toBe(200); const names = ((await res.json()) as Array<{ name: string }>).map((t) => t.name); - for (const expected of ['account', 'packages', 'planes', 'sessions', 'sim_state', 'users']) { + for (const expected of ['packages', 'players', 'planes', 'sessions', 'sim_state', 'users']) { expect(names, `missing ${expected}`).toContain(expected); } expect(names.some((n) => n.startsWith('sqlite_'))).toBe(false); diff --git a/app/api/market/buy-plane/route.test.ts b/app/api/market/buy-plane/route.test.ts index 433dbf3..de9eeee 100644 --- a/app/api/market/buy-plane/route.test.ts +++ b/app/api/market/buy-plane/route.test.ts @@ -2,7 +2,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import * as schema from '@/lib/db/schema'; +import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer'; import { getGameDb } from '@/lib/db/client'; import { PLANE_TYPES } from '@/lib/game/catalog'; import { POST } from './route'; @@ -38,14 +38,13 @@ describe('POST /api/market/buy-plane', () => { expect(((await res.json()) as { error: string }).error).toMatch(/insufficient funds/i); // Cash untouched by the failed attempt. - const account = (await (await getGameDb()).db.select().from(schema.account))[0]; - expect(account.cash).toBe(20_000); + expect((await getLocalPlayer(await getGameDb())).cash).toBe(20_000); }); it('deducts the full price and adds a full-tank plane at EDDF to the snapshot', async () => { - const { db } = await getGameDb(); - // Single-row account table — top up cash so the purchase can succeed. - await db.update(schema.account).set({ cash: 500_000 }); + const inst = await getGameDb(); + // Local player row — top up cash so the purchase can succeed. + await updateLocalPlayerEconomy(inst, { cash: 500_000 }); const res = await post({ model: 'atr72' }); expect(res.status).toBe(200); diff --git a/app/api/market/sell-plane/route.test.ts b/app/api/market/sell-plane/route.test.ts index 70d161d..e7ece28 100644 --- a/app/api/market/sell-plane/route.test.ts +++ b/app/api/market/sell-plane/route.test.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { eq } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import * as schema from '@/lib/db/schema'; +import { getLocalPlayer } from '@/lib/db/localPlayer'; import { getGameDb } from '@/lib/db/client'; import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog'; import { POST } from './route'; @@ -59,10 +60,10 @@ describe('POST /api/market/sell-plane', () => { }); it('sells a grounded second plane for SELL_FRACTION of the catalog price', async () => { - const { db } = await getGameDb(); + const inst = await getGameDb(); // The previous test left 'plane-extra' in the DB (now flying) — ground it. - await db.update(schema.planes).set({ status: 'available' }).where(eq(schema.planes.id, 'plane-extra')); - const cashBefore = (await db.select().from(schema.account))[0].cash; + await inst.db.update(schema.planes).set({ status: 'available' }).where(eq(schema.planes.id, 'plane-extra')); + const cashBefore = (await getLocalPlayer(inst)).cash; const res = await post({ planeId: 'plane-extra' }); expect(res.status).toBe(200); diff --git a/app/api/packages/[id]/abandon/route.test.ts b/app/api/packages/[id]/abandon/route.test.ts index eaa5b06..3b1c155 100644 --- a/app/api/packages/[id]/abandon/route.test.ts +++ b/app/api/packages/[id]/abandon/route.test.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { eq } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import * as schema from '@/lib/db/schema'; +import { getLocalPlayer } from '@/lib/db/localPlayer'; import { getGameDb } from '@/lib/db/client'; import { POST } from './route'; @@ -84,8 +85,9 @@ describe('POST /api/packages/:id/abandon', () => { it('abandons a loaded package for the exact formula penalty and returns the full snapshot', async () => { const times = await insertLoadedPackage('p-ab', 1_000); // created "now" → near the 15% floor - const { db } = await getGameDb(); - const cashBefore = (await db.select().from(schema.account))[0].cash; + const inst = await getGameDb(); + const { db } = inst; + const cashBefore = (await getLocalPlayer(inst)).cash; const res = await post('p-ab'); expect(res.status).toBe(200); diff --git a/app/api/test/add-cash/route.ts b/app/api/test/add-cash/route.ts index 32e15ff..167a171 100644 --- a/app/api/test/add-cash/route.ts +++ b/app/api/test/add-cash/route.ts @@ -1,5 +1,5 @@ import { getGameDb, persist } from '@/lib/db/client'; -import * as schema from '@/lib/db/schema'; +import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer'; import { getStateSnapshot } from '@/lib/game/state'; import { tickNow } from '@/lib/sim/engine'; @@ -46,9 +46,8 @@ export async function POST(req: Request) { try { const inst = await getGameDb(); await tickNow(); // clock is current before the snapshot we return (same as siblings) - const account = (await inst.db.select().from(schema.account))[0]; - if (!account) throw new Error('account row missing'); - await inst.db.update(schema.account).set({ cash: Math.round((account.cash + amount) * 100) / 100 }); + const player = await getLocalPlayer(inst); + await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash + amount) * 100) / 100 }); await persist(inst); return Response.json(await getStateSnapshot()); } catch (err) { diff --git a/app/api/test/create-package/route.ts b/app/api/test/create-package/route.ts index 8891dc2..6640ff6 100644 --- a/app/api/test/create-package/route.ts +++ b/app/api/test/create-package/route.ts @@ -1,5 +1,6 @@ import { randomInt, randomUUID } from 'node:crypto'; import { getGameDb, persist } from '@/lib/db/client'; +import { getLocalPlayer } from '@/lib/db/localPlayer'; import * as schema from '@/lib/db/schema'; import { loadAirportsGeoJson } from '@/lib/airports'; import { generatePackage, mulberry32 } from '@/lib/game/seed'; @@ -84,8 +85,7 @@ export async function POST(req: Request) { const rng = mulberry32(randomInt(0, 2 ** 31)); // v0.38.0 (ISS-072): hook-created packages carry the LIVE account reputation — same job quality // as engine spawns; never force a fixed value here. - const account = (await inst.db.select().from(schema.account))[0]; - if (!account) throw new Error('account row missing'); + const player = await getLocalPlayer(inst); const created = generatePackage({ id: randomUUID(), airports, @@ -97,7 +97,7 @@ export async function POST(req: Request) { weightKg: input.weightKg as number | undefined, // Omitted → force null (no roll) so existing e2e reward/rep assertions stay deterministic. special: input.special === undefined ? null : (input.special as string | null), - reputation: account.reputation, + reputation: player.reputation, }); await inst.db.insert(schema.packages).values(created); await persist(inst); diff --git a/app/api/test/set-cash/route.ts b/app/api/test/set-cash/route.ts index 584fa1b..14ff445 100644 --- a/app/api/test/set-cash/route.ts +++ b/app/api/test/set-cash/route.ts @@ -1,5 +1,5 @@ import { getGameDb, persist } from '@/lib/db/client'; -import * as schema from '@/lib/db/schema'; +import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer'; import { getStateSnapshot } from '@/lib/game/state'; import { tickNow } from '@/lib/sim/engine'; @@ -42,9 +42,8 @@ export async function POST(req: Request) { try { const inst = await getGameDb(); await tickNow(); // clock is current before the snapshot we return (same as siblings) - const account = (await inst.db.select().from(schema.account))[0]; - if (!account) throw new Error('account row missing'); - await inst.db.update(schema.account).set({ cash: Math.round(amount * 100) / 100 }); + await getLocalPlayer(inst); // existence check — the snapshot below would fail anyway if it's gone + await updateLocalPlayerEconomy(inst, { cash: Math.round(amount * 100) / 100 }); await persist(inst); return Response.json(await getStateSnapshot()); } catch (err) { diff --git a/lib/db/client.test.ts b/lib/db/client.test.ts index c0882cf..cb843ac 100644 --- a/lib/db/client.test.ts +++ b/lib/db/client.test.ts @@ -4,10 +4,14 @@ import path from 'node:path'; import initSqlJs from 'sql.js'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { WORLD_TIME_SCALE } from '@/lib/worldClock'; -import { STARTING_CLOCK_MS } from '@/lib/game/seed'; +import { STARTING_CASH, STARTING_CLOCK_MS, STARTING_REPUTATION } from '@/lib/game/seed'; import { openGameDb, persist } from './client'; +import { getLocalPlayer, updateLocalPlayerEconomy } from './localPlayer'; import * as schema from './schema'; +// v1.0.0 (ISS-105): the local player row every fresh/migrated world must carry. +const LOCAL_PLAYER_ROW = { id: 'local', provider: 'local', username: 'kamilkrzywda' }; + // The v0.6.0 DDL — used to build a "legacy" DB file for the migration test. const LEGACY_DDL = ` CREATE TABLE planes ( @@ -110,8 +114,13 @@ describe('openGameDb', () => { 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); + // v1.0.0 (ISS-105): the singleton economy is now a seeded local player row… + expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: STARTING_CASH, reputation: STARTING_REPUTATION }); + // …and fresh DBs never create an `account` table. + const tables = new Set( + inst.raw.exec("SELECT name FROM sqlite_master WHERE type='table'")[0].values.map((r) => String(r[0])), + ); + expect(tables.has('account')).toBe(false); const sim = (await inst.db.select().from(schema.simState))[0]; // ISS-117: fresh worlds start at the year-2000 game clock with the fixed world rate. expect(sim?.clockMs).toBe(STARTING_CLOCK_MS); @@ -126,25 +135,25 @@ describe('openGameDb', () => { const file = path.join(dir, 'reopen.db'); const first = await openGameDb(file); const packages1 = (await first.db.select().from(schema.packages)).map((p) => p.id).sort(); - const account1 = (await first.db.select().from(schema.account))[0]; + const player1 = await getLocalPlayer(first); // Simulate a process restart: brand-new instance over the same file. const second = await openGameDb(file); 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); + expect(await getLocalPlayer(second)).toEqual(player1); }); it('persist() durably saves writes made after open', async () => { const file = path.join(dir, 'write.db'); const inst = await openGameDb(file); - await inst.db.update(schema.account).set({ cash: 12345 }); + await updateLocalPlayerEconomy(inst, { cash: 12345 }); await persist(inst); // A fresh instance over the same file sees the update. const reloaded = await openGameDb(file); - expect((await reloaded.db.select().from(schema.account))[0]?.cash).toBe(12345); + expect((await getLocalPlayer(reloaded)).cash).toBe(12345); }); it('migrates a pre-v0.7.0 database: adds new columns and fixes last_real_tick_ms', async () => { @@ -161,8 +170,8 @@ describe('openGameDb', () => { const inst = await openGameDb(file); - // Not re-seeded (account row exists)… - expect((await inst.db.select().from(schema.account))[0]?.cash).toBe(20000); + // Not re-seeded (the legacy account row was migrated into the local player)… + expect((await getLocalPlayer(inst)).cash).toBe(20000); // …but the new columns exist and are usable… const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane?.departureSimTime ?? null).toBeNull(); @@ -194,8 +203,8 @@ describe('openGameDb', () => { 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 }); + // Wallet (migrated into the local player) + sim clock survive byte-identical… + expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, 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)… @@ -257,8 +266,8 @@ describe('openGameDb', () => { const rows = inst.raw.exec("SELECT id, username, admin FROM users WHERE username = 'kamilkrzywda'"); expect(rows[0].values).toHaveLength(1); expect(rows[0].values[0]).toEqual(['admin-kamilkrzywda', 'kamilkrzywda', 1]); - // The game world is untouched by the seed. - expect((await inst.db.select().from(schema.account))[0]?.cash).toBe(20000); + // The game world is untouched by the seed (its account row migrated to the local player). + expect((await getLocalPlayer(inst)).cash).toBe(20000); }); it('rotates a v0.71.0 legacy admin digest on open (idempotent)', async () => { @@ -315,4 +324,30 @@ describe('openGameDb', () => { expect(plane.status).toBe('flying'); expect(plane.destIcao).toBe('LEMD'); }); + + it('migrates a pre-v1.0.0 database: copies the singleton account into the local player and drops it', async () => { + // Minimal legacy file — just an `account` table; open() creates everything else from DDL. + const SQL = await initSqlJs(); + const raw = new SQL.Database(); + raw.exec(`CREATE TABLE account (cash REAL NOT NULL, reputation REAL NOT NULL)`); + raw.exec(`INSERT INTO account VALUES (77123.45, 88.5)`); + const file = path.join(dir, 'pre-v1.db'); + await fs.writeFile(file, Buffer.from(raw.export())); + + const inst = await openGameDb(file); + expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 77123.45, reputation: 88.5 }); + let tables = new Set( + inst.raw.exec("SELECT name FROM sqlite_master WHERE type='table'")[0].values.map((r) => String(r[0])), + ); + expect(tables.has('account')).toBe(false); + + // Second open is a no-op (idempotent): the row survives unchanged, still exactly one player. + const second = await openGameDb(file); + expect(await getLocalPlayer(second)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 77123.45, reputation: 88.5 }); + tables = new Set( + second.raw.exec("SELECT name FROM sqlite_master WHERE type='table'")[0].values.map((r) => String(r[0])), + ); + expect(tables.has('account')).toBe(false); + expect(Number(second.raw.exec('SELECT COUNT(*) FROM players')[0].values[0][0])).toBe(1); + }); }); diff --git a/lib/db/client.ts b/lib/db/client.ts index 6bab57c..92fd944 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -5,6 +5,7 @@ import { drizzle, type SQLJsDatabase } from 'drizzle-orm/sql-js'; import initSqlJs, { type Database as RawSqlJsDatabase } from 'sql.js'; import { loadAirportsGeoJson } from '@/lib/airports'; import { buildSeed, mulberry32 } from '@/lib/game/seed'; +import { LOCAL_PLAYER } from './localPlayer'; import * as schema from './schema'; // SQLite via sql.js (WASM) — no native compilation, so it builds and runs in the @@ -51,7 +52,12 @@ CREATE TABLE IF NOT EXISTS packages ( created_real_ms INTEGER NOT NULL DEFAULT 0, slot_free_at_real_ms INTEGER ); -CREATE TABLE IF NOT EXISTS account ( +-- v1.0.0 (ISS-105): per-player economy — replaces the singleton account table; the local +-- single-player row is seeded by seedEmpty() or migrated from account in open(). +CREATE TABLE IF NOT EXISTS players ( + id TEXT PRIMARY KEY NOT NULL, + provider TEXT NOT NULL, + username TEXT NOT NULL, cash REAL NOT NULL, reputation REAL NOT NULL ); @@ -182,6 +188,17 @@ function migrateContractsToPackages(raw: RawSqlJsDatabase): void { raw.exec('DROP TABLE contracts'); } +// v1.0.0 (ISS-105) one-time migration: singleton `account` row → seeded local player, then drop +// the table. Detected by sqlite_master like migrateContractsToPackages — fresh DBs never have +// `account`, so this runs exactly once per legacy file and is a no-op on later opens. +function migrateAccountToPlayers(raw: RawSqlJsDatabase): void { + raw.run( + 'INSERT INTO players (id, provider, username, cash, reputation) SELECT ?, ?, ?, cash, reputation FROM account', + [LOCAL_PLAYER.id, LOCAL_PLAYER.provider, LOCAL_PLAYER.username], + ); + raw.exec('DROP TABLE account'); +} + // FLIGHT_DB_PATH override exists so unit tests can point at a temp file instead // of the real ./db/flight.db. export function defaultDbPath(): string { @@ -209,12 +226,18 @@ async function open(file: string): Promise { if (tables.has('contracts')) { migrateContractsToPackages(raw); } + // v1.0.0 (ISS-105): legacy files still carry the singleton `account` table — copy its row into a + // seeded local player and drop it. The players-empty guard makes this idempotent even if a file + // somehow had both tables; fresh DBs never have `account`, so this runs exactly once per file. + const playerCount = Number(raw.exec('SELECT COUNT(*) FROM players')[0].values[0]?.[0] ?? 0); + if (tables.has('account') && playerCount === 0) { + migrateAccountToPlayers(raw); + } const instance: GameDb = { db: drizzle(raw, { schema }), raw, file }; - // First-run seed: only when the DB is empty (no account row yet). - const res = raw.exec('SELECT COUNT(*) FROM account'); - if (Number(res[0]?.values?.[0]?.[0] ?? 0) === 0) { + // First-run seed: only when the DB is empty (no player row yet). + if (playerCount === 0 && !tables.has('account')) { console.warn(`flight db: no existing world at ${file} — seeding a fresh one`); await seedEmpty(instance); } @@ -246,7 +269,8 @@ async function seedEmpty(instance: GameDb): Promise { const { queue, ...planeCols } = data.plane; await instance.db.insert(schema.planes).values({ ...planeCols, queue: JSON.stringify(queue ?? []) }); await instance.db.insert(schema.packages).values(data.packages); - await instance.db.insert(schema.account).values(data.account); + // v1.0.0 (ISS-105): the singleton economy now lives in a seeded local player row. + await instance.db.insert(schema.players).values({ ...LOCAL_PLAYER, cash: data.account.cash, reputation: data.account.reputation }); await instance.db.insert(schema.simState).values(data.simState); await persist(instance); } diff --git a/lib/db/localPlayer.ts b/lib/db/localPlayer.ts new file mode 100644 index 0000000..6f68743 --- /dev/null +++ b/lib/db/localPlayer.ts @@ -0,0 +1,23 @@ +import { eq } from 'drizzle-orm'; +import type { GameDb } from './client'; +import * as schema from './schema'; + +// v1.0.0 (ISS-105): TEMPORARY single-player economy access — resolves the local player row so all +// pre-multiplayer code keeps working while `account` is replaced by `players`. A later step +// parameterizes these by playerId for real multiplayer; until then every read/write of the +// singleton economy goes through this file and nowhere else. + +export const LOCAL_PLAYER = { id: 'local', provider: 'local', username: 'kamilkrzywda' } as const; + +export async function getLocalPlayer(inst: GameDb): Promise { + const [row] = await inst.db.select().from(schema.players).where(eq(schema.players.id, LOCAL_PLAYER.id)); + if (!row) throw new Error('local player row missing'); + return row; +} + +export async function updateLocalPlayerEconomy( + inst: GameDb, + patch: { cash?: number; reputation?: number }, +): Promise { + await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, LOCAL_PLAYER.id)); +} diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 6bc03e0..8221f03 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -66,7 +66,13 @@ export const packages = sqliteTable('packages', { slotFreeAtRealMs: integer('slot_free_at_real_ms'), }); -export const account = sqliteTable('account', { +// v1.0.0 (ISS-105): per-player economy — replaces the singleton `account` table. The local +// single-player row (id 'local') is seeded on first run / migrated from `account` by open(); +// all pre-multiplayer code reaches it via lib/db/localPlayer.ts only. +export const players = sqliteTable('players', { + id: text('id').primaryKey().notNull(), + provider: text('provider').notNull(), + username: text('username').notNull(), cash: real('cash').notNull(), reputation: real('reputation').notNull(), }); @@ -104,7 +110,7 @@ export const sessions = sqliteTable('sessions', { export type PlaneRow = typeof planes.$inferSelect; export type PackageRow = typeof packages.$inferSelect; -export type AccountRow = typeof account.$inferSelect; +export type PlayerRow = typeof players.$inferSelect; export type SimStateRow = typeof simState.$inferSelect; export type UserRow = typeof users.$inferSelect; export type SessionRow = typeof sessions.$inferSelect; diff --git a/lib/game/actions.test.ts b/lib/game/actions.test.ts index 31fb2c0..64ca2ea 100644 --- a/lib/game/actions.test.ts +++ b/lib/game/actions.test.ts @@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { openGameDb, type GameDb } from '@/lib/db/client'; import * as schema from '@/lib/db/schema'; +import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer'; import { loadAirportsGeoJson } from '@/lib/airports'; import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { weatherFor } from '@/lib/weather'; @@ -204,14 +205,14 @@ describe('flyPlane', () => { const inst = await freshDb('fly-broken.db'); // EDDF → LEMD const fuelBefore = PLANE_TYPES.atr72.maxFuel; // seeded full tank await inst.db.update(schema.planes).set({ health: 0 }).where(eq(schema.planes.id, 'plane-starter')); - const cashBefore = (await inst.db.select().from(schema.account))[0].cash; + const cashBefore = (await getLocalPlayer(inst)).cash; await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /broken \(health 0\)/); const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.status).toBe('available'); // still grounded… expect(plane.fuel).toBe(fuelBefore); // …no auto-refuel ran — the tank is untouched… - expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // …and no cash left the account + expect((await getLocalPlayer(inst)).cash).toBe(cashBefore); // …and no cash left the account }); it('still flies a worn-but-not-broken plane (health > 0) with plain burn', async () => { @@ -249,8 +250,8 @@ describe('flyPlane', () => { const startFuel = Math.round(required / 2); // partial tank below the requirement await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter')); // Elevated rep → non-zero discount rate; expectations computed with the SAME helpers flyPlane uses. - await inst.db.update(schema.account).set({ reputation: 30 }); - const cashBefore = (await inst.db.select().from(schema.account))[0].cash; + await updateLocalPlayerEconomy(inst, { reputation: 30 }); + const cashBefore = (await getLocalPlayer(inst)).cash; await flyPlane(inst, 'plane-starter', 'LEMD'); @@ -263,22 +264,22 @@ describe('flyPlane', () => { const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.status).toBe('flying'); expect(plane.fuel).toBeCloseTo(target, 6); // topped up to exactly the 150% target… - const account = (await inst.db.select().from(schema.account))[0]; - expect(account.cash).toBeCloseTo(cashBefore - expectedCost, 6); // …at exactly the rep-discounted cost + const player = await getLocalPlayer(inst); + expect(player.cash).toBeCloseTo(cashBefore - expectedCost, 6); // …at exactly the rep-discounted cost }); it('is a free no-op when the tank already covers the 150% target (cash unchanged)', async () => { const inst = await freshDb('fly-autorefuel-full.db'); // seeded starter is full at EDDF const required = await adjustedRequiredForHubToLemd(); expect(required * 1.5).toBeLessThan(PLANE_TYPES.atr72.maxFuel); // sanity: the target stays below a full tank - const cashBefore = (await inst.db.select().from(schema.account))[0].cash; + const cashBefore = (await getLocalPlayer(inst)).cash; await flyPlane(inst, 'plane-starter', 'LEMD'); const plane = (await inst.db.select().from(schema.planes))[0]; expect(plane.status).toBe('flying'); expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // untouched… - expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // …and free + expect((await getLocalPlayer(inst)).cash).toBe(cashBefore); // …and free }); it('rejects dispatch with 409 insufficient funds when cash cannot cover the top-up — nothing mutated', async () => { @@ -286,7 +287,7 @@ describe('flyPlane', () => { const required = await adjustedRequiredForHubToLemd(); const startFuel = Math.round(required / 2); await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter')); - await inst.db.update(schema.account).set({ cash: 0 }); + await updateLocalPlayerEconomy(inst, { cash: 0 }); await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient funds/i); @@ -451,7 +452,7 @@ describe('abandonPackage', () => { it('charges exactly 15% of the payout right after creation (clock at createdSimTime)', async () => { const inst = await setupLoaded('ab-start.db'); - const cashBefore = (await inst.db.select().from(schema.account))[0].cash; + const cashBefore = (await getLocalPlayer(inst)).cash; const { penalty } = await abandonPackage(inst, 'p-ab'); @@ -459,8 +460,8 @@ describe('abandonPackage', () => { const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0]; expect(row.status).toBe('abandoned'); expect(row.planeId).toBeNull(); // slot freed - const account = (await inst.db.select().from(schema.account))[0]; - expect(account.cash).toBeCloseTo(cashBefore - penalty, 6); + const player = await getLocalPlayer(inst); + expect(player.cash).toBeCloseTo(cashBefore - penalty, 6); }); it('scales to exactly 37.5% at the midpoint of the window', async () => { @@ -487,24 +488,24 @@ describe('abandonPackage', () => { it('is free for an already-expired package', async () => { const inst = await setupLoaded('ab-expired.db', 'expired'); - const cashBefore = (await inst.db.select().from(schema.account))[0].cash; + const cashBefore = (await getLocalPlayer(inst)).cash; const { penalty } = await abandonPackage(inst, 'p-ab'); expect(penalty).toBe(0); - expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); + expect((await getLocalPlayer(inst)).cash).toBe(cashBefore); const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0]; expect(row.status).toBe('abandoned'); }); it('floors cash at €0 — no debt', async () => { const inst = await setupLoaded('ab-floor.db'); - await inst.db.update(schema.account).set({ cash: 10 }); // penalty is 150 > 10 + await updateLocalPlayerEconomy(inst, { cash: 10 }); // penalty is 150 > 10 const { penalty } = await abandonPackage(inst, 'p-ab'); expect(penalty).toBe(150); - expect((await inst.db.select().from(schema.account))[0].cash).toBe(0); + expect((await getLocalPlayer(inst)).cash).toBe(0); }); it('rejects abandoning while the plane is in flight with 409', async () => { @@ -532,13 +533,13 @@ describe('abandonPackage', () => { describe('buyPlane', () => { it('deducts the full price and delivers a full-tank plane at the home base', async () => { const inst = await freshDb('buy-ok.db'); - await inst.db.update(schema.account).set({ cash: 500_000 }); + await updateLocalPlayerEconomy(inst, { cash: 500_000 }); const before = (await inst.db.select().from(schema.planes)).length; const { planeId } = await buyPlane(inst, 'atr72'); - const account = (await inst.db.select().from(schema.account))[0]; - expect(account.cash).toBe(500_000 - PLANE_TYPES.atr72.price); + const player = await getLocalPlayer(inst); + expect(player.cash).toBe(500_000 - PLANE_TYPES.atr72.price); const planes = await inst.db.select().from(schema.planes); expect(planes.length).toBe(before + 1); @@ -567,7 +568,7 @@ describe('buyPlane', () => { it('deducts the reputation-scaled cost at elevated rep (v0.38.0)', async () => { const inst = await freshDb('buy-rep.db'); // rep 30 → rate exactly 0.2 → ATR costs 160k, not 200k. - await inst.db.update(schema.account).set({ cash: 500_000, reputation: 30 }); + await updateLocalPlayerEconomy(inst, { cash: 500_000, reputation: 30 }); const before = (await inst.db.select().from(schema.planes)).length; await buyPlane(inst, 'atr72'); @@ -576,23 +577,23 @@ describe('buyPlane', () => { expect(rate).toBeCloseTo(0.2, 12); // sanity pin on the formula input const expectedCost = Math.round(PLANE_TYPES.atr72.price * (1 - rate) * 100) / 100; expect(expectedCost).toBe(160_000); // 200k × 0.8 — exact value pin - const account = (await inst.db.select().from(schema.account))[0]; - expect(account.cash).toBeCloseTo(500_000 - expectedCost, 6); + const player = await getLocalPlayer(inst); + expect(player.cash).toBeCloseTo(500_000 - expectedCost, 6); expect((await inst.db.select().from(schema.planes)).length).toBe(before + 1); }); it('buys with cash between the discounted and full price; rejects below the discounted cost (v0.38.0)', async () => { const inst = await freshDb('buy-boundary.db'); // rep 30 → discounted cost 160k: 170k is enough now, though it was not before v0.38.0. - await inst.db.update(schema.account).set({ cash: 170_000, reputation: 30 }); + await updateLocalPlayerEconomy(inst, { cash: 170_000, reputation: 30 }); const { planeId } = await buyPlane(inst, 'atr72'); expect(planeId).toBeTruthy(); // the old full-price check would have rejected this - const account = (await inst.db.select().from(schema.account))[0]; - expect(account.cash).toBeCloseTo(170_000 - 160_000, 6); + const player = await getLocalPlayer(inst); + expect(player.cash).toBeCloseTo(170_000 - 160_000, 6); const inst2 = await freshDb('buy-boundary-poor.db'); // just below the discounted cost - await inst2.db.update(schema.account).set({ cash: 159_999.99, reputation: 30 }); + await updateLocalPlayerEconomy(inst2, { cash: 159_999.99, reputation: 30 }); await expectActionError(buyPlane(inst2, 'atr72'), 409, /insufficient funds/i); }); }); @@ -605,13 +606,13 @@ describe('sellPlane', () => { id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel, locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null, }); - const cashBefore = (await inst.db.select().from(schema.account))[0].cash; + const cashBefore = (await getLocalPlayer(inst)).cash; const { payout } = await sellPlane(inst, 'plane-extra'); expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100); - const account = (await inst.db.select().from(schema.account))[0]; - expect(account.cash).toBeCloseTo(cashBefore + payout, 6); + const player = await getLocalPlayer(inst); + expect(player.cash).toBeCloseTo(cashBefore + payout, 6); const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id); expect(ids).not.toContain('plane-extra'); // row deleted expect(ids).toContain('plane-starter'); @@ -668,7 +669,7 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () => it('charges exactly REPAIR_COST and grounds the plane for REPAIR_DURATION_MS without touching health yet', async () => { const inst = await freshDb('repair-ok.db'); await inst.db.update(schema.planes).set({ health: 42 }).where(eq(schema.planes.id, 'plane-starter')); - const cashBefore = (await inst.db.select().from(schema.account))[0].cash; + const cashBefore = (await getLocalPlayer(inst)).cash; const { cost } = await repairPlane(inst, 'plane-starter'); @@ -677,13 +678,13 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () => expect(plane.status).toBe('repairing'); expect(plane.health).toBe(42); // NOT restored yet — the engine's finishRepairs pass heals it gradually… expect(plane.repairUntilSimTime).toBe(STARTING_CLOCK_MS + REPAIR_DURATION_MS); // …with the gate set from the seeded game clock (ISS-117; no tick in direct calls) - const account = (await inst.db.select().from(schema.account))[0]; - expect(account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6); + const player = await getLocalPlayer(inst); + expect(player.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6); }); it('is a free no-op when health is already full', async () => { const inst = await freshDb('repair-full.db'); // seeded starter at 100 - const cashBefore = (await inst.db.select().from(schema.account))[0].cash; + const cashBefore = (await getLocalPlayer(inst)).cash; const { cost } = await repairPlane(inst, 'plane-starter'); @@ -692,7 +693,7 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () => expect(plane.status).toBe('available'); // not grounded… expect(plane.health).toBe(100); expect(plane.repairUntilSimTime ?? null).toBeNull(); // …and no gate set - expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); + expect((await getLocalPlayer(inst)).cash).toBe(cashBefore); }); it('rejects a flying plane with 409', async () => { @@ -732,7 +733,7 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () => it('rejects the repair when cash is below REPAIR_COST with 409', async () => { const inst = await freshDb('repair-poor.db'); await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter')); - await inst.db.update(schema.account).set({ cash: REPAIR_COST - 1 }); + await updateLocalPlayerEconomy(inst, { cash: REPAIR_COST - 1 }); await expectActionError(repairPlane(inst, 'plane-starter'), 409, /insufficient funds/i); }); diff --git a/lib/game/actions.ts b/lib/game/actions.ts index 6b004de..14cde42 100644 --- a/lib/game/actions.ts +++ b/lib/game/actions.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; import { and, eq, inArray } from 'drizzle-orm'; import * as schema from '@/lib/db/schema'; +import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer'; import { persist, type GameDb } from '@/lib/db/client'; import { loadAirportsGeoJson } from '@/lib/airports'; import { haversineKm, initialBearingDeg } from '@/lib/geo'; @@ -140,11 +141,10 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string): // any mutation, so a 409 leaves fuel and cash untouched. const target = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5)); const liters = Math.max(0, target - plane.fuel); - const account = (await inst.db.select().from(schema.account))[0]; - if (!account) throw new Error('account row missing'); - const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100; - if (cost > 0 && account.cash < cost) { - throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`); + const player = await getLocalPlayer(inst); + const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(player.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100; + if (cost > 0 && player.cash < cost) { + throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(player.cash)}`); } // Backstop: a leg that doesn't fit even a full tank is unflyable (target caps at maxFuel). if (requiredL > target) { @@ -173,7 +173,7 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string): }) .where(eq(schema.planes.id, planeId)); if (cost > 0) { - await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 }); + await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash - cost) * 100) / 100 }); } await persist(inst); } @@ -255,9 +255,8 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{ if (!sim) throw new Error('sim_state row missing'); const penalty = abandonPenalty(pkg, sim.clockMs); - const account = (await inst.db.select().from(schema.account))[0]; - if (!account) throw new Error('account row missing'); - await inst.db.update(schema.account).set({ cash: Math.max(0, Math.round((account.cash - penalty) * 100) / 100) }); + const player = await getLocalPlayer(inst); + await updateLocalPlayerEconomy(inst, { cash: Math.max(0, Math.round((player.cash - penalty) * 100) / 100) }); await inst.db.update(schema.packages).set({ status: 'abandoned', planeId: null }).where(eq(schema.packages.id, packageId)); await persist(inst); return { penalty }; @@ -272,12 +271,11 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId: const type = PLANE_TYPES[model]; if (!type) throw new GameActionError(400, `unknown plane model "${model}"`); - const account = (await inst.db.select().from(schema.account))[0]; - if (!account) throw new Error('account row missing'); + const player = await getLocalPlayer(inst); // v0.38.0 (ISS-072): the EFFECTIVE cost drives both the funds check and the deduction. - const cost = Math.round(type.price * (1 - repRate(account.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)) * 100) / 100; - if (account.cash < cost) { - throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${cost}, you have ${Math.round(account.cash)}`); + const cost = Math.round(type.price * (1 - repRate(player.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)) * 100) / 100; + if (player.cash < cost) { + throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${cost}, you have ${Math.round(player.cash)}`); } const fc = await loadAirportsGeoJson(); @@ -298,7 +296,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId: departureSimTime: null, health: 100, // new planes arrive at full health (v0.39.0, ISS-069) }); - await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 }); + await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash - cost) * 100) / 100 }); await persist(inst); return { planeId }; } @@ -337,9 +335,8 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout const payout = Math.round(type.price * SELL_FRACTION * 100) / 100; await inst.db.delete(schema.planes).where(eq(schema.planes.id, planeId)); - const account = (await inst.db.select().from(schema.account))[0]; - if (!account) throw new Error('account row missing'); - await inst.db.update(schema.account).set({ cash: Math.round((account.cash + payout) * 100) / 100 }); + const player = await getLocalPlayer(inst); + await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash + payout) * 100) / 100 }); await persist(inst); return { payout }; } @@ -364,17 +361,16 @@ export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost const sim = (await inst.db.select().from(schema.simState))[0]; if (!sim) throw new Error('sim_state row missing'); - const account = (await inst.db.select().from(schema.account))[0]; - if (!account) throw new Error('account row missing'); - if (account.cash < REPAIR_COST) { - throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(account.cash)}`); + const player = await getLocalPlayer(inst); + if (player.cash < REPAIR_COST) { + throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(player.cash)}`); } // Health is NOT set here — the engine's finishRepairs pass heals it gradually along the gate. await inst.db.update(schema.planes) .set({ status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS }) .where(eq(schema.planes.id, planeId)); - await inst.db.update(schema.account).set({ cash: Math.round((account.cash - REPAIR_COST) * 100) / 100 }); + await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash - REPAIR_COST) * 100) / 100 }); await persist(inst); return { cost: REPAIR_COST }; } diff --git a/lib/game/state.ts b/lib/game/state.ts index 144bce3..1247eeb 100644 --- a/lib/game/state.ts +++ b/lib/game/state.ts @@ -2,6 +2,7 @@ import { loadAirportsGeoJson } from '@/lib/airports'; import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { weatherFor } from '@/lib/weather'; import { getGameDb } from '@/lib/db/client'; +import { getLocalPlayer } from '@/lib/db/localPlayer'; import * as schema from '@/lib/db/schema'; import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog'; import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement'; @@ -114,19 +115,21 @@ export function sseFrame(snapshot: StateSnapshot): StateSnapshot { } export async function getStateSnapshot(): Promise { - const { db } = await getGameDb(); + const inst = await getGameDb(); + const { db } = inst; // NOTE: use plain select(), not the relational db.query.* API — in drizzle-orm // 0.45.x the sql-js driver drops the result mapper for relational queries and // returns raw rows keyed by SQL column names (snake_case, no type decoding). - const [accountRows, simRows, planeRows, packageRows] = await Promise.all([ - db.select().from(schema.account), + // v1.0.0 (ISS-105): the snapshot's `account` field is now the local player row — same shape, + // resolved through the temporary single-player helper until multiplayer lands. + const [player, simRows, planeRows, packageRows] = await Promise.all([ + getLocalPlayer(inst), db.select().from(schema.simState), db.select().from(schema.planes), db.select().from(schema.packages), ]); - const accountRow = accountRows[0]; const simRow = simRows[0]; - if (!accountRow || !simRow) throw new Error('game state missing account/sim_state row'); + if (!simRow) throw new Error('game state missing sim_state row'); const fc = await loadAirportsGeoJson(); const nameByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties.name])); @@ -169,7 +172,7 @@ export async function getStateSnapshot(): Promise { }; return { - account: { cash: accountRow.cash, reputation: accountRow.reputation }, + account: { cash: player.cash, reputation: player.reputation }, simState: { clockMs: simRow.clockMs }, catalog: catalogEntries(), fuelPricePerL: FUEL_PRICE_PER_L, diff --git a/lib/openapi.ts b/lib/openapi.ts index 0677894..eeedae6 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -445,7 +445,7 @@ export const openApiSpec = { get: { summary: 'Read table rows (admin)', description: - 'Column headers + up to `limit` rows of the named table (default 500, cap 1000). rowid is exposed as __rowid so the PK-less tables (account, sim_state) are editable like any other. Table names are validated against sqlite_master and values are always bound as parameters.', + 'Column headers + up to `limit` rows of the named table (default 500, cap 1000). rowid is exposed as __rowid so the PK-less tables (sim_state) are editable like any other. Table names are validated against sqlite_master and values are always bound as parameters.', operationId: 'listAdminRows', parameters: [ { name: 'name', in: 'path', required: true, schema: { type: 'string' } }, diff --git a/lib/sim/engine.test.ts b/lib/sim/engine.test.ts index 52c3211..5483366 100644 --- a/lib/sim/engine.test.ts +++ b/lib/sim/engine.test.ts @@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { openGameDb } from '@/lib/db/client'; import * as schema from '@/lib/db/schema'; +import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer'; import { loadAirportsGeoJson } from '@/lib/airports'; import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { weatherFor } from '@/lib/weather'; @@ -228,20 +229,20 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => { it('does nothing before the arrival time', async () => { const { inst, km, type } = await setupFlight('deliver-early.db'); - const accountBefore = (await inst.db.select().from(schema.account))[0]!; + const playerBefore = (await getLocalPlayer(inst)); const clock = T0 + travelMs(km, type.speedKmh) - 1; // one ms before arrival expect(await deliverPackages(inst, clock)).toBe(0); const plane = (await inst.db.select().from(schema.planes))[0]; - const account = (await inst.db.select().from(schema.account))[0]!; + const player = (await getLocalPlayer(inst)); expect(plane.status).toBe('flying'); - expect(account.cash).toBe(accountBefore.cash); // no award before landing + expect(player.cash).toBe(playerBefore.cash); // no award before landing }); it('delivers in-deadline cargo, unbinds expired cargo for free, keeps other-dest cargo bound', async () => { const { inst, from, to, km, type } = await setupFlight('deliver-mixed.db'); - const accountBefore = (await inst.db.select().from(schema.account))[0]!; + const playerBefore = (await getLocalPlayer(inst)); // Three packages on the plane: one in-deadline for TO, one expired for TO, // one loaded for a DIFFERENT airport (stays bound for a later leg). @@ -274,23 +275,23 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => { expect(byId.get('p-elsewhere')!.status).toBe('loaded'); // not this airport's cargo… expect(byId.get('p-elsewhere')!.planeId).toBe('plane-starter'); // …stays bound for the next leg - const account = (await inst.db.select().from(schema.account))[0]!; - expect(account.cash).toBeCloseTo(accountBefore.cash + 5_000, 9); // ONLY p-pay paid out - expect(account.reputation).toBeCloseTo( - Math.round((accountBefore.reputation + reputationGainFor(km)) * 100) / 100, + const player = (await getLocalPlayer(inst)); + expect(player.cash).toBeCloseTo(playerBefore.cash + 5_000, 9); // ONLY p-pay paid out + expect(player.reputation).toBeCloseTo( + Math.round((playerBefore.reputation + reputationGainFor(km)) * 100) / 100, 9, ); // A later tick must not award again — the plane is no longer 'flying'. expect(await deliverPackages(inst, clock + 3_600_000)).toBe(0); - const accountAfter = (await inst.db.select().from(schema.account))[0]!; - expect(accountAfter.cash).toBe(account.cash); - expect(accountAfter.reputation).toBe(account.reputation); + const playerAfter = (await getLocalPlayer(inst)); + expect(playerAfter.cash).toBe(player.cash); + expect(playerAfter.reputation).toBe(player.reputation); }); it('delivers every in-deadline package for the arrival airport with per-package rewards', async () => { const { inst, km } = await setupFlight('deliver-multi.db'); - const accountBefore = (await inst.db.select().from(schema.account))[0]!; + const playerBefore = (await getLocalPlayer(inst)); await inst.db.insert(schema.packages).values([ { id: 'p-a', originIcao: FROM, destIcao: TO, reward: 1_000, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 }, @@ -300,17 +301,17 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => { const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh); expect(await deliverPackages(inst, clock)).toBe(1); - const account = (await inst.db.select().from(schema.account))[0]!; - expect(account.cash).toBeCloseTo(accountBefore.cash + 3_500, 9); // both rewards - expect(account.reputation).toBeCloseTo( - Math.round((accountBefore.reputation + reputationGainFor(km) * 2) * 100) / 100, + const player = (await getLocalPlayer(inst)); + expect(player.cash).toBeCloseTo(playerBefore.cash + 3_500, 9); // both rewards + expect(player.reputation).toBeCloseTo( + Math.round((playerBefore.reputation + reputationGainFor(km) * 2) * 100) / 100, 9, ); // rep per delivered package }); it('adds the registry rep bonus for a special package at delivery (v0.37.0)', async () => { const { inst, km } = await setupFlight('deliver-special.db'); - const accountBefore = (await inst.db.select().from(schema.account))[0]!; + const playerBefore = (await getLocalPlayer(inst)); await inst.db.insert(schema.packages).values([ { id: 'p-prio', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Mail', cargoCount: 10, special: 'priority', deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 }, @@ -319,11 +320,11 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => { const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh); // exactly at arrival expect(await deliverPackages(inst, clock)).toBe(1); - const account = (await inst.db.select().from(schema.account))[0]!; - expect(account.cash).toBeCloseTo(accountBefore.cash + 5_000, 9); + const player = (await getLocalPlayer(inst)); + expect(player.cash).toBeCloseTo(playerBefore.cash + 5_000, 9); // Distance-based gain + the registry rep bonus — double-rounded like every other award. - expect(account.reputation).toBeCloseTo( - Math.round((accountBefore.reputation + reputationGainFor(km) + SPECIALS.priority.repBonus) * 100) / 100, + expect(player.reputation).toBeCloseTo( + Math.round((playerBefore.reputation + reputationGainFor(km) + SPECIALS.priority.repBonus) * 100) / 100, 9, ); }); @@ -381,7 +382,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => { .set({ fuel: depFuel, queue: JSON.stringify(['EHAM']) }) .where(eq(schema.planes.id, 'plane-starter')); - const accountBefore = (await inst.db.select().from(schema.account))[0]!; // seeded €20k — the top-up is affordable + const playerBefore = (await getLocalPlayer(inst)); // seeded €20k — the top-up is affordable const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival expect(await deliverPackages(inst, clock)).toBe(1); @@ -402,7 +403,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => { const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5)); const liters = Math.max(0, target - landedFuel); expect(liters).toBeGreaterThan(0); // the hop genuinely needs more than what's left - const rate = repRate(accountBefore.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT); + const rate = repRate(playerBefore.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT); const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100; const plane = (await inst.db.select().from(schema.planes))[0]; @@ -412,21 +413,21 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => { expect(JSON.parse(plane.queue)).toEqual([]); // …leg consumed, rest kept in order expect(plane.fuel).toBeCloseTo(target, 6); // …departing with the post-top-up level - const account = (await inst.db.select().from(schema.account))[0]!; - expect(account.cash).toBeCloseTo(accountBefore.cash - expectedCost, 9); // exactly the rep-discounted cost + const player = (await getLocalPlayer(inst)); + expect(player.cash).toBeCloseTo(playerBefore.cash - expectedCost, 9); // exactly the rep-discounted cost }); it('grounds with the queue intact when cash cannot cover the auto top-up (ISS-082)', async () => { const { inst, from, to, km, type } = await setupFlight('chain-nofuel-poor.db'); - // Same empty-tank landing as above, but the account is broke — the top-up is unaffordable. + // Same empty-tank landing as above, but the player is broke — the top-up is unaffordable. const w = weatherFor(FROM); const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)); const depFuel = requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km); await inst.db.update(schema.planes) .set({ fuel: depFuel, queue: JSON.stringify(['EHAM']) }) .where(eq(schema.planes.id, 'plane-starter')); - await inst.db.update(schema.account).set({ cash: 0 }); + await updateLocalPlayerEconomy(inst, { cash: 0 }); const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival expect(await deliverPackages(inst, clock)).toBe(1); @@ -438,7 +439,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => { expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …queue kept for a later dispatch const landedFuel = fuelAtWeighted(depFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1); expect(plane.fuel).toBeCloseTo(landedFuel, 6); // no top-up happened — the tank is untouched - expect((await inst.db.select().from(schema.account))[0]!.cash).toBe(0); + expect((await getLocalPlayer(inst)).cash).toBe(0); }); it('grounds as before when the queue is empty', async () => { diff --git a/lib/sim/engine.ts b/lib/sim/engine.ts index d4fac1a..f91ac1a 100644 --- a/lib/sim/engine.ts +++ b/lib/sim/engine.ts @@ -1,6 +1,7 @@ import { randomInt, randomUUID } from 'node:crypto'; import { eq, sql } from 'drizzle-orm'; import { getGameDb, persist, type GameDb } from '@/lib/db/client'; +import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer'; import * as schema from '@/lib/db/schema'; import { loadAirportsGeoJson } from '@/lib/airports'; import { haversineKm, initialBearingDeg } from '@/lib/geo'; @@ -250,13 +251,12 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise= cost) { queue.shift(); chainedTo = nextDest; topUpFuel = target; - if (cost > 0) await inst.db.update(schema.account).set({ cash: Math.round((fresh.cash - cost) * 100) / 100 }); + if (cost > 0) await updateLocalPlayerEconomy(inst, { cash: Math.round((fresh.cash - cost) * 100) / 100 }); } } } @@ -383,9 +382,8 @@ export async function spawnPoolPackages( lon: f.properties.lon, })); const rand = rng ?? mulberry32(randomInt(0, 2 ** 31)); - // v0.38.0 (ISS-072): spawned jobs carry the account's CURRENT reputation quality — read once per spawn batch. - const account = (await inst.db.select().from(schema.account))[0]; - if (!account) throw new Error('account row missing'); + // v0.38.0 (ISS-072): spawned jobs carry the local player's CURRENT reputation quality — read once per spawn batch. + const player = await getLocalPlayer(inst); let n = 0; for (const originIcao of pool) { @@ -415,7 +413,7 @@ export async function spawnPoolPackages( clockMs, id: randomUUID(), createdRealMs: real, - reputation: account.reputation, + reputation: player.reputation, }), ); openByBand[bandIdx]++; diff --git a/package.json b/package.json index ef1b4ff..8dc1781 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.73.0", + "version": "1.0.0", "private": true, "scripts": { "dev": "next dev -p 3000",