Compare commits

...
Author SHA1 Message Date
kamilkrzywda 929452ca33 Merge pull request 'Reputation drives the economy: purchase/fuel discounts + better jobs (ISS-072, v0.38.0)' (#76) from feat/reputation-economy into master
CI / build + unit + e2e (push) Successful in 9m4s
2026-09-21 13:01:53 +02:00
kamilkrzywda 8daca54840 Reputation economy: rep-scaled buy/refuel discounts + job reward quality (ISS-072, v0.38.0)
CI / build + unit + e2e (pull_request) Successful in 9m3s
2026-09-21 10:51:22 +00:00
kamilkrzywda 0f5f3ae23e Merge pull request 'Special jobs: data-driven registry + priority status (ISS-073, v0.37.0)' (#75) from feat/special-jobs into master
CI / build + unit + e2e (push) Successful in 9m6s
Special jobs: data-driven registry + priority status (ISS-073, v0.37.0)
2026-09-21 12:17:42 +02:00
16 changed files with 273 additions and 33 deletions
+10
View File
@@ -1,5 +1,15 @@
# Changelog # Changelog
## v0.38.0 — 2026-09-21 10:47
- **Reputation economy (ISS-072).** Reputation is no longer a dead stat — it drives the economy through three simple tunable formulas, all built on one new pure helper `repRate(reputation, perPoint, cap)` in `lib/game/catalog.ts` (linear above the starting value of 10, capped; exactly 0 at/below start so default-rep behavior is byte-identical):
- **Cheaper plane purchases** — `buyPlane` (`lib/game/actions.ts`) charges `price × (1 − repRate(rep, PLANE_DISCOUNT_PER_REP = 0.01, MAX_PLANE_DISCOUNT = 0.4))`, rounded to 2dp; the effective cost drives both the funds check and the deduction (up to 40% off at rep ≥ 50)
- **Cheaper refuel** — `refuelPlane` multiplies ONLY the price term: `liters × FUEL_PRICE_PER_L × (1 − repRate(rep, FUEL_DISCOUNT_PER_REP = 0.005, MAX_FUEL_DISCOUNT = 0.2))`; burn/target math (wind factor, ×1.5 forFlight margin) untouched
- **Better jobs at higher reputation** — `generatePackage` (`lib/game/seed.ts`) takes an optional `reputation` input (defaults to the starting value) and multiplies the final reward by `1 + repRate(rep, JOB_BONUS_PER_REP = 0.01, MAX_JOB_BONUS = 1.0)` at the same spot as the special multiplier (base × jobBonus × special, single round at the end; up to ×2 at rep ≥ 110). Consumes NO extra RNG draw
- **Spawn sites carry live reputation.** `spawnOpenPackages` + `spawnCoveragePackages` (`lib/sim/engine.ts`) read the account row once per spawn batch and thread its reputation into `generatePackage`; the test hook POST /api/test/create-package does the same (hook-created packages get current job quality, never a forced value). `buildSeed` keeps defaulting — no rep at seed time
- **UI shows the effect.** The market buy row (`components/RightPane.tsx`) previews the EFFECTIVE price computed client-side from the snapshot's reputation + the imported constants, with a sky-blue "−N%" pill (`market-buy-discount`, reusing `.special-badge`) when the discount is non-zero; tech-spec cards keep showing full list prices. The left-pane Refuel section (`components/LeftPane.tsx`) previews the discounted fill-up cost with its own `refuel-discount` pill. No new snapshot fields — rep was already in the state, so the OpenAPI schema is unchanged
- **OpenAPI (descriptions only).** buy-plane + refuel endpoint descriptions mention the rep-scaled discounts; `CatalogEntry.price`, `StateSnapshot.fuelPricePerL` and `AccountState.reputation` gain notes that the discount applies at purchase/refuel time. No properties or endpoints added/removed
- **Tests.** New unit tests: `repRate` (zero at/below start, linear mid-range exact values, cap clamping), `buyPlane` at rep 30 (exact discounted cash delta + funds boundary between discounted and full price), `refuelPlane` at fixed rep in both 'full' and 'forFlight' modes (discount on the price term only), `generatePackage` job bonus (×1.5 at rep 60, default-rep byte-identity, composition with the special multiplier, no extra RNG draw). All existing unit tests pass unmodified — at the starting reputation every rate is exactly 0. E2E: the full-price pins in `right-pane.spec.ts`, `two-planes.spec.ts` and `actions.spec.ts` now compute expectations from the LIVE snapshot's reputation via the imported helpers (rep accumulates across specs), plus a new assertion that the buy-row discount pill appears iff live rep > starting with text round(rate × 100)
## v0.37.0 — 2026-09-21 09:29 ## v0.37.0 — 2026-09-21 09:29
- **Special jobs: data-driven special-status registry for packages (ISS-073).** New `lib/game/specials.ts` holds a flat `SPECIALS` record (`special status → { rewardMultiplier, deadlineScale, repBonus }`) in the same house style as `PLANE_TYPES`, plus `SPECIAL_SPAWN_CHANCE = 0.15` and a pure `pickSpecial(rng, registry?)` helper (one chance draw, then a uniform pick among the registry's keys; the registry is a param defaulting to the real one). Adding or removing a special is an edit of ONE registry entry — no scattered branches. First status: **priority** (×1.5 reward, ×0.5 deadline window, +0.5 rep at delivery; all balance-tunable) - **Special jobs: data-driven special-status registry for packages (ISS-073).** New `lib/game/specials.ts` holds a flat `SPECIALS` record (`special status → { rewardMultiplier, deadlineScale, repBonus }`) in the same house style as `PLANE_TYPES`, plus `SPECIAL_SPAWN_CHANCE = 0.15` and a pure `pickSpecial(rng, registry?)` helper (one chance draw, then a uniform pick among the registry's keys; the registry is a param defaulting to the real one). Adding or removing a special is an edit of ONE registry entry — no scattered branches. First status: **priority** (×1.5 reward, ×0.5 deadline window, +0.5 rep at delivery; all balance-tunable)
- **Spawn & modifiers.** `generatePackage` (`lib/game/seed.ts`) appends one RNG draw at the END of its fixed order to roll a special (~15% of packages); a forced `special` input (including explicit null) skips the draw, mirroring the other overrides. The reward multiplier applies where the reward is computed; the deadline scale applies only to the drawn 6–72h window — a forced `deadlineOffsetMs` skips scaling - **Spawn & modifiers.** `generatePackage` (`lib/game/seed.ts`) appends one RNG draw at the END of its fixed order to roll a special (~15% of packages); a forced `special` input (including explicit null) skips the draw, mirroring the other overrides. The reward multiplier applies where the reward is computed; the deadline scale applies only to the drawn 6–72h window — a forced `deadlineOffsetMs` skips scaling
+5
View File
@@ -82,6 +82,10 @@ export async function POST(req: Request) {
} }
const rng = mulberry32(randomInt(0, 2 ** 31)); const rng = mulberry32(randomInt(0, 2 ** 31));
// v0.38.0 (ISS-072): hook-created packages carry the LIVE account reputation — same job quality
// as engine spawns; never force a fixed value here.
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
const created = generatePackage({ const created = generatePackage({
id: randomUUID(), id: randomUUID(),
airports, airports,
@@ -93,6 +97,7 @@ export async function POST(req: Request) {
weightKg: input.weightKg as number | undefined, weightKg: input.weightKg as number | undefined,
// Omitted → force null (no roll) so existing e2e reward/rep assertions stay deterministic. // Omitted → force null (no roll) so existing e2e reward/rep assertions stay deterministic.
special: input.special === undefined ? null : (input.special as string | null), special: input.special === undefined ? null : (input.special as string | null),
reputation: account.reputation,
}); });
await inst.db.insert(schema.packages).values(created); await inst.db.insert(schema.packages).values(created);
await persist(inst); await persist(inst);
+11 -2
View File
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react'; import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
import type { GeoJsonFeatureCollection } from '@/lib/airports'; import type { GeoJsonFeatureCollection } from '@/lib/airports';
import type { PlaneState, StateSnapshot } from '@/lib/game/state'; import type { PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES } from '@/lib/game/catalog'; import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, PLANE_TYPES, repRate } from '@/lib/game/catalog';
import { abandonPenalty } from '@/lib/game/penalty'; import { abandonPenalty } from '@/lib/game/penalty';
import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock, formatSpecialLabel } from '@/lib/format'; import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock, formatSpecialLabel } from '@/lib/format';
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale'; import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
@@ -217,7 +217,12 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
(state?.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel)) ?? (state?.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel)) ??
refuelable[0] ?? refuelable[0] ??
null; null;
const refuelCost = refuelPlane && state ? (refuelPlane.maxFuel - refuelPlane.fuel) * state.fuelPricePerL : null; // v0.38.0 (ISS-072): rep-scaled preview — same formula as the server's refuelPlane 'full' mode.
const fuelDiscountRate = state ? repRate(state.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT) : 0;
const refuelCost =
refuelPlane && state
? Math.round((refuelPlane.maxFuel - refuelPlane.fuel) * state.fuelPricePerL * (1 - fuelDiscountRate) * 100) / 100
: null;
// Slider commit (v0.9.1): the displayed value follows the drag optimistically via // Slider commit (v0.9.1): the displayed value follows the drag optimistically via
// pendingScale; POST /api/sim fires after a 250 ms debounce, or immediately when the // pendingScale; POST /api/sim fires after a 250 ms debounce, or immediately when the
@@ -566,6 +571,10 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
<span data-testid="market-refuel-cost" className="market-preview"> <span data-testid="market-refuel-cost" className="market-preview">
{refuelCost !== null ? formatCash(refuelCost) : ''} {refuelCost !== null ? formatCash(refuelCost) : ''}
</span> </span>
{/* v0.38.0 (ISS-072): rep discount hint — only when the account actually gets one. */}
{fuelDiscountRate > 0 && (
<span data-testid="refuel-discount" className="special-badge">−{Math.round(fuelDiscountRate * 100)}%</span>
)}
</> </>
)} )}
<button <button
+9 -2
View File
@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state'; import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog'; import { MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, SELL_FRACTION } from '@/lib/game/catalog';
import { maxRangeKm } from '@/lib/sim/movement'; import { maxRangeKm } from '@/lib/sim/movement';
import { formatCargo, formatCash, formatDeadlineRelative, formatSpecialLabel } from '@/lib/format'; import { formatCargo, formatCash, formatDeadlineRelative, formatSpecialLabel } from '@/lib/format';
import { useGameAction } from '@/lib/useGameAction'; import { useGameAction } from '@/lib/useGameAction';
@@ -151,6 +151,9 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
// Previews computed client-side from the snapshot's catalog + economy constants (v0.10.0). // Previews computed client-side from the snapshot's catalog + economy constants (v0.10.0).
const sellEntry = sellPlane ? catalog.find((e) => e.model === sellPlane.model) : undefined; const sellEntry = sellPlane ? catalog.find((e) => e.model === sellPlane.model) : undefined;
const sellPayout = sellEntry ? Math.round(sellEntry.price * state.sellFraction * 100) / 100 : null; const sellPayout = sellEntry ? Math.round(sellEntry.price * state.sellFraction * 100) / 100 : null;
// v0.38.0 (ISS-072): the buy row shows the EFFECTIVE price — same rep-scaled formula as the server's buyPlane.
const planeDiscountRate = repRate(state.account.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
const buyPrice = buyEntry ? Math.round(buyEntry.price * (1 - planeDiscountRate) * 100) / 100 : null;
return ( return (
<aside data-testid="right-pane" className="pane right-pane"> <aside data-testid="right-pane" className="pane right-pane">
@@ -265,8 +268,12 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
))} ))}
</select> </select>
<span data-testid="market-buy-price" className="market-preview"> <span data-testid="market-buy-price" className="market-preview">
{buyEntry ? formatCash(buyEntry.price) : ''} {buyPrice !== null ? formatCash(buyPrice) : ''}
</span> </span>
{/* v0.38.0 (ISS-072): rep discount hint — only when the account actually gets one. */}
{planeDiscountRate > 0 && (
<span data-testid="market-buy-discount" className="special-badge">−{Math.round(planeDiscountRate * 100)}%</span>
)}
<button <button
type="button" type="button"
data-testid="market-buy" data-testid="market-buy"
+6 -3
View File
@@ -5,6 +5,8 @@ import { expect, test } from '@playwright/test';
import { initialBearingDeg } from '../lib/geo'; import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather'; import { weatherFor } from '../lib/weather';
import { windBurnFactor } from '../lib/sim/movement'; import { windBurnFactor } from '../lib/sim/movement';
// v0.38.0 (ISS-072) — reputation discounts: expectations computed from the LIVE snapshot's rep with the real helpers.
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, repRate } from '../lib/game/catalog';
// v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl): // v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl):
// the full core loop through the UI — hook creates a short package at EDDF → Load // the full core loop through the UI — hook creates a short package at EDDF → Load
@@ -21,7 +23,7 @@ const FAR = 'LEZL'; // Seville — a long leg: under the v0.16.0 weighted model
// Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn) + lib/game/catalog.ts (atr72). // Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn) + lib/game/catalog.ts (atr72).
const FUEL_DENSITY_KG_PER_L = 0.8; // Jet-A1 density — must match FUEL_DENSITY_KG_PER_L in movement.ts const FUEL_DENSITY_KG_PER_L = 0.8; // Jet-A1 density — must match FUEL_DENSITY_KG_PER_L in movement.ts
const FUEL_PRICE_PER_L = 0.8; // must match catalog.ts // FUEL_PRICE_PER_L is imported from ../lib/game/catalog (v0.38.0) instead of mirrored here.
const REP_BASE = 1; // lib/sim/engine.ts const REP_BASE = 1; // lib/sim/engine.ts
const REP_PER_KM = 0.0005; const REP_PER_KM = 0.0005;
@@ -239,13 +241,14 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
6, 6,
); );
// Refuel via the API: cost = (maxFuel − fuel) × FUEL_PRICE_PER_L, deducted exactly. // Refuel via the API: cost = (maxFuel − fuel) × FUEL_PRICE_PER_L, rep-scaled (v0.38.0, ISS-072), deducted exactly.
const refuelRes = await page.request.post(`/api/planes/${planeId}/refuel`); const refuelRes = await page.request.post(`/api/planes/${planeId}/refuel`);
expect(refuelRes.ok()).toBeTruthy(); expect(refuelRes.ok()).toBeTruthy();
s = await getState(page); s = await getState(page);
const afterRefuel = s.planes.find((p) => p.id === planeId)!; const afterRefuel = s.planes.find((p) => p.id === planeId)!;
expect(afterRefuel.fuel).toBe(afterRefuel.maxFuel); // back to full expect(afterRefuel.fuel).toBe(afterRefuel.maxFuel); // back to full
const refuelCost = Math.round((afterRefuel.maxFuel - landed.fuel) * FUEL_PRICE_PER_L * 100) / 100; const fuelRate = repRate(s.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
const refuelCost = Math.round((afterRefuel.maxFuel - landed.fuel) * FUEL_PRICE_PER_L * (1 - fuelRate) * 100) / 100;
expect(s.account.cash).toBeCloseTo(before.account.cash + pkg.reward - refuelCost, 6); expect(s.account.cash).toBeCloseTo(before.account.cash + pkg.reward - refuelCost, 6);
// Restore: fly home (repositioning — no package needed in the v0.12.0 model). ISS-041: the // Restore: fly home (repositioning — no package needed in the v0.12.0 model). ISS-041: the
+20 -4
View File
@@ -5,6 +5,9 @@ import { expect, test } from '@playwright/test';
import { initialBearingDeg } from '../lib/geo'; import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather'; import { weatherFor } from '../lib/weather';
import { windBurnFactor } from '../lib/sim/movement'; import { windBurnFactor } from '../lib/sim/movement';
// v0.38.0 (ISS-072) — reputation discounts: expectations computed from the LIVE snapshot's rep with the real helpers.
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, repRate } from '../lib/game/catalog';
import { STARTING_REPUTATION } from '../lib/game/seed';
// v0.12.0 — right Jobs & Market pane against the live origin: open packages render with // v0.12.0 — right Jobs & Market pane against the live origin: open packages render with
// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list // names/reward/deadline and Load (UI click) binds one to the starter; the on-board list
@@ -367,11 +370,20 @@ test('market buy/sell render with previews from the catalog in /api/state; left-
// v0.28.0 (ISS-047): the market rows live in the Planes market tab — switch to it first. // v0.28.0 (ISS-047): the market rows live in the Planes market tab — switch to it first.
await page.getByTestId('tab-planes-market').click(); await page.getByTestId('tab-planes-market').click();
// Buy: the model picker lists the catalog entry and the price preview is its full price. // Buy: the model picker lists the catalog entry and the price preview is its EFFECTIVE price —
// rep-scaled (v0.38.0) from the live snapshot's reputation; at the starting rep this equals list price.
const buyModel = page.getByTestId('market-buy-model'); const buyModel = page.getByTestId('market-buy-model');
await expect(buyModel).toBeVisible({ timeout: 15_000 }); await expect(buyModel).toBeVisible({ timeout: 15_000 });
await expect(buyModel.locator(`option[value="${entry.model}"]`)).toHaveText(entry.label); await expect(buyModel.locator(`option[value="${entry.model}"]`)).toHaveText(entry.label);
await expect(page.getByTestId('market-buy-price')).toContainText(eur(entry.price)); const planeRate = repRate(body.account.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
const buyPrice = Math.round(entry.price * (1 - planeRate) * 100) / 100;
await expect(page.getByTestId('market-buy-price')).toContainText(eur(buyPrice));
// The discount pill is present iff the live rep earns one, and shows round(rate × 100).
if (body.account.reputation > STARTING_REPUTATION) {
await expect(page.getByTestId('market-buy-discount')).toHaveText(`−${Math.round(planeRate * 100)}%`);
} else {
await expect(page.getByTestId('market-buy-discount')).toHaveCount(0);
}
await expect(page.getByTestId('market-buy')).toBeVisible(); await expect(page.getByTestId('market-buy')).toBeVisible();
// Sell: the grounded-plane picker lists plane-starter and the payout preview is // Sell: the grounded-plane picker lists plane-starter and the payout preview is
@@ -387,7 +399,9 @@ test('market buy/sell render with previews from the catalog in /api/state; left-
// Refuel through the pane: the flight genuinely burned fuel… // Refuel through the pane: the flight genuinely burned fuel…
if (starter.fuel < starter.maxFuel) { if (starter.fuel < starter.maxFuel) {
const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * 100) / 100; // v0.38.0 (ISS-072): rep-scaled cost — same formula as the server's refuelPlane 'full'.
const fuelRate = repRate(body.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * (1 - fuelRate) * 100) / 100;
const cashBefore = body.account.cash; const cashBefore = body.account.cash;
// …the refuel picker lists it, the preview matches the exact cost, and the click deducts it. // …the refuel picker lists it, the preview matches the exact cost, and the click deducts it.
@@ -455,7 +469,9 @@ test('on-board row Refuel buys 150% of the weather-adjusted leg requirement; lef
const target = Math.min(entry.maxFuel, required * 1.5); // ISS-071: 150% margin, capped at tank capacity const target = Math.min(entry.maxFuel, required * 1.5); // ISS-071: 150% margin, capped at tank capacity
const liters = Math.max(0, target - fuelBefore); const liters = Math.max(0, target - fuelBefore);
expect(liters).toBeGreaterThan(0); // the long leg genuinely needs more than what's left expect(liters).toBeGreaterThan(0); // the long leg genuinely needs more than what's left
const expectedCost = Math.round(liters * pre.fuelPricePerL * 100) / 100; // v0.38.0 (ISS-072): rep-scaled cost — same formula as the server's refuelPlane 'forFlight'.
const fuelRate = repRate(pre.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
const expectedCost = Math.round(liters * pre.fuelPricePerL * (1 - fuelRate) * 100) / 100;
await page.goto('/'); await page.goto('/');
const refuelBtn = page.getByTestId(`refuel-for-${pkg2.id}`); const refuelBtn = page.getByTestId(`refuel-for-${pkg2.id}`);
+6 -2
View File
@@ -5,6 +5,8 @@ import { expect, test } from '@playwright/test';
import { initialBearingDeg } from '../lib/geo'; import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather'; import { weatherFor } from '../lib/weather';
import { windBurnFactor } from '../lib/sim/movement'; import { windBurnFactor } from '../lib/sim/movement';
// v0.38.0 (ISS-072) — reputation discounts: expectations computed from the LIVE snapshot's rep with the real helpers.
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, repRate } from '../lib/game/catalog';
// v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo // v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a // if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a
@@ -452,8 +454,10 @@ test('refuel section lists both planes; refueling one tops only its tank', async
await expect(refuelSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1); await expect(refuelSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1);
await expect(refuelSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1); await expect(refuelSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1);
// …select the SECOND plane — the preview is its exact fill-up cost (mirrors refuelPlane 'full'). // …select the SECOND plane — the preview is its exact fill-up cost (mirrors refuelPlane 'full'),
const expectedCost = Math.round((second.maxFuel - second.fuel) * pre.fuelPricePerL * 100) / 100; // rep-scaled (v0.38.0, ISS-072) from the live snapshot's reputation.
const fuelRate = repRate(pre.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
const expectedCost = Math.round((second.maxFuel - second.fuel) * pre.fuelPricePerL * (1 - fuelRate) * 100) / 100;
await refuelSelect.selectOption(secondId); await refuelSelect.selectOption(secondId);
await expect(page.getByTestId('market-refuel-cost')).toContainText(eur(expectedCost)); await expect(page.getByTestId('market-refuel-cost')).toContainText(eur(expectedCost));
+75 -1
View File
@@ -9,7 +9,16 @@ import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather'; import { weatherFor } from '@/lib/weather';
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement'; 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 { HUB_ICAO } from './seed';
import { import {
abandonPackage, abandonPackage,
@@ -487,6 +496,38 @@ describe('buyPlane', () => {
const inst = await freshDb('buy-poor.db'); // seeded cash is 20k < 200k const inst = await freshDb('buy-poor.db'); // seeded cash is 20k < 200k
await expectActionError(buyPlane(inst, 'atr72'), 409, /insufficient funds/i); 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', () => { describe('sellPlane', () => {
@@ -585,6 +626,39 @@ describe('refuelPlane', () => {
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /insufficient funds/i); 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 () => { it('rejects an unknown id with 404', async () => {
const inst = await freshDb('refuel-unknown.db'); const inst = await freshDb('refuel-unknown.db');
await expectActionError(refuelPlane(inst, 'ghost'), 404); await expectActionError(refuelPlane(inst, 'ghost'), 404);
+20 -7
View File
@@ -8,7 +8,16 @@ import { weatherFor } from '@/lib/weather';
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement'; import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
import { runAction } from '@/lib/sim/engine'; import { runAction } from '@/lib/sim/engine';
import { getStateSnapshot, parseIcaoQueue } from './state'; 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 { abandonPenalty } from './penalty';
import { HUB_ICAO } from './seed'; 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}) --- // --- Market (POST /api/market/buy-plane {model}) ---
// Buy a catalog model at full price; it arrives 'available' with a full tank at // Buy a catalog model at its reputation-scaled price (v0.38.0, ISS-072 — discount grows with
// the home base (EDDF). // 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 }> { export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId: string }> {
const type = PLANE_TYPES[model]; const type = PLANE_TYPES[model];
if (!type) throw new GameActionError(400, `unknown plane model "${model}"`); if (!type) throw new GameActionError(400, `unknown plane model "${model}"`);
const account = (await inst.db.select().from(schema.account))[0]; const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing'); if (!account) throw new Error('account row missing');
if (account.cash < type.price) { // v0.38.0 (ISS-072): the EFFECTIVE cost drives both the funds check and the deduction.
throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${type.price}, you have ${Math.round(account.cash)}`); 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(); const fc = await loadAirportsGeoJson();
@@ -273,7 +285,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
lon: home.lon, lon: home.lon,
departureSimTime: null, 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); await persist(inst);
return { planeId }; 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 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]; const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing'); 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) { if (cost > 0 && account.cash < cost) {
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`); throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`);
} }
+22 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'; 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. // v0.28.0 (ISS-047) — catalog sanity: the progression ladder must stay well-formed as types are added.
describe('PLANE_TYPES', () => { describe('PLANE_TYPES', () => {
@@ -22,3 +23,23 @@ describe('PLANE_TYPES', () => {
expect(PLANE_TYPES[STARTER_MODEL]).toBeDefined(); 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);
});
});
+20
View File
@@ -1,5 +1,9 @@
import { STARTING_REPUTATION } from './seed';
// Aircraft catalog — in-code constants for now (no DB table yet). One type is // 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. // 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 { export interface PlaneType {
id: string; 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). // Fraction of the catalog price paid out when selling a plane (v0.8.0).
export const SELL_FRACTION = 0.5; 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 // 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 // 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. // previews (buy price, sell payout, refuel cost) from these + the economy constants.
+37 -1
View File
@@ -1,10 +1,12 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { haversineKm } from '@/lib/geo'; 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 { SPECIALS } from './specials';
import { import {
BASE_REWARD, BASE_REWARD,
buildSeed, buildSeed,
JOB_BONUS_PER_REP,
MAX_JOB_BONUS,
PACKAGE_COUNT_MAX, PACKAGE_COUNT_MAX,
PACKAGE_COUNT_MIN, PACKAGE_COUNT_MIN,
generatePackage, generatePackage,
@@ -298,4 +300,38 @@ describe('generatePackage (shared seed/spawn builder)', () => {
expect(countDraws()).toBeGreaterThanOrEqual(7); // pickSpecial draws 1–2 extra 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
View File
@@ -1,5 +1,5 @@
import { haversineKm } from '@/lib/geo'; 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'; import { pickSpecial, SPECIALS } from './specials';
// First-run seed. Everything here is a pure function of (airports, rng, nowMs) // 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_KM = 2.5;
export const REWARD_PER_KG = 0.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. // Package deadlines: now + a plausible game-time window of 6–72 hours.
const DEADLINE_MIN_HOURS = 6; const DEADLINE_MIN_HOURS = 6;
const DEADLINE_MAX_HOURS = 72; const DEADLINE_MAX_HOURS = 72;
@@ -127,6 +134,9 @@ export function rewardFor(km: number, weightKg: number): number {
// jitter draw, same pattern as forced endpoints. // jitter draw, same pattern as forced endpoints.
// v0.37.0 (ISS-073): special optionally FORCES the special status (string or explicit null) — // 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. // 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: { export function generatePackage(input: {
id: string; id: string;
airports: SeedAirport[]; airports: SeedAirport[];
@@ -137,6 +147,7 @@ export function generatePackage(input: {
destIcao?: string; destIcao?: string;
weightKg?: number; weightKg?: number;
special?: string | null; special?: string | null;
reputation?: number;
}): SeedPackage { }): SeedPackage {
const { airports, rng, nowMs } = input; const { airports, rng, nowMs } = input;
if (airports.length < 2) throw new Error('package generation needs at least two airports'); 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. // Forced deadlines skip the special scaling — consistent with overrides skipping draws.
const deadlineMs = input.deadlineOffsetMs ?? drawnWindowMs * (mods?.deadlineScale ?? 1); 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 { return {
id: input.id, id: input.id,
originIcao: origin.icao, originIcao: origin.icao,
destIcao: dest.icao, destIcao: dest.icao,
reward: Math.round(rewardFor(km, weightKg) * (mods?.rewardMultiplier ?? 1)), reward: Math.round(rewardFor(km, weightKg) * repBonus * (mods?.rewardMultiplier ?? 1)),
cargoType, cargoType,
cargoCount, cargoCount,
weightKg, weightKg,
+5 -5
View File
@@ -306,7 +306,7 @@ export const openApiSpec = {
post: { post: {
summary: 'Buy a plane from the market', summary: 'Buy a plane from the market',
description: description:
'Buy an aircraft at its full catalog price (v0.8.0). The new plane arrives "available" with a full tank at the home base (EDDF). Only models in the in-code catalog are known.', 'Buy an aircraft at its catalog price minus a reputation-scaled discount (v0.38.0, ISS-072 — higher reputation buys cheaper; no discount at the starting reputation). The new plane arrives "available" with a full tank at the home base (EDDF). Only models in the in-code catalog are known.',
operationId: 'buyPlane', operationId: 'buyPlane',
requestBody: { requestBody: {
required: true, required: true,
@@ -370,7 +370,7 @@ export const openApiSpec = {
post: { post: {
summary: 'Refuel a grounded plane', summary: 'Refuel a grounded plane',
description: description:
'Refuel a grounded plane at the fuel price per added liter, deducted from cash (v0.8.0). The body is optional (v0.16.0): no body or mode "full" fills to maxFuel; mode "forFlight" with packageId buys 150% of the weather-adjusted fuel required for that loaded package\'s destination leg, capped at tank capacity — liters = min(maxFuel, max(current fuel, 1.5 × required)) − current fuel, where required is the weight-proportional burn model over the great-circle distance with a ±20% wind adjustment (v0.36.0).', 'Refuel a grounded plane at the fuel price per added liter, deducted from cash (v0.8.0); the per-liter price is reduced by a reputation-scaled discount (v0.38.0, ISS-072 — higher reputation refuels cheaper; no discount at the starting reputation). The body is optional (v0.16.0): no body or mode "full" fills to maxFuel; mode "forFlight" with packageId buys 150% of the weather-adjusted fuel required for that loaded package\'s destination leg, capped at tank capacity — liters = min(maxFuel, max(current fuel, 1.5 × required)) − current fuel, where required is the weight-proportional burn model over the great-circle distance with a ±20% wind adjustment (v0.36.0).',
operationId: 'refuelPlane', operationId: 'refuelPlane',
parameters: [ parameters: [
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } }, { name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
@@ -476,7 +476,7 @@ export const openApiSpec = {
description: 'Aircraft catalog (v0.10.0) — static per build, one entry per plane type.', description: 'Aircraft catalog (v0.10.0) — static per build, one entry per plane type.',
items: { $ref: '#/components/schemas/CatalogEntry' }, items: { $ref: '#/components/schemas/CatalogEntry' },
}, },
fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; refuel cost = (maxFuel − fuel) × this.' }, fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; refuel cost = (maxFuel − fuel) × this, reduced by a reputation-scaled discount at refuel time (v0.38.0).' },
sellFraction: { type: 'number', description: 'Fraction of the catalog price paid out when selling a plane.' }, sellFraction: { type: 'number', description: 'Fraction of the catalog price paid out when selling a plane.' },
}, },
required: ['account', 'simState', 'planes', 'packages', 'catalog', 'fuelPricePerL', 'sellFraction'], required: ['account', 'simState', 'planes', 'packages', 'catalog', 'fuelPricePerL', 'sellFraction'],
@@ -486,7 +486,7 @@ export const openApiSpec = {
properties: { properties: {
model: { type: 'string', description: 'Catalog model id (e.g. "atr72") — used by buy/fly.' }, model: { type: 'string', description: 'Catalog model id (e.g. "atr72") — used by buy/fly.' },
label: { type: 'string', description: 'Display name (e.g. "ATR 72-600").' }, label: { type: 'string', description: 'Display name (e.g. "ATR 72-600").' },
price: { type: 'number', description: 'Market price in € (buy cost; sell pays out sellFraction × this).' }, price: { type: 'number', description: 'Market price in € — the full catalog list price. Buying costs this minus a reputation-scaled discount at purchase time (v0.38.0); selling pays out sellFraction × this.' },
maxFuel: { type: 'number', description: 'Fuel capacity, liters.' }, maxFuel: { type: 'number', description: 'Fuel capacity, liters.' },
burnPerKm: { type: 'number', description: 'Cruise fuel burn at a full tank, liters/km (v0.16.0 weighted model calibrates to this). Actual per-leg burn varies ±20% with wind — departure weather vs route heading (v0.36.0).' }, burnPerKm: { type: 'number', description: 'Cruise fuel burn at a full tank, liters/km (v0.16.0 weighted model calibrates to this). Actual per-leg burn varies ±20% with wind — departure weather vs route heading (v0.36.0).' },
emptyWeightKg: { type: 'number', description: 'Operating empty weight, kg — with burnPerKm and maxFuel it drives the v0.16.0 weight-proportional burn model.' }, emptyWeightKg: { type: 'number', description: 'Operating empty weight, kg — with burnPerKm and maxFuel it drives the v0.16.0 weight-proportional burn model.' },
@@ -499,7 +499,7 @@ export const openApiSpec = {
type: 'object', type: 'object',
properties: { properties: {
cash: { type: 'number' }, cash: { type: 'number' },
reputation: { type: 'number' }, reputation: { type: 'number', description: 'Reputation — grows with delivered packages; drives the purchase/fuel discounts and job reward quality (v0.38.0, ISS-072).' },
}, },
required: ['cash', 'reputation'], required: ['cash', 'reputation'],
}, },
+10 -2
View File
@@ -269,9 +269,14 @@ export async function spawnOpenPackages(
lon: f.properties.lon, lon: f.properties.lon,
})); }));
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31)); const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
// v0.38.0 (ISS-072): spawned jobs carry the account's CURRENT reputation quality — read once per spawn batch.
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
for (let i = 0; i < needed; i++) { for (let i = 0; i < needed; i++) {
await inst.db.insert(schema.packages).values(generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs })); await inst.db.insert(schema.packages).values(
generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, reputation: account.reputation }),
);
} }
return needed; return needed;
} }
@@ -313,12 +318,15 @@ export async function spawnCoveragePackages(
lon: f.properties.lon, lon: f.properties.lon,
})); }));
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31)); const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
// v0.38.0 (ISS-072): same reputation quality as spawnOpenPackages — read once per batch.
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
let n = 0; let n = 0;
for (const icao of missing) { for (const icao of missing) {
if (!airports.some((a) => a.icao === icao)) continue; // defensive — the dataset is the source of all ICACs if (!airports.some((a) => a.icao === icao)) continue; // defensive — the dataset is the source of all ICACs
await inst.db.insert(schema.packages).values( await inst.db.insert(schema.packages).values(
generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, originIcao: icao }), generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, originIcao: icao, reputation: account.reputation }),
); );
n++; n++;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "flight-ops", "name": "flight-ops",
"version": "0.37.0", "version": "0.38.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",