v1.0.0: players table + migration from singleton account (ISS-105)
CI / build + unit + e2e (pull_request) Successful in 14m25s
CI / build + unit + e2e (pull_request) Successful in 14m25s
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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,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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user