Compare commits
6 Commits
87c17699c2
...
241e2613f4
| Author | SHA1 | Date |
|---|---|---|
|
|
241e2613f4 | |
|
|
2c211524d3 | |
|
|
6804a7120c | |
|
|
10ded07ea6 | |
|
|
24d9942336 | |
|
|
8ff2df976e |
|
|
@ -11,8 +11,3 @@ BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
# HTTP port the API server listens on
|
# HTTP port the API server listens on
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
# Game clock epoch (decision 0.5.9): the real instant the game clock starts,
|
|
||||||
# and the in-game ephemeris date it maps to. Both ISO 8601, UTC.
|
|
||||||
GAME_EPOCH_REAL_START=2026-01-01T00:00:00Z
|
|
||||||
GAME_EPOCH_EPHEMERIS_DATE=2100-01-01T00:00:00Z
|
|
||||||
|
|
|
||||||
46
DECISIONS.md
46
DECISIONS.md
|
|
@ -31,3 +31,49 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **One Postgres container, two databases.** `docker-compose.yml` runs a single `postgres:18` server on port 5432 (user/password `deadhead`). The default database `deadhead` is for development; `docker/postgres/init/01-create-test-db.sql` creates `deadhead_test` the first time the data volume is created. To recreate both from scratch, run `docker compose down -v`.
|
- **One Postgres container, two databases.** `docker-compose.yml` runs a single `postgres:18` server on port 5432 (user/password `deadhead`). The default database `deadhead` is for development; `docker/postgres/init/01-create-test-db.sql` creates `deadhead_test` the first time the data volume is created. To recreate both from scratch, run `docker compose down -v`.
|
||||||
- **Env var names** in `.env.example`: `DATABASE_URL`, `TEST_DATABASE_URL`, `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` (Better Auth's own names), `PORT`, and `GAME_EPOCH_REAL_START` plus `GAME_EPOCH_EPHEMERIS_DATE` (decision 0.5.9). The game-clock speed is not an env var; it belongs in GameConfig (rule 0.1.5).
|
- **Env var names** in `.env.example`: `DATABASE_URL`, `TEST_DATABASE_URL`, `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` (Better Auth's own names), `PORT`, and `GAME_EPOCH_REAL_START` plus `GAME_EPOCH_EPHEMERIS_DATE` (decision 0.5.9). The game-clock speed is not an env var; it belongs in GameConfig (rule 0.1.5).
|
||||||
- **Epoch values in `.env.example` are placeholders.** The design docs do not fix an ephemeris date yet.
|
- **Epoch values in `.env.example` are placeholders.** The design docs do not fix an ephemeris date yet.
|
||||||
|
|
||||||
|
## Step 2.1
|
||||||
|
|
||||||
|
- **Ids are lowercase kebab-case strings** (`earth`, `bulk-hauler`, `long-haul`).
|
||||||
|
- **One landing-class enum** (`vacuum`, `atmospheric`, `hardened`) serves both a location's landing requirement and a ship's landing class. `hardened` is not used in the base game.
|
||||||
|
- **Location `body`** is an enum of the five astronomy bodies (`earth`, `moon`, `mars`, `ceres`, `jupiter`) so the sim can map each to a position source in Step 3.3.
|
||||||
|
- **Epoch fields are ISO 8601 UTC strings** (`epoch.realStart`, `epoch.ephemerisDate`), not `Date` objects, so content stays plain data.
|
||||||
|
- **`tutorialBoardSize`** is a GameConfig field in addition to `contractBoardSize`, because decision 0.5.21 sets the tutorial board's size separately.
|
||||||
|
- **XP per delivery inputs** are `base`, `perMillionKm` and `perCargoTier`. Step 3.16 decides how they combine.
|
||||||
|
- **`pilotLevelXpThresholds`** lists the _total_ XP needed for level 1, 2, 3, ...; it and the Long Haul cut-offs must be strictly increasing.
|
||||||
|
- **`tierPayMultiplier`** is one number per cargo type. Step 3.8 decides how it scales with tier.
|
||||||
|
- **Imports omit the `.ts` extension** (`moduleResolution: "Bundler"`).
|
||||||
|
|
||||||
|
## Step 2.2
|
||||||
|
|
||||||
|
- **Location ids:** `earth`, `moon`, `mars`, `ceres-station`, `jupiter-cloud-platform`.
|
||||||
|
- **Cargo flows** follow GDD §9: Earth and Mars produce standard and high-value goods and demand ore (heavy) and fuel (dangerous); the Moon produces standard and heavy; Ceres produces heavy; Jupiter produces dangerous (fuel). The outposts import high-value equipment.
|
||||||
|
- **Launch multipliers:** Earth 3, Jupiter Cloud Platform 2, Mars 1.5, Moon 0.5, Ceres 0.3.
|
||||||
|
- **Bay lease prices are first guesses** (Earth 5,000 up to Jupiter 15,000). Step 2.5 tunes the Earth price against the tutorial.
|
||||||
|
- **The cargo-id check uses a placeholder list** of the four cargo ids planned in Step 2.4 until the cargo data exists.
|
||||||
|
|
||||||
|
## Step 2.3
|
||||||
|
|
||||||
|
- **Ranges** (million km): Courier 5 (Moon only; Mars is ~55 at its closest), Freighter 650 (covers Ceres at its farthest, ~600, and Jupiter near its closest, ~590, per decision 0.5.25), Bulk Hauler 1,000 (Jupiter at its farthest, ~970). Step 3.7 checks these against real positions.
|
||||||
|
- **Speeds and overheads** aim at GDD §6 durations at compression 60: Courier 3M km/h with 2 h overhead (Earth→Moon ≈ 2 real minutes, mostly overhead), Freighter 2M km/h with 4 h (Earth→Mars ≈ 1–2 real hours), Bulk Hauler 1.2M km/h with 8 h.
|
||||||
|
- **Prices** (30,000 / 150,000 / 600,000), **capacities** (20 / 100 / 400 t), **fuel costs** (20 / 40 / 80 per million km) and **hull ratings** (1 / 2 / 3) are first guesses; Step 2.5 tunes the Courier price.
|
||||||
|
|
||||||
|
## Step 2.4
|
||||||
|
|
||||||
|
- **Ids:** cargo `standard`, `heavy`, `high-value`, `dangerous`; skills `long-haul`, `heavy-cargo`, `expensive-cargo`, `dangerous-cargo`.
|
||||||
|
- **Base pay** (credits per tonne per million km): Standard 10, Heavy 14, High-Value 18, Dangerous 20. **Tier multipliers:** 1, 1.2, 1.25, 1.3. First guesses, to be tuned in Phase 12.
|
||||||
|
- **Cargo and skills share one data file** (`src/data/cargo.ts`), since every cargo skill exists to gate a cargo type.
|
||||||
|
|
||||||
|
## Step 2.5
|
||||||
|
|
||||||
|
- **The game epoch lives in GameConfig, not in env vars.** Step 2.1 puts it in GameConfig, and rule 0.1.5 keeps game numbers in `packages/content`. The `GAME_EPOCH_*` variables added to `.env.example` in Step 1.5 are removed; the placeholder values (real start 2026-01-01, in-game 2100-01-01) are unchanged.
|
||||||
|
- **Money values scaled up.** With the Step 2.2–2.4 rates a tutorial run paid ~24 credits. Pay rates and fuel costs are ×10, and prices are set so a Courier (4,000) plus an Earth bay (1,000) takes ~21 runs at ~240 credits each. Freighter 100,000, Bulk Hauler 400,000; leases Moon 800, Mars 20,000, Ceres 40,000, Jupiter 80,000.
|
||||||
|
- **Placeholder tutorial payout in the test:** Standard pay × 0.384M km × average mass (midpoint of the contract minimum and Courier capacity, 12.5 t) × tutorial share (0.5).
|
||||||
|
- **Starting credits are 0.** The company pays fuel during the tutorial, and the ~21-run target already assumes the player starts with nothing.
|
||||||
|
- **Other defaults:** board size 8, tutorial board 40 (more than the ~30 runs that fit in a 60-minute period), first contact 10% at ×2 pay, contract mass 5–400 t, Long Haul cut-offs 100/250/400/600/800M km, ten pilot levels at 2 skill points each (enough to max all four skills), hire cost 2,000, pilot pay 100 per delivery.
|
||||||
|
- **Known balance issue for Phase 12:** pay is linear in distance, but every trip pays a fixed overhead, so short Earth–Moon runs earn far less per real hour than long runs (a Freighter on Mars earns roughly 50× a Courier on the Moon).
|
||||||
|
|
||||||
|
## Step 2.6
|
||||||
|
|
||||||
|
- **Lookups return `undefined`** for an unknown id ("not found" without throwing). Callers decide whether a missing id is an error.
|
||||||
|
- **The package also exports each data list and schema individually**, alongside the `content` bundle, so tests and the server can validate data with the schemas.
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,12 @@
|
||||||
- [x] **Step 1.3 — Test runner.** Vitest 5 with one project per workspace package (`pnpm test`). Trivial smoke test in `packages/sim/test/smoke.test.ts`; 1 passing.
|
- [x] **Step 1.3 — Test runner.** Vitest 5 with one project per workspace package (`pnpm test`). Trivial smoke test in `packages/sim/test/smoke.test.ts`; 1 passing.
|
||||||
- [x] **Step 1.4 — Continuous integration.** Repo pushed to Gitea; `.gitea/workflows/ci.yml` runs install, typecheck, lint and test on every push (runner `homelab-runner`). Run #1 failed because Prettier scanned the runner's in-workspace `.pnpm-store/`; ignored it, and run #2 is green.
|
- [x] **Step 1.4 — Continuous integration.** Repo pushed to Gitea; `.gitea/workflows/ci.yml` runs install, typecheck, lint and test on every push (runner `homelab-runner`). Run #1 failed because Prettier scanned the runner's in-workspace `.pnpm-store/`; ignored it, and run #2 is green.
|
||||||
- [x] **Step 1.5 — Local Postgres.** `docker compose up -d` starts Postgres 18 with `deadhead` (dev) and `deadhead_test` databases; connected to both with psql. `.env.example` lists the server's env vars.
|
- [x] **Step 1.5 — Local Postgres.** `docker compose up -d` starts Postgres 18 with `deadhead` (dev) and `deadhead_test` databases; connected to both with psql. `.env.example` lists the server's env vars.
|
||||||
|
|
||||||
|
## Phase 2 — Content Package
|
||||||
|
|
||||||
|
- [x] **Step 2.1 — Content schemas.** Zod 4 schemas in `packages/content/src/schemas.ts` for Location, ShipModel, CargoType, Skill and GameConfig, with inferred types. Tests check that each schema accepts a valid example and rejects it with any one field missing.
|
||||||
|
- [x] **Step 2.2 — The five locations.** `src/data/locations.ts`: Earth, Moon, Mars, Ceres Station, Jupiter Cloud Platform. Earth and Moon start discovered; Earth has the highest launch multiplier.
|
||||||
|
- [x] **Step 2.3 — The three ship models.** `src/data/ships.ts`: Courier, Freighter, Bulk Hauler, all atmospheric. Speed, capacity and price orderings tested; range rules are checked in Step 3.7.
|
||||||
|
- [x] **Step 2.4 — Cargo types and skills.** `src/data/cargo.ts`: Standard, Heavy, High-Value, Dangerous; skills Long Haul, Heavy Cargo, Expensive Cargo, Dangerous Cargo (max level 5). The location test now checks cargo ids against the real data.
|
||||||
|
- [x] **Step 2.5 — Default game config.** `src/data/config.ts`. Courier + Earth bay ≈ 21 tutorial runs with the placeholder average payout. Money values rescaled ×10 so tutorial runs pay meaningful amounts; epoch moved from `.env.example` into GameConfig.
|
||||||
|
- [x] **Step 2.6 — Single content bundle.** `content` in `src/bundle.ts` holds all data plus `location`, `shipModel`, `cargoType` and `skill` lookups that return `undefined` for unknown ids.
|
||||||
|
|
|
||||||
|
|
@ -7,5 +7,8 @@
|
||||||
"types": "./src/index.ts",
|
"types": "./src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^4.6.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { cargoTypes, skills } from "./data/cargo";
|
||||||
|
import { gameConfig } from "./data/config";
|
||||||
|
import { locations } from "./data/locations";
|
||||||
|
import { shipModels } from "./data/ships";
|
||||||
|
|
||||||
|
/** Returns a lookup that gives `undefined` for an unknown id instead of throwing. */
|
||||||
|
function byId<T extends { id: string }>(items: readonly T[]): (id: string) => T | undefined {
|
||||||
|
const map = new Map(items.map((item) => [item.id, item]));
|
||||||
|
return (id) => map.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All static game data in one object, with lookups by id. */
|
||||||
|
export const content = {
|
||||||
|
locations,
|
||||||
|
shipModels,
|
||||||
|
cargoTypes,
|
||||||
|
skills,
|
||||||
|
config: gameConfig,
|
||||||
|
location: byId(locations),
|
||||||
|
shipModel: byId(shipModels),
|
||||||
|
cargoType: byId(cargoTypes),
|
||||||
|
skill: byId(skills),
|
||||||
|
};
|
||||||
|
export type Content = typeof content;
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import type { CargoType, Skill } from "../schemas";
|
||||||
|
|
||||||
|
/** Standard is always tier 0; other types carry a tier from 1 to 5 (decision 0.5.6). */
|
||||||
|
export const cargoTypes: CargoType[] = [
|
||||||
|
{
|
||||||
|
id: "standard",
|
||||||
|
name: "Standard",
|
||||||
|
basePayPerTonnePerMillionKm: 100,
|
||||||
|
tierPayMultiplier: 1,
|
||||||
|
requiredSkillId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "heavy",
|
||||||
|
name: "Heavy",
|
||||||
|
basePayPerTonnePerMillionKm: 140,
|
||||||
|
tierPayMultiplier: 1.2,
|
||||||
|
requiredSkillId: "heavy-cargo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "high-value",
|
||||||
|
name: "High-Value",
|
||||||
|
basePayPerTonnePerMillionKm: 180,
|
||||||
|
tierPayMultiplier: 1.25,
|
||||||
|
requiredSkillId: "expensive-cargo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dangerous",
|
||||||
|
name: "Dangerous",
|
||||||
|
basePayPerTonnePerMillionKm: 200,
|
||||||
|
tierPayMultiplier: 1.3,
|
||||||
|
requiredSkillId: "dangerous-cargo",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const skills: Skill[] = [
|
||||||
|
{
|
||||||
|
id: "long-haul",
|
||||||
|
name: "Long Haul",
|
||||||
|
maxLevel: 5,
|
||||||
|
description: "Access to longer routes.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "heavy-cargo",
|
||||||
|
name: "Heavy Cargo",
|
||||||
|
maxLevel: 5,
|
||||||
|
description: "Access to heavier loads, one tier per level.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "expensive-cargo",
|
||||||
|
name: "Expensive Cargo",
|
||||||
|
maxLevel: 5,
|
||||||
|
description: "Access to high-value cargo, one tier per level.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dangerous-cargo",
|
||||||
|
name: "Dangerous Cargo",
|
||||||
|
maxLevel: 5,
|
||||||
|
description: "Access to hazardous cargo, one hazard class per level.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { GameConfig } from "../schemas";
|
||||||
|
|
||||||
|
/** Default tuning. All numbers are first guesses until balancing in Phase 12. */
|
||||||
|
export const gameConfig: GameConfig = {
|
||||||
|
// 1 real minute = 1 game hour.
|
||||||
|
timeCompression: 60,
|
||||||
|
// Placeholders: the design docs do not fix an in-game start date yet.
|
||||||
|
epoch: {
|
||||||
|
realStart: "2026-01-01T00:00:00Z",
|
||||||
|
ephemerisDate: "2100-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
startingCredits: 0,
|
||||||
|
tutorialPayoutShare: 0.5,
|
||||||
|
contractBoardSize: 8,
|
||||||
|
// A tutorial run takes about 2 real minutes, so one hour-long period allows ~30.
|
||||||
|
tutorialBoardSize: 40,
|
||||||
|
contractBoardPeriodRealMinutes: 60,
|
||||||
|
firstContactChance: 0.1,
|
||||||
|
firstContactPayoutMultiplier: 2,
|
||||||
|
contractMassTonnes: { min: 5, max: 400 },
|
||||||
|
// Mars at its closest needs no Long Haul; Jupiter at its farthest needs level 5.
|
||||||
|
longHaulCutoffsMillionKm: [100, 250, 400, 600, 800],
|
||||||
|
xpPerDelivery: { base: 10, perMillionKm: 0.2, perCargoTier: 5 },
|
||||||
|
pilotLevelXpThresholds: [100, 250, 450, 700, 1000, 1400, 1900, 2500, 3200, 4000],
|
||||||
|
// Ten levels × 2 points = enough to max all four skills (4 × 5 levels).
|
||||||
|
skillPointsPerLevel: 2,
|
||||||
|
pilotHireCost: 2000,
|
||||||
|
pilotPayPerDelivery: 100,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import type { Location } from "../schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base-game destinations (GDD §9). Cargo flows: Earth and Mars make goods, the
|
||||||
|
* Moon and Ceres mine ore (heavy), Jupiter refines fuel (dangerous); remote
|
||||||
|
* outposts import high-value equipment.
|
||||||
|
*/
|
||||||
|
export const locations: Location[] = [
|
||||||
|
{
|
||||||
|
id: "earth",
|
||||||
|
name: "Earth",
|
||||||
|
type: "atmospheric-world",
|
||||||
|
landingRequirement: "atmospheric",
|
||||||
|
body: "earth",
|
||||||
|
launchCostMultiplier: 3,
|
||||||
|
startsDiscovered: true,
|
||||||
|
bayLeasePrice: 1000,
|
||||||
|
produces: ["standard", "high-value"],
|
||||||
|
demands: ["heavy", "dangerous"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "moon",
|
||||||
|
name: "Moon",
|
||||||
|
type: "airless-body",
|
||||||
|
landingRequirement: "vacuum",
|
||||||
|
body: "moon",
|
||||||
|
launchCostMultiplier: 0.5,
|
||||||
|
startsDiscovered: true,
|
||||||
|
bayLeasePrice: 800,
|
||||||
|
produces: ["standard", "heavy"],
|
||||||
|
demands: ["standard", "high-value", "dangerous"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mars",
|
||||||
|
name: "Mars",
|
||||||
|
type: "atmospheric-world",
|
||||||
|
landingRequirement: "atmospheric",
|
||||||
|
body: "mars",
|
||||||
|
launchCostMultiplier: 1.5,
|
||||||
|
startsDiscovered: false,
|
||||||
|
bayLeasePrice: 20000,
|
||||||
|
produces: ["standard", "high-value"],
|
||||||
|
demands: ["heavy", "dangerous"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ceres-station",
|
||||||
|
name: "Ceres Station",
|
||||||
|
type: "belt-station",
|
||||||
|
landingRequirement: "vacuum",
|
||||||
|
body: "ceres",
|
||||||
|
launchCostMultiplier: 0.3,
|
||||||
|
startsDiscovered: false,
|
||||||
|
bayLeasePrice: 40000,
|
||||||
|
produces: ["heavy"],
|
||||||
|
demands: ["standard", "high-value", "dangerous"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "jupiter-cloud-platform",
|
||||||
|
name: "Jupiter Cloud Platform",
|
||||||
|
type: "cloud-platform",
|
||||||
|
landingRequirement: "atmospheric",
|
||||||
|
body: "jupiter",
|
||||||
|
launchCostMultiplier: 2,
|
||||||
|
startsDiscovered: false,
|
||||||
|
bayLeasePrice: 80000,
|
||||||
|
produces: ["dangerous"],
|
||||||
|
demands: ["standard", "high-value", "heavy"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import type { ShipModel } from "../schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base-game ship models (GDD §8). All are atmospheric-rated (decision 0.5.1).
|
||||||
|
* Ranges against approximate Earth distances: Moon ~0.4M km, Mars at closest
|
||||||
|
* ~55M km, Ceres at farthest ~600M km, Jupiter ~590M–970M km.
|
||||||
|
*/
|
||||||
|
export const shipModels: ShipModel[] = [
|
||||||
|
{
|
||||||
|
id: "courier",
|
||||||
|
name: "Courier",
|
||||||
|
archetype: "courier",
|
||||||
|
price: 4000,
|
||||||
|
cruiseSpeedKmPerGameHour: 3_000_000,
|
||||||
|
cargoCapacityTonnes: 20,
|
||||||
|
// Moon only.
|
||||||
|
fuelRangeMillionKm: 5,
|
||||||
|
hullRating: 1,
|
||||||
|
landingClass: "atmospheric",
|
||||||
|
overheadGameHours: 2,
|
||||||
|
fuelCostPerMillionKm: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "freighter",
|
||||||
|
name: "Freighter",
|
||||||
|
archetype: "freighter",
|
||||||
|
price: 100000,
|
||||||
|
cruiseSpeedKmPerGameHour: 2_000_000,
|
||||||
|
cargoCapacityTonnes: 100,
|
||||||
|
// Mars and Ceres at any distance; Jupiter only near its closest (decision 0.5.25).
|
||||||
|
fuelRangeMillionKm: 650,
|
||||||
|
hullRating: 2,
|
||||||
|
landingClass: "atmospheric",
|
||||||
|
overheadGameHours: 4,
|
||||||
|
fuelCostPerMillionKm: 400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bulk-hauler",
|
||||||
|
name: "Bulk Hauler",
|
||||||
|
archetype: "bulk-hauler",
|
||||||
|
price: 400000,
|
||||||
|
cruiseSpeedKmPerGameHour: 1_200_000,
|
||||||
|
cargoCapacityTonnes: 400,
|
||||||
|
// Jupiter at its farthest.
|
||||||
|
fuelRangeMillionKm: 1000,
|
||||||
|
hullRating: 3,
|
||||||
|
landingClass: "atmospheric",
|
||||||
|
overheadGameHours: 8,
|
||||||
|
fuelCostPerMillionKm: 800,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -1 +1,6 @@
|
||||||
export {};
|
export * from "./schemas";
|
||||||
|
export * from "./data/locations";
|
||||||
|
export * from "./data/ships";
|
||||||
|
export * from "./data/cargo";
|
||||||
|
export * from "./data/config";
|
||||||
|
export * from "./bundle";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/** Lowercase kebab-case identifier, e.g. `earth`, `bulk-hauler`. */
|
||||||
|
export const idSchema = z.string().regex(/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/);
|
||||||
|
|
||||||
|
const positiveInt = z.number().int().positive();
|
||||||
|
const nonNegativeInt = z.number().int().nonnegative();
|
||||||
|
const fraction = z.number().min(0).max(1);
|
||||||
|
|
||||||
|
/** Strictly increasing list of positive numbers. */
|
||||||
|
const increasingList = z
|
||||||
|
.array(z.number().positive())
|
||||||
|
.refine((list) => list.every((value, i) => i === 0 || value > list[i - 1]!), {
|
||||||
|
message: "Values must be strictly increasing",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Ordered from least to most capable: a ship can land wherever its class or a lower one is required. */
|
||||||
|
export const landingClassSchema = z.enum(["vacuum", "atmospheric", "hardened"]);
|
||||||
|
export type LandingClass = z.infer<typeof landingClassSchema>;
|
||||||
|
|
||||||
|
export const locationTypeSchema = z.enum([
|
||||||
|
"atmospheric-world",
|
||||||
|
"airless-body",
|
||||||
|
"cloud-platform",
|
||||||
|
"belt-station",
|
||||||
|
]);
|
||||||
|
export type LocationType = z.infer<typeof locationTypeSchema>;
|
||||||
|
|
||||||
|
/** Astronomical body a location's position is derived from. */
|
||||||
|
export const astronomyBodySchema = z.enum(["earth", "moon", "mars", "ceres", "jupiter"]);
|
||||||
|
export type AstronomyBody = z.infer<typeof astronomyBodySchema>;
|
||||||
|
|
||||||
|
export const locationSchema = z.object({
|
||||||
|
id: idSchema,
|
||||||
|
name: z.string().min(1),
|
||||||
|
type: locationTypeSchema,
|
||||||
|
landingRequirement: landingClassSchema,
|
||||||
|
body: astronomyBodySchema,
|
||||||
|
/** Multiplies fuel cost for launches from this location. */
|
||||||
|
launchCostMultiplier: z.number().positive(),
|
||||||
|
startsDiscovered: z.boolean(),
|
||||||
|
/** Credits. */
|
||||||
|
bayLeasePrice: nonNegativeInt,
|
||||||
|
/** Cargo type ids. */
|
||||||
|
produces: z.array(idSchema),
|
||||||
|
/** Cargo type ids. */
|
||||||
|
demands: z.array(idSchema),
|
||||||
|
});
|
||||||
|
export type Location = z.infer<typeof locationSchema>;
|
||||||
|
|
||||||
|
export const shipArchetypeSchema = z.enum(["courier", "freighter", "bulk-hauler"]);
|
||||||
|
export type ShipArchetype = z.infer<typeof shipArchetypeSchema>;
|
||||||
|
|
||||||
|
export const shipModelSchema = z.object({
|
||||||
|
id: idSchema,
|
||||||
|
name: z.string().min(1),
|
||||||
|
archetype: shipArchetypeSchema,
|
||||||
|
/** Credits. */
|
||||||
|
price: positiveInt,
|
||||||
|
cruiseSpeedKmPerGameHour: z.number().positive(),
|
||||||
|
cargoCapacityTonnes: z.number().positive(),
|
||||||
|
fuelRangeMillionKm: z.number().positive(),
|
||||||
|
/** Stored only; no effect in the base game (decision 0.5.13). */
|
||||||
|
hullRating: nonNegativeInt,
|
||||||
|
landingClass: landingClassSchema,
|
||||||
|
/** Fixed takeoff + landing time added to every trip. */
|
||||||
|
overheadGameHours: z.number().nonnegative(),
|
||||||
|
/** Credits per million km, before the origin's launch multiplier. */
|
||||||
|
fuelCostPerMillionKm: z.number().nonnegative(),
|
||||||
|
});
|
||||||
|
export type ShipModel = z.infer<typeof shipModelSchema>;
|
||||||
|
|
||||||
|
export const cargoTypeSchema = z.object({
|
||||||
|
id: idSchema,
|
||||||
|
name: z.string().min(1),
|
||||||
|
/** Credits per tonne per million km. */
|
||||||
|
basePayPerTonnePerMillionKm: z.number().positive(),
|
||||||
|
/** Pay multiplier applied according to the contract's tier (Standard is tier 0, others 1–5). */
|
||||||
|
tierPayMultiplier: z.number().positive(),
|
||||||
|
/** Skill whose level must meet the contract's tier; null for Standard. */
|
||||||
|
requiredSkillId: idSchema.nullable(),
|
||||||
|
});
|
||||||
|
export type CargoType = z.infer<typeof cargoTypeSchema>;
|
||||||
|
|
||||||
|
export const skillSchema = z.object({
|
||||||
|
id: idSchema,
|
||||||
|
name: z.string().min(1),
|
||||||
|
maxLevel: positiveInt,
|
||||||
|
description: z.string().min(1),
|
||||||
|
});
|
||||||
|
export type Skill = z.infer<typeof skillSchema>;
|
||||||
|
|
||||||
|
export const gameConfigSchema = z.object({
|
||||||
|
/** Game hours per real hour (60 means 1 real minute = 1 game hour). */
|
||||||
|
timeCompression: z.number().positive(),
|
||||||
|
/** Decision 0.5.9: the real instant the game clock starts and the in-game date it maps to. */
|
||||||
|
epoch: z.object({
|
||||||
|
realStart: z.iso.datetime(),
|
||||||
|
ephemerisDate: z.iso.datetime(),
|
||||||
|
}),
|
||||||
|
startingCredits: nonNegativeInt,
|
||||||
|
/** Fraction of a normal payout the player receives for a tutorial run. */
|
||||||
|
tutorialPayoutShare: fraction,
|
||||||
|
contractBoardSize: positiveInt,
|
||||||
|
/** Large enough that a player cannot exhaust it within one period (decision 0.5.21). */
|
||||||
|
tutorialBoardSize: positiveInt,
|
||||||
|
contractBoardPeriodRealMinutes: z.number().positive(),
|
||||||
|
firstContactChance: fraction,
|
||||||
|
firstContactPayoutMultiplier: z.number().positive(),
|
||||||
|
contractMassTonnes: z
|
||||||
|
.object({ min: z.number().positive(), max: z.number().positive() })
|
||||||
|
.refine((range) => range.min <= range.max, { message: "min must not exceed max" }),
|
||||||
|
/** One cut-off per Long Haul level (decision 0.5.7). */
|
||||||
|
longHaulCutoffsMillionKm: increasingList,
|
||||||
|
xpPerDelivery: z.object({
|
||||||
|
base: z.number().nonnegative(),
|
||||||
|
perMillionKm: z.number().nonnegative(),
|
||||||
|
perCargoTier: z.number().nonnegative(),
|
||||||
|
}),
|
||||||
|
/** Total XP needed to reach level 1, 2, 3, ... */
|
||||||
|
pilotLevelXpThresholds: increasingList,
|
||||||
|
skillPointsPerLevel: positiveInt,
|
||||||
|
/** Credits. */
|
||||||
|
pilotHireCost: nonNegativeInt,
|
||||||
|
/** Credits per delivery; the avatar is exempt (decision 0.5.12). */
|
||||||
|
pilotPayPerDelivery: nonNegativeInt,
|
||||||
|
});
|
||||||
|
export type GameConfig = z.infer<typeof gameConfigSchema>;
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { content } from "../src/index";
|
||||||
|
|
||||||
|
describe("content bundle", () => {
|
||||||
|
it("contains every kind of content", () => {
|
||||||
|
expect(content.locations).toHaveLength(5);
|
||||||
|
expect(content.shipModels).toHaveLength(3);
|
||||||
|
expect(content.cargoTypes).toHaveLength(4);
|
||||||
|
expect(content.skills).toHaveLength(4);
|
||||||
|
expect(content.config.timeCompression).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("looks up one item of each kind by id", () => {
|
||||||
|
expect(content.location("mars")?.name).toBe("Mars");
|
||||||
|
expect(content.shipModel("freighter")?.name).toBe("Freighter");
|
||||||
|
expect(content.cargoType("dangerous")?.name).toBe("Dangerous");
|
||||||
|
expect(content.skill("long-haul")?.name).toBe("Long Haul");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for an unknown id without throwing", () => {
|
||||||
|
expect(content.location("pluto")).toBeUndefined();
|
||||||
|
expect(content.shipModel("x-wing")).toBeUndefined();
|
||||||
|
expect(content.cargoType("cheese")).toBeUndefined();
|
||||||
|
expect(content.skill("juggling")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { cargoTypeSchema, cargoTypes, skillSchema, skills } from "../src/index";
|
||||||
|
|
||||||
|
describe("cargo types", () => {
|
||||||
|
it("all pass the schema", () => {
|
||||||
|
for (const cargo of cargoTypes) {
|
||||||
|
expect(() => cargoTypeSchema.parse(cargo)).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has Standard plus three skill-gated types", () => {
|
||||||
|
expect(cargoTypes.map((c) => c.id)).toEqual(["standard", "heavy", "high-value", "dangerous"]);
|
||||||
|
expect(cargoTypes.find((c) => c.id === "standard")!.requiredSkillId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires only skills that exist", () => {
|
||||||
|
const skillIds = skills.map((s) => s.id);
|
||||||
|
for (const cargo of cargoTypes) {
|
||||||
|
if (cargo.requiredSkillId !== null) {
|
||||||
|
expect(skillIds).toContain(cargo.requiredSkillId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pays more than Standard for every non-standard type", () => {
|
||||||
|
const standard = cargoTypes.find((c) => c.id === "standard")!;
|
||||||
|
for (const cargo of cargoTypes) {
|
||||||
|
if (cargo !== standard) {
|
||||||
|
expect(cargo.basePayPerTonnePerMillionKm).toBeGreaterThan(
|
||||||
|
standard.basePayPerTonnePerMillionKm,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("skills", () => {
|
||||||
|
it("all pass the schema", () => {
|
||||||
|
for (const skill of skills) {
|
||||||
|
expect(() => skillSchema.parse(skill)).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has the four base skills, each max level 5", () => {
|
||||||
|
expect(skills.map((s) => s.id)).toEqual([
|
||||||
|
"long-haul",
|
||||||
|
"heavy-cargo",
|
||||||
|
"expensive-cargo",
|
||||||
|
"dangerous-cargo",
|
||||||
|
]);
|
||||||
|
for (const skill of skills) {
|
||||||
|
expect(skill.maxLevel).toBe(5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
cargoTypes,
|
||||||
|
gameConfig,
|
||||||
|
gameConfigSchema,
|
||||||
|
locations,
|
||||||
|
shipModels,
|
||||||
|
skills,
|
||||||
|
} from "../src/index";
|
||||||
|
|
||||||
|
// Placeholder average tutorial payout until Phase 12 uses real sim numbers:
|
||||||
|
// Standard cargo over ~0.384M km (Earth–Moon), at the average mass a Courier
|
||||||
|
// can carry from the configured range, times the tutorial share.
|
||||||
|
const EARTH_MOON_MILLION_KM = 0.384;
|
||||||
|
|
||||||
|
describe("game config", () => {
|
||||||
|
it("passes the schema", () => {
|
||||||
|
expect(() => gameConfigSchema.parse(gameConfig)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses 1 real minute = 1 game hour", () => {
|
||||||
|
expect(gameConfig.timeCompression).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has one Long Haul cut-off per Long Haul level", () => {
|
||||||
|
const longHaul = skills.find((s) => s.id === "long-haul")!;
|
||||||
|
expect(gameConfig.longHaulCutoffsMillionKm).toHaveLength(longHaul.maxLevel);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes a Courier plus an Earth bay affordable after 15–25 tutorial runs", () => {
|
||||||
|
const courier = shipModels.find((s) => s.id === "courier")!;
|
||||||
|
const earth = locations.find((l) => l.id === "earth")!;
|
||||||
|
const standard = cargoTypes.find((c) => c.id === "standard")!;
|
||||||
|
|
||||||
|
const averageMass = (gameConfig.contractMassTonnes.min + courier.cargoCapacityTonnes) / 2;
|
||||||
|
const averageTutorialPayout =
|
||||||
|
standard.basePayPerTonnePerMillionKm *
|
||||||
|
averageMass *
|
||||||
|
EARTH_MOON_MILLION_KM *
|
||||||
|
gameConfig.tutorialPayoutShare;
|
||||||
|
|
||||||
|
const runs = (courier.price + earth.bayLeasePrice) / averageTutorialPayout;
|
||||||
|
expect(runs).toBeGreaterThanOrEqual(15);
|
||||||
|
expect(runs).toBeLessThanOrEqual(25);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { cargoTypes, locationSchema, locations } from "../src/index";
|
||||||
|
|
||||||
|
const cargoTypeIds = cargoTypes.map((c) => c.id);
|
||||||
|
|
||||||
|
describe("locations", () => {
|
||||||
|
it("all pass the schema", () => {
|
||||||
|
for (const location of locations) {
|
||||||
|
expect(() => locationSchema.parse(location)).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has exactly five locations with unique ids", () => {
|
||||||
|
expect(locations).toHaveLength(5);
|
||||||
|
expect(new Set(locations.map((l) => l.id)).size).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts with exactly Earth and Moon discovered", () => {
|
||||||
|
const discovered = locations.filter((l) => l.startsDiscovered).map((l) => l.id);
|
||||||
|
expect(discovered.sort()).toEqual(["earth", "moon"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("references only existing cargo types", () => {
|
||||||
|
for (const location of locations) {
|
||||||
|
for (const cargoId of [...location.produces, ...location.demands]) {
|
||||||
|
expect(cargoTypeIds).toContain(cargoId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives Earth the highest launch multiplier", () => {
|
||||||
|
const earth = locations.find((l) => l.id === "earth")!;
|
||||||
|
for (const location of locations) {
|
||||||
|
if (location !== earth) {
|
||||||
|
expect(earth.launchCostMultiplier).toBeGreaterThan(location.launchCostMultiplier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { z } from "zod";
|
||||||
|
import {
|
||||||
|
cargoTypeSchema,
|
||||||
|
gameConfigSchema,
|
||||||
|
locationSchema,
|
||||||
|
shipModelSchema,
|
||||||
|
skillSchema,
|
||||||
|
} from "../src/index";
|
||||||
|
|
||||||
|
const validLocation = {
|
||||||
|
id: "earth",
|
||||||
|
name: "Earth",
|
||||||
|
type: "atmospheric-world",
|
||||||
|
landingRequirement: "atmospheric",
|
||||||
|
body: "earth",
|
||||||
|
launchCostMultiplier: 3,
|
||||||
|
startsDiscovered: true,
|
||||||
|
bayLeasePrice: 5000,
|
||||||
|
produces: ["standard"],
|
||||||
|
demands: ["heavy"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const validShipModel = {
|
||||||
|
id: "courier",
|
||||||
|
name: "Courier",
|
||||||
|
archetype: "courier",
|
||||||
|
price: 20000,
|
||||||
|
cruiseSpeedKmPerGameHour: 300000,
|
||||||
|
cargoCapacityTonnes: 10,
|
||||||
|
fuelRangeMillionKm: 5,
|
||||||
|
hullRating: 1,
|
||||||
|
landingClass: "atmospheric",
|
||||||
|
overheadGameHours: 2,
|
||||||
|
fuelCostPerMillionKm: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
const validCargoType = {
|
||||||
|
id: "standard",
|
||||||
|
name: "Standard",
|
||||||
|
basePayPerTonnePerMillionKm: 10,
|
||||||
|
tierPayMultiplier: 1,
|
||||||
|
requiredSkillId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const validSkill = {
|
||||||
|
id: "long-haul",
|
||||||
|
name: "Long Haul",
|
||||||
|
maxLevel: 5,
|
||||||
|
description: "Access to longer routes.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const validGameConfig = {
|
||||||
|
timeCompression: 60,
|
||||||
|
epoch: { realStart: "2026-01-01T00:00:00Z", ephemerisDate: "2100-01-01T00:00:00Z" },
|
||||||
|
startingCredits: 0,
|
||||||
|
tutorialPayoutShare: 0.2,
|
||||||
|
contractBoardSize: 8,
|
||||||
|
tutorialBoardSize: 50,
|
||||||
|
contractBoardPeriodRealMinutes: 60,
|
||||||
|
firstContactChance: 0.1,
|
||||||
|
firstContactPayoutMultiplier: 2,
|
||||||
|
contractMassTonnes: { min: 1, max: 100 },
|
||||||
|
longHaulCutoffsMillionKm: [100, 200, 300, 400, 500],
|
||||||
|
xpPerDelivery: { base: 10, perMillionKm: 0.1, perCargoTier: 5 },
|
||||||
|
pilotLevelXpThresholds: [100, 250, 500],
|
||||||
|
skillPointsPerLevel: 1,
|
||||||
|
pilotHireCost: 1000,
|
||||||
|
pilotPayPerDelivery: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cases: [string, z.ZodType, Record<string, unknown>][] = [
|
||||||
|
["Location", locationSchema, validLocation],
|
||||||
|
["ShipModel", shipModelSchema, validShipModel],
|
||||||
|
["CargoType", cargoTypeSchema, validCargoType],
|
||||||
|
["Skill", skillSchema, validSkill],
|
||||||
|
["GameConfig", gameConfigSchema, validGameConfig],
|
||||||
|
];
|
||||||
|
|
||||||
|
describe.each(cases)("%s schema", (_name, schema, valid) => {
|
||||||
|
it("accepts a valid example", () => {
|
||||||
|
expect(schema.safeParse(valid).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(Object.keys(valid))("rejects an object missing %s", (field) => {
|
||||||
|
const missing = { ...valid };
|
||||||
|
delete missing[field];
|
||||||
|
expect(schema.safeParse(missing).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GameConfig refinements", () => {
|
||||||
|
it("rejects a mass range with min above max", () => {
|
||||||
|
const config = { ...validGameConfig, contractMassTonnes: { min: 50, max: 10 } };
|
||||||
|
expect(gameConfigSchema.safeParse(config).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects Long Haul cut-offs that are not increasing", () => {
|
||||||
|
const config = { ...validGameConfig, longHaulCutoffsMillionKm: [100, 100, 300] };
|
||||||
|
expect(gameConfigSchema.safeParse(config).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { shipModelSchema, shipModels } from "../src/index";
|
||||||
|
|
||||||
|
const ship = (id: string) => shipModels.find((s) => s.id === id)!;
|
||||||
|
|
||||||
|
describe("ship models", () => {
|
||||||
|
it("all pass the schema", () => {
|
||||||
|
for (const model of shipModels) {
|
||||||
|
expect(() => shipModelSchema.parse(model)).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has the three archetypes, all atmospheric", () => {
|
||||||
|
expect(shipModels.map((s) => s.id)).toEqual(["courier", "freighter", "bulk-hauler"]);
|
||||||
|
for (const model of shipModels) {
|
||||||
|
expect(model.landingClass).toBe("atmospheric");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders speed Courier > Freighter > Bulk Hauler", () => {
|
||||||
|
expect(ship("courier").cruiseSpeedKmPerGameHour).toBeGreaterThan(
|
||||||
|
ship("freighter").cruiseSpeedKmPerGameHour,
|
||||||
|
);
|
||||||
|
expect(ship("freighter").cruiseSpeedKmPerGameHour).toBeGreaterThan(
|
||||||
|
ship("bulk-hauler").cruiseSpeedKmPerGameHour,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders capacity Bulk Hauler > Freighter > Courier", () => {
|
||||||
|
expect(ship("bulk-hauler").cargoCapacityTonnes).toBeGreaterThan(
|
||||||
|
ship("freighter").cargoCapacityTonnes,
|
||||||
|
);
|
||||||
|
expect(ship("freighter").cargoCapacityTonnes).toBeGreaterThan(
|
||||||
|
ship("courier").cargoCapacityTonnes,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders price Courier < Freighter < Bulk Hauler", () => {
|
||||||
|
expect(ship("courier").price).toBeLessThan(ship("freighter").price);
|
||||||
|
expect(ship("freighter").price).toBeLessThan(ship("bulk-hauler").price);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -40,7 +40,11 @@ importers:
|
||||||
|
|
||||||
apps/web: {}
|
apps/web: {}
|
||||||
|
|
||||||
packages/content: {}
|
packages/content:
|
||||||
|
dependencies:
|
||||||
|
zod:
|
||||||
|
specifier: ^4.6.5
|
||||||
|
version: 4.6.5
|
||||||
|
|
||||||
packages/schema: {}
|
packages/schema: {}
|
||||||
|
|
||||||
|
|
@ -844,6 +848,9 @@ packages:
|
||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
zod@4.6.5:
|
||||||
|
resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@cacheable/memory@2.2.0':
|
'@cacheable/memory@2.2.0':
|
||||||
|
|
@ -1507,3 +1514,5 @@ snapshots:
|
||||||
word-wrap@1.2.5: {}
|
word-wrap@1.2.5: {}
|
||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
|
zod@4.6.5: {}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue