16 KiB
16 KiB
Decisions
Implementation decisions not spelled out in memory-bank/implementation-plan.md. Design decisions from plan §0.5 are logged here as they are implemented.
Step 1.1
- Repo lives on an NTFS drive (
C:\Projects\...). The original location (G:) is exFAT, which cannot hold symlinks, so pnpm could not link workspace packages. Do not move the repo back to an exFAT drive. - TypeScript is pinned to
~6.0. TypeScript 7 is the latest release, buttypescript-eslint(needed in Step 1.2) only supports<6.1. Upgrade once typescript-eslint supports 7. - Packages export TypeScript source directly (
main/typespoint atsrc/index.ts). There is no build step for internal packages; Vite, Vitest and the server's TS runner consume the source. The base config usesmoduleResolution: "Bundler"andnoEmit. - Node 24 is the required runtime (
engines+.nvmrc). - Line endings are LF everywhere (
.gitattributes), overriding the machine's globalcore.autocrlf, to match Prettier and the Linux CI runner.
Step 1.2
- ESLint 10 flat config with
typescript-eslintrecommended (not type-aware, to keep lint fast) andeslint-config-prettier.pnpm lintruns ESLint thenprettier --check. - Purity rule for
packages/simandpackages/content:no-restricted-propertiesbansMath.randomandDate.now;no-restricted-syntaxbansnew Date()with no arguments (new Date(x)is allowed). - Prettier skips
memory-bank/so tooling never rewrites the design docs.
Step 1.3
- Vitest projects: the root
vitest.config.tstreats eachapps/*andpackages/*folder as a project, sopnpm testruns every package's tests. Packages can add their ownvitest.config.tswhen they need a different environment. - Tests live in each package's
test/folder and are included in that package's tsconfig, sopnpm typecheckcovers them.
Step 1.4
- CI runs on Gitea Actions (
.gitea/workflows/ci.yml): install with a frozen lockfile, then typecheck, lint and test. .pnpm-store/is ignored by Prettier, ESLint and git. On the Gitea runner, pnpm puts its store inside the workspace, andprettier --checkfailed on the store's files (CI run #1).
Step 1.5
- One Postgres container, two databases.
docker-compose.ymlruns a singlepostgres:18server on port 5432 (user/passworddeadhead). The default databasedeadheadis for development;docker/postgres/init/01-create-test-db.sqlcreatesdeadhead_testthe first time the data volume is created. To recreate both from scratch, rundocker compose down -v. - Env var names in
.env.example:DATABASE_URL,TEST_DATABASE_URL,BETTER_AUTH_SECRETandBETTER_AUTH_URL(Better Auth's own names),PORT, andGAME_EPOCH_REAL_STARTplusGAME_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.exampleare 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.hardenedis not used in the base game. - Location
bodyis 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), notDateobjects, so content stays plain data. tutorialBoardSizeis a GameConfig field in addition tocontractBoardSize, because decision 0.5.21 sets the tutorial board's size separately.- XP per delivery inputs are
base,perMillionKmandperCargoTier. Step 3.16 decides how they combine. pilotLevelXpThresholdslists the total XP needed for level 1, 2, 3, ...; it and the Long Haul cut-offs must be strictly increasing.tierPayMultiplieris one number per cargo type. Step 3.8 decides how it scales with tier.- Imports omit the
.tsextension (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; skillslong-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. TheGAME_EPOCH_*variables added to.env.examplein 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
undefinedfor 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
contentbundle, so tests and the server can validate data with the schemas.
Step 3.1
- Times are Unix-millisecond numbers. A real time is a wall-clock instant; a game time is the instant of the in-game date, so
new Date(gameTime)gives the in-game date and can be passed straight toastronomy-engine. - Every sim function takes the content it needs as arguments (here the
GameConfig), rather than importing the default bundle, so tests can pass custom fixtures.
Step 3.2
- Seeds are 32-bit unsigned integers. The PRNG is mulberry32; child seeds are an FNV-1a hash of the label, started from the parent seed, plus a final mixing step.
createRngreturns an object with internal state. It is still deterministic: the same seed always gives the same sequence. Each use (a board, a name) creates its own generator from a derived seed, so draws never depend on call order elsewhere.
Step 3.3
- Positions are heliocentric, in the J2000 mean ecliptic plane (
HelioVectorrotated byRotation_EQJ_ECL), keeping x and y and dropping the small out-of-plane z. - The Moon is Earth plus 0.384M km on the +x axis, as the plan specifies; its real orbit is ignored.
- Ceres uses a circular orbit (astronomy-engine has no Ceres): radius 2.7675 AU (~414M km), period 1,680.5 days, mean longitude ~160° at J2000. The angle is approximate, which is fine for gameplay.
- Test date is 2100-03-01, close to the placeholder game epoch.
Step 3.4
- Route distance is the straight line between the two positions at one game time. Orbital transfer paths are not modelled.
Step 3.5
- Travel time takes the distance as a number, not two locations and a time. The caller measures the distance at departure, so the function cannot accidentally use a mid-flight distance.
Step 3.7
- Landing is checked before range, so a ship that can neither land nor reach reports
cannot-land. - The route check looks only at the destination's landing requirement. A ship is always already sitting at its origin, so it can take off from there.
- Range tests find each planet's closest and farthest point from Earth by sampling every 4 days over 12 years. All Step 2.3 range choices hold, including decision 0.5.25 (a Freighter reaches Jupiter at its closest).
Step 3.8
- Tier scaling is compounding: pay is multiplied by
tierPayMultiplier ** tier. Standard (tier 0) is ×1; a tier-5 Dangerous contract pays 1.3⁵ ≈ 3.7× its base rate. - Payouts are rounded to whole credits when the contract is offered.
- Contracts refer to content by id (
originId,destinationId,cargoTypeId), so they can be stored and sent over the API as plain data.requiredSkillis{ skillId, level }, ornullfor Standard.
Step 3.9
- Board seed:
deriveSeed(worldSeed, "board:<bayLocationId>:<periodIndex>"), with one generator per board. - Board periods count from the Unix epoch: period index = floor(real ms ÷ period length). Contracts are offered at the start of their period, and their payout and Long Haul level use the distance at that moment, so a board generated later in the period is identical (decision 0.5.5).
- Contract ids are
<origin>:<periodIndex>:<slot>, unique within a player's world. The server may add its own database ids. - First contact is rolled only when an undiscovered location exists. If no other location is discovered, every contract is first contact. Destinations are picked uniformly from the eligible list.
- Mass is a whole number of tonnes drawn uniformly from the configured range. Tier is drawn uniformly from 1 to the required skill's max level.
- Sim functions take a
SimContentobject (locations,cargoTypes,skills,config); thecontentbundle satisfies it, and tests can pass fixtures.
Step 3.10
- Tutorial contract mass is capped at the loaner's capacity: a whole number from the contract minimum (5 t) up to the smaller of the contract maximum and the Courier's 20 t. Otherwise most tutorial contracts would be too heavy to fly.
- The loaner is the content's Courier, found by archetype
courier.SimContentnow includesshipModels. - Tutorial payout = round(normal payout × tutorial share), where the normal payout is the regular rounded payout for the same contract. The contract stores this reduced amount, so resolution credits
contract.payoutdirectly. - Tutorial board seed is
tutorial:<loanerLocation>:<periodIndex>, giving one Earth→Moon and one Moon→Earth board per period (decision 0.5.21). Contract ids use the same prefix.
Step 3.11
- Pilot skills are a
{ skillId: level }map; a missing skill counts as level 0. - The Long Haul skill id is the constant
long-haulin sim, because the rule (decision 0.5.7) is specifically about that skill. - The cargo skill is checked before Long Haul when reporting a failure reason.
Step 3.12
- Minimal state shapes:
ShipState{ id, modelId, locationId, inFlight } andPilotState{ id, isAvatar, assignedShipId, inFlight, skills }. The server maps its database rows to these. - Tutorial dispatch: the caller passes the loaner as the ship (a
ShipStatebuilt from the player'stutorialobject, decision 0.5.26) and setstutorial: true. The pilot must be the avatar, and the fuel check is skipped. The validator does not check that a tutorial contract came from the tutorial board; the server does that. - Checks run in the plan's order, and the first failure is returned: ship idle → ship at origin → pilot idle → pilot assigned → capacity → landing → range → cargo skill → Long Haul → expiry → fuel credits.
- Fuel is charged in whole credits (
tripFuelCostrounds), using the distance at departure (now).
Step 3.13
- Extra record fields:
tutorial(resolution pays the tutorial share and skips pilot pay) anddistanceMillionKm(the distance at departure, used for XP), on top of the plan's fields. - The caller supplies the dispatch id; the seed is
deriveSeed(worldSeed, "dispatch:<id>"). - Arrival = departure + travel time in game ms ÷ compression, rounded to whole real milliseconds.
fuelCostis what the player pays: 0 for tutorial runs.expectedPayoutis the contract payout before pilot pay; resolution deducts pilot pay.
Step 3.14
- A reposition needs a player bay at the destination, checked against
bayLocationIds. Bays exist only at discovered locations, so this also guarantees the destination is discovered. - Reposition check order: ship idle → pilot idle → pilot assigned → not already there → bay at destination → landing → range → fuel credits. There are no skill checks (decision 0.5.24). Tutorial loaners cannot reposition.
- Contract and reposition records share one builder; a reposition has
contract: nullandexpectedPayout: 0. - "Zero XP" is enforced at resolution (Step 3.17), because the record has no XP field. That step's tests cover it.
Step 3.15
- Progress is linear in real time. Takeoff and landing overhead are not modelled separately, so the ship moves at a constant rate along its line.
- A zero-length flight reports progress 1 instead of dividing by zero.
Step 3.16
- Delivery XP = round(base + perMillionKm × distance + perCargoTier × tier), using the distance at departure stored on the dispatch record.
- Pilot level is the number of
pilotLevelXpThresholdsthe total XP has reached (0 below the first threshold), so levels cap at the list length (10). - Skill points available = level × points per level − points spent, floored at 0.
Step 3.17
- Resolution credits = contract payout − pilot pay. Fuel is not included, because it is charged when the ship departs (the record's
fuelCost). Credits can be negative if a payout is below pilot pay; the server applies the number as given. - Tutorial runs grant XP to the avatar like any delivery; the plan does not exclude it. They credit the stored tutorial payout with no pilot pay.
- Repositions resolve to 0 credits and 0 XP and never discover anything, since they only fly to bays.
- The caller passes
pilotIsAvatarand the current discovered set, so the function stays pure. "Exactly once" is the server's job: it must store the resolution and the acknowledged-at state transactionally.
Step 3.18
- Explicit public API.
packages/sim/src/index.tslists every export. Internal helpers (findLocation,findShipModel,standardCargo,buildContract,skillLevel) are not exported; tests that need one import it from its module. - Coverage uses
@vitest/coverage-v8, configured in the rootvitest.config.tsto measure onlypackages/sim/srcwith a 90% line threshold.pnpm coverageruns the whole suite with coverage, and CI runs it in place ofpnpm test, so a coverage drop fails CI. - The Step 1.3 smoke test is removed now that the sim package has real tests.