v1.5.0: shared-world spawn quality rule — max reputation across players (ISS-111)
CI / build + unit + e2e (pull_request) Successful in 13m53s

This commit is contained in:
2026-09-26 19:07:50 +00:00
parent c3fa5bf613
commit eae680c002
6 changed files with 79 additions and 17 deletions
+50 -1
View File
@@ -11,7 +11,7 @@ import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
import { SPECIALS } from '@/lib/game/specials';
import { JOB_LIFETIME_MS, POOL_BANDS, POOL_TARGET_PER_AIRPORT, REFILL_DELAY_MS, SMALL_REFILL_DELAY_MS, mulberry32, poolBandOf, STARTING_CLOCK_MS } from '@/lib/game/seed';
import { JOB_BONUS_PER_REP, JOB_LIFETIME_MS, MAX_JOB_BONUS, POOL_BANDS, POOL_TARGET_PER_AIRPORT, REFILL_DELAY_MS, SMALL_REFILL_DELAY_MS, STARTING_CLOCK_MS, STARTING_REPUTATION, mulberry32, poolBandOf, rewardFor } from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
import {
deliverPackages,
@@ -911,6 +911,55 @@ describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT * 2); // both pools held through the full tick pipeline
expect(openRows.every((p) => p.originIcao === 'EDDF' || p.originIcao === 'LEMD')).toBe(true);
});
// v1.5.0 (ISS-111): shared-world spawn quality — MAX reputation across players. Independent
// recomputation of a spawned package's reward at a given reputation, from the stored row with the
// same formula generatePackage uses (rewardFor × rep bonus × special multiplier) — checks the rule
// against the math, not against itself.
async function expectedRewardAt(p: schema.PackageRow, reputation: number): Promise<number> {
const fc = await loadAirportsGeoJson();
const o = fc.features.find((f) => f.properties.icao === p.originIcao)!;
const d = fc.features.find((f) => f.properties.icao === p.destIcao)!;
const km = haversineKm(o.properties.lat, o.properties.lon, d.properties.lat, d.properties.lon);
return Math.round(
rewardFor(km, p.weightKg) * (1 + repRate(reputation, JOB_BONUS_PER_REP, MAX_JOB_BONUS)) * (p.special ? SPECIALS[p.special]?.rewardMultiplier ?? 1 : 1),
);
}
it('scales job quality by the MAX reputation across players, not the local row (v1.5.0, ISS-111)', async () => {
const inst = await freshDb('pool-shared-rep.db');
// Local sits at the starting rep (×1 bonus); player-b is far ahead — the shared-world rule must pick b's.
await inst.db.insert(schema.players).values({ id: 'player-b', provider: 'test', username: 'b', cash: 1_000, reputation: 60 });
for (const p of await inst.db.select().from(schema.packages)) {
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
}
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(POOL_TARGET_PER_AIRPORT);
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT);
for (const p of openRows) {
expect(p.reward).toBe(await expectedRewardAt(p, 60)); // every job scaled to the MAX…
expect(p.reward).not.toBe(await expectedRewardAt(p, STARTING_REPUTATION)); // …not to local's starting rep
}
});
it("single player: spawn quality is that player's own reputation (v1.5.0 regression guard)", async () => {
const inst = await freshDb('pool-single-rep.db');
// Raise the only player's rep so the bonus is non-trivial, then respawn from zero with a fixed RNG —
// with one row, MAX equals that row: identical to the pre-v1.5.0 singleton read.
await updateLocalPlayerEconomy(inst, { reputation: 60 });
for (const p of await inst.db.select().from(schema.packages)) {
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
}
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(POOL_TARGET_PER_AIRPORT);
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
for (const p of openRows) {
expect(p.reward).toBe(await expectedRewardAt(p, 60));
}
});
});
// Local helper mirroring lib/sim/movement.ts (kept private there on purpose — the