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/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 }; 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)';