v1.5.0: shared-world spawn quality rule — max reputation across players (ISS-111)
CI / build + unit + e2e (pull_request) Successful in 13m53s
CI / build + unit + e2e (pull_request) Successful in 13m53s
This commit is contained in:
+12
-4
@@ -1,12 +1,12 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, max } from 'drizzle-orm';
|
||||
import type { GameDb } from './client';
|
||||
import * as schema from './schema';
|
||||
|
||||
// v1.0.0 (ISS-105): single-player economy access — resolves the seeded local player row. As of
|
||||
// v1.3.0 (ISS-110) every per-player read/write goes through getPlayer/updatePlayerEconomy below;
|
||||
// these 'local' helpers remain ONLY for world-level reads with no acting session (the engine's
|
||||
// shared-pool spawn quality — pools are SHARED state, so the seeded row sets job quality) and
|
||||
// env-gated /api/test/* admin hooks. Since v1.4.0 (ISS-112) even the SSE broadcast is per-player.
|
||||
// these 'local' helpers remain ONLY for env-gated /api/test/* admin hooks that stage the SEEDED
|
||||
// account with no acting session. Since v1.4.0 (ISS-112) even the SSE broadcast is per-player, and
|
||||
// since v1.5.0 (ISS-111) shared-world job quality reads getSharedWorldReputation instead of a row.
|
||||
|
||||
export const LOCAL_PLAYER = { id: 'local', provider: 'local', username: 'kamilkrzywda' } as const;
|
||||
|
||||
@@ -39,3 +39,11 @@ export async function updatePlayerEconomy(
|
||||
): Promise<void> {
|
||||
await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, id));
|
||||
}
|
||||
|
||||
// v1.5.0 (ISS-111), the #101 decision point resolved: shared-world job quality is the MAX
|
||||
// reputation across ALL player rows — spawned pool packages scale to the best-progressed player so
|
||||
// nobody is stuck on low-rep jobs. 0 when there are no players (repRate floors at ×1 either way).
|
||||
export async function getSharedWorldReputation(inst: GameDb): Promise<number> {
|
||||
const [row] = await inst.db.select({ m: max(schema.players.reputation) }).from(schema.players);
|
||||
return row?.m ?? 0;
|
||||
}
|
||||
|
||||
+50
-1
@@ -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
|
||||
|
||||
+6
-6
@@ -1,7 +1,7 @@
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
|
||||
import { getLocalPlayer, getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer';
|
||||
import { getSharedWorldReputation, getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||
@@ -385,10 +385,10 @@ export async function spawnPoolPackages(
|
||||
}));
|
||||
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
|
||||
// v0.38.0 (ISS-072): spawned jobs carry a reputation-based job quality — read once per spawn batch.
|
||||
// ponytail: stays on the seeded 'local' row — pools are SHARED world state (packages have no
|
||||
// owner), so there is no single owning player whose rep should set job quality; per-player job
|
||||
// quality is a later multiplayer step.
|
||||
const player = await getLocalPlayer(inst);
|
||||
// v1.5.0 (ISS-111), the #101 decision point: pools are SHARED world state, so quality follows the
|
||||
// agreed shared-world rule — MAX reputation across all players — and scales to the best-progressed
|
||||
// player instead of the seeded local row.
|
||||
const reputation = await getSharedWorldReputation(inst);
|
||||
|
||||
let n = 0;
|
||||
for (const originIcao of pool) {
|
||||
@@ -418,7 +418,7 @@ export async function spawnPoolPackages(
|
||||
clockMs,
|
||||
id: randomUUID(),
|
||||
createdRealMs: real,
|
||||
reputation: player.reputation,
|
||||
reputation,
|
||||
}),
|
||||
);
|
||||
openByBand[bandIdx]++;
|
||||
|
||||
Reference in New Issue
Block a user