Reputation economy: rep-scaled buy/refuel discounts + job reward quality (ISS-072, v0.38.0)
CI / build + unit + e2e (pull_request) Successful in 9m3s

This commit is contained in:
2026-09-21 10:51:22 +00:00
parent 0f5f3ae23e
commit 8daca54840
16 changed files with 273 additions and 33 deletions
+75 -1
View File
@@ -9,7 +9,16 @@ import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
import {
FUEL_DISCOUNT_PER_REP,
FUEL_PRICE_PER_L,
MAX_FUEL_DISCOUNT,
MAX_PLANE_DISCOUNT,
PLANE_DISCOUNT_PER_REP,
PLANE_TYPES,
repRate,
SELL_FRACTION,
} from './catalog';
import { HUB_ICAO } from './seed';
import {
abandonPackage,
@@ -487,6 +496,38 @@ describe('buyPlane', () => {
const inst = await freshDb('buy-poor.db'); // seeded cash is 20k < 200k
await expectActionError(buyPlane(inst, 'atr72'), 409, /insufficient funds/i);
});
it('deducts the reputation-scaled cost at elevated rep (v0.38.0)', async () => {
const inst = await freshDb('buy-rep.db');
// rep 30 → rate exactly 0.2 → ATR costs 160k, not 200k.
await inst.db.update(schema.account).set({ cash: 500_000, reputation: 30 });
const before = (await inst.db.select().from(schema.planes)).length;
await buyPlane(inst, 'atr72');
const rate = repRate(30, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
expect(rate).toBeCloseTo(0.2, 12); // sanity pin on the formula input
const expectedCost = Math.round(PLANE_TYPES.atr72.price * (1 - rate) * 100) / 100;
expect(expectedCost).toBe(160_000); // 200k × 0.8 — exact value pin
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(500_000 - expectedCost, 6);
expect((await inst.db.select().from(schema.planes)).length).toBe(before + 1);
});
it('buys with cash between the discounted and full price; rejects below the discounted cost (v0.38.0)', async () => {
const inst = await freshDb('buy-boundary.db');
// rep 30 → discounted cost 160k: 170k is enough now, though it was not before v0.38.0.
await inst.db.update(schema.account).set({ cash: 170_000, reputation: 30 });
const { planeId } = await buyPlane(inst, 'atr72');
expect(planeId).toBeTruthy(); // the old full-price check would have rejected this
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(170_000 - 160_000, 6);
const inst2 = await freshDb('buy-boundary-poor.db'); // just below the discounted cost
await inst2.db.update(schema.account).set({ cash: 159_999.99, reputation: 30 });
await expectActionError(buyPlane(inst2, 'atr72'), 409, /insufficient funds/i);
});
});
describe('sellPlane', () => {
@@ -585,6 +626,39 @@ describe('refuelPlane', () => {
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /insufficient funds/i);
});
it('applies the reputation-scaled discount to the price term only, in both modes (v0.38.0)', async () => {
const inst = await freshDb('refuel-rep.db');
// Elevated rep → non-zero rate; expectations computed with the SAME helpers refuelPlane uses.
await inst.db.update(schema.account).set({ reputation: 30 });
const rate = repRate(30, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
expect(rate).toBeGreaterThan(0);
// 'full': exact discounted cost, tank math untouched.
await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
const { cost } = await refuelPlane(inst, 'plane-starter');
expect(cost).toBe(Math.round((PLANE_TYPES.atr72.maxFuel - 1_000) * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // target unchanged by the discount
expect((await inst.db.select().from(schema.account))[0].cash).toBeCloseTo(cashBefore - cost, 6);
// 'forFlight': same discount on the price term; burn/target math (wind factor, ×1.5 margin) untouched.
await insertOpenPackage(inst, 'p-ff-r', { destIcao: 'LEMD' }); // EDDF → LEMD
await loadPackage(inst, 'p-ff-r', 'plane-starter');
const required = await adjustedRequiredForHubToLemd();
const startFuel = Math.round(required / 2);
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
const cashBefore2 = (await inst.db.select().from(schema.account))[0].cash;
const { cost: ffCost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff-r' });
const targetFuel = Math.min(PLANE_TYPES.atr72.maxFuel, Math.max(startFuel, required * 1.5));
expect(ffCost).toBe(Math.round((targetFuel - startFuel) * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100);
const plane2 = (await inst.db.select().from(schema.planes))[0];
expect(plane2.fuel).toBeCloseTo(targetFuel, 6); // topped up to exactly the 150% target
expect((await inst.db.select().from(schema.account))[0].cash).toBeCloseTo(cashBefore2 - ffCost, 6);
});
it('rejects an unknown id with 404', async () => {
const inst = await freshDb('refuel-unknown.db');
await expectActionError(refuelPlane(inst, 'ghost'), 404);