Skip to content
Merged
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
114 changes: 114 additions & 0 deletions deploy/bot.greenfield.env.example
Original file line number Diff line number Diff line change
@@ -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. <AGENT>-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.
# ─────────────────────────────────────────────────────────────────────────────
20 changes: 16 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
119 changes: 119 additions & 0 deletions packages/bot/src/clients/__tests__/persistence.markets.test.ts
Original file line number Diff line number Diff line change
@@ -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<wp, Market>`, 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<string, Market>();
const calls: { putBodies: unknown[] } = { putBodies: [] };

const fetchImpl = (async (input: string | URL, init?: RequestInit): Promise<Response> => {
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<string, Market> = { '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<string, Market> = { '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<string, Market> = { '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<wp, Market>
});

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
});
});
20 changes: 17 additions & 3 deletions packages/bot/src/clients/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<wp, Market>`,
// 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<Record<string, Market>> {
return (await getJson<Record<string, Market>>('/markets')) ?? {};
const rows = (await getJson<Array<{ waypoint: string; data: Market }>>('/markets')) ?? [];
const out: Record<string, Market> = {};
for (const r of rows) out[r.waypoint] = r.data;
return out;
},
async getMarket(waypoint: string): Promise<Market | null> {
return getJson<Market>(`/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<Market>>(`/markets/${waypoint}`);
if (!row) return null;
return (row.data ?? (row as Market));
},
putMarkets(markets: Record<string, Market>): 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) ────────────────────────────────────────
Expand Down
2 changes: 1 addition & 1 deletion packages/bot/src/contracts/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ export async function contractManager(deps: SubsystemDeps): Promise<void> {
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;
Expand Down
6 changes: 5 additions & 1 deletion packages/shared/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down