From d00a012c4b6a8533174d5fd6285d291aa580d6e1 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sat, 26 Sep 2026 17:03:15 +0000 Subject: [PATCH] v1.3.0: per-player money & reputation in engine + actions (ISS-110) --- CHANGELOG.md | 5 +++ app/api/events/route.ts | 6 ++- app/api/state/route.test.ts | 22 +++++++++- app/api/state/route.ts | 5 ++- app/api/test/add-cash/route.ts | 6 ++- app/api/test/set-cash/route.ts | 6 ++- app/api/test/set-fuel/route.ts | 4 +- app/api/test/set-health/route.ts | 4 +- lib/db/localPlayer.ts | 14 +++--- lib/game/actions.ts | 3 +- lib/game/state.ts | 11 ++--- lib/sim/engine.test.ts | 74 +++++++++++++++++++++++++++++++- lib/sim/engine.ts | 26 +++++++---- package.json | 2 +- 14 files changed, 153 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6135163..7376cbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v1.3.0 — 2026-09-26 17:05 +- **Multiplayer step 6/12 (ISS-110): per-player money & reputation in the engine + snapshot.** The last singleton economy accesses are converted to per-player rows. `deliverPackages` (`lib/sim/engine.ts`) now pays cash + reputation to the DELIVERED PLANE'S OWNER row (`plane.owner_id`, fresh read per award so multiple deliveries in one tick all count), and the ISS-082 auto-chain top-up debits the same owner's row with their own rep discount — a plane owned by player B earning or refueling never touches any other account. `getStateSnapshot(playerId)` sources the snapshot's `account` field from the ACTING SESSION player's row; the field name/shape (`{cash, reputation}`) is unchanged and OpenAPI stays untouched. Session-scoped callers thread it: `/api/state`, every action response (`performAction`) and the SSE connect frame all pass `auth.player.id`. +- **Sites that stay on 'local' (deliberate).** The shared SSE broadcast has no acting session — it passes `LOCAL_PLAYER.id` until per-player frames land (#112). The engine's pool-spawn job-quality read stays on the seeded local row: pools are SHARED world state (packages have no owner), so there is no single owning player whose rep should set quality; a ponytail comment names #112 as the decision point. The env-gated `/api/test/*` economy hooks (add-cash, set-cash) remain admin-style tools with no session — they stage the SEEDED local account, which is exactly the player the e2e suite plays via `mint-player-session`'s default; create-package's job-quality read matches the engine spawn. +- **Tests.** Unit: two new engine tests in `lib/sim/engine.test.ts` — a plane owned by player B delivers → B's cash/rep increase and local is untouched (both directions), and an empty-tank landing with a queued leg debits the top-up from B (B's rep discount) while local stays put; new `/api/state` route test mints a second account with a distinct economy and asserts `account` carries THAT session player's row. All existing suites pass unchanged — every seeded plane is owned by 'local', so single-player behavior is byte-identical. No e2e spec changes: the minted e2e session IS the local player, and CI's existing suite covers the UI paths. + ## v1.2.0 — 2026-09-26 16:25 - **Multiplayer step 4/12 (ISS-108): per-player onboarding.** New registrants start with a starter plane at EDDF, not just cash: `buildStarterPlane(airports, id)` is extracted from `buildSeed` in `lib/game/seed.ts` (byte-identical values to the fresh-install seed — same model, full tank, hub location, no RNG draws; only the id differs) and reused by `POST /api/register`, which inserts it owned by the new player. The test-only mint hook does the same for custom usernames, so e2e-created accounts are onboarded like real ones. ponytail comment marks legacy players without planes (pre-v1.2.0 test mints) as out of scope — they keep exactly what they had. - **Multiplayer step 5/12 (ISS-109): `planes.owner_id` + ownership checks.** New `owner_id TEXT NOT NULL` column in the raw DDL and Drizzle mirror; one-time guarded migration (`migrateColumns`, PRAGMA table_info check) backfills every pre-v1.2.0 row to `'local'` via SQLite's constant ALTER default, so a migrated world's planes all belong to the seeded local player exactly as before — idempotent on second open. All seven plane actions plus `repairPlane` now take the session player's id and reject foreign planes with 403 "not your plane" through one shared `assertPlaneOwner()` (checked right after the 404 fetch, so ownership errors win over state errors); `performAction` passes `auth.player.id` as its callback's second argument, keeping every route a pure pass-through. Economy is per-player: new `getPlayer(inst, id)` / `updatePlayerEconomy(inst, id, patch)` in `lib/db/localPlayer.ts` replace the local-only helpers at every action call site (a non-local buyer now debits their own cash; `getLocalPlayer` stays for the snapshot and engine until per-player views land). The sell-plane last-plane guard is PER OWNER — you can't sell your last plane even if other players' planes exist, and selling one of two of yours is fine. `buyPlane` stamps the buyer as owner of the delivered plane. diff --git a/app/api/events/route.ts b/app/api/events/route.ts index d78283a..aa4f731 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -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 { diff --git a/app/api/state/route.test.ts b/app/api/state/route.test.ts index 84b483d..56ca1ea 100644 --- a/app/api/state/route.test.ts +++ b/app/api/state/route.test.ts @@ -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 + }); }); diff --git a/app/api/state/route.ts b/app/api/state/route.ts index 0595342..32fd956 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -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 }); diff --git a/app/api/test/add-cash/route.ts b/app/api/test/add-cash/route.ts index 167a171..3ebad81 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 { 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 }); diff --git a/app/api/test/set-cash/route.ts b/app/api/test/set-cash/route.ts index 14ff445..0197214 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 { 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 }); diff --git a/app/api/test/set-fuel/route.ts b/app/api/test/set-fuel/route.ts index 19ee22a..fec5f85 100644 --- a/app/api/test/set-fuel/route.ts +++ b/app/api/test/set-fuel/route.ts @@ -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 }); diff --git a/app/api/test/set-health/route.ts b/app/api/test/set-health/route.ts index cac2b4e..32a8151 100644 --- a/app/api/test/set-health/route.ts +++ b/app/api/test/set-health/route.ts @@ -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 }); diff --git a/lib/db/localPlayer.ts b/lib/db/localPlayer.ts index cbb8e2c..2254258 100644 --- a/lib/db/localPlayer.ts +++ b/lib/db/localPlayer.ts @@ -2,10 +2,10 @@ 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. +// v1.0.0 (ISS-105): single-player economy access — resolves the seeded local player row. As of +// v1.3.0 (ISS-110) every per-player read/write goes through getPlayer/updatePlayerEconomy below; +// these 'local' helpers remain ONLY for world-level reads with no acting session (the engine's +// shared-pool spawn quality, the SSE broadcast account) and env-gated /api/test/* admin hooks. export const LOCAL_PLAYER = { id: 'local', provider: 'local', username: 'kamilkrzywda' } as const; @@ -22,9 +22,9 @@ export async function updateLocalPlayerEconomy( await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, LOCAL_PLAYER.id)); } -// v1.2.0 (ISS-109): per-player variants — the actions thread the session player's id through and -// read/write THAT row instead of the local one; getLocalPlayer stays for the snapshot/engine until -// per-player views land in a later multiplayer step. +// v1.2.0 (ISS-109), standard path since v1.3.0 (ISS-110): actions, engine delivery/top-up and +// session-scoped snapshots thread the acting player's id through and read/write THAT row; the +// 'local' helpers above are reserved for world-level reads with no acting session. export async function getPlayer(inst: GameDb, id: string): Promise { const [row] = await inst.db.select().from(schema.players).where(eq(schema.players.id, id)); if (!row) throw new Error(`player ${id} missing`); diff --git a/lib/game/actions.ts b/lib/game/actions.ts index 1a8d9a6..b310a0a 100644 --- a/lib/game/actions.ts +++ b/lib/game/actions.ts @@ -411,7 +411,8 @@ export async function performAction(req: Request, fn: (inst: GameDb, playerId: s if (!auth.ok) return auth.response; try { await runAction((inst) => fn(inst, auth.player.id)); - return Response.json(await getStateSnapshot()); + // v1.3.0 (ISS-110): the response snapshot carries the ACTING player's economy in `account`. + return Response.json(await getStateSnapshot(auth.player.id)); } catch (err) { if (err instanceof GameActionError) { return Response.json({ error: err.message }, { status: err.status }); diff --git a/lib/game/state.ts b/lib/game/state.ts index 1247eeb..9c8e282 100644 --- a/lib/game/state.ts +++ b/lib/game/state.ts @@ -2,7 +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 { getPlayer } 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,16 +114,17 @@ export function sseFrame(snapshot: StateSnapshot): StateSnapshot { return { ...snapshot, packages: snapshot.packages.filter((p) => (SSE_PACKAGE_STATUSES as readonly string[]).includes(p.status)) }; } -export async function getStateSnapshot(): Promise { +// v1.3.0 (ISS-110): the snapshot's `account` field is the ACTING SESSION player's row — same +// shape as before ({cash, reputation}), sourced per-player. Callers without a session context +// (the shared SSE broadcast) pass LOCAL_PLAYER.id until per-player frames land (#112). +export async function getStateSnapshot(playerId: string): Promise { 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). - // 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), + getPlayer(inst, playerId), db.select().from(schema.simState), db.select().from(schema.planes), db.select().from(schema.packages), diff --git a/lib/sim/engine.test.ts b/lib/sim/engine.test.ts index bf3f2ed..6c534ea 100644 --- a/lib/sim/engine.test.ts +++ b/lib/sim/engine.test.ts @@ -5,7 +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 { LOCAL_PLAYER, getPlayer, getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer'; import { loadAirportsGeoJson } from '@/lib/airports'; import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { weatherFor } from '@/lib/weather'; @@ -497,6 +497,78 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => { expect(plane.destIcao).toBeNull(); expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …the rest of the queue kept for a later dispatch }); + + // --- Per-owner economy (v1.3.0, ISS-110) --- + + it('pays cash + reputation to the plane OWNER, not the local player', async () => { + const { inst, km } = await setupFlight('deliver-owner.db'); + // A second player owns the starter — distinct economy so any leak into 'local' is visible. + await inst.db.insert(schema.players).values({ id: 'player-b', provider: 'test', username: 'b', cash: 1_000, reputation: 5 }); + await inst.db.update(schema.planes) + .set({ ownerId: 'player-b' }) + .where(eq(schema.planes.id, 'plane-starter')); + + const localBefore = (await getPlayer(inst, LOCAL_PLAYER.id)); + const bBefore = (await getPlayer(inst, 'player-b')); + await inst.db.insert(schema.packages).values([ + { id: 'p-owner', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 }, + ]); + + const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh); // exactly at arrival + expect(await deliverPackages(inst, clock)).toBe(1); + + const bAfter = (await getPlayer(inst, 'player-b')); + expect(bAfter.cash).toBeCloseTo(bBefore.cash + 5_000, 9); // the owner was paid… + expect(bAfter.reputation).toBeCloseTo(Math.round((bBefore.reputation + reputationGainFor(km)) * 100) / 100, 9); // …with the distance-based rep gain + const localAfter = (await getPlayer(inst, LOCAL_PLAYER.id)); + expect(localAfter.cash).toBe(localBefore.cash); // the local player is untouched… + expect(localAfter.reputation).toBe(localBefore.reputation); // …in both directions + }); + + it('debits the auto top-up from the plane OWNER, not the local player (ISS-082 × ISS-110)', async () => { + const { inst, from, to, km, type } = await setupFlight('chain-owner.db'); + // Owner B funds the chain: distinct cash + rep so both the debit and the discount are attributable. + await inst.db.insert(schema.players).values({ id: 'player-b', provider: 'test', username: 'b', cash: 50_000, reputation: 10 }); + + // Depart with exactly the wind-adjusted fuel for EDDF→LEMD → lands empty; LEMD→EHAM needs a top-up. + 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']), ownerId: 'player-b' }) + .where(eq(schema.planes.id, 'plane-starter')); + + const localBefore = (await getPlayer(inst, LOCAL_PLAYER.id)); + const bBefore = (await getPlayer(inst, 'player-b')); + const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival + expect(await deliverPackages(inst, clock)).toBe(1); + + // Expected cost from the SAME helpers the engine uses — computed with B's rep discount. + const fc = await loadAirportsGeoJson(); + const nextP = fc.features.find((x) => x.properties.icao === 'EHAM')!.properties; + const landedFuel = fuelAtWeighted(depFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1); + expect(landedFuel).toBeCloseTo(0, 6); // sanity: arrived empty — the top-up path is what ran + const wTo = weatherFor(TO); + const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, nextP.lat, nextP.lon)); + const nextRequiredL = requiredFuelFor( + type.burnPerKm * fNext, + type.emptyWeightKg, + type.maxFuel, + haversineKm(to.lat, to.lon, nextP.lat, nextP.lon), + ); + 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(bBefore.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]; + expect(plane.status).toBe('flying'); // B's cash funded the chain… + expect(plane.destIcao).toBe('EHAM'); + const bAfter = (await getPlayer(inst, 'player-b')); + expect(bAfter.cash).toBeCloseTo(bBefore.cash - expectedCost, 9); // …debited from B… + expect((await getPlayer(inst, LOCAL_PLAYER.id)).cash).toBe(localBefore.cash); // …not local + }); }); describe('wear on landed legs (v0.39.0, ISS-069)', () => { diff --git a/lib/sim/engine.ts b/lib/sim/engine.ts index f91ac1a..57b67ee 100644 --- a/lib/sim/engine.ts +++ b/lib/sim/engine.ts @@ -1,7 +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 { LOCAL_PLAYER, getLocalPlayer, getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer'; import * as schema from '@/lib/db/schema'; import { loadAirportsGeoJson } from '@/lib/airports'; import { haversineKm, initialBearingDeg } from '@/lib/geo'; @@ -251,12 +251,13 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise= cost) { queue.shift(); chainedTo = nextDest; topUpFuel = target; - if (cost > 0) await updateLocalPlayerEconomy(inst, { cash: Math.round((fresh.cash - cost) * 100) / 100 }); + if (cost > 0) await updatePlayerEconomy(inst, p.ownerId, { cash: Math.round((fresh.cash - cost) * 100) / 100 }); } } } @@ -382,7 +384,10 @@ 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 local player's CURRENT reputation quality — read once per spawn batch. + // v0.38.0 (ISS-072): spawned jobs carry a reputation-based job quality — read once per spawn batch. + // ponytail: stays on the seeded 'local' row in v1.3.0 (ISS-110) — pools are SHARED world state + // (packages have no owner), so there is no single owning player whose rep should set job quality; + // per-player job quality / pools is a later multiplayer step (#112). const player = await getLocalPlayer(inst); let n = 0; @@ -431,9 +436,12 @@ export async function spawnPoolPackages( // package history is dropped, everything else matches GET /api/state exactly. Failures are logged, // not fatal: polling still works either way. Background ticks (~1 s) cover action mutations for // other clients too. +// v1.3.0 (ISS-110): the shared broadcast channel has no acting session — it carries the seeded +// local player's account until per-player SSE frames land (#112). Session-scoped reads (/api/state, +// /api/events connect frame, action responses) all source their own row. async function broadcastState(): Promise { try { - broadcast(JSON.stringify(sseFrame(await getStateSnapshot()))); + broadcast(JSON.stringify(sseFrame(await getStateSnapshot(LOCAL_PLAYER.id)))); } catch (err) { console.error('flight: state broadcast failed', err); } diff --git a/package.json b/package.json index 714e817..1ef9b64 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "1.2.0", + "version": "1.3.0", "private": true, "scripts": { "dev": "next dev -p 3000", -- 2.54.0