Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 26 additions & 9 deletions deploy/bot.greenfield.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,22 +48,40 @@ 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

# 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
Expand All @@ -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
Expand All @@ -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

Expand Down
39 changes: 38 additions & 1 deletion packages/bot/src/fleet/__tests__/fleet.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
Expand All @@ -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', () => {
Expand Down
84 changes: 60 additions & 24 deletions packages/bot/src/fleet/scale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,36 +132,51 @@ async function buyShip(shipType: string, waypointSymbol: string, deps: Pick<Subs
}
}

function pickAnchorYards(yards: Record<string, ShipyardOffer>): { 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<string, string[]>): { probeYard: string | null; cargoYard: string | null; anchorYard: string | null } {
const byWp = new Map<string, Set<string>>();
for (const [type, offer] of Object.entries(yards)) {
const set = byWp.get(offer.wp) ?? new Set<string>();
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<string>();
set.add(type);
byWp.set(wp, set);
}
}
const entries = [...byWp.entries()];
const hasProbe = ([, types]: [string, Set<string>]) => types.has('SHIP_PROBE');
const hasCargo = ([, types]: [string, Set<string>]) => 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<void> {
const { state, cfg, client } = deps;
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<string>();
while (!state.stop) {
try {
if (state.gateCache.built) {
Expand All @@ -171,21 +186,27 @@ export async function fleetScaleManager(deps: SubsystemDeps): Promise<void> {
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);
Expand Down Expand Up @@ -244,6 +265,17 @@ export async function fleetScaleManager(deps: SubsystemDeps): Promise<void> {
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<boolean> => {
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;
Expand Down Expand Up @@ -291,17 +323,21 @@ export async function fleetScaleManager(deps: SubsystemDeps): Promise<void> {
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)
}
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)
Expand All @@ -316,4 +352,4 @@ export async function fleetScaleManager(deps: SubsystemDeps): Promise<void> {
}
}

export const __test = { SHIPYARD_TTL_MS, STARTUP_DELAY_MS };
export const __test = { SHIPYARD_TTL_MS, STARTUP_DELAY_MS, pickAnchorYards };
34 changes: 34 additions & 0 deletions packages/bot/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,40 @@ export async function main(): Promise<void> {
);
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<string, Market> } = { data: {} };
const marketsRef = (): Record<string, Market> => marketHolder.data;
Expand Down
16 changes: 12 additions & 4 deletions packages/bot/src/trade/lanes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,11 +283,19 @@ export interface ClaimDeps {
export function selectLane(ship: Ship, lanes: Lane[], markets: Record<string, Market>, 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;
Expand Down
Loading