Reputation economy: rep-scaled buy/refuel discounts + job reward quality (ISS-072, v0.38.0)
CI / build + unit + e2e (pull_request) Successful in 9m3s
CI / build + unit + e2e (pull_request) Successful in 9m3s
This commit is contained in:
@@ -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);
|
||||
|
||||
+20
-7
@@ -8,7 +8,16 @@ import { weatherFor } from '@/lib/weather';
|
||||
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
|
||||
import { runAction } from '@/lib/sim/engine';
|
||||
import { getStateSnapshot, parseIcaoQueue } from './state';
|
||||
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 { abandonPenalty } from './penalty';
|
||||
import { HUB_ICAO } from './seed';
|
||||
|
||||
@@ -244,16 +253,19 @@ export async function ignorePackage(inst: GameDb, packageId: string): Promise<vo
|
||||
|
||||
// --- Market (POST /api/market/buy-plane {model}) ---
|
||||
|
||||
// Buy a catalog model at full price; it arrives 'available' with a full tank at
|
||||
// the home base (EDDF).
|
||||
// Buy a catalog model at its reputation-scaled price (v0.38.0, ISS-072 — discount grows with
|
||||
// reputation, exactly full price at the starting value); it arrives 'available' with a full tank
|
||||
// at the home base (EDDF).
|
||||
export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId: string }> {
|
||||
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');
|
||||
if (account.cash < type.price) {
|
||||
throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${type.price}, you have ${Math.round(account.cash)}`);
|
||||
// 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 fc = await loadAirportsGeoJson();
|
||||
@@ -273,7 +285,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
|
||||
lon: home.lon,
|
||||
departureSimTime: null,
|
||||
});
|
||||
await inst.db.update(schema.account).set({ cash: account.cash - type.price });
|
||||
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
|
||||
await persist(inst);
|
||||
return { planeId };
|
||||
}
|
||||
@@ -376,9 +388,10 @@ export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOpt
|
||||
}
|
||||
|
||||
const liters = Math.max(0, targetFuel - plane.fuel);
|
||||
const cost = Math.round(liters * FUEL_PRICE_PER_L * 100) / 100;
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
if (!account) throw new Error('account row missing');
|
||||
// v0.38.0 (ISS-072): rep-scaled discount on the PRICE term only — the burn/target math above is untouched.
|
||||
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)}`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
|
||||
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||||
import { STARTING_REPUTATION } from './seed';
|
||||
|
||||
// v0.28.0 (ISS-047) — catalog sanity: the progression ladder must stay well-formed as types are added.
|
||||
describe('PLANE_TYPES', () => {
|
||||
@@ -22,3 +23,23 @@ describe('PLANE_TYPES', () => {
|
||||
expect(PLANE_TYPES[STARTER_MODEL]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// v0.38.0 (ISS-072) — the shared rep-discount shape: linear above the starting value, capped.
|
||||
describe('repRate', () => {
|
||||
it('is exactly 0 at and below the starting reputation', () => {
|
||||
expect(repRate(STARTING_REPUTATION, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBe(0);
|
||||
expect(repRate(0, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBe(0);
|
||||
expect(repRate(-5, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)).toBe(0); // below-start clamps too
|
||||
});
|
||||
|
||||
it('is linear in reputation above the starting value (exact mid-range values)', () => {
|
||||
expect(repRate(STARTING_REPUTATION + 10, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBeCloseTo(0.1, 12); // 0.01 × 10
|
||||
expect(repRate(STARTING_REPUTATION + 30, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBeCloseTo(0.3, 12); // 0.01 × 30
|
||||
expect(repRate(STARTING_REPUTATION + 40, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)).toBeCloseTo(0.2, 12); // 0.005 × 40 = cap
|
||||
});
|
||||
|
||||
it('clamps at the cap', () => {
|
||||
expect(repRate(STARTING_REPUTATION + 50, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBe(MAX_PLANE_DISCOUNT); // 0.5 → capped to 0.4
|
||||
expect(repRate(1_000_000, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)).toBe(MAX_FUEL_DISCOUNT);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { STARTING_REPUTATION } from './seed';
|
||||
|
||||
// Aircraft catalog — in-code constants for now (no DB table yet). One type is
|
||||
// enough for the MVP; add more entries to PLANE_TYPES as they are designed.
|
||||
// ponytail: circular import with seed.ts is safe — neither module touches the other's
|
||||
// bindings at top level, only inside function bodies.
|
||||
|
||||
export interface PlaneType {
|
||||
id: string;
|
||||
@@ -102,6 +106,22 @@ export const FUEL_PRICE_PER_L = 0.8;
|
||||
// Fraction of the catalog price paid out when selling a plane (v0.8.0).
|
||||
export const SELL_FRACTION = 0.5;
|
||||
|
||||
// Shared rep-discount shape (v0.38.0, ISS-072): linear in reputation above the starting value,
|
||||
// capped — exactly 0 at/below STARTING_REPUTATION so default-rep behavior is unchanged.
|
||||
export function repRate(reputation: number, perPoint: number, cap: number): number {
|
||||
return Math.min(cap, perPoint * Math.max(0, reputation - STARTING_REPUTATION));
|
||||
}
|
||||
|
||||
// ponytail: balance-tunable — plane purchase discount per rep point above the starting value.
|
||||
export const PLANE_DISCOUNT_PER_REP = 0.01;
|
||||
// ponytail: balance-tunable — max plane purchase discount (reached at rep ≥ 50).
|
||||
export const MAX_PLANE_DISCOUNT = 0.4;
|
||||
|
||||
// ponytail: balance-tunable — refuel price discount per rep point above the starting value.
|
||||
export const FUEL_DISCOUNT_PER_REP = 0.005;
|
||||
// ponytail: balance-tunable — max refuel price discount (reached at rep ≥ 50).
|
||||
export const MAX_FUEL_DISCOUNT = 0.2;
|
||||
|
||||
// Snapshot shape of one catalog entry in GET /api/state (v0.10.0): `model` is the
|
||||
// id used by buy/dispatch, `label` the display name — clients compute all market
|
||||
// previews (buy price, sell payout, refuel cost) from these + the economy constants.
|
||||
|
||||
+37
-1
@@ -1,10 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
|
||||
import { PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||||
import { SPECIALS } from './specials';
|
||||
import {
|
||||
BASE_REWARD,
|
||||
buildSeed,
|
||||
JOB_BONUS_PER_REP,
|
||||
MAX_JOB_BONUS,
|
||||
PACKAGE_COUNT_MAX,
|
||||
PACKAGE_COUNT_MIN,
|
||||
generatePackage,
|
||||
@@ -298,4 +300,38 @@ describe('generatePackage (shared seed/spawn builder)', () => {
|
||||
expect(countDraws()).toBeGreaterThanOrEqual(7); // pickSpecial draws 1–2 extra
|
||||
});
|
||||
});
|
||||
|
||||
describe('reputation job bonus (v0.38.0, ISS-072)', () => {
|
||||
it('multiplies the reward by 1 + repRate at elevated reputation', () => {
|
||||
const c = generatePackage({ id: 'c-rep', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', weightKg: 1_000, special: null, reputation: 60 });
|
||||
const km = haversineKm(AIRPORTS[0].lat, AIRPORTS[0].lon, AIRPORTS[1].lat, AIRPORTS[1].lon); // EDDF → EHAM
|
||||
expect(repRate(60, JOB_BONUS_PER_REP, MAX_JOB_BONUS)).toBeCloseTo(0.5, 12); // rep 60 → ×1.5
|
||||
expect(c.reward).toBe(Math.round(rewardFor(km, 1_000) * (1 + repRate(60, JOB_BONUS_PER_REP, MAX_JOB_BONUS))));
|
||||
});
|
||||
|
||||
it('defaults to the starting reputation when omitted — byte-identical output', () => {
|
||||
const a = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
|
||||
const b = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, reputation: STARTING_REPUTATION });
|
||||
expect(a).toEqual(b); // no bonus at the starting value
|
||||
});
|
||||
|
||||
it('composes with the special multiplier (base × jobBonus × special)', () => {
|
||||
const c = generatePackage({ id: 'c-rep-sp', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', weightKg: 1_000, special: 'priority', reputation: 60 });
|
||||
const km = haversineKm(AIRPORTS[0].lat, AIRPORTS[0].lon, AIRPORTS[1].lat, AIRPORTS[1].lon); // EDDF → EHAM
|
||||
expect(c.reward).toBe(
|
||||
Math.round(rewardFor(km, 1_000) * (1 + repRate(60, JOB_BONUS_PER_REP, MAX_JOB_BONUS)) * SPECIALS.priority.rewardMultiplier),
|
||||
);
|
||||
});
|
||||
|
||||
it('consumes no extra RNG draw', () => {
|
||||
const countDraws = (reputation?: number): number => {
|
||||
let n = 0;
|
||||
const base = mulberry32(42);
|
||||
generatePackage({ id: 'c-rep-n', airports: AIRPORTS, rng: () => { n += 1; return base(); }, nowMs: NOW, special: null, reputation });
|
||||
return n;
|
||||
};
|
||||
expect(countDraws()).toBe(6); // forced-null special → the fixed order minus the appended draw
|
||||
expect(countDraws(60)).toBe(6); // the rep hook consumes no draws
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+16
-2
@@ -1,5 +1,5 @@
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
|
||||
import { PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||||
import { pickSpecial, SPECIALS } from './specials';
|
||||
|
||||
// First-run seed. Everything here is a pure function of (airports, rng, nowMs)
|
||||
@@ -92,6 +92,13 @@ export const BASE_REWARD = 500;
|
||||
export const REWARD_PER_KM = 2.5;
|
||||
export const REWARD_PER_KG = 0.5;
|
||||
|
||||
// Job reward bonus (v0.38.0, ISS-072): reward ×= 1 + repRate(reputation, …) — better jobs at
|
||||
// higher reputation, exactly ×1 at the starting value.
|
||||
// ponytail: balance-tunable — job reward bonus per rep point above the starting value.
|
||||
export const JOB_BONUS_PER_REP = 0.01;
|
||||
// ponytail: balance-tunable — max job reward bonus (up to ×2 at rep ≥ 110).
|
||||
export const MAX_JOB_BONUS = 1.0;
|
||||
|
||||
// Package deadlines: now + a plausible game-time window of 6–72 hours.
|
||||
const DEADLINE_MIN_HOURS = 6;
|
||||
const DEADLINE_MAX_HOURS = 72;
|
||||
@@ -127,6 +134,9 @@ export function rewardFor(km: number, weightKg: number): number {
|
||||
// jitter draw, same pattern as forced endpoints.
|
||||
// v0.37.0 (ISS-073): special optionally FORCES the special status (string or explicit null) —
|
||||
// it skips the appended pickSpecial draw; otherwise ~SPECIAL_SPAWN_CHANCE of packages roll one.
|
||||
// v0.38.0 (ISS-072): reputation optionally scales the reward (×(1 + repRate)) at the same spot
|
||||
// as the special multiplier — a pure input, it consumes NO RNG draws and defaults to the
|
||||
// starting value so seed-time behavior is byte-identical.
|
||||
export function generatePackage(input: {
|
||||
id: string;
|
||||
airports: SeedAirport[];
|
||||
@@ -137,6 +147,7 @@ export function generatePackage(input: {
|
||||
destIcao?: string;
|
||||
weightKg?: number;
|
||||
special?: string | null;
|
||||
reputation?: number;
|
||||
}): SeedPackage {
|
||||
const { airports, rng, nowMs } = input;
|
||||
if (airports.length < 2) throw new Error('package generation needs at least two airports');
|
||||
@@ -181,11 +192,14 @@ export function generatePackage(input: {
|
||||
// Forced deadlines skip the special scaling — consistent with overrides skipping draws.
|
||||
const deadlineMs = input.deadlineOffsetMs ?? drawnWindowMs * (mods?.deadlineScale ?? 1);
|
||||
|
||||
// v0.38.0 (ISS-072): reputation bonus composed with the special multiplier, single round at the end.
|
||||
const repBonus = 1 + repRate(input.reputation ?? STARTING_REPUTATION, JOB_BONUS_PER_REP, MAX_JOB_BONUS);
|
||||
|
||||
return {
|
||||
id: input.id,
|
||||
originIcao: origin.icao,
|
||||
destIcao: dest.icao,
|
||||
reward: Math.round(rewardFor(km, weightKg) * (mods?.rewardMultiplier ?? 1)),
|
||||
reward: Math.round(rewardFor(km, weightKg) * repBonus * (mods?.rewardMultiplier ?? 1)),
|
||||
cargoType,
|
||||
cargoCount,
|
||||
weightKg,
|
||||
|
||||
Reference in New Issue
Block a user