v1.3.0: per-player money & reputation in engine + actions (ISS-110)
CI / build + unit + e2e (pull_request) Successful in 13m20s

This commit is contained in:
2026-09-26 17:03:15 +00:00
parent 306f81aec6
commit d00a012c4b
14 changed files with 153 additions and 35 deletions
+4 -2
View File
@@ -30,8 +30,10 @@ export async function GET(req: Request) {
start(controller) {
// Snapshot FIRST, then subscribe — a broadcast can never land before the initial event, so
// events are strictly ordered by sim clock. A tick in the gap just means the client waits ≤1 s
// for the next push instead of seeing an out-of-order frame.
getStateSnapshot()
// for the next push instead of seeing an out-of-order frame. v1.3.0 (ISS-110): the connect
// frame carries THIS session's account row; later pushes come from the shared broadcast
// (local player) until per-player frames land (#112).
getStateSnapshot(auth.player.id)
.then((snapshot) => {
unsubscribe = subscribe((text) => {
try {
+21 -1
View File
@@ -5,7 +5,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { createSession } from '@/lib/auth';
import { createSession, hashPassword } from '@/lib/auth';
import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client';
import { GET } from './route';
@@ -113,4 +114,23 @@ describe('GET /api/state', () => {
expect(snap.fuelPricePerL).toBe(FUEL_PRICE_PER_L);
expect(snap.sellFraction).toBe(SELL_FRACTION);
});
it('returns the SESSION player\'s economy in account, not the local one (v1.3.0, ISS-110)', async () => {
const inst = await getGameDb();
// A second account with a distinct economy — rows minted like /api/register does.
const id = 'test-player-b';
await inst.db.insert(schema.users).values({
id, username: 'e2e-owner-b', passwordHash: hashPassword('unused'), admin: false, createdAt: Date.now(),
});
await inst.db.insert(schema.players).values({
id, provider: 'local', username: 'e2e-owner-b', cash: 12_345.67, reputation: 42.5,
});
const sid = await createSession(inst, id);
const res = await GET(new Request('http://localhost/api/state', { headers: { 'X-Flight-Session': sid } }));
expect(res.status).toBe(200);
const body = (await res.json()) as { account: { cash: number; reputation: number } };
expect(body.account.cash).toBeCloseTo(12_345.67, 9); // the session player's row…
expect(body.account.reputation).toBeCloseTo(42.5, 9); // …not the seeded local account
});
});
+3 -2
View File
@@ -3,7 +3,8 @@ import { tickNow } from '@/lib/sim/engine';
import { getStateSnapshot } from '@/lib/game/state';
// v1.1.0 (ISS-107): guarded — 401 without a live X-Flight-Session. The snapshot shape is
// unchanged; per-player snapshots are a later step.
// unchanged; per-player snapshots are a later step (#112).
// v1.3.0 (ISS-110): `account` carries the SESSION player's economy row.
export async function GET(req: Request) {
const auth = await requireAuth(req);
if (!auth.ok) return auth.response;
@@ -11,7 +12,7 @@ export async function GET(req: Request) {
// Tick-on-read (v0.7.0): advance the sim clock before snapshotting so every
// read reflects current game time (expiry/finalize/spawn run inside the tick).
await tickNow();
return Response.json(await getStateSnapshot());
return Response.json(await getStateSnapshot(auth.player.id));
} catch (err) {
console.error('failed to load game state', err);
return Response.json({ error: 'failed to load game state' }, { status: 500 });
+4 -2
View File
@@ -1,5 +1,5 @@
import { getGameDb, persist } from '@/lib/db/client';
import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
import { LOCAL_PLAYER, getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine';
@@ -46,10 +46,12 @@ export async function POST(req: Request) {
try {
const inst = await getGameDb();
await tickNow(); // clock is current before the snapshot we return (same as siblings)
// v1.3.0 (ISS-110): admin-style hook — no session, grants to the SEEDED local account (the
// player the e2e suite plays via mint-player-session's default).
const player = await getLocalPlayer(inst);
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash + amount) * 100) / 100 });
await persist(inst);
return Response.json(await getStateSnapshot());
return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
+4 -2
View File
@@ -1,5 +1,5 @@
import { getGameDb, persist } from '@/lib/db/client';
import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
import { LOCAL_PLAYER, getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine';
@@ -42,10 +42,12 @@ export async function POST(req: Request) {
try {
const inst = await getGameDb();
await tickNow(); // clock is current before the snapshot we return (same as siblings)
// v1.3.0 (ISS-110): admin-style hook — no session, stages the SEEDED local account (the player
// the e2e suite plays via mint-player-session's default).
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());
return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
+3 -1
View File
@@ -1,5 +1,6 @@
import { eq } from 'drizzle-orm';
import { getGameDb, persist } from '@/lib/db/client';
import { LOCAL_PLAYER } from '@/lib/db/localPlayer';
import * as schema from '@/lib/db/schema';
import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine';
@@ -57,7 +58,8 @@ export async function POST(req: Request) {
await inst.db.update(schema.planes).set({ fuel }).where(eq(schema.planes.id, planeId));
await persist(inst);
return Response.json(await getStateSnapshot());
// v1.3.0 (ISS-110): admin-style hook — no session; the snapshot reports the seeded local account.
return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
+3 -1
View File
@@ -1,5 +1,6 @@
import { eq } from 'drizzle-orm';
import { getGameDb, persist } from '@/lib/db/client';
import { LOCAL_PLAYER } from '@/lib/db/localPlayer';
import * as schema from '@/lib/db/schema';
import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine';
@@ -74,7 +75,8 @@ export async function POST(req: Request) {
if (repairUntilMs !== undefined) sets.repairUntilSimTime = Math.round(sim.clockMs + repairUntilMs);
await inst.db.update(schema.planes).set(sets).where(eq(schema.planes.id, planeId));
await persist(inst);
return Response.json(await getStateSnapshot());
// v1.3.0 (ISS-110): admin-style hook — no session; the snapshot reports the seeded local account.
return Response.json(await getStateSnapshot(LOCAL_PLAYER.id));
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });