From d7503e8dad5ffbf748d3b4e878c2dd61eb33d13e Mon Sep 17 00:00:00 2001 From: Daniel Kajewski Date: Sun, 21 Jun 2026 11:15:59 -0500 Subject: [PATCH 1/2] fix(bot): make greenfield bootstrap actually trade (issue #17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh-reset agent could never establish a trade loop — verified live on a new reset, the bot sat at its starting credits with the sole trader parked. Three compounding bugs, two fixed here in code: 1. Empty coords → no lanes. On greenfield the persistence waypoint table is empty, so the distance fn D() has no coordinates; every candidate lane fails the MAXD gate and buildLanes() returns nothing → the ship parks forever. main.ts now eagerly fetches the home system's waypoints from the game API at boot and populates the coords map (idempotent; skipped once coords exist). 2. Unaffordable full lot → lane rejected. selectLane() rejected a whole lane when the full tradeVolume lot exceeded the working budget, so a capital- constrained bot could not trade high-value goods at all. It now scales the lot down to the affordable unit count instead of rejecting outright. 3. (config) Greenfield capital settings in deploy/bot.greenfield.env.example: OPERATING_RESERVE/GOODS_CUSHION default reserve exceeded the ~175k starting capital and froze all spending; BOOTSTRAP_FLEET_MIN=1 to exit BOOTSTRAP with one trader; mining/contracts off (extract path yields 0 ore, separate bug). Also adds a one-line worker diagnostic logged when a ship can't claim a lane (markets/priced/lanes/budget), which is what pinpointed the coords bug. Verified live on a fresh agent: home coords load (83 wps) → buildLanes finds the lane → buys an affordable 17-unit lot → sells → credits 68k→122k over 2 lanes. 276 bot tests pass, tsc + eslint clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- deploy/bot.greenfield.env.example | 35 +++++++++++++++++++++++-------- packages/bot/src/main.ts | 34 ++++++++++++++++++++++++++++++ packages/bot/src/trade/lanes.ts | 16 ++++++++++---- packages/bot/src/worker.ts | 7 ++++++- 4 files changed, 78 insertions(+), 14 deletions(-) diff --git a/deploy/bot.greenfield.env.example b/deploy/bot.greenfield.env.example index 2567015..a92d6c9 100644 --- a/deploy/bot.greenfield.env.example +++ b/deploy/bot.greenfield.env.example @@ -48,15 +48,32 @@ NEGOTIATOR= # ── LIVE switch ────────────────────────────────────────────────────────────── DRY_RUN=0 +# ── GREENFIELD CAPITAL (CRITICAL — proved necessary by a live reset run, issue #17) ── +# A fresh agent starts with ~175k credits. The default OPERATING_RESERVE (200k) + goods +# cushions compute a reserve LARGER than starting capital, which FREEZES all spending +# (no ship buys, no trades). These low values let the bootstrap spend its starter capital; +# the rolling reserve scales back up automatically (fuel + working-capital) as the fleet grows. +OPERATING_RESERVE=5000 +GOODS_CUSHION=2000 +GOODS_CUSHION_PER_SHIP=1000 +RESERVE_CONCURRENCY=1 +# Exit BOOTSTRAP into the trade loop with a single starter trader (default 2 strands a fresh agent +# that only has 1 cargo ship + probes). +BOOTSTRAP_FLEET_MIN=1 +# Prioritise trading over the starter mining contract (mining a procurement contract with the lone +# cargo ship is a slow, low-value opening; see MINE/CONTRACTS notes below). +TRADE_FIRST=1 + # ── earner stack (the subsystems that make a greenfield account grow) ───────── # Fleet scaling: auto-buys probes + haulers from auto-discovered shipyards once # credits clear the floor — no hardcoded ship references. First buy at 30k. FLEET_SCALE=1 FLEET_SCALE_FLOOR=30000 -# Contracts: accept + run profitable contracts (starting contract works with NEGOTIATOR -# unset; new-contract negotiation needs NEGOTIATOR pinned, see above). -CONTRACTS=1 +# Contracts: OFF for greenfield. The starter PROCUREMENT contract routes the sole cargo ship to +# MINE (slow + the mining extract path is currently broken — yields 0 ore, issue #17), starving the +# trade loop. Re-enable once mining/buy-and-deliver is fixed. (Accepting-but-not-fulfilling is penalty-free.) +CONTRACTS=0 CONTRACT_MIN_MARGIN_PCT=0.04 CONTRACT_AVOID_GATE_PRODUCER=1 CONTRACT_BEST_SHIP=1 @@ -64,6 +81,7 @@ CONTRACT_BEST_SHIP=1 # Gate supply: buy + deliver jump-gate construction materials once well-capitalized. # GATE_HAULERS intentionally UNSET → up to GATE_MAX_SUPPLIERS idle traders are # auto-assigned as suppliers (orphan-delivery fallback), so no ship pins needed. +# Credit-floored at 900k so it never engages before the trade loop has built capital. GATE_SUPPLY=1 GATE_CREDIT_FLOOR=900000 GATE_CREDIT_RESUME_GAP=250000 @@ -72,11 +90,10 @@ GATE_RESUME_PRICE_FACTOR=0.9 GATE_BUDGET_FRACTION=0.8 GATE_PROTECT=1 -# Mining feed: self-provisions miners/surveyors (MINE_EXPAND) and auto-selects a -# transport tender — no ship pins. Auto-buy is gated at MINE_EXPAND_CREDIT_FLOOR -# (600k) so it never starves early trade capital. MINE_GOOD/MINE_PRODUCER auto-discover. -MINE_FEED=1 -MINE_EXPAND=1 +# Mining feed: OFF for greenfield — the extract path currently yields 0 ore (issue #17). Leave off +# until fixed; trading is the reliable greenfield engine. +MINE_FEED=0 +MINE_EXPAND=0 MINE_BATCH=30 MINE_FUEL_RESERVE=20 MINE_ORE_RESERVE=0 @@ -103,7 +120,7 @@ EXPAND_SEED_FUELED=1 # EXPAND_SEED_HULLS left at default (SHIP_LIGHT_SHUTTLE,SHIP_LIGHT_HAULER) — no override needed. # ── trade-loop floor (battle-tested, reset-agnostic) ───────────────────────── -MIN_NET=1200 +MIN_NET=500 FILL_BIAS=1 FILL_BIAS_EPS=0.10 diff --git a/packages/bot/src/main.ts b/packages/bot/src/main.ts index 97df738..d077550 100644 --- a/packages/bot/src/main.ts +++ b/packages/bot/src/main.ts @@ -82,6 +82,40 @@ export async function main(): Promise { ); const D: DistFn = (a, b) => distance(a, b, coords); + // [GREENFIELD] Eager home-system coordinates. On a fresh reset the persistence waypoint table is + // empty, so D() has no coords → every candidate lane fails the MAXD distance gate → buildLanes() + // returns nothing → the bot can never trade (sole starter ship parks forever). Pull the home + // system's waypoints straight from the game API and populate the coords map so routing + lane + // building work from the first tick. Idempotent: skipped once home coords are already present + // (established runs load them from persistence). (issues #16/#17) + if (!cfg.DRY_RUN && cfg.SYSTEM) { + const haveHomeCoords = Object.keys(coords).some((k) => k.startsWith(`${cfg.SYSTEM}-`)); + if (!haveHomeCoords) { + try { + let page = 1; + let total = Infinity; + let added = 0; + while ((page - 1) * 20 < total) { + const env = await client.api<{ data: Array<{ symbol: string; x?: number; y?: number }>; meta?: { total?: number } }>( + 'GET', + `/systems/${cfg.SYSTEM}/waypoints?limit=20&page=${page}`, + ); + total = env.meta?.total ?? env.data.length; + for (const w of env.data) + if (typeof w.x === 'number' && typeof w.y === 'number') { + coords[w.symbol] = [w.x, w.y] as const; + added += 1; + } + if (!env.data.length) break; + page += 1; + } + log.info(`🗺 eager home coords: loaded ${added} ${cfg.SYSTEM} waypoint coordinate(s) for routing`); + } catch (e) { + log.warn(`eager home coords failed (${(e as Error).message}) — routing may stall until the crawler maps home`); + } + } + } + // one shared, synchronously-readable market snapshot (mirrors bot2's marketCache.data). const marketHolder: { data: Record } = { data: {} }; const marketsRef = (): Record => marketHolder.data; diff --git a/packages/bot/src/trade/lanes.ts b/packages/bot/src/trade/lanes.ts index c5c0050..f413e90 100644 --- a/packages/bot/src/trade/lanes.ts +++ b/packages/bot/src/trade/lanes.ts @@ -283,11 +283,19 @@ export interface ClaimDeps { export function selectLane(ship: Ship, lanes: Lane[], markets: Record, deps: ClaimDeps): ClaimResult { const { state, cfg, router } = deps; const cand: Array<{ l: Lane; score: number; estCost: number; net: number; bias?: number }> = []; - for (const l of lanes) { - const st = gs(state, l.sym); + for (const l0 of lanes) { + const st = gs(state, l0.sym); if (st.lockedBy || now() < st.cooldownUntil) continue; // locked or cooling down - const estCost = Math.ceil(l.units * l.buy * 1.1); // buy cost + slippage headroom - if (estCost > availableForWork(state)) continue; // would breach operating reserve + // [GREENFIELD/CAPITAL] Scale the lot to what we can actually afford instead of rejecting the whole + // lane when the full tradeVolume lot exceeds the working budget. A capital-constrained bot (fresh + // reset, tiny starting credits) must still trade high-value goods by buying fewer units — the old + // hard reject (estCost > available → continue) left the sole starter ship parked despite real lanes. + const unitCost = l0.buy * 1.1; // per-unit buy cost + slippage headroom + const affordableUnits = unitCost > 0 ? Math.floor(availableForWork(state) / unitCost) : 0; + const units = Math.min(l0.units, affordableUnits); + if (units < 1) continue; // can't afford even one unit → skip this lane + const l: Lane = units === l0.units ? l0 : { ...l0, units, gross: l0.margin * units }; + const estCost = Math.ceil(l.units * l.buy * 1.1); const repo = router.routeCost(ship.nav.waypointSymbol, l.buyWp, ship); const haul = router.routeCost(l.buyWp, l.sellWp, ship); const fuelCr = repo.fuelCr + haul.fuelCr; diff --git a/packages/bot/src/worker.ts b/packages/bot/src/worker.ts index 19df46f..1decf62 100644 --- a/packages/bot/src/worker.ts +++ b/packages/bot/src/worker.ts @@ -17,7 +17,7 @@ import type { MarketsServiceExtra } from './market/markets.js'; import type { DistFn } from './trade/lanes.js'; import { buildLanes, claimLane, planRideAlongs, cooldownFor } from './trade/lanes.js'; import { gs } from './runtime/state.js'; -import { commit, uncommit, growthBudget } from './budget/budget.js'; +import { commit, uncommit, growthBudget, availableForWork } from './budget/budget.js'; import { record } from './status.js'; import { saveIntent, clearIntent, reconcileHeldCargo, type StoredRideAlong } from './recovery.js'; import { logger } from './core/logger.js'; @@ -204,6 +204,11 @@ export async function worker(shipSym: string, rawDeps: WorkerDeps): Promise (m.tradeGoods?.length ?? 0) > 0).length; + log.info( + `🔎 ${shipSym.slice(-3)} no-claim @${ship.nav.waypointSymbol.slice(-4)}: markets=${Object.keys(markets).length} priced=${priced} lanes=${lanes.length} avail=${Math.round(availableForWork(state))} top=${lanes[0] ? `${lanes[0].sym} ${lanes[0].buyWp.slice(-4)}→${lanes[0].sellWp.slice(-4)} buy${lanes[0].buy} u${lanes[0].units}` : 'none'}`, + ); if (await hooks.inputFeedTrip(shipSym, ship, markets)) continue; // [INPUT_FEED] no lane → profitable feed if (await hooks.gateSupplyTrip(shipSym, ship, markets)) continue; // [GATE] still no work → supply the gate ($0) state.perShip[shipSym].last = 'PARKED (no profitable lane)'; From 097af590c70b45210f9c5390cc47be6a2344e139 Mon Sep 17 00:00:00 2001 From: Daniel Kajewski Date: Sun, 21 Jun 2026 11:31:10 -0500 Subject: [PATCH 2/2] fix(fleet): buy traders at a yard that actually sells them (issue #17) FLEET_SCALE could not buy a 2nd trader on greenfield: it anchored on the probe yard and issued the cargo-ship purchase there, but that yard sold only probes -> POST /my/ships 400 "SHIP_LIGHT_SHUTTLE does not exist". The single starter trader did all the trading and the flywheel could not scale. Two root causes: - getShipyards collapses each ship type to its cheapest yard, hiding that one yard sells BOTH probes and cargo (a cheaper probe elsewhere wins the key). pickAnchorYards now reads the full per-yard type cache (state.fleet.shipyards) so a combined yard is detected and preferred as the anchor. - Cargo buys were hardcoded to anchorYard. They now target cargoYard, anchor a probe there when it differs from the probe yard, and dock before buying. Verified live on UPRISING (X1-SQ96): anchor moved C40 -> A2 (sells probe+cargo), 400s stopped. 279 bot tests pass (3 new for pickAnchorYards), tsc clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../bot/src/fleet/__tests__/fleet.test.ts | 39 ++++++++- packages/bot/src/fleet/scale.ts | 84 +++++++++++++------ 2 files changed, 98 insertions(+), 25 deletions(-) diff --git a/packages/bot/src/fleet/__tests__/fleet.test.ts b/packages/bot/src/fleet/__tests__/fleet.test.ts index 5417071..0c7625b 100644 --- a/packages/bot/src/fleet/__tests__/fleet.test.ts +++ b/packages/bot/src/fleet/__tests__/fleet.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { loadConfig } from '@st/shared'; import { makeShip } from '../../__tests__/fixtures.js'; -import { cappedProbeTarget, isProbeHull, probeTargetFor } from '../scale.js'; +import { cappedProbeTarget, isProbeHull, probeTargetFor, __test } from '../scale.js'; import { repairTierDecision } from '../repair.js'; const cfg = loadConfig({ REPAIR_COND_MIN: '0.85', REPAIR_INTEG_FORCE: '0.5', REPAIR_MAX_COST: '100000' }); @@ -22,6 +22,43 @@ describe('fleet scale helpers', () => { expect(isProbeHull(makeShip({ frame: { symbol: 'FRAME_PROBE', condition: 1, integrity: 1 }, cargo: { capacity: 0, units: 0, inventory: [] }, fuel: { current: 0, capacity: 0 } }))).toBe(true); expect(isProbeHull(makeShip({ frame: { symbol: 'FRAME_FRIGATE', condition: 1, integrity: 1 }, cargo: { capacity: 40, units: 0, inventory: [] } }))).toBe(false); }); + + describe('pickAnchorYards (issue #17 trader-scale bug)', () => { + const { pickAnchorYards } = __test; + + it('prefers a single yard that sells BOTH probes and cargo as the anchor', () => { + // A1 sells probe + cargo; C40 sells probe only. A1 must win as the combined anchor even + // though C40 also sells probes (the live X1-SQ96 case where a cheaper probe at C40 used to + // hide that A2 sold both → cargo buys went to the probe-only yard → 400). + const r = pickAnchorYards({ + SHIP_PROBE: ['X1-C40', 'X1-A1'], + SHIP_LIGHT_SHUTTLE: ['X1-A1'], + SHIP_LIGHT_HAULER: ['X1-A1'], + SHIP_SIPHON_DRONE: ['X1-C40'], + }); + expect(r.anchorYard).toBe('X1-A1'); + expect(r.probeYard).toBe('X1-A1'); + expect(r.cargoYard).toBe('X1-A1'); + }); + + it('falls back to separate probe and cargo yards when no yard sells both', () => { + const r = pickAnchorYards({ + SHIP_PROBE: ['X1-C40'], + SHIP_SIPHON_DRONE: ['X1-C40'], + SHIP_LIGHT_SHUTTLE: ['X1-A1'], + }); + expect(r.probeYard).toBe('X1-C40'); + expect(r.cargoYard).toBe('X1-A1'); + expect(r.anchorYard).toBe('X1-C40'); // anchor follows the probe yard for the parked-probe buys + }); + + it('returns null cargoYard when no yard sells a cargo ship', () => { + const r = pickAnchorYards({ SHIP_PROBE: ['X1-C40'], SHIP_MINING_DRONE: ['X1-H51'] }); + expect(r.probeYard).toBe('X1-C40'); + expect(r.cargoYard).toBeNull(); + expect(r.anchorYard).toBe('X1-C40'); + }); + }); }); describe('fleet repair helpers', () => { diff --git a/packages/bot/src/fleet/scale.ts b/packages/bot/src/fleet/scale.ts index 14da8e6..902c0fa 100644 --- a/packages/bot/src/fleet/scale.ts +++ b/packages/bot/src/fleet/scale.ts @@ -132,20 +132,29 @@ async function buyShip(shipType: string, waypointSymbol: string, deps: Pick): { probeYard: string | null; cargoYard: string | null; anchorYard: string | null } { +// Picks the yards to buy probes and cargo ships from. Takes the FULL per-yard type map +// (type → every waypoint selling it) rather than the price-collapsed offer map: a yard that +// sells a type only counts if its waypoint appears here, and the offer map keeps just the +// cheapest waypoint per type, which can hide that a single yard sells BOTH probes and cargo. +function pickAnchorYards(yardTypes: Record): { probeYard: string | null; cargoYard: string | null; anchorYard: string | null } { const byWp = new Map>(); - for (const [type, offer] of Object.entries(yards)) { - const set = byWp.get(offer.wp) ?? new Set(); - set.add(type); - byWp.set(offer.wp, set); + for (const [type, wps] of Object.entries(yardTypes)) { + for (const wp of wps) { + if (!wp) continue; + const set = byWp.get(wp) ?? new Set(); + set.add(type); + byWp.set(wp, set); + } } const entries = [...byWp.entries()]; const hasProbe = ([, types]: [string, Set]) => types.has('SHIP_PROBE'); const hasCargo = ([, types]: [string, Set]) => types.has('SHIP_LIGHT_HAULER') || types.has('SHIP_LIGHT_SHUTTLE'); - const anchorYard = entries.find((e) => hasProbe(e) && hasCargo(e))?.[0] ?? null; - const probeYard = anchorYard ?? entries.find(hasProbe)?.[0] ?? null; - const cargoYard = anchorYard ?? entries.find(hasCargo)?.[0] ?? null; - return { probeYard, cargoYard, anchorYard: anchorYard ?? probeYard }; + // Prefer a single yard that sells BOTH probes and cargo ships so one anchored probe can buy + // everything. Only fall back to separate probe/cargo yards when no combined yard exists. + const combined = entries.find((e) => hasProbe(e) && hasCargo(e))?.[0] ?? null; + const probeYard = combined ?? entries.find(hasProbe)?.[0] ?? null; + const cargoYard = combined ?? entries.find(hasCargo)?.[0] ?? null; + return { probeYard, cargoYard, anchorYard: combined ?? probeYard }; } export async function fleetScaleManager(deps: SubsystemDeps): Promise { @@ -153,15 +162,21 @@ export async function fleetScaleManager(deps: SubsystemDeps): Promise { if (!cfg.FLEET_SCALE) return; await sleep(STARTUP_DELAY_MS); - const yards = await getShipyards(deps, true); - const { probeYard, cargoYard, anchorYard } = pickAnchorYards(yards); + // Populate the per-yard shipyard cache (state.fleet.shipyards), then pick anchor yards from the + // full type→waypoints map so a yard selling both probes and cargo is detected even when a cheaper + // probe exists elsewhere. + await getShipyards(deps, true); + const { probeYard, cargoYard, anchorYard } = pickAnchorYards(state.fleet.shipyards ?? {}); if (!probeYard || !anchorYard) { log.info('🛰 FLEET_SCALE: no probe-selling shipyard found — disabled'); return; } log.info(`🛰 FLEET_SCALE armed — anchorYard ${anchorYard.slice(-3)} (probe ${probeYard.slice(-3)}, cargo ${cargoYard?.slice(-3) ?? 'none'}), floor ${cfg.FLEET_SCALE_FLOOR.toLocaleString()}`); - let anchorSent = false; + // Yards we must keep a docked ship at so buys can fire. When one combined yard sells everything + // these collapse to a single entry; otherwise we anchor a probe at both the probe and cargo yard. + const buyYards = [...new Set([probeYard, cargoYard, anchorYard].filter(Boolean) as string[])]; + const anchored = new Set(); while (!state.stop) { try { if (state.gateCache.built) { @@ -171,21 +186,27 @@ export async function fleetScaleManager(deps: SubsystemDeps): Promise { const markets = await deps.markets.getMarkets(); const marketWps = Object.keys(markets); const all = await client.getAllShips(); - const atYard = all.find((s) => s.nav.waypointSymbol === anchorYard && s.nav.status !== 'IN_TRANSIT'); - const headingYard = all.find((s) => s.nav.status === 'IN_TRANSIT' && s.nav.route?.destination?.symbol === anchorYard); - if (!atYard && !headingYard && !anchorSent) { - const freeProbe = all.find((s) => isProbeHull(s) && s.symbol !== cfg.NEGOTIATOR && s.nav.status !== 'IN_TRANSIT'); + const shipReadyAt = (yard: string) => all.find((s) => s.nav.waypointSymbol === yard && s.nav.status !== 'IN_TRANSIT'); + const shipHeadingTo = (yard: string) => all.find((s) => s.nav.status === 'IN_TRANSIT' && s.nav.route?.destination?.symbol === yard); + // Keep a non-negotiator probe parked at each buy yard so purchases can fire there. Don't pull + // a probe that's already sitting on one of the buy yards (it may be anchoring a different one). + for (const yard of buyYards) { + if (shipReadyAt(yard) || shipHeadingTo(yard) || anchored.has(yard)) continue; + const freeProbe = all.find( + (s) => isProbeHull(s) && s.symbol !== cfg.NEGOTIATOR && s.nav.status !== 'IN_TRANSIT' && !buyYards.includes(s.nav.waypointSymbol), + ); if (freeProbe) { try { if (freeProbe.nav.status === 'DOCKED') await client.api('POST', `/my/ships/${freeProbe.symbol}/orbit`); - await client.api('POST', `/my/ships/${freeProbe.symbol}/navigate`, { waypointSymbol: anchorYard }); - anchorSent = true; - log.info(`🛰 FLEET_SCALE: anchoring ${freeProbe.symbol.slice(-3)} → ${anchorYard.slice(-3)} for buys`); + await client.api('POST', `/my/ships/${freeProbe.symbol}/navigate`, { waypointSymbol: yard }); + anchored.add(yard); + log.info(`🛰 FLEET_SCALE: anchoring ${freeProbe.symbol.slice(-3)} → ${yard.slice(-3)} for buys`); } catch (e) { log.warn(`🛰 anchor ERR ${(e as Error).message}`); } } } + const atYard = shipReadyAt(anchorYard); if (growthBudget(state) < 1000 || state.cachedCredits < cfg.FLEET_SCALE_FLOOR) { await sleep(cfg.FLEET_SCALE_MS); @@ -244,6 +265,17 @@ export async function fleetScaleManager(deps: SubsystemDeps): Promise { if (atYard.nav.status !== 'DOCKED') { try { await client.api('POST', `/my/ships/${atYard.symbol}/dock`); } catch {} } + // Ensure a ship is docked at an arbitrary buy yard (used when the cargo yard differs from the + // probe anchor yard). Returns false when no ship is parked there yet — the anchor loop above + // will route one over on a later tick. + const ensureDockedAt = async (yard: string): Promise => { + const s = shipReadyAt(yard); + if (!s) return false; + if (s.nav.status !== 'DOCKED') { + try { await client.api('POST', `/my/ships/${s.symbol}/dock`); } catch {} + } + return true; + }; const freshYards = await getShipyards(deps, true); const probePx = freshYards.SHIP_PROBE?.price ?? PROBE_PRICE_EST; @@ -291,8 +323,12 @@ export async function fleetScaleManager(deps: SubsystemDeps): Promise { continue; } } - if (cargoShips < cfg.FLEET_TARGET_TRADERS && state.cachedCredits >= cfg.FLEET_SHUTTLE_MIN && canAfford(shuttlePx)) { - const bought = await buyShip('SHIP_LIGHT_SHUTTLE', anchorYard, deps); + // Cargo ships (traders) are bought at the cargo yard, which may differ from the probe anchor + // yard — buying SHIP_LIGHT_SHUTTLE at a probe-only yard returns a 400. Require a docked ship + // there first. + const cargoReady = cargoYard ? (cargoYard === anchorYard ? true : await ensureDockedAt(cargoYard)) : false; + if (cargoYard && cargoReady && cargoShips < cfg.FLEET_TARGET_TRADERS && state.cachedCredits >= cfg.FLEET_SHUTTLE_MIN && canAfford(shuttlePx)) { + const bought = await buyShip('SHIP_LIGHT_SHUTTLE', cargoYard, deps); if (bought) { log.info(`🛰 FLEET_SCALE bought LIGHT_SHUTTLE ${bought.slice(-3)} @ ${shuttlePx.toLocaleString()} → trade pool (cargo ships ${cargoShips + 1})`); deps.launchWorker(bought); // bought trader joins the supervised pool (bot2 L3062) @@ -300,8 +336,8 @@ export async function fleetScaleManager(deps: SubsystemDeps): Promise { await sleep(cfg.FLEET_SCALE_MS); continue; } - if (cargoShips < cfg.FLEET_TARGET_TRADERS && state.cachedCredits >= cfg.FLEET_HAULER_MIN && haulers < cfg.FLEET_MAX_HAULERS && canAfford(haulPx)) { - const bought = await buyShip('SHIP_LIGHT_HAULER', anchorYard, deps); + if (cargoYard && cargoReady && cargoShips < cfg.FLEET_TARGET_TRADERS && state.cachedCredits >= cfg.FLEET_HAULER_MIN && haulers < cfg.FLEET_MAX_HAULERS && canAfford(haulPx)) { + const bought = await buyShip('SHIP_LIGHT_HAULER', cargoYard, deps); if (bought) { log.info(`🛰 FLEET_SCALE bought LIGHT_HAULER ${bought.slice(-3)} @ ${haulPx.toLocaleString()} → trade pool (cargo ships ${cargoShips + 1})`); deps.launchWorker(bought); // bought trader joins the supervised pool (bot2 L3062) @@ -316,4 +352,4 @@ export async function fleetScaleManager(deps: SubsystemDeps): Promise { } } -export const __test = { SHIPYARD_TTL_MS, STARTUP_DELAY_MS }; +export const __test = { SHIPYARD_TTL_MS, STARTUP_DELAY_MS, pickAnchorYards };