Compare commits

...
4 Commits
20 changed files with 266 additions and 20 deletions
+11
View File
@@ -1,5 +1,16 @@
# Changelog
## v1.6.0 — 2026-09-26 19:35
- **Multiplayer step 11/12 (ISS-115): leaderboard panel.** Small UI listing every player in the world with cash, reputation and plane count, rendered from snapshot data — no new endpoint. The known gap from step 8 is closed additively: `getStateSnapshot(playerId)` (`lib/game/state.ts`) now projects a `players` array (`PlayerSummary[]`: `{ id, username, cash, reputation, planeCount }`, planeCount = planes owned) into the ONE shared snapshot projection, so GET /api/state, action responses and every SSE frame all carry it in sync automatically. No extra query — the players rows were already loaded for the v1.4.0 owner joins; plane counts are a single in-memory pass over the already-loaded plane rows.
- **UI.** New "Leaderboard" button on the right side of the map top bar (next to Admin) opens the panel as a modal, reusing the existing `Modal` + `.pane-btn` conventions — per the 2026-09-23 placement decision it is NOT embedded in the left pane. New `components/Leaderboard.tsx`: one row per player (username, cash via `formatCash`, reputation, plane count), sorted by reputation descending with cash then username as deterministic tie-breaks so rows don't shuffle between snapshots on equal rep. Small `.leaderboard` table styles mirror `.admin-rows`.
- **OpenAPI.** New `PlayerSummary` schema; `StateSnapshot` gains required `players: PlayerSummary[]` — documented in `lib/openapi.ts`, path count unchanged at eighteen.
- **Tests.** Unit: new `getStateSnapshot players` suite in `lib/game/state.test.ts` against a throwaway seeded DB — single-player regression guard (exactly one row for the seeded local account with its starter plane) and a two-player case asserting cash/reputation/planeCount per owner (local keeps 1, player B counts both of its planes). The `/api/state` route test's exact key-list assertion gains `players`; all four typed snapshot fixtures (`state.test.ts`, `mapData.test.ts`, `deriveEvents.test.ts`, `right-pane.test.ts`) gain the new required field. No e2e spec changes: existing specs assert per-key `toHaveProperty` on planes/packages only, and CI's single minted player sees a one-row leaderboard — behavior unchanged; the button/modal wiring is covered by tsc + the modal pattern already exercised in admin.spec.ts.
## v1.5.0 — 2026-09-26 19:06
- **Multiplayer step 7/12 (ISS-111): shared-world spawn quality rule.** The #101 decision point is resolved with the agreed default proposal: spawned pool packages scale to the MAX reputation across ALL player rows, so job rewards track the best-progressed player and nobody is stuck on low-rep jobs. New `getSharedWorldReputation(inst)` in `lib/db/localPlayer.ts` — one SQL `MAX(reputation)` over the players table, 0 when there are no players (the rep bonus floors at ×1 either way). The engine's pool spawner (`spawnPoolPackages`, `lib/sim/engine.ts`) reads it once per spawn batch and passes it through `generatePoolPackage(..., reputation)`, replacing the last singleton `getLocalPlayer` job-quality read; the env-gated `/api/test/create-package` hook — which documents itself as carrying "the same job quality as engine spawns" — follows the same rule so no surface can drift.
- **Sites that stay on 'local' (deliberate).** The seeded local row is still inserted at first-run seeding (`lib/db/client.ts`) and staged by the env-gated `/api/test/*` economy hooks (add-cash, set-cash) — those stage an account, not job quality. Seed-time package generation (`buildSeed`) passes no reputation at all and keeps defaulting to `STARTING_REPUTATION`, so fresh worlds are byte-identical to before.
- **Tests.** Unit: two new engine tests in the `spawnPoolPackages` suite — with a second player at rep 60 vs local's starting rep, every spawned package's reward matches an independent recomputation (rewardFor × rep bonus × special multiplier) at the MAX and provably not at local's; single-player regression guard asserts one row ⇒ quality is that row's own reputation, identical to the pre-v1.5.0 singleton read. No e2e spec changes: CI plays a single minted player, where MAX equals that player's rep — behavior unchanged.
## v1.4.0 — 2026-09-26 18:35
- **Multiplayer step 8/12 (ISS-112): per-player snapshot + shared market view with owner info.** The state snapshot is now a true SHARED-WORLD view on every surface that serves it. `getStateSnapshot(playerId)` in `lib/game/state.ts` gains two additive fields: every plane row carries `ownerId` + `ownerName` (players.username via planes.owner_id — the snapshot lists ALL planes, so panes can tell whose plane is whose), and every package row carries `carrierName` (players.username of the carrying plane's owner, joined package → planeId → planes.owner_id → players; null when unassigned). One players-table read per projection serves both joins. `account` stays the acting session player's row (v1.3.0) — no renames or removals anywhere, so existing clients and e2e assertions keep working unchanged.
- **SSE frames are now PER PLAYER.** The #101 decision point is resolved: visibility of other players' planes is ON by default — there is no toggle (a shared world you can't see in is pointless; nobody asked for a privacy switch). `lib/sse.ts` keys its subscriber registry by player id (`subscribe(playerId, fn)`, new `subscribedPlayerIds()`), and the engine's tick broadcast (`broadcastState` in `lib/sim/engine.ts`) projects ONE snapshot per connected session's player id — N clients = N projections per tick, N is tiny — fanning each frame out to that player's group only. Every client now sees its own account row in every pushed frame instead of the seeded local player's. The connect frame and all pushes come from the SAME shared projection (`getStateSnapshot` + `sseFrame`), as does GET /api/state, so no two surfaces can drift; `/api/events` subscribes under `auth.player.id`.
@@ -111,6 +111,7 @@ describe('POST /api/packages/:id/abandon', () => {
'fuelPricePerL',
'packages',
'planes',
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
'sellFraction',
'simState',
]);
+1
View File
@@ -111,6 +111,7 @@ describe('POST /api/packages/:id/load', () => {
'fuelPricePerL',
'packages',
'planes',
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
'sellFraction',
'simState',
]);
@@ -102,6 +102,7 @@ describe('POST /api/planes/:planeId/fly', () => {
'fuelPricePerL',
'packages',
'planes',
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
'sellFraction',
'simState',
]);
+1
View File
@@ -46,6 +46,7 @@ describe('GET /api/state', () => {
'fuelPricePerL',
'packages',
'planes',
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
'sellFraction',
'simState',
]);
+5 -5
View File
@@ -1,6 +1,6 @@
import { randomInt, randomUUID } from 'node:crypto';
import { getGameDb, persist } from '@/lib/db/client';
import { getLocalPlayer } from '@/lib/db/localPlayer';
import { getSharedWorldReputation } from '@/lib/db/localPlayer';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
import { generatePackage, mulberry32 } from '@/lib/game/seed';
@@ -83,9 +83,9 @@ 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 player = await getLocalPlayer(inst);
// v0.38.0 (ISS-072): hook-created packages carry shared-world job quality — the same MAX-reputation
// rule as engine spawns (v1.5.0, ISS-111); never force a fixed value here.
const reputation = await getSharedWorldReputation(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: player.reputation,
reputation,
});
await inst.db.insert(schema.packages).values(created);
await persist(inst);
+26
View File
@@ -737,6 +737,32 @@ body {
white-space: nowrap;
}
/* v1.6.0 (ISS-115) — leaderboard panel inside the generic modal, same table look as .admin-rows. */
.leaderboard {
padding: 14px;
overflow-y: auto;
}
.leaderboard table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.leaderboard th,
.leaderboard td {
padding: 4px 6px;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.leaderboard th {
color: #94a3b8;
font-weight: 600;
}
/* v1.1.0 (ISS-107) — full-screen sign-in / create-account screen, same dark slate theme as the panes. */
.sign-in {
position: fixed;
+36
View File
@@ -0,0 +1,36 @@
import { formatCash } from '@/lib/format';
import type { PlayerSummary } from '@/lib/game/state';
// v1.6.0 (ISS-115): leaderboard panel — every player in the world with cash, reputation and
// plane count, rendered straight off the shared snapshot (no endpoint of its own). Sorted by
// reputation desc; cash then username break ties deterministically so rows don't shuffle
// between snapshots when two players share a reputation.
export default function Leaderboard({ players }: { players: PlayerSummary[] }) {
const sorted = [...players].sort(
(a, b) => b.reputation - a.reputation || b.cash - a.cash || a.username.localeCompare(b.username),
);
return (
<div className="leaderboard">
<table data-testid="leaderboard-table">
<thead>
<tr>
<th>Player</th>
<th>Cash</th>
<th>Reputation</th>
<th>Planes</th>
</tr>
</thead>
<tbody>
{sorted.map((p) => (
<tr key={p.id} data-testid={`leaderboard-row-${p.id}`}>
<td>{p.username}</td>
<td>{formatCash(p.cash)}</td>
<td>{p.reputation}</td>
<td>{p.planeCount}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+11
View File
@@ -13,6 +13,7 @@ import { weatherFor } from '../lib/weather';
import { approxLocalTime } from '../lib/localTime';
import Modal from './Modal';
import AdminPanel from './AdminPanel';
import Leaderboard from './Leaderboard';
import { sessionHeaders } from '../lib/authClient';
// v0.70.0 default view: null island [0,0] at zoom 2 — the world is 2048 px wide there and the
@@ -98,6 +99,8 @@ export default function FlightMap({ state, hovered, applySnapshot, onLogout }: M
// v0.71.0: the Admin modal (top-bar button) — unmounted while closed so it re-checks the
// stored session on every open.
const [adminOpen, setAdminOpen] = useState(false);
// v1.6.0 (ISS-115): the leaderboard panel (top-bar button) — rows come from the polled snapshot.
const [leaderboardOpen, setLeaderboardOpen] = useState(false);
// Push the latest planes/routes GeoJSON into whatever sources currently exist.
// No-op until the map (and for routes: the airport index) is ready — the mount
@@ -589,6 +592,10 @@ export default function FlightMap({ state, hovered, applySnapshot, onLogout }: M
<span data-testid="airport-count">
{airportCount === null ? 'loading airports…' : `${airportCount} world airports`}
</span>
{/* v1.6.0 (ISS-115): leaderboard panel entry point — right side of the top bar, opens a modal. */}
<button type="button" data-testid="leaderboard-btn" className="pane-btn" onClick={() => setLeaderboardOpen(true)}>
Leaderboard
</button>
{/* v0.71.0: admin panel entry point — right side of the top bar, opens a modal (no URL). */}
<button type="button" data-testid="admin-btn" className="pane-btn" onClick={() => setAdminOpen(true)}>
Admin
@@ -598,6 +605,10 @@ export default function FlightMap({ state, hovered, applySnapshot, onLogout }: M
<Modal open={adminOpen} onClose={() => setAdminOpen(false)} title="Admin">
<AdminPanel onLogout={onLogout} />
</Modal>
{/* v1.6.0 (ISS-115): leaderboard — players from the shared snapshot, no extra endpoint. */}
<Modal open={leaderboardOpen} onClose={() => setLeaderboardOpen(false)} title="Leaderboard">
<Leaderboard players={state?.players ?? []} />
</Modal>
</div>
);
}
+1
View File
@@ -60,6 +60,7 @@ function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapsho
account: { cash: 0, reputation: 0 },
simState: { clockMs: 0 },
planes,
players: [], // v1.6.0 (ISS-115) — leaderboard rows; unused by the right pane
packages,
catalog: CATALOG,
fuelPricePerL: 0.8,
+12 -4
View File
@@ -1,12 +1,12 @@
import { eq } from 'drizzle-orm';
import { eq, max } from 'drizzle-orm';
import type { GameDb } from './client';
import * as schema from './schema';
// 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 — pools are SHARED state, so the seeded row sets job quality) and
// env-gated /api/test/* admin hooks. Since v1.4.0 (ISS-112) even the SSE broadcast is per-player.
// these 'local' helpers remain ONLY for env-gated /api/test/* admin hooks that stage the SEEDED
// account with no acting session. Since v1.4.0 (ISS-112) even the SSE broadcast is per-player, and
// since v1.5.0 (ISS-111) shared-world job quality reads getSharedWorldReputation instead of a row.
export const LOCAL_PLAYER = { id: 'local', provider: 'local', username: 'kamilkrzywda' } as const;
@@ -39,3 +39,11 @@ export async function updatePlayerEconomy(
): Promise<void> {
await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, id));
}
// v1.5.0 (ISS-111), the #101 decision point resolved: shared-world job quality is the MAX
// reputation across ALL player rows — spawned pool packages scale to the best-progressed player so
// nobody is stuck on low-rep jobs. 0 when there are no players (repRate floors at ×1 either way).
export async function getSharedWorldReputation(inst: GameDb): Promise<number> {
const [row] = await inst.db.select({ m: max(schema.players.reputation) }).from(schema.players);
return row?.m ?? 0;
}
+1
View File
@@ -53,6 +53,7 @@ const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({
account: { cash: 20_000, reputation: 10 },
simState: { clockMs: 0 },
planes: [basePlane('plane-starter')],
players: [], // v1.6.0 (ISS-115) — leaderboard rows; unused by the event diffing
packages: [],
catalog: CATALOG,
fuelPricePerL: 0.8,
+55 -2
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest';
import { SSE_PACKAGE_STATUSES, sseFrame, type PackageState, type StateSnapshot } from './state';
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 { getGameDb } from '@/lib/db/client';
import { PLANE_TYPES } from './catalog';
import { STARTING_CASH, STARTING_REPUTATION } from './seed';
import { getStateSnapshot, SSE_PACKAGE_STATUSES, sseFrame, type PackageState, type StateSnapshot } from './state';
// ISS-133 (v0.59.0) — the SSE projection keeps only the package statuses any component renders.
@@ -30,6 +37,7 @@ const snapshot = (packages: PackageState[]): StateSnapshot => ({
fuelPricePerL: 0.8,
sellFraction: 0.5,
planes: [],
players: [], // v1.6.0 (ISS-115) — leaderboard rows; empty in these sseFrame fixtures
packages,
});
@@ -60,3 +68,48 @@ describe('sseFrame', () => {
expect([...SSE_PACKAGE_STATUSES].sort()).toEqual(['expired', 'loaded', 'open']);
});
});
// v1.6.0 (ISS-115) — the snapshot's players array: one row per player with cash/reputation and
// how many planes they own. Runs against a throwaway seeded DB via the app singleton, exactly
// like the /api/state route tests (fresh module registry per file ⇒ fresh singleton).
describe('getStateSnapshot players', () => {
let dir: string;
beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-snapshot-players-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'players.db');
await getGameDb(); // seeds the local player + starter plane
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
it('single-player regression guard — one row for the seeded local account', async () => {
const snap = await getStateSnapshot('local');
expect(snap.players).toEqual([
{ id: 'local', username: 'kamilkrzywda', cash: STARTING_CASH, reputation: STARTING_REPUTATION, planeCount: 1 },
]);
});
it('two players — cash/reputation/planeCount per owner (v1.6.0, ISS-115)', async () => {
const inst = await getGameDb();
// A second account with a distinct economy and two planes of its own — rows minted like /api/register does.
await inst.db.insert(schema.players).values({ id: 'b', provider: 'local', username: 'player-b', cash: 5_000, reputation: 3 });
const type = PLANE_TYPES.atr72;
for (const id of ['plane-b-1', 'plane-b-2']) {
await inst.db.insert(schema.planes).values({
id, ownerId: 'b', model: 'atr72', status: 'available',
fuel: type.maxFuel, maxFuel: type.maxFuel, locationIcao: 'EDDF', lat: 50.03, lon: 8.56,
});
}
const snap = await getStateSnapshot('b'); // any session sees the whole world's players
expect(snap.players).toHaveLength(2);
const local = snap.players.find((p) => p.id === 'local')!;
expect(local).toEqual({ id: 'local', username: 'kamilkrzywda', cash: STARTING_CASH, reputation: STARTING_REPUTATION, planeCount: 1 }); // starter still counted once…
const b = snap.players.find((p) => p.id === 'b')!;
expect(b).toEqual({ id: 'b', username: 'player-b', cash: 5_000, reputation: 3, planeCount: 2 }); // …and both of B's planes count for B.
});
});
+25
View File
@@ -97,12 +97,25 @@ export interface PackageState {
carrierName: string | null;
}
// v1.6.0 (ISS-115): per-player summary for the leaderboard panel — rides on every snapshot
// (GET /api/state + SSE frames), so no new endpoint is needed. planeCount = planes owned.
export interface PlayerSummary {
id: string;
username: string;
cash: number;
reputation: number;
planeCount: number;
}
export interface StateSnapshot {
account: { cash: number; reputation: number };
// ISS-117 (v0.52.0): the world rate is a fixed constant (WORLD_TIME_SCALE), not per-save state —
// only the clock itself is reported here.
simState: { clockMs: number };
planes: PlaneState[];
// v1.6.0 (ISS-115): leaderboard data — one row per player, projected from the same players +
// planes reads as everything else in this snapshot (no extra query).
players: PlayerSummary[];
packages: PackageState[];
// Market reference data (v0.10.0) — static per build, sent with every snapshot so
// the client can compute buy/sell previews without a separate endpoint.
@@ -166,6 +179,10 @@ export async function getStateSnapshot(playerId: string): Promise<StateSnapshot>
const usernameByPlayerId = new Map(playerRows.map((pl) => [pl.id, pl.username]));
const planeById = new Map(planeRows.map((p) => [p.id, p]));
// v1.6.0 (ISS-115): leaderboard — count owned planes per player from the rows already loaded above.
const planeCountByOwner = new Map<string, number>();
for (const p of planeRows) planeCountByOwner.set(p.ownerId, (planeCountByOwner.get(p.ownerId) ?? 0) + 1);
// Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its
// departure values — live position and fuel are pure functions of the clock.
const livePlane = (p: schema.PlaneRow) => {
@@ -218,6 +235,14 @@ export async function getStateSnapshot(playerId: string): Promise<StateSnapshot>
ownerName: usernameByPlayerId.get(p.ownerId) ?? p.ownerId, // missing players row → raw id, never a crash
};
}),
// v1.6.0 (ISS-115): leaderboard rows — every player in the world with their economy + fleet size.
players: playerRows.map((pl) => ({
id: pl.id,
username: pl.username,
cash: pl.cash,
reputation: pl.reputation,
planeCount: planeCountByOwner.get(pl.id) ?? 0,
})),
packages: packageRows.filter((p) => p.status !== 'open' || pool.has(p.originIcao)).map((p) => ({
id: p.id,
originIcao: p.originIcao,
+1
View File
@@ -36,6 +36,7 @@ function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
account: { cash: 0, reputation: 0 },
simState: { clockMs: 0 },
planes: [],
players: [], // v1.6.0 (ISS-115) — leaderboard rows; unused by the map projections
packages: [],
catalog: [
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, maxPayloadKg: 4_800 },
+3
View File
@@ -77,6 +77,7 @@ describe('OpenAPI spec', () => {
'fuelPricePerL',
'packages',
'planes',
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
'sellFraction',
'simState',
]);
@@ -86,12 +87,14 @@ describe('OpenAPI spec', () => {
'fuelPricePerL',
'packages',
'planes',
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
'sellFraction',
'simState',
]);
expect(snap.account.$ref).toBe('#/components/schemas/AccountState');
expect(snap.simState.$ref).toBe('#/components/schemas/SimState');
expect(snap.planes.items.$ref).toBe('#/components/schemas/PlaneState');
expect(snap.players.items.$ref).toBe('#/components/schemas/PlayerSummary'); // v1.6.0 (ISS-115)
expect(snap.packages.items.$ref).toBe('#/components/schemas/PackageState');
expect(snap.catalog.items.$ref).toBe('#/components/schemas/CatalogEntry');
expect(snap.fuelPricePerL.type).toBe('number');
+18 -1
View File
@@ -707,6 +707,11 @@ export const openApiSpec = {
type: 'array',
items: { $ref: '#/components/schemas/PlaneState' },
},
players: {
type: 'array',
description: 'Leaderboard rows (v1.6.0, ISS-115) — one per player in the world.',
items: { $ref: '#/components/schemas/PlayerSummary' },
},
packages: {
type: 'array',
items: { $ref: '#/components/schemas/PackageState' },
@@ -719,7 +724,7 @@ export const openApiSpec = {
fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; the automatic dispatch top-up costs liters × this, reduced by a reputation-scaled discount at refuel time (v0.38.0) — ISS-082 (v0.42.0): it now applies to the auto-refuel on /fly and the engine\'s chained-leg top-ups.' },
sellFraction: { type: 'number', description: 'Fraction of the catalog price paid out when selling a plane.' },
},
required: ['account', 'simState', 'planes', 'packages', 'catalog', 'fuelPricePerL', 'sellFraction'],
required: ['account', 'simState', 'planes', 'players', 'packages', 'catalog', 'fuelPricePerL', 'sellFraction'],
},
CatalogEntry: {
type: 'object',
@@ -750,6 +755,18 @@ export const openApiSpec = {
},
required: ['clockMs'],
},
PlayerSummary: {
type: 'object',
description: 'One leaderboard row (v1.6.0, ISS-115) — a player\'s economy plus how many planes they own.',
properties: {
id: { type: 'string' },
username: { type: 'string' },
cash: { type: 'number' },
reputation: { type: 'number' },
planeCount: { type: 'integer', description: 'Number of planes in the world owned by this player.' },
},
required: ['id', 'username', 'cash', 'reputation', 'planeCount'],
},
LoadPackageRequest: {
type: 'object',
properties: {
+50 -1
View File
@@ -11,7 +11,7 @@ import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
import { SPECIALS } from '@/lib/game/specials';
import { JOB_LIFETIME_MS, POOL_BANDS, POOL_TARGET_PER_AIRPORT, REFILL_DELAY_MS, SMALL_REFILL_DELAY_MS, mulberry32, poolBandOf, STARTING_CLOCK_MS } from '@/lib/game/seed';
import { JOB_BONUS_PER_REP, JOB_LIFETIME_MS, MAX_JOB_BONUS, POOL_BANDS, POOL_TARGET_PER_AIRPORT, REFILL_DELAY_MS, SMALL_REFILL_DELAY_MS, STARTING_CLOCK_MS, STARTING_REPUTATION, mulberry32, poolBandOf, rewardFor } from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
import {
deliverPackages,
@@ -911,6 +911,55 @@ describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT * 2); // both pools held through the full tick pipeline
expect(openRows.every((p) => p.originIcao === 'EDDF' || p.originIcao === 'LEMD')).toBe(true);
});
// v1.5.0 (ISS-111): shared-world spawn quality — MAX reputation across players. Independent
// recomputation of a spawned package's reward at a given reputation, from the stored row with the
// same formula generatePackage uses (rewardFor × rep bonus × special multiplier) — checks the rule
// against the math, not against itself.
async function expectedRewardAt(p: schema.PackageRow, reputation: number): Promise<number> {
const fc = await loadAirportsGeoJson();
const o = fc.features.find((f) => f.properties.icao === p.originIcao)!;
const d = fc.features.find((f) => f.properties.icao === p.destIcao)!;
const km = haversineKm(o.properties.lat, o.properties.lon, d.properties.lat, d.properties.lon);
return Math.round(
rewardFor(km, p.weightKg) * (1 + repRate(reputation, JOB_BONUS_PER_REP, MAX_JOB_BONUS)) * (p.special ? SPECIALS[p.special]?.rewardMultiplier ?? 1 : 1),
);
}
it('scales job quality by the MAX reputation across players, not the local row (v1.5.0, ISS-111)', async () => {
const inst = await freshDb('pool-shared-rep.db');
// Local sits at the starting rep (×1 bonus); player-b is far ahead — the shared-world rule must pick b's.
await inst.db.insert(schema.players).values({ id: 'player-b', provider: 'test', username: 'b', cash: 1_000, reputation: 60 });
for (const p of await inst.db.select().from(schema.packages)) {
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
}
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(POOL_TARGET_PER_AIRPORT);
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT);
for (const p of openRows) {
expect(p.reward).toBe(await expectedRewardAt(p, 60)); // every job scaled to the MAX…
expect(p.reward).not.toBe(await expectedRewardAt(p, STARTING_REPUTATION)); // …not to local's starting rep
}
});
it("single player: spawn quality is that player's own reputation (v1.5.0 regression guard)", async () => {
const inst = await freshDb('pool-single-rep.db');
// Raise the only player's rep so the bonus is non-trivial, then respawn from zero with a fixed RNG —
// with one row, MAX equals that row: identical to the pre-v1.5.0 singleton read.
await updateLocalPlayerEconomy(inst, { reputation: 60 });
for (const p of await inst.db.select().from(schema.packages)) {
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
}
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(POOL_TARGET_PER_AIRPORT);
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
for (const p of openRows) {
expect(p.reward).toBe(await expectedRewardAt(p, 60));
}
});
});
// Local helper mirroring lib/sim/movement.ts (kept private there on purpose — the
+6 -6
View File
@@ -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, getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer';
import { getSharedWorldReputation, getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
@@ -385,10 +385,10 @@ export async function spawnPoolPackages(
}));
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
// 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 — 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 is a later multiplayer step.
const player = await getLocalPlayer(inst);
// v1.5.0 (ISS-111), the #101 decision point: pools are SHARED world state, so quality follows the
// agreed shared-world rule — MAX reputation across all players — and scales to the best-progressed
// player instead of the seeded local row.
const reputation = await getSharedWorldReputation(inst);
let n = 0;
for (const originIcao of pool) {
@@ -418,7 +418,7 @@ export async function spawnPoolPackages(
clockMs,
id: randomUUID(),
createdRealMs: real,
reputation: player.reputation,
reputation,
}),
);
openByBand[bandIdx]++;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
"version": "1.4.0",
"version": "1.6.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",