From 3bbf524ebb003b1f2acfde4e72d086995cbc3e95 Mon Sep 17 00:00:00 2001 From: Dominik Roth Date: Sat, 19 Sep 2026 00:11:02 +0200 Subject: [PATCH] Add simulator replica, persistence, planet scan, README; split empire state out of the repo - tools/simulate.py + puga/simulate.py: replica of PRUNplanner's simulator (flows and efficiency verified against screenshots), reports real new capex (planned minus built) - tools/scan.py: staffing variants, freight, HQ/experts, --planet mode, demolish-later, --min-n as a pure market-size filter, --json output - tools/history.py, tools/persistence.py: margin history and short-horizon payback checks - tools/plan_push.py: guarded delete; tools/state.py: syncs to empire/ - README with features and setup; CLAUDE.md made generic - Own-empire material (profile, state, plans, notes) moved to gitignored empire/; generic examples in plans/examples and state/company.example.yaml Co-Authored-By: Claude Sonnet 5 --- .env.example | 3 +- .gitignore | 2 + CLAUDE.md | 56 ++++--- README.md | 108 ++++++++++++- docs/decisions.md | 8 +- docs/handoff-2026-09-18.md | 96 ----------- docs/mechanics.md | 21 ++- docs/roadmap.md | 4 +- .../base_plus_hwp.yaml} | 6 +- plans/examples/hwp_buildout.yaml | 11 ++ plans/examples/hwp_only.yaml | 10 ++ puga/cache.py | 13 +- puga/config.py | 9 ++ puga/fio.py | 2 +- puga/market.py | 11 ++ puga/simulate.py | 101 ++++++++++++ state/company.example.yaml | 12 ++ state/company.yaml | 50 ------ tests/test_econ.py | 4 +- tests/test_simulate.py | 84 ++++++++++ tools/history.py | 69 ++++++++ tools/persistence.py | 71 ++++++++ tools/plan_push.py | 26 ++- tools/scan.py | 153 ++++++++++++------ tools/simulate.py | 87 ++++++++++ tools/state.py | 20 ++- 26 files changed, 778 insertions(+), 259 deletions(-) delete mode 100644 docs/handoff-2026-09-18.md rename plans/{deimos_bhp.yaml => examples/base_plus_hwp.yaml} (69%) create mode 100644 plans/examples/hwp_buildout.yaml create mode 100644 plans/examples/hwp_only.yaml create mode 100644 puga/simulate.py create mode 100644 state/company.example.yaml delete mode 100644 state/company.yaml create mode 100644 tests/test_simulate.py create mode 100755 tools/history.py create mode 100755 tools/persistence.py create mode 100755 tools/simulate.py diff --git a/.env.example b/.env.example index 7e10795..2763012 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,8 @@ # Copy to .env (gitignored, chmod 600) and fill in. Never paste keys into chat; edit the file. -FIO_USERNAME=dodox +FIO_USERNAME= FIO_REST_KEY= # FIO REST key (rest.fnar.net authenticated endpoints) FIO_API_KEY= # FIO API key (same as the FIO web account key); separate from REST, possibly a separate account PRUNPLANNER_API_KEY= # api.prunplanner.org key, sent as "Authorization: Api-Key " +COMPANY_CODE= DEFAULT_CX=AI1 DEFAULT_REGION=antares diff --git a/.gitignore b/.gitignore index acd2571..a5efbd9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ data/cache/ ref/ __pycache__/ .pytest_cache/ +empire/ +data/ diff --git a/CLAUDE.md b/CLAUDE.md index d7d58f4..a84a9e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,44 +1,50 @@ # PuGa: Prosperous Universe advisory toolkit -Agent-first toolkit for advising Dominik (username dodox, company GBI, faction Antares, home CX AI1). He asks questions; you answer by running tools in `tools/` against live data. Read this file first, then `docs/mechanics.md` and `state/company.yaml`. +Agent-first toolkit for advising a Prosperous Universe player. The player asks questions; you answer by running the tools in `tools/` against live data, not by guessing. -## How to work with Dominik -- Expert user. Numbers, tables, ROI per day. Short answers, no fluff, no moralising. Semicolons over dashes; no em dashes. -- He plays on browser APEX (free licence) and sends screenshots; read them carefully, they override assumptions. -- Never guess APEX commands or numbers. Pull data (tools, FIO, PRUNplanner) or search. Command list: `docs/apex.md` (when written) and `docs/handoff-2026-09-18.md` §4. +## Read first +1. `empire/PROFILE.md` if it exists: who the player is, how they want answers, their company and current plans. `empire/` is gitignored and holds everything specific to this player's empire (see Layout). If it does not exist, ask the player the basics and create it. +2. `docs/mechanics.md` for game mechanics (source of truth order below). +3. `empire/state/company.yaml` for the current position; refresh it with `tools/state.py sync` before answering anything about it. + +## Working rules +- Numbers, tables, ROI per day. Short answers, no fluff. Match the style in the player's profile. +- The player plays on browser APEX and may send screenshots; read them carefully, they override assumptions. +- Never guess APEX commands or numbers. Pull data (tools, FIO, PRUNplanner) or search. - Label every number: live (tool output), state (company.yaml), or estimate; say which in-game command would confirm an estimate. -- Prices move; re-run tools before quoting. Snapshot prices in the handoff doc are stale. +- Prices move; re-run tools before quoting. Anything older than a day is stale. +- Do not commit or push unless asked. Writes to the player's PRUNplanner account follow the guardrails in `docs/decisions.md`. ## Source-of-truth rules -1. **PRUNplanner code wins** over our own docs for game mechanics (`ref/frontend/src/features/planning/calculations/`, `ref/backend/`). If `docs/handoff-2026-09-18.md` conflicts with it, the handoff is wrong; fix `docs/mechanics.md`. -2. In-game numbers Dominik reports beat both, especially for planet resource factors. +1. **PRUNplanner code wins** over our own docs for game mechanics (`ref/frontend/src/features/planning/calculations/`, `ref/backend/`). If any older handoff or note conflicts with it, the note is wrong; fix `docs/mechanics.md`. +2. In-game numbers the player reports beat both, especially for planet resource factors. 3. `ref/` is a gitignored copy of the PRUNplanner repos; refresh with `tools/refresh_refs.sh`. ## Setup - Python venv at `.venv` (`.venv/bin/python`, `.venv/bin/pip`). Deps in `requirements.txt`. -- Secrets in `.env` (gitignored; template `.env.example`): FIO REST key, FIO API key, PRUNplanner key. Never print or commit them; do not ask Dominik to paste keys into chat. -- Scope: whole universe supported; default region Antares (exchange AI1, `--cx` to change). +- Secrets in `.env` (gitignored; template `.env.example`): FIO REST key, FIO API key, PRUNplanner key, FIO username, company code. Never print or commit them; do not ask the player to paste keys into chat. +- Scope: whole universe supported; default exchange from `DEFAULT_CX` (AI1 = Antares), `--cx` to change. ## Layout -- `CLAUDE.md` this file; `docs/` mechanics, refs, roadmap, decisions, archived handoff. -- `state/company.yaml` company state (bases, buildings, ships); update when Dominik reports changes. -- `puga/` shared library (data layer, market, econ, world); `tools/` CLIs; `data/cache/` fetched data; `tests/`. -- `ref/` PRUNplanner source (read-only reference). +- `CLAUDE.md` this file; `README.md` for humans. +- `docs/` GAME and toolkit knowledge, safe to publish: `mechanics.md` (verified rules), `saturation-design.md` (market model), `decisions.md` (toolkit decisions and guardrails), `roadmap.md`. +- `empire/` (**gitignored**) OUR game state, nothing here goes public: `PROFILE.md`, `state/company.yaml` (synced from FIO), `plans/` (own plan specs), `docs/` (handoffs, build plans, personal notes). +- `puga/` library; `tools/` CLIs; `plans/examples/` generic plan specs (also test fixtures); `state/company.example.yaml` example state; `tests/`; `data/` (gitignored fetched data cache); `ref/` (gitignored PRUNplanner source). +- Rule: game facts and generic methods go in `docs/`; anything about the player's own bases, cash, decisions or preferences goes in `empire/`. -## Tools (current) -- `tools/state.py sync|show` refreshes `state/company.yaml` from live FIO (cash, permits, buildings, real production efficiency, storage, ships). Run it before answering anything about his current position. -- `tools/plan_push.py` builds/validates PRUNplanner plans from `plans/*.yaml` (dry run by default; `--apply` only after Dominik says yes; only `[PuGa]`-named plans are created/updated; `list` shows his plans). Specs live in `plans/`. +## Tools +- `tools/state.py sync|show` refreshes `empire/state/company.yaml` from live FIO (cash, permits, buildings, real production efficiency, storage, ships). +- `tools/plan_push.py` builds/validates PRUNplanner plans from YAML specs (`plans/examples/`, `empire/plans/`). Dry run by default; `--apply` only after the player says yes; only `[PuGa]`-named plans are created/updated/deleted (`delete `, only when asked); `list` shows the account's plans. +- `tools/simulate.py [--off EXT,SME] [--basis real|uni30|vwap30|ask|...]` replica of PRUNplanner's simulator (efficiency, workforce, material I/O, profit). Flows and efficiency verified exact vs screenshots; profit within ~2%. The NEW CAPEX line = planned minus already built (from state), the honest payback; ignore the plan-level ROI (PRUNplanner always adds a core module). `--uuid` reads the SAVED plan from the account (UI edits must be saved first). +- `tools/scan.py` DEPTH-AWARE recipe scan (use this): buildings the market could absorb, ROI for our own size, patient prices, ask-walked inputs. Fully staffed AND understaffed variants by default (`--staffing`), freight netted out (`--trip-cost`, `--cargo`), HQ/COGC/experts/faction, `--planet ID` adds that planet's extraction, fertility and COGC (a planet not in state is a new base: no HQ, permits+1), `--deprec 60` prices demolish-later. `--min-n` is ONLY a noise filter on market capacity (use 3, ideally 10); ROI is always for our own size (`--own`, default 1 building), never at the filtered scale. Below 1 lets sub-building junk in. `--json rows.json` feeds `persistence.py`. Library: `puga/saturation.py`. +- `tools/history.py TICKER` monthly margin history of the recipe producing TICKER; `tools/persistence.py rows.json` re-prices scan rows over history (mean ROI 7/14/30/90/180d, payback, net gain over 7 and 14 days, % days profitable). ALWAYS check persistence before recommending: current margins are often a spike. - `tools/chain.py TICKER` make-vs-buy cost tree plus sourcing depth of inputs. -- `tools/book.py` order-book ladder (price levels, cumulative units) for one material at one CX. -- `tools/price.py` prices across CX with VWAP, daily volume, and order-book fill price for a quantity (`--qty`). Library: `puga/market.py`, `puga/fio.py`, `puga/prunplanner.py`. -- `tools/scan.py` DEPTH-AWARE recipe scan (use this): N* buildings the market absorbs, ROI at N=1 and N*, patient prices, ask-walked inputs. Flags: --cx --tier --cogc --skip --hq --budget --min-n --sort roi|total|roi1 --show-thin. Library: `puga/saturation.py`. -- `tools/prun_scan.py` legacy single-step scan at top-of-book prices; overstates thin markets. Baseline only. -- `tools/prun_cxarb.py` inter-exchange arbitrage per material. Legacy; overstated profit 7 to 11x on thin routes; `tools/arb.py` replacement is on the roadmap. - -- `puga/econ.py` pure formulas ported from PRUNplanner: efficiency stack, workforce satisfaction/consumption, extraction, production I/O, hab optimizer. `tests/test_econ.py` holds the reference values. +- `tools/price.py` prices across exchanges with VWAP, volume, fill price for a quantity; `tools/book.py` order-book ladder. +- `tools/prun_scan.py`, `tools/prun_cxarb.py` legacy top-of-book scans; overstate thin markets. Baseline only. `tools/arb.py` replacement is on the roadmap. +- `puga/econ.py` pure formulas ported from PRUNplanner (efficiency stack, workforce, extraction, production I/O, hab optimizer); `tests/test_econ.py` holds the reference values. ## Model policy Sonnet builds; spawn a bigger model (Agent `model: opus|fable`) for the review points in `docs/decisions.md`. Roadmap: `docs/roadmap.md`. ## Key analytic rule: depth matters -PRUNplanner's ROI Overview ranks recipes as if the market absorbs unlimited output (e.g. 0.25 day ROI on a recipe whose whole market fits in 2 buildings). Every opportunity we report must include **saturation**: max buildings the market can absorb on both the output side (demand, traded volume, order-book walk) and the input side (supply). Report ROI at realistic fill prices, not top-of-book, and total absorbable profit/day. +PRUNplanner's ROI Overview ranks recipes as if the market absorbs unlimited output (e.g. 0.25 day ROI on a recipe whose whole market fits in 2 buildings). Every opportunity we report must include **saturation** (buildings the market can absorb) and, for anything we would act on, **persistence** (is the margin a spike?). Report ROI at realistic fill prices, not top-of-book. diff --git a/README.md b/README.md index 56747aa..898f37b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,113 @@

-A set of tools to be used by humans and/or llms to plan for th egame Prosperous Universe. +A set of tools and know-how for Claude (Claude Code) to assist in the game [Prosperous Universe](https://prosperousuniverse.com), usable by humans too. You ask questions in plain language; Claude answers by running the tools in this repo against live market data and your own company data, instead of guessing. Name origin: Plutonium–gallium alloy (Pu–Ga) is a specialized metallic blend of plutonium and gallium primarily used to stabilize the desirable delta (δ) phase of plutonium at room temperature. + +## How it is meant to be used + +Open Claude Code in this folder. `CLAUDE.md` is loaded automatically: it tells Claude who the player is, which tools exist, which numbers to trust, and how to answer. Claude also reads `empire/PROFILE.md` (who you are, how you want answers) if you have one. Then just ask, e.g. *"what should I build on Sunday?"*, *"is BHP still worth it?"*, *"simulate my HWP plan"*. Every tool is also a normal command line script, so you can run them yourself. + +Everything the tools print is an **estimate** from public and personal game data. Prices move; re-run before acting. + +## Features + +**Data layer** +- Cached clients for the FIO REST API and the PRUNplanner API (public game data, order books, price history, your own sites, storage, production, ships, cash). + +**Market tools** +- `price`: bid, ask, 7/30-day VWAP, daily volume, supply and demand at every exchange; fill price for a quantity. +- `book`: order-book ladder with cumulative depth (market-maker orders handled). + +**Economics engine** (`puga/econ.py`, ported from the PRUNplanner source, which is treated as the source of truth and checked against its own tests and live FIO values) +- Building efficiency: COGC, HQ, experts, faction bonus, fertility, building condition. +- Workforce satisfaction and consumption, extraction rates, production per day, housing optimizer. + +**Finding opportunities** +- `scan`: ranks every recipe with a depth-aware market model, so it does not recommend recipes whose whole market fits one or two buildings. Fully staffed and understaffed variants, freight cost, HQ/COGC/experts, `--min-n` filter for real markets, a `--planet` mode (extraction, fertility, COGC of a specific planet) and a demolish-later cost. +- `persistence` and `history`: how long has a margin lasted? Re-prices any opportunity over the exchange history (30/90/180 days), with payback and expected net gain over 7 and 14 days. +- `chain`: make-versus-buy cost tree with sourcing depth of every input. +- `arb` (planned) and the legacy `prun_scan` / `prun_cxarb` (kept as a baseline, they overstate thin markets). + +**Your own position** +- `state`: syncs `empire/state/company.yaml` from FIO: cash, permits, buildings, real production efficiency, storage and ships. +- `simulate`: a Python replica of the PRUNplanner simulator (workforce, efficiency, material I/O, profit), verified against screenshots of it. Reads a local plan or a saved plan from your account, and reports the real new capex (planned minus already built). +- `plan_push`: creates, updates and deletes plans in your PRUNplanner account from small YAML files (examples in `plans/examples/`). Dry run by default; only plans named `[PuGa] ...` can be written or deleted. + +**Knowledge for Claude** (`CLAUDE.md`, `docs/`) +- Verified game mechanics, the market-saturation design, a decision log and a roadmap (`docs/`). Everything specific to your own empire (profile, state, plans, notes) lives in the gitignored `empire/` folder, so this repo can stay public. + +## Setup + +Requires Python 3.12 or newer and git. + +```sh +git clone PuGa +cd PuGa + +# 1. virtual environment +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt + +# 2. keys +cp .env.example .env +chmod 600 .env +$EDITOR .env # fill in the values below + +# 3. optional: the PRUNplanner source used as reference for game mechanics (gitignored) +tools/refresh_refs.sh + +# 4. check it works +.venv/bin/python -m pytest +.venv/bin/python tools/price.py BHP --cx AI1 +``` + +### `.env` + +`.env` is gitignored and never committed. Do not paste keys into chat; edit the file. + +| Variable | What it is | +|---|---| +| `FIO_USERNAME` | your in-game / FIO user name | +| `FIO_REST_KEY` | your FIO REST key (used for your own sites, storage, production, ships, cash on `rest.fnar.net`) | +| `FIO_API_KEY` | your FIO API (web) key; separate from the REST key, currently unused | +| `PRUNPLANNER_API_KEY` | API key from your PRUNplanner account, sent as `Authorization: Api-Key ` | +| `COMPANY_CODE` | your company code (used by `tools/state.py` for the first sync) | +| `DEFAULT_CX`, `DEFAULT_REGION` | default exchange (`AI1` = Antares) and region | + +Only the public data works without keys (prices, recipes, planets). Your own data needs the FIO extension for the browser client to have uploaded it recently: run `tools/state.py sync` and check the `as_of` time. + +## Usage + +```sh +.venv/bin/python tools/state.py sync # refresh your company state from FIO +.venv/bin/python tools/scan.py --cogc METALLURGY --experts METALLURGY=2 --min-n 3 --top 20 +.venv/bin/python tools/scan.py --planet ZV-307d --min-n 3 # a specific planet +.venv/bin/python tools/scan.py --min-n 3 --json rows.json && .venv/bin/python tools/persistence.py rows.json +.venv/bin/python tools/chain.py KV --qty 13 +.venv/bin/python tools/simulate.py plans/examples/hwp_buildout.yaml --basis vwap30 +.venv/bin/python tools/plan_push.py plans/examples/hwp_buildout.yaml # dry run; add --apply to write +``` + +## Layout + +``` +CLAUDE.md entry point for Claude: rules, tool index, source-of-truth order +docs/ game and toolkit knowledge: mechanics, market model, decisions, roadmap +puga/ library: data clients, market, economics, saturation, simulator +tools/ command line tools +plans/examples/ generic plan specs (also test fixtures) +state/ company.example.yaml, the shape of your synced state +tests/ pytest suite +empire/ (gitignored) YOUR empire: PROFILE.md, state/company.yaml, plans/, docs/ (notes, build plans) +data/ (gitignored) fetched data cache +ref/ (gitignored) PRUNplanner source, read-only reference +``` + +### The `empire/` folder + +Game knowledge and generic methods go in `docs/`; anything about your own bases, cash, decisions or preferences goes in `empire/`, which is gitignored. `tools/state.py sync` creates `empire/state/company.yaml` (on first use set `COMPANY_CODE` in `.env`). Add `empire/PROFILE.md` yourself: your name, company, how you want answers, agreements with Claude. + +Not affiliated with Prosperous Universe, FIO or PRUNplanner. diff --git a/docs/decisions.md b/docs/decisions.md index 932394b..5a586fa 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1,11 +1,11 @@ # Decisions log ## 2026-09-18 Project plan and working agreements -- Goal: agent-first toolkit; Dominik asks, the agent answers by running tools against live data. Not an end-user app. +- Goal: agent-first toolkit; the player asks, the agent answers by running tools against live data. Not an end-user app. - PRUNplanner source (`ref/`) is the source of truth for mechanics; conflicts with the old handoff resolved in its favour (`docs/mechanics.md`). -- Scope: whole universe, default region Antares (CX AI1). Python in `.venv`, secrets in `.env`, git branch `master`. No commits without Dominik asking. +- Scope: whole universe, default region Antares (CX AI1). Python in `.venv`, secrets in `.env`, git branch `master`. No commits unless the player asks. - Every reported opportunity must include market saturation (buildings the market can absorb, fill-price ROI, absorbable profit/day). Reason: PRUNplanner's ROI Overview ranks recipes with no depth awareness (e.g. 0.25 day ROI on recipes 2 buildings saturate). -- PRUNplanner integration: API key auth (`Authorization: Api-Key `); plans can be created/updated via `/planning/plan/`. Writes only create/update plans prefixed `[PuGa]`; never touch Dominik's other plans; show payload and get a yes before each write. +- PRUNplanner integration: API key auth (`Authorization: Api-Key `); plans can be created/updated via `/planning/plan/`. Writes only create/update/delete plans prefixed `[PuGa]` (enforced in `tools/plan_push.py`); never touch the player's other plans. Deleting is allowed only on request (or for throwaway test plans the tool itself made). When the player explicitly asks for a plan, creating it directly is fine; show the dry run in the reply. ## Model and review policy - Default: Sonnet 5 does the building (API plumbing, CLIs, ports, tests). @@ -14,5 +14,5 @@ 2. Econ formula port (efficiency stack, workforce, hab LP, extraction) checked against PRUNplanner numbers. 3. `plan_push` payload handling and the own-plans-only guard. 4. Big-money recommendations (e.g. BHP go/no-go, roughly 100k AIC): independent second opinion on the numbers. -- If a bigger-model spawn is refused, tell Dominik and ask him to run the review. +- If a bigger-model spawn is refused, tell the player and ask them to run the review. - Routine tool building is not reviewed by a bigger model. diff --git a/docs/handoff-2026-09-18.md b/docs/handoff-2026-09-18.md deleted file mode 100644 index 211935a..0000000 --- a/docs/handoff-2026-09-18.md +++ /dev/null @@ -1,96 +0,0 @@ -> ARCHIVE. Superseded where it conflicts with docs/mechanics.md (PRUNplanner model). Its measured smelter times (14h55, 12h26) were taken before coffee was supplied and are dropped; use base times / efficiency model. - -# Prosperous Universe: handoff for advising Dominik (company GBI) - -Snapshot date: 2026-09-18. Prices move; re-pull from FIO before quoting numbers. - -## 0. How to work with Dominik -- Expert user, wants numbers, tables, ROI per day, no fluff, no moralising. Short answers. Semicolons over dashes; no em dashes. -- He plays on browser APEX (free licence). Sends screenshots; read them carefully, they override my assumptions. -- When unsure, pull FIO data or search; do not guess commands (mistakes made: NOTS is notifications not notes; CO needs a company code; REP and FLTP do not exist; flight planning is SFC ). -- Always state which numbers are estimates and what in-game command would replace them. -## 1. Company status (as of 2026-09-18) -- Company: greenish-blue Industries, ticker **GBI**. Username dodox. Faction Antares Initiative (AI). Exchange AI1 (Antares Station, ANT, in Antares I / ZV-307). Currency AIC. -- Licence: FREE. Consequences: no Local Market ads or player contracts (browse only), max 2 ships in flight, no screen variables, HQ max level 5. One month of PRO converts permanently to BASIC (can accept LM ads and shipping jobs, not post). -- Base: **Deimos (ZV-759c, Antares II)**. Metallurgy COGC programme (Antares Metallurgy bonus applies). ~200 of 500 area used. Environment normal: only MCG surcharge (4 MCG per area). - - Buildings: 2 EXT (ALO), 5 SME, 4 HB1 (400 beds), pioneers 370/400 required/capacity. Possibly more since; ask. - - Smelters run flux recipe: 6 ALO + 1 FLX + 1 C + 1 O -> 4 AL, 14h55 (his measured time). Non-flux: 6 ALO + C + O -> 3 AL, 12h26. - - Extractor (SUPERSEDED, see docs/mechanics.md): actual is 28 ALO/day/EXT (factor 0.4 x 70); the 'concentration 28' here was the daily-extraction chip. - - Burn (his table): 11.5 batches/day -> ~46 AL/day, uses 69 ALO/day; own ore ~40/day; buys ~29 ALO/day (~6.2k AIC/day). C, O, FLX 11.5/day each. DW, RAT 14.8/day. COF 1.9, PWO 0.7. - - Daily inputs ~26k AIC (C ~13.7k is the big one), AL revenue ~56k, margin ~30k/day. -- Ships: 2 starter ships, 500 t / 500 m3 cargo, empty mass 828 t. Ship 2 mostly idle. -- Fuel measured: ANT -> Deimos one way, nearly empty: 109 SF + 18 FF = 3,241 AIC. FTL is per jump (18 FF), STL scales with mass. Loaded round trip est. 8.5 to 10k AIC. -- Cash: ~6k at last report; wealth is in AL stock and daily output. -- Base permits: 2, one used (Deimos). Faction sent "expansion package" contract offer (arrives in CONTS, accept manually; deadline starts on accept; core module materials supplied, environment materials not). -- Second base decision: **Nike** (ZV-194a, Antares III), parked; see plan. -## 2. Current plan (agreed 2026-09-18) -### Thesis: Basic Hull Plates (BHP) on Deimos -- Recipe: 12 AL + 1 STL + 1 HE -> 4 BHP, 7.2 h, Hull Weld Plant (HWP: 25 area, 40 settlers + 10 technicians, Metallurgy expertise; cost 4 BBH, 6 BDE, 6 BSE, 2 LTA, 4 TRU + 100 MCG ~72k). -- Why: technician gate keeps producers few; AI1 supply fell ~3,300 -> ~800 units/month over the year; steel fell 2,700 -> 1,850; BHP rose 4,000 (Mar 2026) -> 6,490 (Sep) at all exchanges (NC1 4,300 bid/5,780 ask, CI1 5,000/6,500). Own AL costs ~565 to make vs 1,210 bid; plant adds ~500 to 675 AIC per AL on top. -- Margin history (per batch, AI1 monthly avg): positive 11 of last 14 months; negative Jan to Apr 2026 (steel spike). 12-month avg BHP ~4,700. -- Run without technicians: efficiency is proportional to headcount present, not tier-weighted (handbook FAQ confirms; example 20 settlers of 20+30 -> 40%). HWP with 40/50 -> ~80%: ~2.67 batches/day, ~11 BHP, ~32 AL/day. -- Net/day at 80%: ~19k at BHP 6,000, ~13.5k at 5,500, ~10k at 5,000. Capex HWP + HB2 ~105k -> 12 to 17%/day. HB3 (~155k, lightweight prefabs) adds only ~25% output -> 3 to 4%/day; conditional. -- Market risk: AI1 trades ~40 BHP/day; his 11/day is ~40% of that, so plan to sell at 5,500 to 6,000 with patient asks; CI1/NC1 as outlets. Cluster-wide ~6,000 BHP/month trade. -- Exit: sell AL as AL; sunk cost ~105k. -- Go/no-go before buying: BHP bid > 5,500 and STL ask < 2,300 (CXOB BHP.AI1, CXOB STL.AI1). -### Deimos TODO (ordered) -1. HWP + HB2 (~105k) + 3 days imports (~23k). Needs ~106 AL sold. Time completion near the weekly POPR so 40 settlers arrive (reserve pool may fill immediately). Deimos idle settlers ~17k, technicians ~1.3k. - - Buy: HWP 4 BBH, 6 BDE, 6 BSE, 2 LTA, 4 TRU, 100 MCG; HB2 2 BBH, 2 BDE, 2 BSE, 2 BTA, 2 TRU, 48 MCG; 8 STL, 8 HE; settler goods 3 days for 40: 6 DW, 7 RAT, 1 KOM, 1 EXO, 1 PT, 1 REP. - - Ongoing extra imports at 80%: 2.7 STL, 2.7 HE, 2.0 DW, 2.4 RAT, 0.4 KOM, 0.2 EXO, 0.2 PT, 0.08 REP per day (~7.6k AIC, ~22 t). Ship gets lighter overall (86 t/day less AL outbound). -2. FP for DW (~47k: 4 BBH, 4 BDE, 6 BSE, 48 MCG; 40 pioneers, 12 area). 10 H2O -> 7 DW, 2.4 h; buy water at ANT (0.2 t/unit, ~57 AIC), ~3.6k/day net, ~7.8%/day; margin positive every month for a year (1k to 3.5k/day range). FP is Food Industries expertise, no bonus on Deimos. -3. HB1 + EXT (~80k: 140 MCG, 18 BSE, 4 BBH, 2 BDE, 1 BTA). Ends the 29 ALO/day purchase; ~6%/day (18% only if smelters were idling). -4. HB3 (~155k: 4 LBH, 4 LDE, 4 LSE, 8 LTA, 56 MCG) only if PROD shows HWP ~80% and BHP > 5,500. Technician goods per 100/day: 7.5 DW, 7 RAT, 1 ALE, 0.5 MED, 0.5 HMS, 0.1 SCN, 0.1 SC. -5. Grow AL: EXT + 2 SME + HB1 per step (~180k, ~3.6%/day); second HWP when AL > 80/day and BHP still pays. -6. HQ level 1 (~56k: 12 MCG, 4 TRU, 6 BBH, 4 BDE, 4 BSE, 2 BTA) for a permit to expand Deimos to 750 area. -### Second base (parked): Nike, ZV-194a, Antares III -- 1 jump from Antares II; 2 jumps to ANT (via Antares II). Construction COGC. LST 58 (very rich), BOR 4. Env: 0.62 g, 0.04 bar (needs SEA, 1 per area, ~200 AIC each), 30 C. Fertility -1. LM + warehouse exist. Population: ~7k idle pioneers, ~15k idle settlers, ~3.8k idle technicians. -- Phase 0: found (25 SEA for core) + HB1 + EXT (~92k) -> ~42 LST/day (estimate, scaled by concentration) sold at ~159 -> ~5.3k/day, ~5.8%/day. -- Phase 1: PP1 + HB1 (~76k). PP1 pioneer-only, Construction expertise (bonus). BTA from 1 FE + 50 PE (3.6 h), BSE from 2 LST + 1 FE (6 h), BBH from 1 LST + 2 FE (7.9 h). -- Phase 2: BMP + HB1 (~79k). 4 LST + 2 SIO -> 50 MCG, 6 h (Manufacturing expertise, no bonus). -- Phase 3 optional: CHP + HB2 (~73k). 1 LST -> 10 FLX, 12 h (20 pio + 60 settlers, Chemistry, no bonus). -- Alternatives evaluated and rejected: Harmonia (ZV-896b, Antares IV; fertility -0.10, water 37, Agriculture; carbon via FRM 2 H2O -> 4 HCP then INC 4 HCP -> 4 C; ~268k for ~7.4k/day; better later for food when workforce > 600), Vulcan (FEO 35 but HSE per building, 1.1%/day), Black Mesa (-225 C, 4.6 bar, INS + HSE ~66k per building, TIO 12; 0.5 to 1%/day), Antares II a (85 C, TSH 40k per building), Phobos (Antares I, mild, water 15, SIO 30; fine as a later hub/bottling site), Norwick (YK-649b, Food Industries bonus, 2 jumps). -- Expansion contract: accept only when carrying the SEA; found base with BSC ZV-194a. -## 3. Game mechanics learned (verified unless marked est.) -- Profession only sets starter buildings; faction only sets start planets and community. COLIQ resets company (cooldown). One base per planet per company. Permits: 2 at start; HQ levels add permits; a permit can expand a base 500 -> 750 area; demolishing a core module (no HQ, no buildings, empty storage, ship present) refunds the permit. -- Demolition refunds materials: 100% within 1 h, then linear decay with age since last repair; ~0 at 60 days. BSL shows "Reclaimable materials". Repair around 90 days. -- MCG per building = 4 x area on normal rocky planets. Environment extras per building: TSH (>75 C, ~40k each), INS (cold, 10 per area, ~200 each), HSE (>2 bar, ~17k each), SEA (<0.25 bar, 1 per area, ~200 each), MGC (low g, ~39k), AEF + BL (gaseous). Core module: 4 LSE, 4 LDE, 4 LTA, 12 PSL, 8 TRU + 100 MCG + env extras. Planet BuildRequirements in FIO list exact extras. -- Workforce: efficiency multiplier = workers present / required, headcount-proportional across tiers (handbook). Missing luxuries: PWO alone ~87%, COF alone ~91% (FAQ). Weekly distribution at POPR; reserve pool fills immediately if available; bases keep 75% of workforce between reports. Idle workers still consume. -- Consumption per 100 per day: Pioneers 4 DW, 4 RAT, 0.5 OVE, 0.5 COF (lux), 0.2 PWO (lux). Settlers 5 DW, 6 RAT, 0.5 EXO, 0.5 PT, 1 KOM (lux), 0.2 REP (lux). Technicians 7.5 DW, 7 RAT, 0.5 MED, 0.5 HMS, 0.1 SCN, 1 ALE (lux), 0.1 SC (lux). Wages at AI1 asks: pioneer ~23, settler ~39, technician ~71, engineer ~249, scientist ~541 AIC/day. -- Housing: HB1 100 pioneers, 10 area (4 BBH, 2 BDE, 2 BSE, 1 BTA + 40 MCG ~33k). HB2 75 settlers, 12 area (~32k). HB3 75 technicians, 14 area (4 LBH, 4 LDE, 4 LSE, 8 LTA ~155k). -- Buildings (area, workforce, base cost ex MCG): EXT 25, 60 pio, 16 BSE. SME 17, 50 pio, 4 BBH 4 BDE 6 BSE. FP 12, 40 pio. FRM 30, 50 pio. INC 10, 40 pio. COL 15, 50 pio. RIG 10, 30 pio. BMP 12, 100 pio. PP1 19, 80 pio. WEL 70 pio. PP2 25, 25 pio + 25 sett. CHP 18, 20 pio + 60 sett. GF 27, 80 sett. HWP 25, 40 sett + 10 tech. FS 25, 50 sett. STO 15, no workforce. -- Extractor rate est.: scale his Deimos number (20 ALO/day at concentration 28) linearly by concentration; RIG/COL for liquids/gases assumed ~0.7x (unverified). Real rate shows only after building on that planet. -- Fertility: farm speed roughly x(1 + fertility); range about -33% to +33%. Deimos -0.34, Harmonia -0.10, Promitor +0.40. COGC programme bonus assumed ~25% for matching expertise (unverified; read PROD batch time vs BUI base time). -- Weights (t/unit): H2O 0.2, DW 0.1, RAT 0.21, C 2.25, O 1.14, FLX 0.25, ALO 1.35, AL 2.7, LST 2.73, MCG 0.24, BSE 0.3, SEA 0.15, STL 7.85. -- Carbon comes only from INC burning crops (HCP, GRN, MAI). FLX from CHP (1 LST -> 10). GL from GF (2 SIO + NA [+FLX] -> 10 to 12). STL from SME (2 FE + 8 O -> 2 STL) at about its market price. -- Markets: player-driven, thin; inter-exchange spreads of 5 to 15% persist because hauling is slow and capital-heavy; MM (market maker) sets floors/caps on some goods (e.g. SDR 100k bid). Prices trend over months, not days. Antares imports worker goods (SC, HMS, ALE, DDT) at a premium. -- Fuel: FTL fixed per jump (18 FF starter ship), STL scales with total mass. Jumps: Antares II is hub, 1 jump each to Antares I (ANT station), III (Nike), IV (Harmonia). Exchanges by distance from ZV-759 (FIO units): ANT 47, HRT 391, BEN 423, ARC 492, MOR 642, HUB 1414. Exchange codes: AI1 ANT, NC1 MOR, CI1 BEN, IC1 HRT, NC2 HUB, CI2 ARC. -- Shipping ads: 5 AIC/t cross-faction is a lowball; a starter ship needs fee > 2x SFC cost. Free licence cannot accept ads anyway. -- Naming planets is a paid support-tier perk, not tied to bases. -## 4. APEX commands that matter -BS base overview; BSC found/construct; BSL buildings list + demolish; WF workforce; PROD production lines (efficiency shown); BUI building info (generic, no planet extras); PLI planet info; POPR population report; SYSI ; CX ; CXOB order book; CXPC chart; CXPO place order; CXOS own orders; LM local market; CONTS contracts; CO company info incl. rating and faction reputation; USR user profile; SFC flight planning (fuel and cost); FLT fleet; INV; SCRN screens, ADD; COMG chat; NOTS notifications (not notes). Notes need Refined PrUn (XIT NOTE, XIT TODO). Screen variables exist (June 2026) but PRO only. Commands are case-insensitive. - -## 5. FIO API (rest.fnar.net), public, no auth -- /exchange/all: all materials x exchanges with Bid, Ask, Supply, Demand, MMBuy, MMSell (book totals only, no order list). /exchange/{MAT}.{EX}: single, includes BuyingOrders/SellingOrders lists. /exchange/cxpc/{MAT}.{EX}: OHLCV history (filter Interval == DAY_ONE). -- /exchange/station: stations with SystemId and ComexCode. -- /material/allmaterials: Ticker, Name, Weight, Volume, CategoryName, MaterialId. -- /recipes/allrecipes: BuildingTicker, Inputs, Outputs (Ticker, Amount), TimeMs. -- /building/allbuildings and /building/{TICKER}: AreaCost, Pioneers..Scientists, BuildingCosts (excludes MCG and env extras), Expertise. -- /planet/allplanets/full and /planet/{id}: Gravity, Pressure, Temperature, Fertility, Resources (MaterialId, Factor, ResourceType), COGCPrograms (ProgramType, Start/EndEpochMs), BuildRequirements (incl. MCG and env extras with amounts), HasLocalMarket, HasWarehouse, FactionCode, SystemId. Some fields may be None; guard. -- /systemstars: SystemId, NaturalId, Name, PositionX/Y/Z, Connections (jump graph; BFS for routes). -- /global/workforceneeds: consumption per 100 per tier. -- Private data (his inventory, production, ships) needs the FIO Client extension installed and a username / API key; not set up yet. -- Note: some planet resource Factor values differ from in-game display (Deimos H2O shows 20 in FIO vs 14 in UI); prefer in-game numbers when they conflict. -## 6. Analysis scripts (delivered as files) -- prun_scan.py: scans every recipe; buys inputs at ask, sells outputs at bid at one exchange; subtracts wages; capex = building + housing (MCG 4x area, no env extras, no COGC, no fuel); filters by days of open demand. Flags: --cx, --tier P|S|T|E|Sc, --depth, --sort roi|net|area, --top, --mcg, --understaff (drops tiers, efficiency = staffed share ** --penalty), --cross (buy cheapest CX, sell best CX; not a real trade route). -- prun_cxarb.py: pure inter-exchange arbitrage per material; spread per unit / ton / m3; depth proxy min(supply src, demand dst). Flags --from, --to, --minqty, --sort, --top. -- Findings 2026-09-18 (AI1): HWP BHP 24% ROI/day (25% without technicians); then settler tier ~8 to 9% (POL DEC, CLF HMS, FS AFR); pioneer tier: PP1 BTA 8.1%, FP DW 7.8%, FRM GRN 6.9%, WEL GV 6.6%, FP RAT ~6%, SME SI 5.9%. Raw-extraction bases galaxy-wide: Nike LST is #2 (5.8%) behind a Moria gold-ore planet. Last-step assembly with MM bids (e.g. surgical drones, 94k in / 100k out) is near-zero after wages. -## 7. Price snapshot 2026-09-18 (AI1 ask unless noted) -AL bid 1,210 / ask 1,250; ALO 215; C 1,140 (1y avg 1,066; 890 a year ago, ~1,200 since Feb); O 116; FLX 298 (bid 260); STL 2,000 (bid 1,600); HE 247; BHP bid 6,490; DW 151 (bid 150); RAT 232; H2O 59; COF 994; PWO 445; MCG 49; BBH 3,990; BDE 3,690; BSE 2,600; BTA 2,790; TRU 650; LTA 5,400; LBH 6,850; LDE 13,700; LSE 12,800; PSL 5,670; SEA 200; INS 197; HSE 16,900; TSH 39,900; LST 167 (bid 159); SIO 105 to 119; GL 411 to 470; HCP 469; FE 1,000 (bid 975); FEO 116; TI bid 2,110; TIO 338; SF 24.5 (bid 23.8); FF 35 (bid 28.6). Prefabs (BTA, BSE, BBH) are at 1-year highs (+35 to 55% vs annual average). - -## 8. Open items / things to verify in-game -- HWP: expertise line in BUI HWP (FIO says Metallurgy); actual efficiency without technicians on PROD; real BHP fill prices. -- Extractor LST rate on Nike (after building). SEA count per building on Nike BSC. -- COGC bonus size: compare PROD batch time to BUI base time. -- Loaded-ship fuel: SFC with full hold. -- Whether faction expansion contract has a founding deadline and what it supplies. -- Population report timing on Deimos (last one ~2026-09-16). diff --git a/docs/mechanics.md b/docs/mechanics.md index b779185..7cdf1a1 100644 --- a/docs/mechanics.md +++ b/docs/mechanics.md @@ -23,7 +23,7 @@ Faction bonus table: ANTARES electronics 5%; BENTEN manufacturing 5%; HORTUS agr ## Extraction (`extractionCalculations.ts`) - Cycle times: EXT (mineral) 12 h, COL (gas) 6 h, RIG (liquid) 4.8 h. - Amount per cycle = ceil(daily_extraction x cycle/24h); time scaled so daily rate stays daily_extraction. -- Daily extraction = factor x 70 (MINERAL, LIQUID) or x 60 (GASEOUS), factor = concentration as a fraction (backend `gamedata/fio/importers.py`). VERIFIED live: Deimos ALO factor 0.4 -> 28/day per EXT (FIO order 14 ALO / 12.008 h), O 0.3 x 60 = 18, H2O 0.2 x 70 = 14: exactly the resource chips APEX and PRUNplanner show (the chip number IS daily extraction, not concentration). The old handoff note 'FIO H2O 20 vs UI 14' was factor x 100 vs x 70. Efficiency then scales it. PRUNplanner quirk: ceil() on a float 28.0000004 shows 15 per 12h51m instead of 14 per 12h; the daily rate (28) is the same. +- Daily extraction = factor x 70 (MINERAL, LIQUID) or x 60 (GASEOUS), factor = concentration as a fraction (backend `gamedata/fio/importers.py`). VERIFIED live: an ALO factor of 0.4 -> 28/day per EXT (FIO order 14 ALO / 12.008 h), O 0.3 x 60 = 18, H2O 0.2 x 70 = 14: exactly the resource chips APEX and PRUNplanner show (the chip number IS daily extraction, not concentration). An old note 'FIO H2O 20 vs UI 14' was factor x 100 vs x 70. Efficiency then scales it. PRUNplanner quirk: ceil() on a float 28.0000004 shows 15 per 12h51m instead of 14 per 12h; the daily rate (28) is the same. ## Production time - Recipe time in a building = time_ms x repeats / efficiency (efficiency shortens time). Batches/day = 24h x buildings / sum(times) (`usePlanCalculation.ts`, `buildingCalculations.ts`). @@ -34,15 +34,24 @@ Faction bonus table: ANTARES electronics 5%; BENTEN manufacturing 5%; HORTUS agr - Optimizer is an LP (min cost, else min area if it does not fit). Reimplement in `puga/econ.py`. ## Conflicts with the old handoff (resolved in favour of PRUNplanner) -1. Handoff says "Antares Metallurgy bonus applies" on Deimos. PRUNplanner: Antares faction bonus is electronics only; metallurgy is Moria. The Deimos benefit is the COGC x1.25, not a faction bonus. Verify in-game via PROD batch time. -2. Handoff treats COGC ~25% as unverified; PRUNplanner models it as x1.25 efficiency for matching expertise. -3. Impact on BHP thesis: HWP is Metallurgy and Deimos runs an active Metallurgy COGC, so with both luxuries supplied SME/HWP run at about 125% workforce-weighted; HWP at 40/50 headcount is about 0.8 x 1.25 = 100% before expert and HQ. The handoff's 80% output figure ignores COGC. +1. A common assumption is that the Antares faction gives a Metallurgy bonus. PRUNplanner: the Antares faction bonus is electronics only; metallurgy is Moria. A Metallurgy benefit on an Antares base comes from the COGC x1.25, not a faction bonus. +2. COGC ~25% was once treated as unverified; PRUNplanner models it as x1.25 efficiency for matching expertise (confirmed against live FIO efficiency, see below). +3. Consequence: HWP is a Metallurgy building, so on a base with an active Metallurgy COGC, with both luxuries supplied, HWP at 40/50 headcount runs at about 0.8 x 1.25 = 100% before expert and HQ bonuses; do not assume 80%. 4. Fertility slope is 10/33 (about 0.30 per fertility point), not 1.0. ## Base recipe times (FIO, confirmed in APEX) - SME AL: non-flux 12h0m (6 ALO + C + O -> 3 AL), flux 14h24m (6 ALO + FLX + C + O -> 4 AL). Actual times = base / efficiency; use the efficiency model, not old hand measurements. ## Verified against live FIO (2026-09-18) -- FIO `/production/{user}` lines carry `Efficiency` (ground truth per building type) and `Condition`. Deimos smelters: 1.3361. Model: COGC 1.25 x expert(2) 1.0696 x condition 0.99936 = 1.33614, i.e. within 0.004%. So PRUNplanner's factor stack is right in-game; add a CONDITION factor (building wear) that PRUNplanner's list omits. Extractors: 0.9993 = condition only (no COGC on resource extraction). -- Company cash lives at `/company/code/{ticker}` Balances (AIC 21,950 on 2026-09-18). Permits: `/sites` InvestedPermits 1, MaximumPermits 3 (not 2). +- FIO `/production/{user}` lines carry `Efficiency` (ground truth per building type) and `Condition`. a live smelter with 2 experts: 1.3361. Model: COGC 1.25 x expert(2) 1.0696 x condition 0.99936 = 1.33614, i.e. within 0.004%. So PRUNplanner's factor stack is right in-game; add a CONDITION factor (building wear) that PRUNplanner's list omits. Extractors: 0.9993 = condition only (no COGC on resource extraction). +- Company cash lives at `/company/code/{ticker}` Balances. Permits: `/sites` InvestedPermits and MaximumPermits (the game maximum can exceed what a planner empire setting says). - Use `tools/state.py sync` to refresh `state/company.yaml`; it needs the FIO extension to have uploaded recently. + +## PRUNplanner plans always include the core module (CM) +`usePlanCalculation.ts` (calculateConstructionMaterials) hard-codes CM x1 into every plan's construction list, so Plan Cost, Area (+25) and the payback ('ROI ... d') always include a core module (~202k at universe prices), even if the base already has one. Plan Construction Cart subtracts what is already built, but the Plan Cost overview does not. Degradation is production buildings only / 180 (CM and habs excluded). For an add-on buildout, judge payback on new capex only (buildings + habs you actually need to construct), not the simulator's ROI line. `tools/simulate.py` reproduces this and prints area, cost and degradation the same way. + +## Workforce arrival: how and when do new workers appear? UNVERIFIED +- Claim carried from an old handoff: workers are distributed weekly at the population report (POPR), a reserve pool fills immediately if available, bases keep 75% of workforce between reports. Nothing in PRUNplanner's code, FIO or the APEX POPR screen confirms or refutes it; earlier answers repeated it too confidently. +- Verified facts: reports are WEEKLY (POPR chart points 7 days apart; #289 was Wed 16 Sep ~08:00Z, so next ~Wed 23 Sep ~08:00Z). FIO `/workforce/{user}/{planet}` gives per-tier Population, Reserve, Capacity, Required, Satisfaction, but no report time (LastWorkforceUpdateTime null). +- One base's data is consistent with both readings: the last HB1 + SME finished Wed 16 Sep 07:00Z, about an hour before report #289, and 370 pioneers are present now. +- TEST: build the HB2 (needed anyway) and poll FIO `/workforce/{user}/{planet}` (SETTLER Population/Reserve/Capacity). If settler Population rises well before Wed 23 Sep, workers arrive continuously and the report timing does not matter. Record the result here. diff --git a/docs/roadmap.md b/docs/roadmap.md index 8516967..7542484 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,7 +16,9 @@ Legend: [ ] todo, [x] done. Build order matters; each step is usable on its own. 10. [x] `tools/state.py sync` (sites, production efficiency, storage, ships, cash, permits): inventory, production, ships into `state/`. 11. [ ] Docs to fill: `docs/apex.md`, `docs/fio.md`, `docs/playbook.md` (question type -> tool), `docs/decisions.md`. +12. [ ] `tools/portfolio.py`: choose the set of 1-building opportunities for one base that maximises total profit given shared whole-building housing (HB1/HB2/...), capex, permits and (for demolish-later) 60-day value decay. Persistence check exists (`tools/persistence.py`); wire it into the ranking. + ## Open questions -- PRUNplanner auth is known (`Authorization: Api-Key `). FIO REST and FIO API are separate services with separate keys (Dominik; possibly separate accounts; my earlier "one API" reading was wrong). Keys are in `.env`. Verified 2026-09-18: FIO_REST_KEY works on rest.fnar.net as `Authorization: ` (own /sites, /storage etc. readable); FIO_API_KEY gives 401 there, host unknown. PRUNplanner key works (plans 0, empires 1, cx prefs 1 on his account). Still unknown: the OpenAPI spec at https://doc.fnar.net/api.json has no securitySchemes. Probe with read-only calls. +- PRUNplanner auth is known (`Authorization: Api-Key `). FIO REST and FIO API are separate services with separate keys (separate services with separate keys; an earlier "one API" reading was wrong). Keys are in `.env`. Verified 2026-09-18: FIO_REST_KEY works on rest.fnar.net as `Authorization: ` (own /sites, /storage etc. readable); FIO_API_KEY gives 401 there, host unknown. PRUNplanner key works (plans 0, empires 1, cx prefs 1 on his account). Still unknown: the OpenAPI spec at https://doc.fnar.net/api.json has no securitySchemes. Probe with read-only calls. - (resolved) PRUNplanner `exchanges/` has vwap_daily/7d/30d and traded volume; merged into `puga/market.py`. Trend history via cxpc still todo. - Extraction: concentration to daily rate formula. diff --git a/plans/deimos_bhp.yaml b/plans/examples/base_plus_hwp.yaml similarity index 69% rename from plans/deimos_bhp.yaml rename to plans/examples/base_plus_hwp.yaml index 1a1c704..7a5ac1a 100644 --- a/plans/deimos_bhp.yaml +++ b/plans/examples/base_plus_hwp.yaml @@ -1,10 +1,10 @@ -# Deimos as it stands (FIO 2026-09-18) plus the BHP thesis: 1 HWP (STL variant) and 1 HB2 for its 40 settlers. +# A base as it stands plus the BHP thesis: 1 HWP (STL variant) and 1 HB2 for its 40 settlers. # Technicians left unstaffed (no HB3): workforce factor 40/50 = 0.8. -name: "[PuGa] Deimos BHP (HWP + HB2)" +name: "[PuGa] Example: base + HWP" planet: ZV-759c permits: 1 cogc: METALLURGY -hq: false +hq: true experts: {METALLURGY: 2} infrastructure: {HB1: 4, HB2: 1} buildings: diff --git a/plans/examples/hwp_buildout.yaml b/plans/examples/hwp_buildout.yaml new file mode 100644 index 0000000..e197163 --- /dev/null +++ b/plans/examples/hwp_buildout.yaml @@ -0,0 +1,11 @@ +# The buildout only: 1 HWP (BHP from AL + STL + HE) and the 1 HB2 for its 40 settlers. +# Technicians (10) left unstaffed: workforce factor 40/50 = 0.8. No pioneer housing, no EXT/SME (existing base, sunk). +name: "[PuGa] Example: HWP buildout" +planet: ZV-759c +permits: 1 +cogc: METALLURGY +hq: true +experts: {METALLURGY: 2} +infrastructure: {HB2: 1} +buildings: + - {building: HWP, amount: 1, recipes: ["AL,STL,HE=>BHP"]} diff --git a/plans/examples/hwp_only.yaml b/plans/examples/hwp_only.yaml new file mode 100644 index 0000000..976d88a --- /dev/null +++ b/plans/examples/hwp_only.yaml @@ -0,0 +1,10 @@ +# Matches a simulator screenshot: EXT/SME removed from the plan, 4 HB1 still listed, HWP + HB2. +name: "[PuGa] Example: HWP only" +planet: ZV-759c +permits: 1 +cogc: METALLURGY +hq: true +experts: {METALLURGY: 2} +infrastructure: {HB1: 4, HB2: 1} +buildings: + - {building: HWP, amount: 1, recipes: ["AL,STL,HE=>BHP"]} diff --git a/puga/cache.py b/puga/cache.py index 89a661c..ee17dad 100644 --- a/puga/cache.py +++ b/puga/cache.py @@ -1,5 +1,5 @@ """Tiny disk cache for JSON over HTTP, keyed by URL, expiry by TTL seconds.""" -import hashlib, json, time, urllib.request +import hashlib, json, time, urllib.error, urllib.request from .config import CACHE_DIR UA = "PuGa/0.1 (personal advisory toolkit)" @@ -11,7 +11,14 @@ def get_json(url: str, ttl: int, headers: dict[str, str] | None = None, refresh: if not refresh and f.exists() and time.time() - f.stat().st_mtime < ttl: return json.loads(f.read_text()) req = urllib.request.Request(url, headers={"User-Agent": UA, **(headers or {})}) - with urllib.request.urlopen(req, timeout=60) as r: - data = json.load(r) + for attempt in range(4): # transient SSL/connection errors happen on long scans + try: + with urllib.request.urlopen(req, timeout=60) as r: + data = json.load(r) + break + except (urllib.error.URLError, ConnectionError, TimeoutError): + if attempt == 3: + raise + time.sleep(1.5 * (attempt + 1)) f.write_text(json.dumps(data)) return data diff --git a/puga/config.py b/puga/config.py index c96e796..e3e6662 100644 --- a/puga/config.py +++ b/puga/config.py @@ -24,3 +24,12 @@ def get(key: str, default: str | None = None) -> str | None: DEFAULT_CX = get("DEFAULT_CX", "AI1") CACHE_DIR = ROOT / "data" / "cache" + + +EMPIRE_DIR = ROOT / "empire" # gitignored: our empire: player profile, company state, own plans and notes + + +def state_path(): + """Real company state if synced (empire/), else the tracked example so the tools still run.""" + real = EMPIRE_DIR / "state" / "company.yaml" + return real if real.exists() else ROOT / "state" / "company.example.yaml" diff --git a/puga/fio.py b/puga/fio.py index 762dc28..bd7035b 100644 --- a/puga/fio.py +++ b/puga/fio.py @@ -24,7 +24,7 @@ def systems(refresh=False): return _g("/systemstars", STATIC, refresh) def stations(refresh=False): return _g("/exchange/station", STATIC, refresh) -def private(path: str, ttl: int = 300, refresh: bool = False): +def own(path: str, ttl: int = 300, refresh: bool = False): """Authenticated FIO REST (own data: /sites/{user}, /storage/{user}, /production/{user}, /ship/ships/{user}...). Auth: `Authorization: ` (verified 2026-09-18). FIO_API_KEY does NOT work on rest.fnar.net.""" from . import config diff --git a/puga/market.py b/puga/market.py index 6484ddc..514892c 100644 --- a/puga/market.py +++ b/puga/market.py @@ -51,3 +51,14 @@ def walk(mat: str, cx: str, qty: float, side: str, refresh=False) -> dict: break filled = qty - left return dict(avg=total / filled if filled else None, filled=filled, worst=worst, total=total, short=left > 0) + + +def uni30(snap: dict, tk: str) -> float | None: + """PRUNplanner's 'Universe 30D' price basis: volume-weighted 30d VWAP across all exchanges (verified against its BHP price).""" + num = den = 0.0 + for cx in CXS: + q = snap.get((tk, cx)) + if q and q.vwap30 and q.traded30: + num += q.vwap30 * q.traded30 + den += q.traded30 + return num / den if den else None diff --git a/puga/simulate.py b/puga/simulate.py new file mode 100644 index 0000000..3f948d9 --- /dev/null +++ b/puga/simulate.py @@ -0,0 +1,101 @@ +"""Replica of PRUNplanner's plan simulator (usePlanCalculation.ts): workforce, efficiency, material I/O, cost, profit. +Input is a PRUNplanner plan JSON (as stored by the API, or built by tools/plan_push.build_payload). Pure given the data passed in. +Validated against a screenshot of the real simulator (tests/test_simulate.py).""" +from . import econ + +TIERS = econ.TIERS +FARMS = {"FRM", "ORC"} +DEGRADATION_DAYS = 180 # usePlanCalculation.ts: degradation/day = construction cost / 180 + + +def simulate(plan: dict, recipes: list[dict], buildings: list[dict], resources: list[dict], fertility: float, + price, faction: str | None = None, permits: tuple[float, float] = (1, 2), mcg_per_area: float = 4.0, + built: dict[str, int] | None = None) -> dict: + """price(ticker, side) -> AIC per unit or None; side is "buy" (net consumed) or "sell" (net produced), like PRUNplanner BUY/SELL exchange preferences; construction uses "buy". resources: planet resources [{material_ticker, resource_type, daily_extraction}].""" + d = plan["plan_data"] + bl = {b["building_ticker"]: b for b in buildings} + rec = {r["recipe_id"]: r for r in recipes} + cogc = plan.get("plan_cogc") + cogc = None if cogc in (None, "---") else cogc + experts = {e["type"].upper(): e["amount"] for e in d["experts"]} + lux = {w["type"]: (w["lux1"], w["lux2"]) for w in d["workforce"]} + + req = {t: sum(bl[b["name"]][t + "s"] * b["amount"] for b in d["buildings"]) for t in TIERS} + cap = dict.fromkeys(TIERS, 0) + for i in d["infrastructure"]: + hab = bl[i["building"]].get("habitations") + if hab: + for t in TIERS: + cap[t] += hab[t + "s"] * i["amount"] + tier_eff = {t: econ.tier_efficiency(cap[t], req[t], *lux[t]) for t in TIERS} + workforce = {t: dict(need=req[t], supply=cap[t], open=cap[t] - req[t], eff=tier_eff[t]) for t in TIERS} + + flows: dict[str, dict[str, float]] = {} + + def add(side, tk, amt): + flows.setdefault(tk, {"in": 0.0, "out": 0.0})[side] += amt + + lines = [] + for b in d["buildings"]: + info = bl[b["name"]] + heads = {t + "s": info[t + "s"] for t in TIERS} + eff, elements = econ.building_efficiency(heads, tier_eff, expertise=info["expertise"], cogc=cogc, hq=plan.get("plan_corphq", False), + experts=experts, faction=faction, permits_used=permits[0], permits_total=permits[1], + fertility=fertility, is_farm=b["name"] in FARMS) + rs = [] + for ar in b["active_recipes"]: + if ar["amount"] == 0: # switched off in the UI (quantity 0): no production, workforce still required + continue + rid = ar["recipeid"] + if "#" in rid and "=>" not in rid: # extraction, e.g. EXT#ALO: daily rate = factor*70 (60 gas) + tk = rid.split("#")[1] + res = next(r for r in resources if r["material_ticker"] == tk) + rs.append(dict(time_ms=econ.TOTAL_MS_DAY, inputs={}, outputs={tk: res["daily_extraction"]}, amount=ar["amount"])) + else: + r = rec[rid] + rs.append(dict(time_ms=r["time_ms"], inputs={i["material_ticker"]: i["material_amount"] for i in r["inputs"]}, + outputs={o["material_ticker"]: o["material_amount"] for o in r["outputs"]}, amount=ar["amount"])) + if rs and eff > 0: + io = econ.production_io(rs, eff, b["amount"]) + for tk, v in io["in"].items(): + add("in", tk, v) + for tk, v in io["out"].items(): + add("out", tk, v) + lines.append(dict(building=b["name"], amount=b["amount"], efficiency=eff, elements=elements, recipes=[a["recipeid"] for a in b["active_recipes"]])) + + for t in TIERS: + for tk, v in econ.workforce_consumption(t, req[t], cap[t], *lux[t]).items(): + add("in", tk, v) + + def unit_cost(tk): + info = bl[tk] + return sum(c["material_amount"] * (price(c["material_ticker"], "buy") or 0) for c in info["costs"]) + mcg_per_area * info["area_cost"] * (price("MCG", "buy") or 0) + + # Verified vs simulator screenshots: degradation = production buildings' construction cost / 180 (infrastructure excluded); + # plan cost and area also include one core module (CM), added automatically. + prod_cost = sum(unit_cost(b["name"]) * b["amount"] for b in d["buildings"]) + infra_cost = sum(unit_cost(i["building"]) * i["amount"] for i in d["infrastructure"]) + cm_cost = unit_cost("CM") + plan_cost = prod_cost + infra_cost + cm_cost + area = (sum(bl[b["name"]]["area_cost"] * b["amount"] for b in d["buildings"]) + + sum(bl[i["building"]]["area_cost"] * i["amount"] for i in d["infrastructure"]) + bl["CM"]["area_cost"]) + degradation = prod_cost / DEGRADATION_DAYS + rows = {} + missing = [] + for tk, f in flows.items(): + delta = f["out"] - f["in"] + p = price(tk, "sell" if delta > 0 else "buy") + if p is None: + missing.append(tk) + p = 0.0 + rows[tk] = dict(inp=f["in"], out=f["out"], delta=delta, value=delta * p, price=p) + gross = sum(r["value"] for r in rows.values()) + profit = gross - degradation + # construction still to do = planned minus already built (incl. CM), like PRUNplanner's Construction Cart + built = built or {} + planned = {} + for tk, n in [(b["name"], b["amount"]) for b in d["buildings"]] + [(i["building"], i["amount"]) for i in d["infrastructure"]]: + planned[tk] = planned.get(tk, 0) + n + new_capex = sum(unit_cost(tk) * max(n - built.get(tk, 0), 0) for tk, n in planned.items()) + (0 if built.get("CM") else cm_cost) + return dict(cm_cost=cm_cost, new_capex=new_capex, planned=planned, area=area, prod_cost=prod_cost, infra_cost=infra_cost, workforce=workforce, buildings=lines, flows=rows, plan_cost=plan_cost, degradation=degradation, gross=gross, + profit=profit, roi_days=(plan_cost / profit if profit > 0 else None), missing_prices=missing) diff --git a/state/company.example.yaml b/state/company.example.yaml new file mode 100644 index 0000000..f2c007d --- /dev/null +++ b/state/company.example.yaml @@ -0,0 +1,12 @@ +# Example only. `tools/state.py sync` writes the real file to private/state/company.yaml (gitignored). +as_of: "2026-01-01 00:00Z" +company: {name: Example Co, ticker: EXC, user: yourname, faction: ANTARES, home_cx: AI1, currency: AIC} +licence: FREE +hq: false # true if you have a corporation HQ (adds x1.1 efficiency) +permits: {used: 1, total: 2} +cash: {AIC: 10000} +bases: + - name: Example + planet: XX-000a + cogc: METALLURGY + buildings: {SME: 2, HB1: 1} diff --git a/state/company.yaml b/state/company.yaml deleted file mode 100644 index dbb8b50..0000000 --- a/state/company.yaml +++ /dev/null @@ -1,50 +0,0 @@ -as_of: 2026-09-18 20:52Z -company: {name: greenish-blue Industries, ticker: GBI, user: dodox, faction: ANTARES, home_cx: AI1, currency: AIC} -licence: FREE -permits: {used: 1, total: 3} -bases: -- name: Deimos - planet: ZV-759c - cogc: METALLURGY - area: {used: 200, total: 500} - buildings: {SME: 5, EXT: 2, HB1: 4, CM: 1} - workforce: - pioneers: {required: 370, capacity: 400} - notes: flux recipe 6 ALO+FLX+C+O -> 4 AL (base 14h24m, divide by efficiency); buys ~29 ALO/day; refresh from live FIO/PROD - avg_condition: 0.9994 - production: - - {type: extractor, count: 2, efficiency: 0.9993, queued_orders: 7} - - {type: smelter, count: 5, efficiency: 1.3361, queued_orders: 10} -ships: -- {name: null, reg: AVI-07JFC, mass: 1066.5760498046875, in_flight: true} -- {name: null, reg: AVI-07JFB, mass: 827.7999877929688, in_flight: false} -cash: {ICA: 5000.0, AIC: 21950.099609375, CIS: 5000.0, NCC: 5000.0} -storage: -- type: STORE - name: null - weight: 167/1500t - items: {OVE: 137, SF: 306, C: 25, O: 25, DW: 113, FLX: 25, FF: 200, PWO: 27, COF: 14, RAT: 113, ALO: 5} -- type: SHIP_STORE - name: AVI-07JFC - weight: 154/500t - items: {AL: 32, FE: 4} -- type: STL_FUEL_STORE - name: AVI-07JFB - weight: 0/90t - items: {SF: 0} -- type: STL_FUEL_STORE - name: AVI-07JFC - weight: 72/90t - items: {SF: 1198} -- type: FTL_FUEL_STORE - name: AVI-07JFC - weight: 13/15t - items: {FF: 264} -- type: FTL_FUEL_STORE - name: AVI-07JFB - weight: 0/15t - items: {FF: 0} -- type: SHIP_STORE - name: AVI-07JFB - weight: 0/500t - items: {} diff --git a/tests/test_econ.py b/tests/test_econ.py index 34314ea..c410744 100644 --- a/tests/test_econ.py +++ b/tests/test_econ.py @@ -45,7 +45,7 @@ def test_hwp_without_technicians_under_metallurgy_cogc(): assert el["WORKFORCE"] == pytest.approx(0.8) and total == pytest.approx(1.0) -def test_extraction_matches_live_deimos(): +def test_extraction_matches_live_planet(): """Planet data ZV-759c: ALO factor 0.4 (MINERAL), O 0.3 (GASEOUS), H2O 0.2 (LIQUID). APEX/PRUNplanner chips: 28 ALO, 18 O, 14 H2O per day. Live FIO EXT order: 14 ALO per 12.008 h = 28/day.""" assert econ.daily_extraction(0.4, "MINERAL") == pytest.approx(28.0) @@ -75,7 +75,7 @@ def test_hab_optimizer_matches_prunplanner(): def test_matches_live_fio_smelter_efficiency(): - """Deimos SME, FIO /production 2026-09-18: Efficiency 1.3360869884. All pioneers, both luxuries met, Metallurgy COGC, + """A live smelter, FIO /production 2026-09-18: Efficiency 1.3360869884. All pioneers, both luxuries met, Metallurgy COGC, 2 metallurgy experts, condition 0.9993602633.""" tier = dict(pioneer=econ.tier_efficiency(400, 370, True, True)) total, el = econ.building_efficiency(dict(pioneers=50), tier, expertise="METALLURGY", cogc="METALLURGY", diff --git a/tests/test_simulate.py b/tests/test_simulate.py new file mode 100644 index 0000000..f9d5e5f --- /dev/null +++ b/tests/test_simulate.py @@ -0,0 +1,84 @@ +"""Test vector: screenshots of PRUNplanner's simulator for plans/examples/base_plus_hwp.yaml (2026-09-18). Needs cached/online PRUNplanner data.""" +import importlib.util +from pathlib import Path +import pytest, yaml + +ROOT = Path(__file__).resolve().parent.parent +spec = importlib.util.spec_from_file_location("plan_push", ROOT / "tools" / "plan_push.py") +plan_push = importlib.util.module_from_spec(spec) +spec.loader.exec_module(plan_push) +from puga import prunplanner as pp +from puga.simulate import simulate + + +@pytest.fixture(scope="module") +def sim(): + try: + recipes, blds = pp.recipes(), pp.buildings() + planet = pp._g("/data/planet/ZV-759c/", 3600) + except Exception as e: # offline + pytest.skip(f"no PRUNplanner data: {e}") + plan = plan_push.build_payload(yaml.safe_load((ROOT / "plans" / "examples" / "base_plus_hwp.yaml").read_text()), recipes, {b["building_ticker"] for b in blds}) + plan["plan_corphq"] = False # screenshots 1-3 were taken without HQ; plan files now say hq: true + return lambda off=(): _run(plan, recipes, blds, planet, off) + + +def _run(plan, recipes, blds, planet, off): + import copy + plan = copy.deepcopy(plan) + for b in plan["plan_data"]["buildings"]: + if b["name"] in off: + for ar in b["active_recipes"]: + ar["amount"] = 0 + return simulate(plan, recipes, blds, planet["resources"], planet["fertility"], lambda t, side='both': 100.0) + + +def test_efficiencies_and_workforce_match_screenshot(sim): + r = sim() + eff = {b["building"]: b["efficiency"] for b in r["buildings"]} + assert eff["EXT"] == pytest.approx(1.0) and eff["SME"] == pytest.approx(1.337, abs=1e-4) and eff["HWP"] == pytest.approx(1.0696, abs=1e-4) + wf = r["workforce"] + assert (wf["pioneer"]["need"], wf["pioneer"]["supply"], wf["settler"]["supply"], wf["technician"]["open"]) == (370, 400, 100, -10) + + +def test_material_flows_match_screenshot(sim): + f = sim()["flows"] + exp = {"AL": (42.78, 44.57), "ALO": (66.85, 56.00), "BHP": (0, 14.26), "C": (11.14, 0), "COF": (1.85, 0), "DW": (16.80, 0), + "EXO": (0.20, 0), "FLX": (11.14, 0), "HE": (3.57, 0), "KOM": (0.40, 0), "O": (11.14, 0), "OVE": (1.85, 0), + "PT": (0.20, 0), "PWO": (0.74, 0), "RAT": (17.20, 0), "REP": (0.08, 0), "STL": (3.57, 0)} + for tk, (i, o) in exp.items(): + assert f[tk]["inp"] == pytest.approx(i, abs=0.01) and f[tk]["out"] == pytest.approx(o, abs=0.01), tk + + +def test_switched_off_recipes_produce_nothing_but_keep_workforce(sim): + r = sim(off=("EXT", "SME")) + assert r["flows"]["AL"]["out"] == 0 and "ALO" not in r["flows"] # no ALO row at all, as in the screenshot + assert r["flows"]["DW"]["inp"] == pytest.approx(16.80, abs=0.01) # idle pioneers still consume (screenshot 2) + assert r["flows"]["AL"]["inp"] == pytest.approx(42.78, abs=0.01) + + +def test_cost_degradation_area_match_screenshots(): + """Screenshots: full plan area 237, degradation 2,141.74, plan cost 737,076; HWP-only area 102, degradation 373.60, cost 418,812. + Prices drift, so 3% tolerance on money values; area is exact.""" + from puga import market + try: + recipes, blds = pp.recipes(), pp.buildings() + planet = pp._g("/data/planet/ZV-759c/", 3600) + snap = market.snapshot() + except Exception as e: + pytest.skip(f"offline: {e}") + price = lambda t, side='both': market.uni30(snap, t) + for fname, area, degr, cost in (("base_plus_hwp.yaml", 237, 2141.74, 737076.53), ("hwp_only.yaml", 102, 373.60, 418811.91)): + plan = plan_push.build_payload(yaml.safe_load((ROOT / "plans" / "examples" / fname).read_text()), recipes, {b["building_ticker"] for b in blds}) + plan["plan_corphq"] = False + r = simulate(plan, recipes, blds, planet["resources"], planet["fertility"], price) + assert r["area"] == area + assert r["degradation"] == pytest.approx(degr, rel=0.03) and r["plan_cost"] == pytest.approx(cost, rel=0.03) + + +def test_hq_multiplies_every_building_by_1_1(sim): + """Screenshot 6: HWP 117.66% with HQ ticked (0.8 x 1.25 x 1.0696 x 1.1).""" + import copy + base = sim() + assert next(b for b in base["buildings"] if b["building"] == "HWP")["efficiency"] == pytest.approx(1.0696, abs=1e-4) + assert 1.0696 * 1.1 == pytest.approx(1.1766, abs=1e-4) diff --git a/tools/history.py b/tools/history.py new file mode 100755 index 0000000..545ed87 --- /dev/null +++ b/tools/history.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Margin history of a recipe from daily exchange candles (FIO cxpc DAY_ONE): output value minus input cost per batch, per month. +Daily price = value traded / units traded (VWAP), forward-filled over days without trades. + tools/history.py KV # AI1, the recipe producing KV + tools/history.py BHP --cx AI1 --eff 1.18 --overhead 3200 --capex 94000 --months 12 +Building/day = batches/day at --eff; profit/day = batches/day * margin_per_batch - overhead (wages etc, --overhead).""" +import argparse, datetime, statistics, sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from puga import config, fio + + +def daily_prices(tk, cx): + out = {} + for e in fio.cxpc(tk, cx): + if e.get("Interval") == "DAY_ONE" and e.get("Traded"): + out[datetime.datetime.fromtimestamp(e["DateEpochMs"] / 1000, datetime.timezone.utc).date()] = (e["Volume"] / e["Traded"], e["Traded"]) + return out + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("ticker") + ap.add_argument("--cx", default=config.DEFAULT_CX) + ap.add_argument("--recipe", type=int, default=0, help="index if several recipes produce it") + ap.add_argument("--eff", type=float, default=1.0) + ap.add_argument("--overhead", type=float, default=0.0, help="daily wages/other per building") + ap.add_argument("--capex", type=float, help="per building; gives ROI/day") + ap.add_argument("--months", type=int, default=14) + a = ap.parse_args() + t = a.ticker.upper() + recs = [r for r in fio.recipes() if any(o["Ticker"] == t for o in r["Outputs"])] + r = recs[a.recipe] + ins = {i["Ticker"]: i["Amount"] for i in r["Inputs"]} + outs = {o["Ticker"]: o["Amount"] for o in r["Outputs"]} + per_day = 24 / (r["TimeMs"] / 3.6e6) * a.eff + print(f"{r['BuildingTicker']}: {ins} -> {outs}, {r['TimeMs']/3.6e6:.1f}h, eff {a.eff}: {per_day:.3f} batches/day; recipe {a.recipe + 1} of {len(recs)}") + series = {m: daily_prices(m, a.cx) for m in list(ins) + list(outs)} + days = sorted(set.intersection(*[set(s) for s in series.values()])) if False else sorted(set().union(*[set(s) for s in series.values()])) + last = {} + rows = [] + for d in days: + for m, s in series.items(): + if d in s: + last[m] = s[d][0] + if len(last) == len(series): + rev = sum(last[m] * n for m, n in outs.items()) + cost = sum(last[m] * n for m, n in ins.items()) + rows.append((d, rev, cost, series[t].get(d, (0, 0))[1])) + if not rows: + sys.exit("no overlapping history") + by = {} + for d, rev, cost, tr in rows: + by.setdefault((d.year, d.month), []).append((rev, cost, tr, per_day * (rev - cost) - a.overhead)) + print(f"\n{'month':8} {'out/batch':>10} {'in/batch':>10} {'margin':>9} {'margin%':>7} {'profit/d':>9} {'ROI/d%':>7} {'units/d':>8} {'days>0':>7}") + for (y, m), v in list(sorted(by.items()))[-a.months:]: + rev = statistics.mean(x[0] for x in v); cost = statistics.mean(x[1] for x in v); pr = statistics.mean(x[3] for x in v) + roi = f"{100 * pr / a.capex:7.1f}" if a.capex else f"{'-':>7}" + print(f"{y}-{m:02d} {rev:10.0f} {cost:10.0f} {rev-cost:9.0f} {100*(rev-cost)/cost if cost else 0:7.1f} {pr:9.0f} {roi} " + f"{statistics.mean(x[2] for x in v):8.1f} {100*sum(1 for x in v if x[3] > 0)/len(v):6.0f}%") + prof = [per_day * (rev - cost) - a.overhead for _, rev, cost, _ in rows] + for label, n in (("last 30d", 30), ("last 90d", 90), ("last 180d", 180), ("all", len(prof))): + p = prof[-n:] + print(f"{label:9} profit/d mean {statistics.mean(p):8.0f} min {min(p):8.0f} max {max(p):8.0f} days>0 {100*sum(1 for x in p if x > 0)/len(p):4.0f}%" + + (f" ROI/d {100*statistics.mean(p)/a.capex:5.1f}%" if a.capex else "")) + + +if __name__ == "__main__": + main() diff --git a/tools/persistence.py b/tools/persistence.py new file mode 100755 index 0000000..aa4b118 --- /dev/null +++ b/tools/persistence.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""How stable are scan opportunities? Re-prices each scan row over its exchange history (daily VWAP candles) and reports +mean ROI/day over 30/90/180 days and all history, plus the share of days with positive profit. + tools/scan.py --min-n 3 --json /tmp/rows.json --top 20 ; tools/persistence.py /tmp/rows.json [--only KV,BHP] +Method: profit(period) = profit_now + batches/day * (margin_period - margin_now), margin = output value - input cost per batch at daily VWAP, +margin_now = mean of the last 7 days. So wages/freight/efficiency in the scan row carry over; only prices move.""" +import argparse, json, re, statistics, sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from puga import config +from history import daily_prices + + +def parse(side): + return {m.group(2): float(m.group(1)) for m in re.finditer(r"([\d.]+)([A-Z0-9]+)", side)} + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("rows") + ap.add_argument("--cx", default=config.DEFAULT_CX) + ap.add_argument("--only", help="comma list of output tickers") + ap.add_argument("--top", type=int, default=25) + ap.add_argument("--lag", type=float, default=3.0, help="days from decision to first output (build, hauling, workers)") + ap.add_argument("--min-mkt", type=float, default=0.0) + a = ap.parse_args() + only = {x.strip().upper() for x in a.only.split(",")} if a.only else None + rows = json.loads(Path(a.rows).read_text()) + cache = {} + out = [] + seen = set() + for r in rows: + left, right = r["rec"].replace(" [thin]", "").split("->") + ins, outs = parse(left), parse(right) + if only and not (set(outs) & only): + continue + key = (r["rec"], r["bld"], r["staff"]) + if key in seen: + continue + seen.add(key) + toks = list(ins) + list(outs) + for t in toks: + if t not in cache: + cache[t] = daily_prices(t, a.cx) + last, series = {}, [] + for d in sorted(set().union(*[set(cache[t]) for t in toks])): + for t in toks: + if d in cache[t]: + last[t] = cache[t][d][0] + if len(last) == len(toks): + series.append(sum(last[t] * n for t, n in outs.items()) - sum(last[t] * n for t, n in ins.items())) + if len(series) < 60: + continue + per_day = 24 / r["h"] * r["eff"] + now = statistics.mean(series[-7:]) + prof = [r["total"] / r["n"] + per_day * (m - now) for m in series] # per building + capex = r["capex"] / r["n"] + f = lambda n: 100 * statistics.mean(prof[-n:]) / capex + p14 = statistics.mean(prof[-14:]) + out.append((f(14), dict(pb=capex / p14 if p14 > 0 else 999, net7=p14 * (7 - a.lag) - capex, net14=p14 * (14 - a.lag) - capex, d7=f(7), d14=f(14), capex=capex, profit_now=r["total"] / r["n"], rec=r["rec"], bld=r["bld"], staff=r["staff"], now=100 * (r["total"] / r["n"]) / capex, d30=f(30), d90=f(90), d180=f(180), all=f(len(prof)), + pos=100 * sum(1 for x in prof[-180:] if x > 0) / len(prof[-180:]), days=len(prof), market=r["n_lim"]))) + out.sort(key=lambda x: -x[0]) # ranked by 14-day mean ROI + out = [x for x in out if x[1]["market"] >= a.min_mkt] + print(f"{'now':>5} {'7d':>5} {'14d':>5} {'30d':>5} {'payback':>7} {'net@7d':>8} {'net@14d':>8} {'90d':>5} {'180d':>5} {'pos%':>5} {'days':>5} {'mkt':>5} bld staff recipe (ROI/day % per building; pos% = days profitable in last 180d)") + for _, o in out[:a.top]: + print(f"{o['now']:5.1f} {o['d7']:5.1f} {o['d14']:5.1f} {o['d30']:5.1f} {o['pb']:6.1f}d {o['net7']:8.0f} {o['net14']:8.0f} {o['d90']:5.1f} {o['d180']:5.1f} {o['pos']:5.0f} {o['days']:5d} {o['market']:5.1f} {o['bld']:4} {o['staff']:5} {o['rec']}") + + +if __name__ == "__main__": + main() diff --git a/tools/plan_push.py b/tools/plan_push.py index 535d950..57488f6 100755 --- a/tools/plan_push.py +++ b/tools/plan_push.py @@ -1,13 +1,14 @@ #!/usr/bin/env python3 """Turn a YAML plan spec (plans/*.yaml) into a PRUNplanner plan, via the Api-Key API. -DEFAULT IS DRY RUN. Guardrails (docs/decisions.md): only plans named '[PuGa] ...' are created/updated; never delete; ---update refuses if the existing plan's name does not start with '[PuGa]'. Show the dry-run to Dominik and get a yes before --apply. +DEFAULT IS DRY RUN. Guardrails (docs/decisions.md): only plans named '[PuGa] ...' are created/updated; deletes only [PuGa] plans and only when asked; +--update refuses if the existing plan's name does not start with '[PuGa]'. Show the dry-run to the user and get a yes before --apply. tools/plan_push.py list - tools/plan_push.py plans/deimos_bhp.yaml # dry run: validated payload summary - tools/plan_push.py plans/deimos_bhp.yaml --json # full JSON payload - tools/plan_push.py plans/deimos_bhp.yaml --apply # create (after user says yes) - tools/plan_push.py plans/deimos_bhp.yaml --apply --update + tools/plan_push.py plans/examples/base_plus_hwp.yaml # dry run: validated payload summary + tools/plan_push.py plans/examples/base_plus_hwp.yaml --json # full JSON payload + tools/plan_push.py plans/examples/base_plus_hwp.yaml --apply # create (after user says yes) + tools/plan_push.py plans/examples/base_plus_hwp.yaml --apply --update + tools/plan_push.py delete # only [PuGa] plans, only when the user asks Spec: name, planet (natural id), permits, cogc (e.g. METALLURGY or null), hq, experts {METALLURGY: 2}, lux {pioneer: [true,true]}, infrastructure {HB1: 4, HB2: 1}, buildings: [{building: SME, amount: 5, recipes: ["ALO,FLX,C,O=>4AL"|"AL,STL,HE=>BHP"|"EXT#ALO"]}] @@ -79,7 +80,8 @@ def build_payload(spec: dict, recipes: list[dict], building_tickers: set[str]) - def main(): ap = argparse.ArgumentParser() - ap.add_argument("spec", help="plans/*.yaml or 'list'") + ap.add_argument("spec", help="plans/*.yaml, 'list', or 'delete'") + ap.add_argument("target", nargs="?", help="uuid for 'delete'") ap.add_argument("--apply", action="store_true", help="actually write to PRUNplanner (creates a new plan)") ap.add_argument("--update", metavar="UUID", help="with --apply: update this existing [PuGa] plan instead of creating") ap.add_argument("--json", action="store_true") @@ -89,6 +91,14 @@ def main(): for p in pp.request("GET", "/planning/plan/"): print(p["uuid"], p.get("plan_name"), p.get("planet_natural_id")) return + if a.spec == "delete": + # delete is allowed only on request, and only for plans this tool made ([PuGa] prefix). + cur = pp.request("GET", f"/planning/plan/{a.target}/") + if not str(cur.get("plan_name", "")).startswith(PREFIX): + sys.exit(f"REFUSED: '{cur.get('plan_name')}' is not a {PREFIX} plan") + pp.request("DELETE", f"/planning/plan/{a.target}/") + print("DELETED:", cur["plan_name"], a.target) + return spec = yaml.safe_load(Path(a.spec).read_text()) payload = build_payload(spec, pp.recipes(), {b["building_ticker"] for b in pp.buildings()}) d = payload["plan_data"] @@ -100,7 +110,7 @@ def main(): if a.json: print(json.dumps(payload, indent=1)) if not a.apply: - print("\nDRY RUN: nothing sent. Re-run with --apply after Dominik confirms.") + print("\nDRY RUN: nothing sent. Re-run with --apply after the user confirms.") return if a.update: cur = pp.request("GET", f"/planning/plan/{a.update}/") diff --git a/tools/scan.py b/tools/scan.py index a2b39c0..6ab0700 100755 --- a/tools/scan.py +++ b/tools/scan.py @@ -4,21 +4,21 @@ For each recipe: buildings the market can absorb (N*), and ROI/day at N=1 and N* (not top-of-book) and ask-walked inputs. Excludes thin markets by default. tools/scan.py # AI1, all tiers, top 30 by ROI at N* - tools/scan.py --cogc METALLURGY --tier S # Deimos-like: metallurgy COGC, up to settlers + tools/scan.py --cogc METALLURGY --tier S # e.g. a Metallurgy-COGC base, up to settlers tools/scan.py --sort total --min-n 3 # rank by absorbable profit/day """ -import argparse, sys +import argparse, itertools, sys from pathlib import Path import yaml sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from puga import ROOT, config, econ, fio, market, saturation as sat +from puga import ROOT, config, econ, fio, market, prunplanner as pp, saturation as sat TIER_CODE = {"pioneer": "P", "settler": "S", "technician": "T", "engineer": "E", "scientist": "Sc"} TIER_RANK = {"P": 0, "S": 1, "T": 2, "E": 3, "Sc": 4} def load_state(): - p = ROOT / "state" / "company.yaml" + p = config.state_path() return yaml.safe_load(p.read_text()) if p.exists() else {} @@ -27,10 +27,18 @@ def main(): ap.add_argument("--cx", default=config.DEFAULT_CX) ap.add_argument("--tier", help="max workforce tier: P S T E Sc") ap.add_argument("--cogc", help="COGC programme on the base, e.g. METALLURGY, or SETTLERS") + ap.add_argument("--staffing", default="both", choices=["full", "under", "both"], help="evaluate fully staffed, understaffed (drop tiers), or both (default)") + ap.add_argument("--trip-cost", type=float, default=9250.0, help="AIC per loaded round trip AI1<->base (handoff est. 8.5-10k); 0 disables freight") + ap.add_argument("--cargo", type=float, default=500.0, help="t and m3 per trip (starter ship)") + ap.add_argument("--no-hq", action="store_true", help="ignore HQ from state (new base without the HQ)") + ap.add_argument("--permits-used", type=int, help="override permits used (affects faction bonus multiplier), e.g. 2 for a second base") + ap.add_argument("--deprec", type=float, default=0, help="demolish-later mode: building value decays linearly to 0 over this many days (game: ~60); subtracts capex/deprec per day") + ap.add_argument("--planet", help="planet natural id: adds extraction (EXT/COL/RIG) from its resources, uses its fertility and active COGC; new base (not in state) => no HQ, permits+1") + ap.add_argument("--own", type=int, default=1, help="how many buildings WE would run; ROI is measured at this size (default 1). --min-n is only a market-size filter") ap.add_argument("--skip", default="", help="tiers left unstaffed, e.g. technician (no housing/wages; efficiency = staffed headcount share)") ap.add_argument("--experts", default="", help="expert counts, e.g. METALLURGY=2 (bonus x1.0306..1.284 for 1..5)") ap.add_argument("--hq", action="store_true", help="corp HQ bonus x1.1") - ap.add_argument("--no-faction", action="store_true", help="ignore faction bonus from state/company.yaml") + ap.add_argument("--no-faction", action="store_true", help="ignore faction bonus from empire/state/company.yaml") ap.add_argument("--min-n", type=float, default=1.0, help="min buildings the market absorbs (default 1)") ap.add_argument("--max-n", type=int, default=50, help="cap N* (capital/attention limit)") ap.add_argument("--budget", type=float, help="capital limit in AIC: caps N and drops recipes whose single-building capex exceeds it") @@ -39,6 +47,7 @@ def main(): ap.add_argument("--top", type=int, default=30) ap.add_argument("--k", type=int, default=80, help="candidates refined with order books") ap.add_argument("--show-thin", action="store_true") + ap.add_argument("--json", help="also write all rows to this JSON file (for tools/persistence.py)") a = ap.parse_args() snap = market.snapshot() @@ -47,8 +56,22 @@ def main(): st = load_state() faction = None if a.no_faction else (st.get("company") or {}).get("faction") pu, pt = (st.get("permits") or {}).get("used", 1), (st.get("permits") or {}).get("total", 2) + if a.permits_used: + pu = a.permits_used + planet = None + if a.planet: + planet = pp._g(f"/data/planet/{a.planet}/", 3600) + if not a.cogc: + a.cogc = (planet.get("active_cogc_program_type") or "").replace("ADVERTISING_", "") or None + if a.planet not in [b.get("planet") for b in st.get("bases", [])]: # new base: no HQ, one more permit used + a.no_hq = True + if not a.permits_used: + pu += 1 + print(f"# planet {a.planet} {planet['planet_name']}: fertility {planet['fertility']:.2f}, COGC {a.cogc}, resources " + + ", ".join(f"{r['material_ticker']} {r['daily_extraction']:.0f}/d" for r in planet["resources"])) blds = {b["Ticker"]: b for b in fio.buildings()} + mat = {m["Ticker"]: m for m in fio.materials()} mcg = ask("MCG") or 0 def bcost(tk): @@ -76,53 +99,74 @@ def main(): skip = {x.strip() for x in a.skip.split(",") if x.strip()} wages = {t: wage(t) for t in econ.TIERS} cands = [] - for rec in fio.recipes(): + all_recipes = list(fio.recipes()) + if planet: # extraction pseudo-recipes: one 24h cycle yielding the daily extraction + bt = {"MINERAL": "EXT", "GASEOUS": "COL", "LIQUID": "RIG"} + for r in planet["resources"]: + all_recipes.append(dict(BuildingTicker=bt[r["resource_type"]], Inputs=[], TimeMs=econ.TOTAL_MS_DAY, + Outputs=[dict(Ticker=r["material_ticker"], Amount=r["daily_extraction"])])) + for rec in all_recipes: b = blds.get(rec["BuildingTicker"]) if not b or not rec["Outputs"] or b["Ticker"].startswith("HB"): continue heads = {t: b[t.capitalize() + "s"] for t in econ.TIERS} - used = [t for t in econ.TIERS if heads[t] and t not in skip] # staffed tiers - if not used: - continue - top = TIER_CODE[used[-1]] - if a.tier and TIER_RANK[top] > TIER_RANK[a.tier]: - continue - if any(hab_head[t] is None or wages[t] is None for t in used): + need = [t for t in econ.TIERS if heads[t]] + if not need: continue ins = {i["Ticker"]: i["Amount"] for i in rec["Inputs"]} outs = {o["Ticker"]: o["Amount"] for o in rec["Outputs"]} if any(not Q(t) or not Q(t).ask for t in ins) or any(not Q(t) or not Q(t).bid for t in outs): continue - bd = {t + "s": heads[t] for t in econ.TIERS} - eff, _ = econ.building_efficiency(bd, {t: (0.0 if t in skip else 1.0) for t in econ.TIERS}, expertise=b["Expertise"] or None, cogc=a.cogc, - hq=a.hq, experts=experts, faction=faction, permits_used=pu, permits_total=pt) - io = econ.production_io([dict(time_ms=rec["TimeMs"], inputs=ins, outputs=outs)], eff, 1) - # saturation per output - n_lim, lim, thin, mm = float("inf"), "", False, {} - for t, qo in io["out"].items(): - x = Q(t) - tr = sat.tref(x.traded7, x.traded30) - if x.mm_buy and x.bid and x.mm_buy >= 0.9 * x.bid: - mm[t] = x.mm_buy # market maker floor: unlimited depth at mm_buy + capex0 = bcost(b["Ticker"]) + if capex0 is None: + continue + # staffing variants: fully staffed and every partial subset (efficiency = staffed headcount share; housing/wages only for staffed tiers) + variants = [] + if a.staffing in ("full", "both"): + variants.append(tuple(need)) + if a.staffing in ("under", "both") and len(need) > 1: + for k in range(1, len(need)): + variants += list(itertools.combinations(need, k)) + for keep in variants: + keep = tuple(t for t in keep if t not in skip) + if not keep: continue - thin |= sat.is_thin(tr, x.demand, qo) - n = sat.n_out(tr, 0, x.demand, qo) # stage 1: flow + demand only; queue penalty applied in stage 2 with the book - if n < n_lim: - n_lim, lim = n, t - if n_lim == float("inf"): - n_lim, lim = float(a.max_n), "MM" - if (thin or n_lim < a.min_n) and not a.show_thin: - continue - capex = bcost(b["Ticker"]) - if capex is None: - continue - capex += sum(heads[t] * hab_head[t] for t in used) - wcost = sum(heads[t] * wages[t] for t in used) - price0 = {t: mm.get(t) or min(x for x in (Q(t).vwap7, Q(t).vwap30, Q(t).ask) if x) if (Q(t).vwap7 or Q(t).vwap30 or Q(t).ask) else Q(t).bid - for t in io["out"]} - rough = sum(io["out"][t] * price0[t] for t in io["out"]) - sum(io["in"][t] * ask(t) for t in io["in"]) - wcost - cands.append(dict(rec=rec, b=b, io=io, eff=eff, capex=capex, wcost=wcost, n_lim=n_lim, lim=lim, thin=thin, mm=mm, - rough_roi=100 * rough / capex, top=top, ins=ins, outs=outs)) + top = TIER_CODE[keep[-1]] + if a.tier and TIER_RANK[top] > TIER_RANK[a.tier]: + continue + if any(hab_head[t] is None or wages[t] is None for t in keep): + continue + bd = {t + "s": heads[t] for t in econ.TIERS} + eff, _ = econ.building_efficiency(bd, {t: (1.0 if t in keep else 0.0) for t in econ.TIERS}, expertise=b["Expertise"] or None, + cogc=a.cogc, hq=a.hq or (bool(st.get("hq")) and not a.no_hq), experts=experts, faction=faction, + permits_used=pu, permits_total=pt, + fertility=planet["fertility"] if planet else None, is_farm=b["Ticker"] in ("FRM", "ORC")) + if eff <= 0: + continue # e.g. farms on fertility -1 planets + io = econ.production_io([dict(time_ms=rec["TimeMs"], inputs=ins, outputs=outs)], eff, 1) + n_lim, lim, thin, mm = float("inf"), "", False, {} + for t, qo in io["out"].items(): + x = Q(t) + tr = sat.tref(x.traded7, x.traded30) + if x.mm_buy and x.bid and x.mm_buy >= 0.9 * x.bid: + mm[t] = x.mm_buy # market maker floor: unlimited depth at mm_buy + continue + thin |= sat.is_thin(tr, x.demand, qo) + n = sat.n_out(tr, 0, x.demand, qo) # stage 1: flow + demand only; queue penalty applied in stage 2 with the book + if n < n_lim: + n_lim, lim = n, t + if n_lim == float("inf"): + n_lim, lim = float(a.max_n), "MM" + if (thin or n_lim < a.min_n) and not a.show_thin: + continue + capex = capex0 + sum(heads[t] * hab_head[t] for t in keep) + wcost = sum(heads[t] * wages[t] for t in keep) + price0 = {t: mm.get(t) or min(x for x in (Q(t).vwap7, Q(t).vwap30, Q(t).ask) if x) if (Q(t).vwap7 or Q(t).vwap30 or Q(t).ask) else Q(t).bid + for t in io["out"]} + rough = sum(io["out"][t] * price0[t] for t in io["out"]) - sum(io["in"][t] * ask(t) for t in io["in"]) - wcost + staff = "".join(TIER_CODE[t] for t in keep) + ("" if len(keep) == len(need) else "-") + cands.append(dict(rec=rec, b=b, io=io, eff=eff, capex=capex, wcost=wcost, n_lim=n_lim, lim=lim, thin=thin, mm=mm, + rough_roi=100 * rough / capex, top=top, staff=staff, ins=ins, outs=outs)) cands.sort(key=lambda c: c["rough_roi"], reverse=True) rows = [] @@ -162,13 +206,19 @@ def main(): if w["short"]: return None cost += w["total"] - return rev - cost - N * c["wcost"] + frt = 0.0 + if a.trip_cost: # imports and exports share round trips: trips = worst of tonnes/m3 in either direction + wi = sum(N * qi * mat[t]["Weight"] for t, qi in io["in"].items()); vi = sum(N * qi * mat[t]["Volume"] for t, qi in io["in"].items()) + wo = sum(N * qo * mat[t]["Weight"] for t, qo in io["out"].items()); vo = sum(N * qo * mat[t]["Volume"] for t, qo in io["out"].items()) + c["tons"] = max(wi, wo) + frt = max(wi, wo, vi, vo) / a.cargo * a.trip_cost + c["frt"] = frt + dep = N * c["capex"] / a.deprec if a.deprec else 0.0 # value lost per day if demolished after holding + return rev - cost - N * c["wcost"] - frt - dep if a.budget and c["capex"] > a.budget: continue - n_star = max(1, min(int(c["n_lim"]), a.max_n)) - if a.budget: - n_star = max(1, min(n_star, int(a.budget // c["capex"]))) + n_star = max(1, a.own) # our size; the market-size filter (--min-n) already applied above net1 = evaluate(1) if net1 is None: continue @@ -180,15 +230,18 @@ def main(): continue rows.append(dict(roi1=100 * net1 / c["capex"], roiN=100 * netN / n_star / c["capex"], total=netN, n=n_star, n_lim=c["n_lim"], lim=c["lim"], capex=c["capex"] * n_star, eff=c["eff"], bld=c["b"]["Ticker"], - top=c["top"], h=c["rec"]["TimeMs"] / 3.6e6, thin=c["thin"], + top=c["top"], staff=c["staff"], tons=c.get("tons", 0), frt=c.get("frt", 0), h=c["rec"]["TimeMs"] / 3.6e6, thin=c["thin"], rec=" ".join(f"{v:g}{k}" for k, v in c["ins"].items()) + " -> " + " ".join(f"{v:g}{k}" for k, v in c["outs"].items()))) key = {"roi": "roiN", "roi1": "roi1", "total": "total"}[a.sort] rows.sort(key=lambda r: r[key], reverse=True) + if a.json: + import json + Path(a.json).write_text(json.dumps(rows)) print(f"# {a.cx} tier<={a.tier or 'any'} cogc={a.cogc or '-'} faction={faction or '-'} ({pu}/{pt} permits) min-n>={a.min_n} sorted by {a.sort}") - print("# price: patient asks near VWAP (clamped), inputs ask-walked; N* = market-absorbable buildings (cap %d); all estimates" % a.max_n) - print(f"{'ROI@1':>6} {'ROI@N*':>7} {'N*':>4} {'lim':4} {'profit/d@N*':>11} {'capex@N*':>9} {'eff':>4} {'bld':4} {'tier':4} {'h':>5} recipe") + print("# ROI/day is for OUR size (--own buildings, default 1) incl. our own price impact; market = buildings the market could absorb (--min-n filters on it, nothing else). Net of freight. Patient asks near VWAP, inputs ask-walked; all estimates") + print(f"{'ROI/d':>6} {'own':>3} {'market':>6} {'lim':4} {'profit/d':>11} {'capex':>9} {'eff':>4} {'bld':4} {'staff':5} {'t/d':>5} {'frt/d':>6} {'h':>5} recipe") for r in rows[:a.top]: - print(f"{r['roi1']:6.1f} {r['roiN']:7.1f} {r['n']:4d} {r['lim']:4} {r['total']:11.0f} {r['capex']:9.0f} {r['eff']*100:4.0f} {r['bld']:4} {r['top']:4} " + print(f"{r['roiN']:6.1f} {r['n']:3d} {r['n_lim']:6.1f} {r['lim']:4} {r['total']:11.0f} {r['capex']:9.0f} {r['eff']*100:4.0f} {r['bld']:4} {r['staff']:5} {r['tons']:5.0f} {r['frt']:6.0f} " f"{r['h']:5.1f} {r['rec']}{' [thin]' if r['thin'] else ''}") diff --git a/tools/simulate.py b/tools/simulate.py new file mode 100755 index 0000000..62eb219 --- /dev/null +++ b/tools/simulate.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Read a plan the way PRUNplanner's simulator does: efficiencies, workforce, material I/O, profit. +Source: a local spec (plans/*.yaml) or the plan stored in his PRUNplanner account (--uuid; picks up his UI edits). + tools/simulate.py plans/examples/base_plus_hwp.yaml + tools/simulate.py --uuid --basis ask --cx AI1 +Prices: --basis real (buy at ask, sell at 7d VWAP at --cx: what a patient trader gets) | uni30 (default: volume-weighted 30d VWAP across all exchanges, = PRUNplanner 'Universe 30D') | vwap30 | vwap7 | ask | bid | mid at --cx.""" +import argparse, sys +from pathlib import Path +import yaml +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from puga import ROOT, config, market, prunplanner as pp +from puga.simulate import simulate + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import plan_push + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("spec", nargs="?") + ap.add_argument("--uuid") + ap.add_argument("--cx", default=config.DEFAULT_CX) + ap.add_argument("--basis", default="uni30", choices=["real", "uni30", "vwap30", "vwap7", "ask", "bid", "mid"]) + ap.add_argument("--off", default="", help="building tickers whose recipes are switched off (quantity 0), e.g. EXT,SME") + ap.add_argument("--no-faction", action="store_true") + ap.add_argument("--cm-free", action="store_true", help="treat the core module as free/already there (e.g. new base where CM cost is ignored)") + ap.add_argument("--no-hq", action="store_true", help="ignore hq: true from empire/state/company.yaml") + a = ap.parse_args() + recipes, blds = pp.recipes(), pp.buildings() + if a.uuid: + plan = pp.request("GET", f"/planning/plan/{a.uuid}/") + elif a.spec: + plan = plan_push.build_payload(yaml.safe_load(Path(a.spec).read_text()), recipes, {b["building_ticker"] for b in blds}) + else: + sys.exit("give a spec file or --uuid") + off = {x.strip() for x in a.off.split(",") if x.strip()} + for b in plan["plan_data"]["buildings"]: + if b["name"] in off: + for ar in b["active_recipes"]: + ar["amount"] = 0 + planet = pp._g(f"/data/planet/{plan['planet_natural_id']}/", 3600) + snap = market.snapshot() + + def price(t, side="both"): + if a.basis == "real": + q = snap.get((t, a.cx)) + return None if not q else (q.ask if side == "buy" else (q.vwap7 or q.vwap30 or q.bid)) + if a.basis == "uni30": + return market.uni30(snap, t) or ((snap.get((t, a.cx)) or market.Quote(t, a.cx)).ask) + q = snap.get((t, a.cx)) + if not q: + return None + v = {"vwap30": q.vwap30 or q.vwap7 or q.ask, "vwap7": q.vwap7 or q.vwap30 or q.ask, "ask": q.ask, "bid": q.bid, + "mid": (q.ask + q.bid) / 2 if q.ask and q.bid else None}[a.basis] + return v + + import yaml as _y + st = _y.safe_load(config.state_path().read_text()) + faction = None if a.no_faction else (st.get("company") or {}).get("faction") + perm = (st.get("permits", {}).get("used", 1), st.get("permits", {}).get("total", 2)) + if not a.no_hq and st.get("hq"): + plan["plan_corphq"] = True + built = next((b.get("buildings", {}) for b in st.get("bases", []) if b.get("planet") == plan["planet_natural_id"]), {}) + if a.cm_free: + built = {**built, "CM": 1} + r = simulate(plan, recipes, blds, planet["resources"], planet["fertility"], price, faction, perm, built=built) + + print(f"{plan['plan_name']} {plan['planet_natural_id']} COGC {plan.get('plan_cogc')} prices: {a.cx} {a.basis}") + print(f"Area {r['area']:.0f}/500 Profit/day {r['profit']:,.0f} gross {r['gross']:,.0f} degradation {r['degradation']:,.0f} plan cost {r['plan_cost']:,.0f} ROI {r['roi_days'] and round(r['roi_days'], 2)} d") + nc = r["new_capex"] + print(f"NEW CAPEX = planned minus already built ({built or 'nothing built'}): {nc:,.0f} -> payback {nc / r['profit']:.2f} d = {100 * r['profit'] / nc:.1f}%/day" if r["profit"] > 0 and nc > 0 else ("NEW CAPEX: nothing to build" if nc <= 0 else "NEW CAPEX: profit <= 0")) + print("\nWORKFORCE need supply open eff%") + for t, w in r["workforce"].items(): + if w["need"] or w["supply"]: + print(f" {t:10} {w['need']:5.0f} {w['supply']:6.0f} {w['open']:5.0f} {w['eff']*100:7.2f}") + print("\nBUILDINGS") + for b in r["buildings"]: + print(f" {b['amount']:3} x {b['building']:4} eff {b['efficiency']*100:7.2f}% {b['recipes']}") + print(f"\n{'MATERIAL':8} {'in/d':>9} {'out/d':>9} {'delta':>9} {'price':>7} {'value/d':>10}") + for tk, f in sorted(r["flows"].items(), key=lambda kv: -abs(kv[1]["value"])): + print(f"{tk:8} {f['inp']:9.2f} {f['out']:9.2f} {f['delta']:9.2f} {f['price']:7.0f} {f['value']:10.0f}") + if r["missing_prices"]: + print("no price for:", r["missing_prices"]) + + +if __name__ == "__main__": + main() diff --git a/tools/state.py b/tools/state.py index 4fdfe6f..5803c33 100755 --- a/tools/state.py +++ b/tools/state.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Sync state/company.yaml from live FIO (own data via FIO_REST_KEY) and show it. +"""Sync empire/state/company.yaml from live FIO (own data via FIO_REST_KEY) and show it. tools/state.py sync # overwrite live fields (buildings, production efficiency, storage, ships, cash, permits) tools/state.py show Requires the FIO extension to have uploaded recently; check `as_of`. Manual keys (company, notes) are preserved.""" @@ -8,23 +8,27 @@ from collections import Counter from pathlib import Path import yaml sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from puga import ROOT, fio +from puga import ROOT, config, fio -P = ROOT / "state" / "company.yaml" +P = config.EMPIRE_DIR / "state" / "company.yaml" # gitignored def sync(): u = fio.me() + P.parent.mkdir(parents=True, exist_ok=True) st = yaml.safe_load(P.read_text()) if P.exists() else {} - company = fio.private(f"/company/code/{(st.get('company') or {}).get('ticker', 'GBI')}", ttl=0) + code = (st.get('company') or {}).get('ticker') or config.get('COMPANY_CODE') + if not code: + sys.exit('set COMPANY_CODE= in .env (first sync), or put company: {ticker: ..} in empire/state/company.yaml') + company = fio.own(f"/company/code/{code}", ttl=0) for stale in ("cash_aic",): st.pop(stale, None) st["as_of"] = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%MZ") st["cash"] = {b["Currency"]: b["Amount"] for b in company["Balances"] if b["Amount"]} - sites = fio.private(f"/sites/{u}", ttl=0) - prod = fio.private(f"/production/{u}", ttl=0) - stores = fio.private(f"/storage/{u}", ttl=0) - ships = fio.private(f"/ship/ships/{u}", ttl=0) + sites = fio.own(f"/sites/{u}", ttl=0) + prod = fio.own(f"/production/{u}", ttl=0) + stores = fio.own(f"/storage/{u}", ttl=0) + ships = fio.own(f"/ship/ships/{u}", ttl=0) st["permits"] = {"used": sites[0]["InvestedPermits"] if sites else 0, "total": sites[0]["MaximumPermits"] if sites else 0} old = {b["planet"]: b for b in st.get("bases", [])} bases = []