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
+17 -9
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, 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);
}