v1.0.0: players table + migration from singleton account (ISS-105)
CI / build + unit + e2e (pull_request) Successful in 14m25s

This commit is contained in:
2026-09-24 14:26:34 +00:00
parent 837aa60a7d
commit b2034ebc52
21 changed files with 258 additions and 166 deletions
+15 -15
View File
@@ -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<Record<string, unknown>> };
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<Record<string, unknown>> }).rows[0].cash;
const before = await (await getRows('sim_state')).json();
const originalClockMs = (before as { rows: Array<Record<string, unknown>> }).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<Record<string, unknown>> }).rows[0].cash).toBe(424_242);
const body = await (await getRows('sim_state')).json();
expect((body as { rows: Array<Record<string, unknown>> }).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);
});
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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);
+5 -6
View File
@@ -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);
+4 -3
View File
@@ -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);
+4 -2
View File
@@ -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);
+3 -4
View File
@@ -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) {
+3 -3
View File
@@ -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);
+3 -4
View File
@@ -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) {