From 8daca548402736e137d12cd93d20b46d77d37690 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 21 Sep 2026 10:51:22 +0000 Subject: [PATCH] Reputation economy: rep-scaled buy/refuel discounts + job reward quality (ISS-072, v0.38.0) --- CHANGELOG.md | 10 ++++ app/api/test/create-package/route.ts | 5 ++ components/LeftPane.tsx | 13 ++++- components/RightPane.tsx | 11 +++- e2e/actions.spec.ts | 9 ++-- e2e/right-pane.spec.ts | 24 +++++++-- e2e/two-planes.spec.ts | 8 ++- lib/game/actions.test.ts | 76 +++++++++++++++++++++++++++- lib/game/actions.ts | 27 +++++++--- lib/game/catalog.test.ts | 23 ++++++++- lib/game/catalog.ts | 20 ++++++++ lib/game/seed.test.ts | 38 +++++++++++++- lib/game/seed.ts | 18 ++++++- lib/openapi.ts | 10 ++-- lib/sim/engine.ts | 12 ++++- package.json | 2 +- 16 files changed, 273 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 061482c..3907836 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # 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 - **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 diff --git a/app/api/test/create-package/route.ts b/app/api/test/create-package/route.ts index 85e6de1..8891dc2 100644 --- a/app/api/test/create-package/route.ts +++ b/app/api/test/create-package/route.ts @@ -82,6 +82,10 @@ export async function POST(req: Request) { } 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({ id: randomUUID(), airports, @@ -93,6 +97,7 @@ export async function POST(req: Request) { weightKg: input.weightKg as number | undefined, // Omitted → force null (no roll) so existing e2e reward/rep assertions stay deterministic. special: input.special === undefined ? null : (input.special as string | null), + reputation: account.reputation, }); await inst.db.insert(schema.packages).values(created); await persist(inst); diff --git a/components/LeftPane.tsx b/components/LeftPane.tsx index e1716de..12197b1 100644 --- a/components/LeftPane.tsx +++ b/components/LeftPane.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react'; import type { GeoJsonFeatureCollection } from '@/lib/airports'; 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 { formatCash, formatDeadlineRelative, formatFuel, formatSimClock, formatSpecialLabel } from '@/lib/format'; 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)) ?? refuelable[0] ?? 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 // 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 {refuelCost !== null ? formatCash(refuelCost) : ''} + {/* v0.38.0 (ISS-072): rep discount hint — only when the account actually gets one. */} + {fuelDiscountRate > 0 && ( + −{Math.round(fuelDiscountRate * 100)}% + )} )}