Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8daca54840 | ||
|
|
0f5f3ae23e |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}`);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user