Maintenance: distance wear, broken-state burn ×1.5, repair action (ISS-069, v0.39.0)
CI / build + unit + e2e (pull_request) Successful in 9m34s
CI / build + unit + e2e (pull_request) Successful in 9m34s
This commit is contained in:
@@ -10,12 +10,15 @@ import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||
import { weatherFor } from '@/lib/weather';
|
||||
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
|
||||
import {
|
||||
BROKEN_BURN_MULTIPLIER,
|
||||
FUEL_DISCOUNT_PER_REP,
|
||||
FUEL_PRICE_PER_L,
|
||||
MAX_FUEL_DISCOUNT,
|
||||
MAX_PLANE_DISCOUNT,
|
||||
PLANE_DISCOUNT_PER_REP,
|
||||
PLANE_TYPES,
|
||||
REPAIR_COST,
|
||||
REPAIR_DURATION_MS,
|
||||
repRate,
|
||||
SELL_FRACTION,
|
||||
} from './catalog';
|
||||
@@ -30,6 +33,7 @@ import {
|
||||
loadPackage,
|
||||
queueLeg,
|
||||
refuelPlane,
|
||||
repairPlane,
|
||||
sellPlane,
|
||||
} from './actions';
|
||||
|
||||
@@ -233,6 +237,30 @@ describe('flyPlane', () => {
|
||||
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient fuel/i);
|
||||
});
|
||||
|
||||
it('uses the ×1.5 broken burn for fuel feasibility when health is 0 (v0.39.0, ISS-069)', async () => {
|
||||
const inst = await freshDb('fly-broken.db'); // EDDF → LEMD
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
||||
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
|
||||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||
const type = PLANE_TYPES.atr72;
|
||||
const w = weatherFor(HUB_ICAO);
|
||||
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
|
||||
// The tank holds EXACTLY the healthy requirement — a broken plane needs more than that.
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ health: 0, fuel: requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km) })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient fuel/i);
|
||||
|
||||
// Same tank at full health is flyable — the broken multiplier is what blocked it.
|
||||
const inst2 = await freshDb('fly-broken-healthy.db');
|
||||
await inst2.db.update(schema.planes)
|
||||
.set({ fuel: requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km) })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
await flyPlane(inst2, 'plane-starter', 'LEMD'); // must not throw
|
||||
});
|
||||
});
|
||||
|
||||
describe('queueLeg (v0.25.0, ISS-043)', () => {
|
||||
@@ -779,3 +807,83 @@ describe('refuelPlane', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repairPlane (v0.39.0, ISS-069)', () => {
|
||||
it('charges exactly REPAIR_COST, restores health to 100 and grounds the plane for REPAIR_DURATION_MS', async () => {
|
||||
const inst = await freshDb('repair-ok.db');
|
||||
await inst.db.update(schema.planes).set({ health: 42 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
const { cost } = await repairPlane(inst, 'plane-starter');
|
||||
|
||||
expect(cost).toBe(REPAIR_COST);
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('repairing');
|
||||
expect(plane.health).toBe(100); // restored to full…
|
||||
expect(plane.repairUntilSimTime).toBe(T0 + REPAIR_DURATION_MS); // …with the gate set from the seeded clock (no tick in direct calls)
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
expect(account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6);
|
||||
});
|
||||
|
||||
it('is a free no-op when health is already full', async () => {
|
||||
const inst = await freshDb('repair-full.db'); // seeded starter at 100
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
const { cost } = await repairPlane(inst, 'plane-starter');
|
||||
|
||||
expect(cost).toBe(0);
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('available'); // not grounded…
|
||||
expect(plane.health).toBe(100);
|
||||
expect(plane.repairUntilSimTime ?? null).toBeNull(); // …and no gate set
|
||||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
|
||||
});
|
||||
|
||||
it('rejects a flying plane with 409', async () => {
|
||||
const inst = await freshDb('repair-flying.db');
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/);
|
||||
});
|
||||
|
||||
it('rejects a plane that is already repairing with 409', async () => {
|
||||
const inst = await freshDb('repair-busy.db');
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'repairing', repairUntilSimTime: T0 + REPAIR_DURATION_MS })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/);
|
||||
});
|
||||
|
||||
it('blocks load/fly/refuel/sell while repairing (the existing status guards)', async () => {
|
||||
const inst = await freshDb('repair-blocks.db');
|
||||
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
await repairPlane(inst, 'plane-starter');
|
||||
|
||||
await insertOpenPackage(inst); // open package at the hub — loadable if the plane were grounded
|
||||
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /repairing/);
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /repairing/);
|
||||
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /repairing/);
|
||||
// A second plane so the "last remaining" guard cannot fire before the status one.
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||
});
|
||||
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /repairing/);
|
||||
});
|
||||
|
||||
it('rejects the repair when cash is below REPAIR_COST with 409', async () => {
|
||||
const inst = await freshDb('repair-poor.db');
|
||||
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
await inst.db.update(schema.account).set({ cash: REPAIR_COST - 1 });
|
||||
|
||||
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /insufficient funds/i);
|
||||
});
|
||||
|
||||
it('rejects an unknown id with 404', async () => {
|
||||
const inst = await freshDb('repair-unknown.db');
|
||||
await expectActionError(repairPlane(inst, 'ghost'), 404);
|
||||
});
|
||||
});
|
||||
|
||||
+41
-3
@@ -9,12 +9,15 @@ import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
|
||||
import { runAction } from '@/lib/sim/engine';
|
||||
import { getStateSnapshot, parseIcaoQueue } from './state';
|
||||
import {
|
||||
conditionBurnMultiplier,
|
||||
FUEL_DISCOUNT_PER_REP,
|
||||
FUEL_PRICE_PER_L,
|
||||
MAX_FUEL_DISCOUNT,
|
||||
MAX_PLANE_DISCOUNT,
|
||||
PLANE_DISCOUNT_PER_REP,
|
||||
PLANE_TYPES,
|
||||
REPAIR_COST,
|
||||
REPAIR_DURATION_MS,
|
||||
repRate,
|
||||
SELL_FRACTION,
|
||||
} from './catalog';
|
||||
@@ -118,8 +121,9 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
||||
const distanceKm = haversineKm(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon);
|
||||
// Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty; wind-
|
||||
// adjusted (v0.36.0) by departure weather vs route heading — every call site uses this same factor.
|
||||
// v0.39.0 (ISS-069): a broken plane (health ≤ 0) burns BROKEN_BURN_MULTIPLIER × more per km.
|
||||
const w = weatherFor(plane.locationIcao);
|
||||
const burnPerKm = type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon));
|
||||
const burnPerKm = type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon)) * conditionBurnMultiplier(plane.health);
|
||||
const requiredL = requiredFuelFor(burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
|
||||
if (requiredL > plane.fuel) {
|
||||
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(plane.fuel)} L`);
|
||||
@@ -284,6 +288,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
|
||||
lat: home.lat,
|
||||
lon: home.lon,
|
||||
departureSimTime: null,
|
||||
health: 100, // new planes arrive at full health (v0.39.0, ISS-069)
|
||||
});
|
||||
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
|
||||
await persist(inst);
|
||||
@@ -367,10 +372,11 @@ export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOpt
|
||||
const origin = await airportByIcao(plane.locationIcao);
|
||||
const dest = await airportByIcao(pkg.destIcao);
|
||||
const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
|
||||
// Same wind-adjusted requirement flyPlane checks (v0.36.0) — departure weather vs route heading.
|
||||
// Same wind-adjusted requirement flyPlane checks (v0.36.0) — departure weather vs route heading;
|
||||
// v0.39.0 (ISS-069): broken planes burn BROKEN_BURN_MULTIPLIER × more, same factor as flyPlane.
|
||||
const w = weatherFor(plane.locationIcao);
|
||||
const requiredL = requiredFuelFor(
|
||||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, dest.lat, dest.lon)),
|
||||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, dest.lat, dest.lon)) * conditionBurnMultiplier(plane.health),
|
||||
type.emptyWeightKg,
|
||||
type.maxFuel,
|
||||
distanceKm,
|
||||
@@ -404,6 +410,38 @@ export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOpt
|
||||
return { cost };
|
||||
}
|
||||
|
||||
// --- Repair (POST /api/planes/:planeId/repair) — v0.39.0 (ISS-069) ---
|
||||
|
||||
// Restore a worn-out plane to full health for the flat REPAIR_COST; it is grounded in 'repairing'
|
||||
// status until sim.clockMs + REPAIR_DURATION_MS, when the engine's tick flips it back to
|
||||
// 'available'. Only GROUNDED planes can be repaired (409 otherwise); an already-full-health
|
||||
// repair is a free no-op (mirrors refuel's). 404 unknown plane; 409 not grounded / insufficient funds.
|
||||
export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost: number }> {
|
||||
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||
const plane = rows[0];
|
||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||
if (plane.status !== 'available') {
|
||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be repaired`);
|
||||
}
|
||||
// Free no-op when the plane is already at full health — nothing to repair.
|
||||
if (plane.health >= 100) return { cost: 0 };
|
||||
|
||||
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||
if (!sim) throw new Error('sim_state row missing');
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
if (!account) throw new Error('account row missing');
|
||||
if (account.cash < REPAIR_COST) {
|
||||
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(account.cash)}`);
|
||||
}
|
||||
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ health: 100, status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS })
|
||||
.where(eq(schema.planes.id, planeId));
|
||||
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - REPAIR_COST) * 100) / 100 });
|
||||
await persist(inst);
|
||||
return { cost: REPAIR_COST };
|
||||
}
|
||||
|
||||
// --- Route helper (response convention, v0.8.0) ---
|
||||
|
||||
// Run an action under the sim lock — tick FIRST so validation/mutation see current
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||||
import { BROKEN_BURN_MULTIPLIER, conditionBurnMultiplier, 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.
|
||||
@@ -43,3 +43,21 @@ describe('repRate', () => {
|
||||
expect(repRate(1_000_000, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)).toBe(MAX_FUEL_DISCOUNT);
|
||||
});
|
||||
});
|
||||
|
||||
// v0.39.0 (ISS-069) — the broken-state burn factor: ×BROKEN_BURN_MULTIPLIER at/below zero health, exactly 1 above it.
|
||||
describe('conditionBurnMultiplier', () => {
|
||||
it('is exactly 1 for any positive health', () => {
|
||||
expect(conditionBurnMultiplier(100)).toBe(1);
|
||||
expect(conditionBurnMultiplier(42)).toBe(1);
|
||||
expect(conditionBurnMultiplier(0.5)).toBe(1); // worn but not broken
|
||||
});
|
||||
|
||||
it('is exactly BROKEN_BURN_MULTIPLIER at and below zero health', () => {
|
||||
expect(conditionBurnMultiplier(0)).toBe(BROKEN_BURN_MULTIPLIER);
|
||||
expect(conditionBurnMultiplier(-3)).toBe(BROKEN_BURN_MULTIPLIER); // defensive — wear clamps at 0, but the gate is <= 0
|
||||
});
|
||||
|
||||
it('pins the broken multiplier at ×1.5', () => {
|
||||
expect(BROKEN_BURN_MULTIPLIER).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,6 +122,25 @@ 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;
|
||||
|
||||
// --- Maintenance (v0.39.0, ISS-069) ---
|
||||
|
||||
// ponytail: balance-tunable — health lost per km of landed legs (~13 mid-range EDDF↔LEMD hops to break from full).
|
||||
export const WEAR_PER_KM = 0.005;
|
||||
|
||||
// ponytail: balance-tunable — burn multiplier for a broken plane (health ≤ 0): it still flies, just thirsty.
|
||||
export const BROKEN_BURN_MULTIPLIER = 1.5;
|
||||
|
||||
// Broken-state burn factor for one plane's health — the third multiplicative term alongside wind at every
|
||||
// per-plane burn site (flyPlane feasibility, refuel forFlight, engine landed fuel + auto-chain, live display).
|
||||
export function conditionBurnMultiplier(health: number): number {
|
||||
return health <= 0 ? BROKEN_BURN_MULTIPLIER : 1;
|
||||
}
|
||||
|
||||
// ponytail: balance-tunable — flat repair cost in € (per-type if balance demands it).
|
||||
export const REPAIR_COST = 1_500;
|
||||
// ponytail: balance-tunable — how long a repair grounds the plane, sim ms (3 game days).
|
||||
export const REPAIR_DURATION_MS = 3 * 86_400_000;
|
||||
|
||||
// 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
|
||||
// previews (buy price, sell payout, refuel cost) from these + the economy constants.
|
||||
|
||||
@@ -49,6 +49,7 @@ describe('buildSeed', () => {
|
||||
lat: 50.0333,
|
||||
lon: 8.5706,
|
||||
queue: [], // v0.25.0 (ISS-043) — no queued legs on first run
|
||||
health: 100, // v0.39.0 (ISS-069) — full health on first run
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface SeedPlane {
|
||||
// Upcoming legs not yet started (v0.25.0, ISS-043) — serialized to the queue
|
||||
// column as JSON text on insert; defaults to an empty queue.
|
||||
queue?: string[];
|
||||
// Structural health in [0, 100] (v0.39.0, ISS-069) — new planes start at full health.
|
||||
health: number;
|
||||
}
|
||||
|
||||
export interface SeedPackage {
|
||||
@@ -237,6 +239,7 @@ export function buildSeed(input: {
|
||||
lat: hub.lat,
|
||||
lon: hub.lon,
|
||||
queue: [], // no queued legs on first run (v0.25.0)
|
||||
health: 100, // full health on first run (v0.39.0, ISS-069)
|
||||
};
|
||||
|
||||
const count = PACKAGE_COUNT_MIN + Math.floor(rng() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
|
||||
|
||||
+13
-3
@@ -3,7 +3,7 @@ import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||
import { weatherFor } from '@/lib/weather';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
|
||||
import { conditionBurnMultiplier, catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
|
||||
import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement';
|
||||
|
||||
// Read-only game-state snapshot for client polling (GET /api/state).
|
||||
@@ -11,7 +11,8 @@ import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/si
|
||||
export interface PlaneState {
|
||||
id: string;
|
||||
model: string;
|
||||
status: 'available' | 'flying';
|
||||
// v0.39.0 (ISS-069): 'repairing' = grounded in the shop until repairUntilSimTime passes.
|
||||
status: 'available' | 'flying' | 'repairing';
|
||||
fuel: number;
|
||||
maxFuel: number;
|
||||
locationIcao: string;
|
||||
@@ -28,6 +29,11 @@ export interface PlaneState {
|
||||
// Upcoming legs NOT yet started (v0.25.0, ISS-043) — ordered ICAOs; the active
|
||||
// leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.
|
||||
queue: string[];
|
||||
// Structural health in [0, 100] (v0.39.0, ISS-069) — wears down with distance flown; at 0 the
|
||||
// plane is broken and burns BROKEN_BURN_MULTIPLIER × more fuel until repaired.
|
||||
health: number;
|
||||
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair.
|
||||
repairUntilSimTime: number | null;
|
||||
}
|
||||
|
||||
// Parse the planes.queue JSON column into an ICAO list (v0.25.0). Guards against
|
||||
@@ -107,10 +113,12 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
||||
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
|
||||
// Wind-adjusted burn (v0.36.0) — weather is a pure function of the departure ICAO, so this
|
||||
// recomputes exactly the factor the engine used for the leg; no stored state needed.
|
||||
// v0.39.0 (ISS-069): broken planes burn BROKEN_BURN_MULTIPLIER × more — p.health is frozen at
|
||||
// departure (wear lands only), so this stays bit-consistent with the engine's landed fuel.
|
||||
const w = weatherFor(p.locationIcao);
|
||||
return { ...pos, fuel: fuelAtWeighted(
|
||||
p.fuel,
|
||||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
|
||||
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)) * conditionBurnMultiplier(p.health),
|
||||
type.emptyWeightKg,
|
||||
type.maxFuel,
|
||||
distanceKm,
|
||||
@@ -140,6 +148,8 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
||||
lat: live?.lat ?? p.lat,
|
||||
lon: live?.lon ?? p.lon,
|
||||
queue: parseIcaoQueue(p.queue),
|
||||
health: p.health, // v0.39.0 (ISS-069) — stored value; wear accumulates at landing only
|
||||
repairUntilSimTime: p.repairUntilSimTime,
|
||||
};
|
||||
}),
|
||||
packages: packageRows.map((p) => ({
|
||||
|
||||
Reference in New Issue
Block a user