v1.3.0: per-player money & reputation in engine + actions (ISS-110)
CI / build + unit + e2e (pull_request) Successful in 13m20s
CI / build + unit + e2e (pull_request) Successful in 13m20s
This commit is contained in:
+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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user