Merge pull request 'v1.3.0: per-player money & reputation in engine + actions (ISS-110)' (#154) from feat/multiplayer-per-player-economy into master
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<schema.PlayerRow> {
|
||||
const [row] = await inst.db.select().from(schema.players).where(eq(schema.players.id, id));
|
||||
if (!row) throw new Error(`player ${id} missing`);
|
||||
|
||||
+2
-1
@@ -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 });
|
||||
|
||||
+6
-5
@@ -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<StateSnapshot> {
|
||||
// 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<StateSnapshot> {
|
||||
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),
|
||||
|
||||
+73
-1
@@ -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)', () => {
|
||||
|
||||
+17
-9
@@ -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<nu
|
||||
for (const pk of onBoard) {
|
||||
if (pk.status === 'loaded' && clockMs <= pk.deadlineSimTime) {
|
||||
await inst.db.update(schema.packages).set({ status: 'delivered' }).where(eq(schema.packages.id, pk.id));
|
||||
// Fresh player read per award so multiple deliveries in one tick all count.
|
||||
const fresh = await getLocalPlayer(inst);
|
||||
// v1.3.0 (ISS-110): the award goes to the DELIVERED PLANE'S OWNER — fresh read per award so
|
||||
// multiple deliveries in one tick all count.
|
||||
const fresh = await getPlayer(inst, p.ownerId);
|
||||
// v0.37.0 (ISS-073): special packages add the registry's flat rep bonus on top of the
|
||||
// distance-based gain — same double-rounding convention as before (gain rounded to cents, total re-rounded).
|
||||
const repBonus = pk.special ? SPECIALS[pk.special]?.repBonus ?? 0 : 0;
|
||||
await updateLocalPlayerEconomy(inst, {
|
||||
await updatePlayerEconomy(inst, p.ownerId, {
|
||||
cash: fresh.cash + pk.reward,
|
||||
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm) + repBonus) * 100) / 100,
|
||||
});
|
||||
@@ -306,14 +307,15 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
||||
// ISS-082: auto top-up — same target/price terms as flyPlane's dispatch refuel.
|
||||
const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5));
|
||||
const liters = Math.max(0, target - landedFuel);
|
||||
// Fresh player read (same pattern as the delivery awards above) so a top-up in this tick sees current cash.
|
||||
const fresh = await getLocalPlayer(inst);
|
||||
// v1.3.0 (ISS-110): the PLANE OWNER's economy pays — fresh read (same pattern as the
|
||||
// delivery awards above) so a top-up in this tick sees current cash.
|
||||
const fresh = await getPlayer(inst, p.ownerId);
|
||||
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(fresh.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
|
||||
if (cost === 0 || fresh.cash >= 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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user