diff --git a/deploy/bot.greenfield.env.example b/deploy/bot.greenfield.env.example new file mode 100644 index 0000000..bd62783 --- /dev/null +++ b/deploy/bot.greenfield.env.example @@ -0,0 +1,114 @@ +# ═════════════════════════════════════════════════════════════════════════════ +# deploy/bot.greenfield.env.example — GREENFIELD LIVE earner profile (EXAMPLE). +# +# A complete, RESET-AGNOSTIC launch profile for running the bot LIVE from a FRESH +# weekly reset (empty DB, ~2 starting ships, a brand-new agent). It hardcodes NO +# system, waypoint, or ship symbols — home is auto-detected from the agent's HQ and +# expansion is fed by the galaxy crawler, so the same file works on any reset. +# +# ─ TOMORROW'S RESET RUNBOOK ────────────────────────────────────────────────── +# 1. Register your agent at reset and copy the agent TOKEN. +# 2. Create the live file from this example: +# cp deploy/bot.greenfield.env.example deploy/bot.greenfield.env +# 3. Edit deploy/bot.greenfield.env: +# - paste the token into SPACETRADERS_PLAYER_AGENT_TOKEN +# - (optional) set NEGOTIATOR to your command ship, e.g. -1, to +# auto-negotiate NEW contracts (leave empty to only accept offered ones). +# 4. Launch the full stack (postgres + api + bot): +# docker compose --env-file deploy/bot.greenfield.env up --build +# +# Why `--env-file`: docker-compose's `environment:` block overrides `env_file:` and +# defaults DRY_RUN→1 (smoke) when the value isn't in its interpolation context. +# Passing the same file via `--env-file` puts DRY_RUN=0 + the token into that context +# so the run goes LIVE; the flag levers below reach the container via the service +# `env_file:` entry (docker-compose.yml already lists deploy/bot.greenfield.env). +# Omit `--env-file` and the stack safely comes up in DRY_RUN smoke instead. +# +# These are EXAMPLE values: code defaults live in @st/shared loadConfig(); anything +# left unset uses those defaults. Numeric floors below mirror the battle-tested legacy +# `overnight_experiment` profile (deploy/bot.env.example) with reset-specific ship/system +# pins stripped and greenfield-safe floors chosen. The issue-#2 scan-optimization levers +# (SCAN_*/COVERAGE_*/SCAN_BUDGET_*/FLEET_COVERAGE_*) are intentionally LEFT AT DEFAULT +# (OFF) — they are not needed to earn and will be enabled + measured post-reset against +# the live credits-per-request metric. Real lane/scan distributions for that tuning: +# session-state .../files/live-metrics.json. +# ═════════════════════════════════════════════════════════════════════════════ + +# ── connection / identity ──────────────────────────────────────────────────── +SPACETRADERS_PLAYER_AGENT_TOKEN=PASTE_AT_RESET +API_BASE_URL=http://api:3000 +BOT_KEY= +# SYSTEM intentionally UNSET → home auto-detected from /my/agent.headquarters +# (resolveHome). Do NOT pin it — that's what makes this profile reset-agnostic. +# NEGOTIATOR: leave empty for reset-agnostic (accepts offered contracts, no new-contract +# negotiation), or set to your command ship after register (e.g. MYAGENT-1) for sustained +# contract income. No hardcoded ship here on purpose. +NEGOTIATOR= + +# ── LIVE switch ────────────────────────────────────────────────────────────── +DRY_RUN=0 + +# ── 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 +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. +GATE_SUPPLY=1 +GATE_CREDIT_FLOOR=900000 +GATE_CREDIT_RESUME_GAP=250000 +GATE_MAX_SUPPLIERS=2 +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 +MINE_BATCH=30 +MINE_FUEL_RESERVE=20 +MINE_ORE_RESERVE=0 +MINE_CLOG_AT=0 + +# Input feed: off (matches battle-tested legacy profile; enable later if a producer +# benefits from fed inputs). +INPUT_FEED=0 + +# ── galaxy-driven expansion (the reset-agnostic core of inter-system growth) ── +# GALAXY_CRAWL maps + ranks the reachable galaxy in the background; AUTO_EXPAND then +# seeds outposts from that ranked map ONCE THE HOME GATE IS BUILT — NO hardcoded +# EXPAND_OUTPOSTS / gate graph. EXPAND_TARGET_SYSTEM left unset → auto-picked from rank. +GALAXY_CRAWL=1 +AUTO_EXPAND=1 +EXPAND_CREDIT_FLOOR=400000 +EXPAND_AUTOBUY=1 +EXPAND_BUY_FLOOR=700000 + +# ── trade-loop floor (battle-tested, reset-agnostic) ───────────────────────── +MIN_NET=1200 +FILL_BIAS=1 +FILL_BIAS_EPS=0.10 + +# ───────────────────────────────────────────────────────────────────────────── +# DELIBERATELY OMITTED (reset-specific — would break greenfield if pinned): +# SYSTEM, NEGOTIATOR pin, GATE_HAULERS, MINE_TRANSPORT, MINE_FUNNEL, MINE_FEEDERS, +# INPUT_FEEDERS, CONTRACT_RUNNER, EXPAND_OUTPOSTS, EXPAND_TARGET_SYSTEM, +# EXPAND_HAULERS, EXPAND_LIGHT, EXPAND_PROBES, GATE_MAX_PRICE (market-tuned). +# All of the above auto-detect / auto-select / auto-populate when left unset. +# +# LEFT AT DEFAULT (OFF) — issue-#2 scan optimization, enable + measure post-reset: +# SCAN_BUDGET_ON, SCAN_BASE_MS/SCAN_MIN_MS/SCAN_MAX_MS, LANE_*, COVERAGE_*, +# SCAN_BUDGET_*, FLEET_COVERAGE_ADAPTIVE/PRUNE/OBSERVE. +# ───────────────────────────────────────────────────────────────────────────── diff --git a/docker-compose.yml b/docker-compose.yml index 844686b..b010c74 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,16 +46,28 @@ services: context: . dockerfile: packages/bot/Dockerfile restart: unless-stopped - # Operator launch profile (live token + flag set) lives in ./deploy/bot.env, loaded if present. - # Left absent, the bot comes up in DRY_RUN (no live SpaceTraders calls, no token needed) so - # `docker compose up` smoke-tests the full stack out of the box. + # Operator launch profiles (live token + flag set) live in ./deploy/*.env, loaded if present and + # injected straight into the container (this is how the FLEET_SCALE/CONTRACTS/GALAXY_CRAWL/etc. + # levers reach the bot — they are NOT in the `environment:` block below). `bot.greenfield.env` is + # the reset-agnostic LIVE profile (see deploy/bot.greenfield.env.example); `bot.env` is the legacy + # profile. Listed last wins when both exist. With NEITHER present the bot comes up in DRY_RUN + # (no live SpaceTraders calls, no token needed) so `docker compose up` smoke-tests the stack. + # + # ⚠ For a LIVE run also pass the same file via `--env-file` so DRY_RUN=0 + the token resolve in + # the `environment:` block (which overrides env_file and defaults DRY_RUN→1 otherwise): + # docker compose --env-file deploy/bot.greenfield.env up --build env_file: - path: ./deploy/bot.env required: false + - path: ./deploy/bot.greenfield.env + required: false environment: API_BASE_URL: ${API_BASE_URL:-http://api:3000} BOT_KEY: ${BOT_KEY:-} - SYSTEM: ${SYSTEM:-X1-PP30} + # SYSTEM is intentionally NOT set here so it stays sourced from the env_file (or host env). + # Left unset on a live run (DRY_RUN=0), the bot auto-detects home from the agent's HQ via + # resolveHome() — reset-agnostic, no hardcoded system. Pin SYSTEM in deploy/bot.env only to + # override auto-detect. (A compose `environment:` entry would clobber the env_file value.) # Default-on smoke mode. Set DRY_RUN=0 (and supply a token) in deploy/bot.env for a live run. DRY_RUN: ${DRY_RUN:-1} DRY_RUN_CREDITS: ${DRY_RUN_CREDITS:-250000} diff --git a/packages/bot/src/clients/__tests__/persistence.markets.test.ts b/packages/bot/src/clients/__tests__/persistence.markets.test.ts new file mode 100644 index 0000000..944cf2e --- /dev/null +++ b/packages/bot/src/clients/__tests__/persistence.markets.test.ts @@ -0,0 +1,119 @@ +/** + * persistence.markets.test.ts — CONTRACT test for the bot↔api markets write-through (issue #8). + * + * The bot caches markets as a `Record`, but the api's bulk `PUT /markets` speaks + * `BulkPutBody = Array<{ waypoint, data }>` and `GET /markets` returns + * `Array<{ waypoint, data, updatedAt }>` (see packages/api/src/routes/markets.ts). Before the fix + * `putMarkets` sent the bare Record → Fastify schema validation 400 → fire-and-forget dropped it, + * so no snapshot ever persisted. These tests pin the wire shape on the bot side so the two can't + * drift apart again: the fake api below validates the body EXACTLY as the api's TypeBox + * `BulkPutBody` schema would (reject anything that isn't an array of `{ waypoint: string, data }`), + * and we assert both the PUT body shape and that a written snapshot round-trips back through GET. + */ +import { describe, it, expect } from 'vitest'; +import type { Market } from '@st/shared'; +import { createPersistenceClient } from '../persistence.js'; + +/** True iff `body` conforms to the api's `BulkPutBody` (Array<{ waypoint: string, data }>). */ +function isBulkPutBody(body: unknown): body is Array<{ waypoint: string; data: unknown }> { + return ( + Array.isArray(body) && + body.every( + (r) => + r !== null && + typeof r === 'object' && + typeof (r as { waypoint?: unknown }).waypoint === 'string' && + 'data' in (r as object), + ) + ); +} + +/** + * In-memory api that mirrors packages/api/src/routes/markets.ts: bulk PUT validates the body + * against `BulkPutBody` (400 on mismatch, like Fastify) and upserts by waypoint; GET returns the + * stored snapshots as `Array<{ waypoint, data, updatedAt }>`. + */ +function makeMarketsApi() { + const store = new Map(); + const calls: { putBodies: unknown[] } = { putBodies: [] }; + + const fetchImpl = (async (input: string | URL, init?: RequestInit): Promise => { + const url = new URL(typeof input === 'string' ? input : input.toString()); + const p = url.pathname; + const method = (init?.method ?? 'GET').toUpperCase(); + const body = init?.body ? JSON.parse(init.body as string) : undefined; + const json = (data: unknown, status = 200): Response => new Response(JSON.stringify(data), { status }); + + if (p === '/markets' && method === 'PUT') { + calls.putBodies.push(body); + if (!isBulkPutBody(body)) return new Response(JSON.stringify({ error: 'body must be array' }), { status: 400 }); + for (const row of body) store.set(row.waypoint, row.data as Market); + return json({ upserted: body.length }); + } + if (p === '/markets' && method === 'GET') { + const rows = [...store.entries()].map(([waypoint, data]) => ({ waypoint, data, updatedAt: new Date(0).toISOString() })); + return json(rows); + } + if (p.startsWith('/markets/') && method === 'GET') { + const wp = decodeURIComponent(p.slice('/markets/'.length)); + const data = store.get(wp); + if (!data) return new Response('', { status: 404 }); + return json({ waypoint: wp, data, updatedAt: new Date(0).toISOString() }); + } + return new Response('', { status: 404 }); + }) as unknown as typeof fetch; + + return { fetchImpl, store, calls }; +} + +const mkt = (symbol: string): Market => ({ symbol, exports: [{ symbol: 'FUEL' }], tradeGoods: [] }); + +describe('persistence markets write-through (issue #8 contract)', () => { + it('putMarkets sends BulkPutBody (Array<{waypoint,data}>), not a Record', async () => { + const api = makeMarketsApi(); + const client = createPersistenceClient({ baseUrl: 'http://api.test', fetchImpl: api.fetchImpl, retries: 0 }); + + const snapshot: Record = { 'X1-AA1-A1': mkt('X1-AA1-A1'), 'X1-AA1-B2': mkt('X1-AA1-B2') }; + client.putMarkets(snapshot); + await client.flush(); + + expect(api.calls.putBodies).toHaveLength(1); + const sent = api.calls.putBodies[0]; + expect(isBulkPutBody(sent)).toBe(true); // matches the api schema → would NOT 400 + expect(sent).toEqual([ + { waypoint: 'X1-AA1-A1', data: snapshot['X1-AA1-A1'] }, + { waypoint: 'X1-AA1-B2', data: snapshot['X1-AA1-B2'] }, + ]); + // …and the write actually persisted (no silent drop). + expect(api.store.size).toBe(2); + expect(api.store.get('X1-AA1-A1')).toEqual(mkt('X1-AA1-A1')); + }); + + it('the OLD bare-Record body would be rejected (400) by the BulkPutBody schema — regression guard', () => { + const recordBody: Record = { 'X1-AA1-A1': mkt('X1-AA1-A1') }; + expect(isBulkPutBody(recordBody)).toBe(false); // exactly why issue #8 dropped the write + }); + + it('GET /markets round-trips: putMarkets → getMarkets reconstitutes the Record', async () => { + const api = makeMarketsApi(); + const client = createPersistenceClient({ baseUrl: 'http://api.test', fetchImpl: api.fetchImpl, retries: 0 }); + + const snapshot: Record = { 'X1-AA1-A1': mkt('X1-AA1-A1'), 'X1-AA1-B2': mkt('X1-AA1-B2') }; + client.putMarkets(snapshot); + await client.flush(); + + const reread = await client.getMarkets(); + expect(reread).toEqual(snapshot); // array-of-rows folded back into Record + }); + + it('getMarket(wp) unwraps the snapshot row to a bare Market', async () => { + const api = makeMarketsApi(); + const client = createPersistenceClient({ baseUrl: 'http://api.test', fetchImpl: api.fetchImpl, retries: 0 }); + + client.putMarkets({ 'X1-AA1-A1': mkt('X1-AA1-A1') }); + await client.flush(); + + expect(await client.getMarket('X1-AA1-A1')).toEqual(mkt('X1-AA1-A1')); + expect(await client.getMarket('X1-AA1-ZZ')).toBeNull(); // 404 → null + }); +}); diff --git a/packages/bot/src/clients/persistence.ts b/packages/bot/src/clients/persistence.ts index 0b1a245..2bdff91 100644 --- a/packages/bot/src/clients/persistence.ts +++ b/packages/bot/src/clients/persistence.ts @@ -239,14 +239,28 @@ export function createPersistenceClient(opts: PersistenceClientOptions = {}): Pe }, // ── markets (latest snapshot) ───────────────────────────────────────────── + // The api stores each snapshot as a row `{ waypoint, data, updatedAt }` and the bulk + // endpoints speak `Array<{ waypoint, data }>` (see `MarketSnapshotSchema` / `BulkPutBody` + // in packages/api/src/routes/markets.ts). The bot caches markets as a `Record`, + // so this client translates between the two shapes on every read/write. (issue #8: sending + // the bare Record 400s against `BulkPutBody`, and the write is fire-and-forget so it was + // silently dropped — no market snapshot ever persisted.) async getMarkets(): Promise> { - return (await getJson>('/markets')) ?? {}; + const rows = (await getJson>('/markets')) ?? []; + const out: Record = {}; + for (const r of rows) out[r.waypoint] = r.data; + return out; }, async getMarket(waypoint: string): Promise { - return getJson(`/markets/${waypoint}`); + // The snapshot row is `{ waypoint, data, updatedAt }`; note `unwrap` already strips a literal + // top-level `data` field, so tolerate either the unwrapped Market or the raw row. + const row = await getJson<{ data?: Market } & Partial>(`/markets/${waypoint}`); + if (!row) return null; + return (row.data ?? (row as Market)); }, putMarkets(markets: Record): void { - fireAndForget('PUT', '/markets', markets, 'markets'); + const body = Object.entries(markets).map(([waypoint, data]) => ({ waypoint, data })); + fireAndForget('PUT', '/markets', body, 'markets'); }, // ── gate-levers (operator control) ──────────────────────────────────────── diff --git a/packages/bot/src/contracts/contracts.ts b/packages/bot/src/contracts/contracts.ts index 9f7859a..92aa7b0 100644 --- a/packages/bot/src/contracts/contracts.ts +++ b/packages/bot/src/contracts/contracts.ts @@ -393,7 +393,7 @@ export async function contractManager(deps: SubsystemDeps): Promise { await client.api('POST', `/my/contracts/${pending.id}/accept`); const ci = toInfo(pending); if (ci) state.activeContractInfo = ci; - } else if (!state.contractWorkingId) { + } else if (!state.contractWorkingId && cfg.NEGOTIATOR) { try { await client.api('POST', `/my/ships/${cfg.NEGOTIATOR}/dock`); } catch {} const r = await client.api<{ data: { contract: Contract } }>('POST', `/my/ships/${cfg.NEGOTIATOR}/negotiate/contract`); const c = r.data.contract; diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index 878f2b1..a45ca39 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -156,7 +156,11 @@ const RawConfigSchema = z.object({ CONTRACT_RUNNER: csvSet, CONTRACT_FORCE: csvSet, DEBUG_CONTRACT: boolOff, - NEGOTIATOR: str('SPACEJAM-DK-2-15'), + // The ship that docks at a faction HQ to negotiate NEW contracts. Empty default ⇒ reset-agnostic + // (no hardcoded ship); pin to the command ship (e.g. `{AGENT}-1`) for sustained negotiation. When + // empty the bot still accepts already-offered/pending contracts, just doesn't auto-negotiate new + // ones (see contracts.ts — the negotiate call is guarded on a non-empty NEGOTIATOR). + NEGOTIATOR: str(''), // ── gate supply ───────────────────────────────────────────────────────── GATE_SUPPLY: boolOn,