v1.0.0: players table + migration from singleton account (ISS-105)
CI / build + unit + e2e (pull_request) Successful in 14m25s

This commit is contained in:
2026-09-24 14:26:34 +00:00
parent 837aa60a7d
commit b2034ebc52
21 changed files with 258 additions and 166 deletions
+19 -23
View File
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto';
import { and, eq, inArray } from 'drizzle-orm';
import * as schema from '@/lib/db/schema';
import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
import { persist, type GameDb } from '@/lib/db/client';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
@@ -140,11 +141,10 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
// any mutation, so a 409 leaves fuel and cash untouched.
const target = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
const liters = Math.max(0, target - plane.fuel);
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
if (cost > 0 && account.cash < cost) {
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`);
const player = await getLocalPlayer(inst);
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(player.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
if (cost > 0 && player.cash < cost) {
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(player.cash)}`);
}
// Backstop: a leg that doesn't fit even a full tank is unflyable (target caps at maxFuel).
if (requiredL > target) {
@@ -173,7 +173,7 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
})
.where(eq(schema.planes.id, planeId));
if (cost > 0) {
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash - cost) * 100) / 100 });
}
await persist(inst);
}
@@ -255,9 +255,8 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
if (!sim) throw new Error('sim_state row missing');
const penalty = abandonPenalty(pkg, sim.clockMs);
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
await inst.db.update(schema.account).set({ cash: Math.max(0, Math.round((account.cash - penalty) * 100) / 100) });
const player = await getLocalPlayer(inst);
await updateLocalPlayerEconomy(inst, { cash: Math.max(0, Math.round((player.cash - penalty) * 100) / 100) });
await inst.db.update(schema.packages).set({ status: 'abandoned', planeId: null }).where(eq(schema.packages.id, packageId));
await persist(inst);
return { penalty };
@@ -272,12 +271,11 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
const type = PLANE_TYPES[model];
if (!type) throw new GameActionError(400, `unknown plane model "${model}"`);
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
const player = await getLocalPlayer(inst);
// v0.38.0 (ISS-072): the EFFECTIVE cost drives both the funds check and the deduction.
const cost = Math.round(type.price * (1 - repRate(account.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)) * 100) / 100;
if (account.cash < cost) {
throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${cost}, you have ${Math.round(account.cash)}`);
const cost = Math.round(type.price * (1 - repRate(player.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)) * 100) / 100;
if (player.cash < cost) {
throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${cost}, you have ${Math.round(player.cash)}`);
}
const fc = await loadAirportsGeoJson();
@@ -298,7 +296,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
departureSimTime: null,
health: 100, // new planes arrive at full health (v0.39.0, ISS-069)
});
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash - cost) * 100) / 100 });
await persist(inst);
return { planeId };
}
@@ -337,9 +335,8 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
const payout = Math.round(type.price * SELL_FRACTION * 100) / 100;
await inst.db.delete(schema.planes).where(eq(schema.planes.id, planeId));
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
await inst.db.update(schema.account).set({ cash: Math.round((account.cash + payout) * 100) / 100 });
const player = await getLocalPlayer(inst);
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash + payout) * 100) / 100 });
await persist(inst);
return { payout };
}
@@ -364,17 +361,16 @@ export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost
const sim = (await inst.db.select().from(schema.simState))[0];
if (!sim) throw new Error('sim_state row missing');
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
if (account.cash < REPAIR_COST) {
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(account.cash)}`);
const player = await getLocalPlayer(inst);
if (player.cash < REPAIR_COST) {
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(player.cash)}`);
}
// Health is NOT set here — the engine's finishRepairs pass heals it gradually along the gate.
await inst.db.update(schema.planes)
.set({ status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS })
.where(eq(schema.planes.id, planeId));
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - REPAIR_COST) * 100) / 100 });
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash - REPAIR_COST) * 100) / 100 });
await persist(inst);
return { cost: REPAIR_COST };
}