commit 7a538cb300b162e43f5fb18c74317752bef7be7a Author: Dominik Roth Date: Fri Sep 18 23:00:21 2026 +0200 Initial PuGa toolkit: data layer, econ, depth-aware scan, state sync, plan push - puga/: cached FIO + PRUNplanner clients, market view with order-book walk, econ formulas ported from PRUNplanner (tested against its suite and live FIO), saturation model v1 (reviewed by Opus) - tools/: scan (depth-aware), price, book, chain, state sync, plan_push (dry run default, [PuGa]-prefixed plans only), legacy prun_scan/prun_cxarb - docs/: mechanics (PRUNplanner is source of truth), roadmap, decisions, saturation design, archived handoff - secrets stay in .env (gitignored); ref/ holds PRUNplanner source (ignored) Co-Authored-By: Claude Sonnet 5 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7e10795 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Copy to .env (gitignored, chmod 600) and fill in. Never paste keys into chat; edit the file. +FIO_USERNAME=dodox +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 " +DEFAULT_CX=AI1 +DEFAULT_REGION=antares diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..acd2571 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +.env +data/cache/ +ref/ +__pycache__/ +.pytest_cache/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d7d58f4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,44 @@ +# 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`. + +## 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. +- 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. + +## 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. +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). + +## 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). + +## 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/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. + +## 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. diff --git a/PuGa.png b/PuGa.png new file mode 100644 index 0000000..05c974a Binary files /dev/null and b/PuGa.png differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..56747aa --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +

+
+ +

+ PuGa +

+

+ +A set of tools to be used by humans and/or llms to plan for th egame Prosperous Universe. + +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. diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..932394b --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,18 @@ +# 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. +- 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. +- 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. + +## Model and review policy +- Default: Sonnet 5 does the building (API plumbing, CLIs, ports, tests). +- Bigger model (Agent tool `model: opus` or `fable`, fresh context, brief with files and question) is allowed and expected at these points: + 1. Saturation model design, before building it, and again on real output. + 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. +- 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 new file mode 100644 index 0000000..211935a --- /dev/null +++ b/docs/handoff-2026-09-18.md @@ -0,0 +1,96 @@ +> 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 new file mode 100644 index 0000000..b779185 --- /dev/null +++ b/docs/mechanics.md @@ -0,0 +1,48 @@ +# Mechanics (PRUNplanner-derived; source of truth) + +Source files in `ref/frontend/src/features/planning/calculations/`. Everything here is read from their code, not verified in-game unless marked. + +## Building efficiency = product of factors (`bonusCalculations.ts`) +| Factor | Value | +|---|---| +| Workforce | sum over tiers of (tier headcount / total headcount) x tier satisfaction-efficiency | +| COGC programme matching building expertise | x1.25 | +| COGC workforce programme (e.g. SETTLERS), building uses that tier | x1.10 | +| Corporation HQ | x1.10 | +| Expert count in that expertise (1..5) | x(1 + 0.0306, 0.0696, 0.1248, 0.1974, 0.284) | +| Fertility (FRM, ORC only) | x(1 + fertility x 10/33); fertility -1 gives 0 | +| Faction bonus | x(1 + bonus x m), m = 2 x (-2 x permits_used/permits_total + 3); needs building expertise | + +Faction bonus table: ANTARES electronics 5%; BENTEN manufacturing 5%; HORTUS agriculture 3%, food industries 2%; MORIA metallurgy 2%, construction 3%; OUTSIDEREGION chemistry, fuel refining, resource extraction 2% each. + +## Workforce satisfaction (`workforceCalculations.ts`) +- Satisfaction per tier = min(1, capacity/required) x base, base = 0.02 x (1+10/3) x 5 x (1+5/6) = 0.794. +- Luxury 1 met: x(1+1/11); luxury 2 met: x(1+2/13); both met gives 1.0. Consistent with handbook (PWO alone ~87%, COF alone ~91%). +- Consumption per worker per day = need/100 (table in the file). Consumption uses min(required, capacity) workers. Engineers and scientists needs (DW 10, MED 0.5, FIM/MEA 7, HSS/LC, PDA/WS, VG/NST lux1, GIN/WIN lux2) are in the source, absent from the old handoff. + +## 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. + +## 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`). + +## Habitation (`habOptimization.ts`) +- Areas: HB1 10, HB2 12, HB3 14, HB4 16, HB5 18, HBB 14, HBC 17, HBM 20, HBL 22. +- Combined habs (75 + 75): HBB pioneer+settler, HBC settler+technician, HBM technician+engineer, HBL engineer+scientist. +- 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. +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). +- Use `tools/state.py sync` to refresh `state/company.yaml`; it needs the FIO extension to have uploaded recently. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..8516967 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,22 @@ +# Roadmap + +Legend: [ ] todo, [x] done. Build order matters; each step is usable on its own. + +1. [x] Repo skeleton, git (master), venv, CLAUDE.md, mechanics from PRUNplanner. +2. [x] `puga/fio.py`: cached FIO client (TTL per endpoint, keys from `.env`), plus `puga/prunplanner.py` for `api.prunplanner.org/data/*`. Inspect what `exchanges/` adds (VWAP?) vs FIO `/exchange/all`. +3. [x] (price + book tools done; cxpc trend history not needed, PRUNplanner has vwap7/30) `puga/market.py` + `tools/price.py`, `tools/book.py`: prices across all CX, trends from cxpc, order-book walk for real fill price at quantity N. +4. [x] `puga/econ.py` (22 tests pass, expected values from PRUNplanner's suite): efficiency stack, workforce consumption, hab LP, extraction (port the formulas from `docs/mechanics.md`, with tests against PRUNplanner values). +5. [x] (v1 done; see saturation-design.md for refinements) `tools/scan.py` (replaces prun_scan.py): depth-aware ROI. Per recipe report: buildings the market can absorb (output demand + traded volume, input supply), ROI at fill price for N buildings, absorbable profit/day. Filter `--min-buildings`. Both CX-local and universe. +5b. [ ] `tools/arb.py` (replaces prun_cxarb.py): inter-CX arbitrage on `market.walk` at real fill size; capital required, fuel/shipping cost from state, profit per trip within cash. Reason: old script uses top-of-book spread x min(supply,demand); on 2 of 4 top hits it overstated profit 7 to 11x (WAI, SCN). +6. [x] `tools/chain.py`: full bill of materials, make-vs-buy per node. +7. [ ] `tools/whatif.py`: marginal ROI of adding a building to the actual state (uses `state/company.yaml`, efficiency stack, imports). +8. [ ] `tools/planet.py`, `tools/found.py`: rank planets for a resource; founding cost including environment extras. +9. [ ] `tools/route.py`, `tools/haul.py`: jump graph, fuel, profit per trip. +9b. [x] (dry-run default, --apply after user yes) `tools/plan_push.py`: create/update `[PuGa]` plans in PRUNplanner from YAML (Api-Key auth; see decisions.md for guardrails). +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`. + +## 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. +- (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/docs/saturation-design.md b/docs/saturation-design.md new file mode 100644 index 0000000..9cb069a --- /dev/null +++ b/docs/saturation-design.md @@ -0,0 +1,50 @@ +# Saturation model design (v1 as implemented; v0 draft below superseded where marked) + +Problem: PRUNplanner's ROI Overview (and our old scan) rank recipes as if the market absorbs unlimited output at top-of-book prices. Result: recipes whose market fits 1 to 2 buildings rank first with absurd ROI (e.g. 0.25 day). We need, per recipe and per CX (or universe), a number of buildings N the market can realistically support, and profit computed at prices you would actually get at that N. + +## Inputs per material and CX (from `puga/market.py`) +- Book totals: ask, bid, supply (units on sell side), demand (units on buy side). +- Flow: `traded7`, `traded30` = average units traded per day; `vwap7`, `vwap30`. +- Live order book (per order price, quantity) via `market.walk`. +- MM (market maker) prices `mm_buy` (floor: MM buys at this) and `mm_sell` (cap: MM sells at this), when present. Treat as unlimited depth at that price. + +## Per-building daily quantities +q_out[m], q_in[m] per building per day at efficiency e (from `econ.production_io`), including wages/upkeep costs per building. + +## Capacity: how many buildings can each side support +1. **Flow cap** (steady state): a producer cannot take more than a share s of the market's traded flow without moving price. `N_flow_out[m] = s * traded_ref[m] / q_out[m]`, with traded_ref = min(traded7, traded30) (conservative), s default 0.25 (parameter). Inputs identical: `N_flow_in[m] = s * traded_ref[m] / q_in[m]`. +2. **Stock cap** (short horizon): standing book absorbs a one-time batch: bids for outputs, asks for inputs. Over hold horizon H days (default 7), `N_stock = book_units / (q * H)`. Stock refills through flow, so the effective cap per side is `max(N_flow, N_stock)`? To be decided (flow is the steady-state truth; stock only helps ramp-up). Proposal: cap = N_flow; report N_stock separately as ramp-up buffer. +3. **MM override**: if the output has `mm_buy` (floor with unlimited depth) then N_out is unbounded but priced at mm_buy; if an input has `mm_sell` then its price is capped at mm_sell, unbounded depth. +4. **N* = floor(min over all outputs and inputs of the capacity)**. If N* < 1: flag "thin", still report ROI at N=1 but exclude from default ranking. + +## Price at N buildings (price impact) +- Selling: two modes. `instant`: walk bids for the quantity N*q_out*H, avg price. `patient`: price = vwap7 with a discount d(N) that grows with our share of flow: `d = k * (N*q_out/traded_ref)` (k calibrated from book slope, default so that at share s the discount equals the walk-price deficit). Report both; default ranking uses patient. +- Buying inputs: mirror (asks, or vwap7 + premium). +- Net per building n(N) = revenue(N) - inputs(N) - wages - amortised capex (optional). Profit curve for N in 1, 2, 4, ... N*. Report N_opt = argmax total profit N * n(N) with n(N) >= a minimum ROI. + +## Output columns +recipe, building, N*, limiting material (which side caps), ROI/day at N=1, ROI/day at N_opt, total profit/day at N_opt, capex at N_opt, price used (mode), thin flag. + +## Known weaknesses / questions for review +1. Is flow-share s the right primitive? traded volume is noisy and includes our own competitors' equilibrium; what better estimate of "room for a new producer"? +2. Existing producers: a market with big standing supply relative to flow signals saturation on the producer side (price pressure). Should supply/flow ratio adjust the price or cap? +3. Stock vs flow double counting; horizon H arbitrary. +4. Input side and output side are coupled through prices (our buying raises input prices): first-order only. +5. Patient price discount model is hand-wavy; alternatives. +6. Universe scope: with `--cross` across CX, shipping costs ignored; how to bound. +7. Intermediate goods we produce ourselves (chains) are not modelled here (see `tools/chain.py`). + + +## Review outcome (Opus, 2026-09-18) and what was implemented in `puga/saturation.py` + `tools/scan.py` +Verdict: flow-share alone is half right. The standing sell queue (supply / flow, in days) is the dominant signal; order-book walking is a red herring for saturation (selling one day of output at N* barely moves the price); what costs money is the bid vs vwap7 haircut. Implemented: +- `tref = min(traded7, traded30)`, with a Poisson lower bound `traded30*(1 - 1.96/sqrt(30*traded30))` when traded30 < 20. +- `N_out = (s*tref/q_out) * min(1, T_q*tref/supply) * min(1, demand/(T_d*tref))`, s=0.25, T_q=T_d=7 days. Limiting output reported. +- Thin flag: `tref < 3*q_out or demand < 7*q_out` (hard exclude; `--show-thin` overrides). NOTE: borderline. HWP BHP 7.2h variant (13.3/day vs tref 39.2) is flagged thin at exactly this threshold while the 8.4h variant passes; the 3x rule is arbitrary and should be revisited. +- Inputs: no flow cap; priced by ask-walk of N*q_in (1 day) and profit falls out negative if the book is shallow. +- Output price: `p_patient` = highest ask level whose units-ahead <= T_w*(tref - produced), clamped to [bid, min(vwap7, vwap30, ask)], T_w = 3 days; falls to the bid when we out-produce flow. +- MM override only when `mm_buy >= 0.9*bid`: unlimited depth at mm_buy. +- Bug fixed: `market.walk` dropped MM orders (`ItemCount: null` means unlimited). +Later refinements (not built): supply-feedback fixpoint (our N raises supply), daily snapshot cache and d(supply)/dt, seller-count crowding (distinct `CompanyCode` per side), use `NarrowPriceBand*`/`WwidePriceBand*` to bound prices, 149/370 AI1 materials have traded7 < 5/day so confidence handling matters, cross-CX with freight, chains. MM-priced materials (SP, AIR, CCD, CBS, RED...) trade AT mm_buy so their volume is the MM absorbing, not player demand. + +## Addendum: effective supply (implemented) +Stage 1 of `tools/scan.py` uses flow + demand only; stage 2 (shortlist) fetches the ask book and counts only standing sell orders priced <= 1.25 x vwap7 (`saturation.effective_supply`). Reason: DEC at AI1 showed 2,013 units 'queue' of which 1,501 sat at 50,000 (1.5x vwap), which made the raw queue penalty call the market saturated (N=0.38) when the effective queue gives about 1.7. diff --git a/plans/deimos_bhp.yaml b/plans/deimos_bhp.yaml new file mode 100644 index 0000000..1a1c704 --- /dev/null +++ b/plans/deimos_bhp.yaml @@ -0,0 +1,13 @@ +# Deimos as it stands (FIO 2026-09-18) 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)" +planet: ZV-759c +permits: 1 +cogc: METALLURGY +hq: false +experts: {METALLURGY: 2} +infrastructure: {HB1: 4, HB2: 1} +buildings: + - {building: EXT, amount: 2, recipes: ["EXT#ALO"]} + - {building: SME, amount: 5, recipes: ["ALO,FLX,C,O=>AL"]} + - {building: HWP, amount: 1, recipes: ["AL,STL,HE=>BHP"]} diff --git a/puga/__init__.py b/puga/__init__.py new file mode 100644 index 0000000..bffdda5 --- /dev/null +++ b/puga/__init__.py @@ -0,0 +1,4 @@ +"""PuGa: Prosperous Universe advisory toolkit.""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent diff --git a/puga/cache.py b/puga/cache.py new file mode 100644 index 0000000..89a661c --- /dev/null +++ b/puga/cache.py @@ -0,0 +1,17 @@ +"""Tiny disk cache for JSON over HTTP, keyed by URL, expiry by TTL seconds.""" +import hashlib, json, time, urllib.request +from .config import CACHE_DIR + +UA = "PuGa/0.1 (personal advisory toolkit)" + + +def get_json(url: str, ttl: int, headers: dict[str, str] | None = None, refresh: bool = False): + CACHE_DIR.mkdir(parents=True, exist_ok=True) + f = CACHE_DIR / (hashlib.sha1(url.encode()).hexdigest()[:16] + ".json") + 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) + f.write_text(json.dumps(data)) + return data diff --git a/puga/config.py b/puga/config.py new file mode 100644 index 0000000..c96e796 --- /dev/null +++ b/puga/config.py @@ -0,0 +1,26 @@ +"""Settings from .env (gitignored) and process environment. Never log secret values.""" +import os +from . import ROOT + + +def _load_env() -> dict[str, str]: + out: dict[str, str] = {} + p = ROOT / ".env" + if p.exists(): + for line in p.read_text().splitlines(): + line = line.split("#", 1)[0].strip() + if "=" in line: + k, v = line.split("=", 1) + out[k.strip()] = v.strip().strip("'\"") + return out + + +_ENV = _load_env() + + +def get(key: str, default: str | None = None) -> str | None: + return os.environ.get(key) or _ENV.get(key) or default + + +DEFAULT_CX = get("DEFAULT_CX", "AI1") +CACHE_DIR = ROOT / "data" / "cache" diff --git a/puga/econ.py b/puga/econ.py new file mode 100644 index 0000000..984c234 --- /dev/null +++ b/puga/econ.py @@ -0,0 +1,179 @@ +"""Game economics ported from PRUNplanner (ref/frontend/src/features/planning/calculations/*). +PRUNplanner is the source of truth; each function names its source file. Pure functions, no I/O.""" +import itertools, math + +TOTAL_MS_DAY = 24 * 3600 * 1000 +TIERS = ["pioneer", "settler", "technician", "engineer", "scientist"] + +# --- bonusCalculations.ts ------------------------------------------------------------- +EXPERT_BONUS = {0: 0.0, 1: 0.0306, 2: 0.0696, 3: 0.1248, 4: 0.1974, 5: 0.284} +FACTION_BONUS = { + "ANTARES": {"ELECTRONICS": 0.05}, + "BENTEN": {"MANUFACTURING": 0.05}, + "HORTUS": {"AGRICULTURE": 0.03, "FOOD_INDUSTRIES": 0.02}, + "MORIA": {"METALLURGY": 0.02, "CONSTRUCTION": 0.03}, + "OUTSIDEREGION": {"CHEMISTRY": 0.02, "FUEL_REFINING": 0.02, "RESOURCE_EXTRACTION": 0.02}, +} +WORKFORCE_COGC = {"PIONEERS": "pioneer", "SETTLERS": "settler", "TECHNICIANS": "technician", + "ENGINEERS": "engineer", "SCIENTISTS": "scientist"} + + +def expert_bonus(n: int) -> float: + return EXPERT_BONUS.get(n, 0.0) # out of range (<0 or >5) gives 0, as in the source + + +def faction_multiplier(faction: str | None, expertise: str | None, permits_used: float, permits_total: float): + """Returns the factor (1 + bonus*m) or None if no bonus applies.""" + if not expertise or not faction: + return None + b = FACTION_BONUS.get(faction, {}).get(expertise) + if not b: + return None + m = 2 * (-2 * (permits_used / permits_total) + 3) + return 1 + b * m + + +# --- workforceCalculations.ts --------------------------------------------------------- +BASE_SAT = 0.02 * (1 + 10 / 3) * (1 + 4) * (1 + 5 / 6) # 0.7944; both luxuries met gives exactly 1.0 +LUX1, LUX2 = 1 + 1 / 11, 1 + 2 / 13 + + +def tier_efficiency(capacity: float, required: float, lux1: bool, lux2: bool) -> float: + """calculateSatisfaction: min(1, capacity/required) * base * luxury multipliers; 0 if nothing required.""" + if required <= 0: + return 0.0 + sat = 1.0 if required < capacity else capacity / required + eff = BASE_SAT * (LUX1 if lux1 else 1) * (LUX2 if lux2 else 1) + return sat * eff + + +# (ticker, need per worker per day, lux1?, lux2?) +CONSUMPTION = { + "pioneer": [("DW", 4, 0, 0), ("RAT", 4, 0, 0), ("OVE", .5, 0, 0), ("PWO", .2, 1, 0), ("COF", .5, 0, 1)], + "settler": [("DW", 5, 0, 0), ("RAT", 6, 0, 0), ("EXO", .5, 0, 0), ("PT", .5, 0, 0), ("REP", .2, 1, 0), ("KOM", 1, 0, 1)], + "technician": [("DW", 7.5, 0, 0), ("RAT", 7, 0, 0), ("MED", .5, 0, 0), ("HMS", .5, 0, 0), ("SCN", .1, 0, 0), ("SC", .1, 1, 0), ("ALE", 1, 0, 1)], + "engineer": [("DW", 10, 0, 0), ("MED", .5, 0, 0), ("FIM", 7, 0, 0), ("HSS", .2, 0, 0), ("PDA", .1, 0, 0), ("VG", .2, 1, 0), ("GIN", 1, 0, 1)], + "scientist": [("DW", 10, 0, 0), ("MED", .5, 0, 0), ("MEA", 7, 0, 0), ("LC", .2, 0, 0), ("WS", .05, 0, 0), ("NST", .1, 1, 0), ("WIN", 1, 0, 1)], +} # needs are per 100 workers per day in the table above; divided below + + +def workforce_consumption(tier: str, required: float, capacity: float, lux1: bool, lux2: bool) -> dict[str, float]: + """calculateSingleWorkforceConsumption: units/day by ticker. Consumers = min(required, capacity).""" + n = min(required, capacity) + if n <= 0: + return {} + out = {} + for tk, need, l1, l2 in CONSUMPTION[tier]: + if (not l1 and not l2) or (l1 and lux1) or (l2 and lux2): + out[tk] = need / 100 * n + return out + + +# --- bonusCalculations.ts: building efficiency --------------------------------------- +def workforce_factor(building: dict, tier_eff: dict[str, float]) -> float: + """building: {'pioneers': n, 'settlers': n, ...} required heads; tier_eff: {'pioneer': eff, ...}.""" + heads = {t: building.get(t + "s", 0) for t in TIERS} + total = sum(heads.values()) + return sum(heads[t] / total * tier_eff.get(t, 0.0) for t in TIERS) if total else 0.0 + + +def building_efficiency(building: dict, tier_eff: dict[str, float], *, expertise: str | None = None, + cogc: str | None = None, hq: bool = False, experts: dict[str, int] | None = None, + faction: str | None = None, permits_used: float = 0, permits_total: float = 1, + fertility: float | None = None, is_farm: bool = False, + condition: float | None = None) -> tuple[float, dict[str, float]]: + """Product of factors. Returns (total, elements). `expertise` uses upper snake (METALLURGY); + `experts` keys are the same names ('METALLURGY': 3). fertility only applies when is_farm (FRM, ORC).""" + el: dict[str, float] = {} + if is_farm and fertility is not None: + el["FERTILITY"] = 1 + fertility * (10 / 33) if fertility != -1.0 else 0.0 + if hq: + el["HQ"] = 1.1 + if expertise: + if cogc == expertise: + el["COGC"] = 1.25 + n = (experts or {}).get(expertise, 0) + if n > 0: + el["EXPERT"] = 1 + expert_bonus(n) + if cogc in WORKFORCE_COGC and building.get(WORKFORCE_COGC[cogc] + "s", 0) > 0: + el["COGC_WORKFORCE"] = 1.1 + el["WORKFORCE"] = workforce_factor(building, tier_eff) + if condition is not None: # building wear; not in PRUNplanner but present in FIO's live Efficiency (verified 2026-09-18) + el["CONDITION"] = condition + fb = faction_multiplier(faction, expertise, permits_used, permits_total) + if fb is not None: + el["FACTION"] = fb + total = 1.0 + for v in el.values(): + total *= v + return total, el + + +# --- extraction: extractionCalculations.ts + backend gamedata/fio/importers.py -------- +CYCLE_MS = {"MINERAL": 12 * 3600e3, "GASEOUS": 6 * 3600e3, "LIQUID": 4.8 * 3600e3} + + +def daily_extraction(factor: float, resource_type: str) -> float: + """Backend: factor*60 for GASEOUS, else factor*70 (factor = concentration as fraction). Before efficiency.""" + return factor * (60.0 if resource_type == "GASEOUS" else 70.0) + + +def extraction_cycle(resource_type: str, daily: float) -> tuple[float, int]: + """(time_ms, amount) per cycle; amount is rounded up, time scaled to keep the daily rate.""" + amt = math.ceil(daily * CYCLE_MS[resource_type] / TOTAL_MS_DAY) + return amt * (TOTAL_MS_DAY / daily), amt + + +# --- production: usePlanCalculation.ts / buildingCalculations.ts ---------------------- +def production_io(recipes: list[dict], efficiency: float, n_buildings: float = 1) -> dict[str, dict[str, float]]: + """recipes: [{'time_ms', 'inputs': {tk: amt}, 'outputs': {tk: amt}, 'amount': repeats (default 1)}]. + Each recipe's time = time_ms * amount / efficiency; batches/day = day*n / sum(times). + Returns {'in': {tk: per day}, 'out': {tk: per day}} for all buildings.""" + times = [r["time_ms"] * r.get("amount", 1) / efficiency for r in recipes] + runs = TOTAL_MS_DAY * n_buildings / sum(times) + io = {"in": {}, "out": {}} + for r in recipes: + a = r.get("amount", 1) + for side, key in (("in", "inputs"), ("out", "outputs")): + for tk, amt in r[key].items(): + io[side][tk] = io[side].get(tk, 0) + amt * a * runs + return io + + +# --- habOptimization.ts --------------------------------------------------------------- +HAB_AREA = {"HB1": 10, "HB2": 12, "HB3": 14, "HB4": 16, "HB5": 18, "HBB": 14, "HBC": 17, "HBM": 20, "HBL": 22} +HAB_CAP = { # tier -> capacity per hab + "HB1": {"pioneer": 100}, "HB2": {"settler": 100}, "HB3": {"technician": 100}, + "HB4": {"engineer": 100}, "HB5": {"scientist": 100}, + "HBB": {"pioneer": 75, "settler": 75}, "HBC": {"settler": 75, "technician": 75}, + "HBM": {"technician": 75, "engineer": 75}, "HBL": {"engineer": 75, "scientist": 75}, +} + + +def optimize_habs(required: dict[str, float], costs: dict[str, float], goal: str = "cost", + max_area: float | None = None) -> dict | None: + """Integer hab mix covering `required` heads per tier, minimising 'cost' or 'area' (optionally area-capped). + Exhaustive over combined habs (<=4 vars), single habs fill the rest. Returns {'habs', 'cost', 'area'} or None.""" + req = {t: max(0.0, required.get(t, 0)) for t in TIERS} + combos = [h for h in HAB_CAP if len(HAB_CAP[h]) == 2] + single = {next(iter(HAB_CAP[h])): h for h in HAB_CAP if len(HAB_CAP[h]) == 1} + limits = [math.ceil(min(req[t] for t in HAB_CAP[h]) / 75) for h in combos] + best = None + for counts in itertools.product(*[range(l + 1) for l in limits]): + cov = dict.fromkeys(TIERS, 0.0) + habs = dict(zip(combos, counts)) + for h, n in habs.items(): + for t, c in HAB_CAP[h].items(): + cov[t] += c * n + for t in TIERS: + rest = req[t] - cov[t] + if rest > 0: + habs[single[t]] = habs.get(single[t], 0) + math.ceil(rest / 100) + cost = sum(costs[h] * n for h, n in habs.items()) + area = sum(HAB_AREA[h] * n for h, n in habs.items()) + if goal == "cost" and max_area is not None and area > max_area: + continue + key = cost if goal == "cost" else area + if best is None or key < best[0]: + best = (key, {h: n for h, n in habs.items() if n}, cost, area) + return None if best is None else {"habs": best[1], "cost": best[2], "area": best[3]} diff --git a/puga/fio.py b/puga/fio.py new file mode 100644 index 0000000..762dc28 --- /dev/null +++ b/puga/fio.py @@ -0,0 +1,39 @@ +"""Public FIO REST (rest.fnar.net). Static game data caches long; market data short.""" +from .cache import get_json + +BASE = "https://rest.fnar.net" +STATIC, MARKET = 24 * 3600, 15 * 60 + + +def _g(path: str, ttl: int, refresh: bool = False): + return get_json(BASE + path, ttl, refresh=refresh) + + +def exchange_all(refresh=False): return _g("/exchange/all", MARKET, refresh) +def order_book(mat: str, cx: str, refresh=False): + """Full book: BuyingOrders / SellingOrders lists (price + quantity). Short TTL.""" + return _g(f"/exchange/{mat}.{cx}", 5 * 60, refresh) +def cxpc(mat: str, cx: str, refresh=False): + return _g(f"/exchange/cxpc/{mat}.{cx}", 3600, refresh) +def materials(refresh=False): return _g("/material/allmaterials", STATIC, refresh) +def recipes(refresh=False): return _g("/recipes/allrecipes", STATIC, refresh) +def buildings(refresh=False): return _g("/building/allbuildings", STATIC, refresh) +def planets_full(refresh=False): return _g("/planet/allplanets/full", STATIC, refresh) +def workforce_needs(refresh=False): return _g("/global/workforceneeds", STATIC, refresh) +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): + """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 + key = config.get("FIO_REST_KEY") + if not key: + raise RuntimeError("FIO_REST_KEY missing in .env") + return get_json(BASE + path, ttl, headers={"Authorization": key}, refresh=refresh) + + +def me() -> str: + from . import config + return config.get("FIO_USERNAME", "") diff --git a/puga/market.py b/puga/market.py new file mode 100644 index 0000000..6484ddc --- /dev/null +++ b/puga/market.py @@ -0,0 +1,53 @@ +"""Unified market view: FIO book totals merged with PRUNplanner VWAP/volume, plus order-book walks.""" +from dataclasses import dataclass +from . import fio, prunplanner as pp + +CXS = ["AI1", "NC1", "CI1", "IC1", "NC2", "CI2"] + + +@dataclass +class Quote: + tk: str + cx: str + ask: float | None = None + bid: float | None = None + supply: float = 0 # units on sell side (order-book total) + demand: float = 0 # units on buy side + vwap7: float | None = None + vwap30: float | None = None + traded7: float = 0 # avg units/day over 7d + traded30: float = 0 # avg units/day over 30d + mm_buy: float | None = None + mm_sell: float | None = None + + +def snapshot(refresh=False) -> dict[tuple[str, str], Quote]: + q: dict[tuple[str, str], Quote] = {} + for e in fio.exchange_all(refresh): + k = (e["MaterialTicker"], e["ExchangeCode"]) + q[k] = Quote(k[0], k[1], e.get("Ask"), e.get("Bid"), e.get("Supply") or 0, e.get("Demand") or 0, + mm_buy=e.get("MMBuy"), mm_sell=e.get("MMSell")) + for e in pp.exchanges(refresh): + k = (e["ticker"], e["exchange_code"]) + x = q.setdefault(k, Quote(k[0], k[1], e.get("ask"), e.get("bid"), e.get("supply") or 0, e.get("demand") or 0)) + x.vwap7, x.vwap30 = e.get("vwap_7d"), e.get("vwap_30d") + x.traded7, x.traded30 = e.get("avg_traded_7d") or 0, e.get("avg_traded_30d") or 0 + return q + + +def walk(mat: str, cx: str, qty: float, side: str, refresh=False) -> dict: + """Walk the live order book. side='buy' takes sell orders (you pay asks); side='sell' hits buy orders (you get bids). + Returns avg price, units filled, worst price reached, total cost/proceeds. Shallow books give filled < qty.""" + ob = fio.order_book(mat, cx, refresh) + orders = ob["SellingOrders"] if side == "buy" else ob["BuyingOrders"] + # MM orders come with ItemCount None = unlimited depth; treat as infinite + orders = sorted((o for o in orders if o.get("ItemCount") != 0), key=lambda o: o["ItemCost"], reverse=(side == "sell")) + left, total, worst = qty, 0.0, None + for o in orders: + take = min(left, float("inf") if o.get("ItemCount") is None else o["ItemCount"]) + total += take * o["ItemCost"] + worst, left = o["ItemCost"], left - take + if left <= 0: + break + filled = qty - left + return dict(avg=total / filled if filled else None, filled=filled, worst=worst, total=total, short=left > 0) diff --git a/puga/prunplanner.py b/puga/prunplanner.py new file mode 100644 index 0000000..0195b08 --- /dev/null +++ b/puga/prunplanner.py @@ -0,0 +1,36 @@ +"""api.prunplanner.org. Public data endpoints need no auth; planning endpoints use `Authorization: Api-Key `.""" +from .cache import get_json +from . import config + +BASE = "https://api.prunplanner.org" +MARKET, STATIC = 15 * 60, 24 * 3600 + + +def _g(path: str, ttl: int, refresh: bool = False): + return get_json(BASE + path, ttl, refresh=refresh) + + +def exchanges(refresh=False): + """Per ticker.CX: ask/bid/supply/demand, vwap_daily/7d/30d, traded_daily, sum/avg_traded_7d/30d, exchange_status.""" + return _g("/data/exchanges/", MARKET, refresh) +def materials(refresh=False): return _g("/data/materials/", STATIC, refresh) +def recipes(refresh=False): return _g("/data/recipes/", STATIC, refresh) +def buildings(refresh=False): return _g("/data/buildings/", STATIC, refresh) + + +def api_key() -> str | None: + return config.get("PRUNPLANNER_API_KEY") + + +def request(method: str, path: str, body: dict | None = None): + """Authenticated call (Api-Key). Callers must enforce write guardrails (see tools/plan_push.py).""" + import json, urllib.request + key = api_key() + if not key: + raise RuntimeError("PRUNPLANNER_API_KEY missing in .env") + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(BASE + path, data=data, method=method, headers={ + "Authorization": "Api-Key " + key, "Content-Type": "application/json", "User-Agent": "PuGa/0.1"}) + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + return json.loads(raw) if raw else None diff --git a/puga/saturation.py b/puga/saturation.py new file mode 100644 index 0000000..7914d20 --- /dev/null +++ b/puga/saturation.py @@ -0,0 +1,56 @@ +"""Saturation model v1 (docs/saturation-design.md, after Opus review 2026-09-18). Pure functions. +Idea: N buildings is limited by (a) share of the market's steady traded flow, (b) the queue of existing +sell orders (supply days) and (c) whether buy-side stock covers it. Price is a haircut model, not a book walk.""" +import math + +S_SHARE = 0.25 # max share of traded flow one producer takes +T_Q = 7 # days of flow the standing sell queue is compared against +T_D = 7 # days of flow standing demand should cover +T_W = 3 # patient-selling window (days) + + +def tref(traded7: float, traded30: float) -> float: + """Conservative daily flow: min(7d, 30d); with a Poisson lower bound when 30d volume is small.""" + t = min(traded7, traded30) + if 0 < traded30 < 20: + t = min(t, max(0.0, traded30 * (1 - 1.96 / math.sqrt(30 * traded30)))) + return max(0.0, t) + + +def is_thin(tr: float, demand: float, q_out: float) -> bool: + return tr < 3 * q_out or demand < 7 * q_out + + +def n_out(tr: float, supply: float, demand: float, q_out: float) -> float: + """Buildings the output market absorbs: flow share x queue penalty x demand coverage. 0 if no flow.""" + if tr <= 0 or q_out <= 0: + return 0.0 + base = S_SHARE * tr / q_out + queue = min(1.0, T_Q * tr / supply) if supply > 0 else 1.0 + cover = min(1.0, demand / (T_D * tr)) + return base * queue * cover + + +def p_patient(asks: list[tuple[float, float]], tr: float, produced_per_day: float, bid: float, vwap7: float | None, + vwap30: float | None, ask: float | None, wide_high: float | None = None) -> float: + """Price we can hold asks at. asks = [(price, units)] ascending. Find the highest ask level whose units-ahead + (levels strictly below) <= what buyers will absorb of the queue over T_W days once our output is counted: + target = T_W * (tr - produced). target <= 0 => sell at the bid. Clamped to [bid, min(vwap7, vwap30, ask, wide_high)].""" + hi = min(x for x in (vwap7, vwap30, ask, wide_high) if x) + target = T_W * (tr - produced_per_day) + if target <= 0: + return bid + p, ahead = hi, 0.0 + for price, units in asks: + if ahead <= target: + p = price + ahead += units + return max(bid, min(p, hi)) + + +def effective_supply(asks: list[tuple[float, float]], ref_price: float | None, band: float = 1.25) -> float: + """Units of standing sell orders that actually compete: price <= band x reference (vwap7 or ask). + Stale asks far above market (e.g. 1500 units at 1.5x vwap) are not competition. asks = [(price, units)].""" + if not ref_price: + return sum(u for _, u in asks) + return sum(u for p, u in asks if p <= band * ref_price) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..5ee6477 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e940406 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +iniconfig==2.3.0 +packaging==26.3 +pluggy==1.6.0 +Pygments==2.21.0 +pytest==9.1.1 +PyYAML==6.0.3 diff --git a/state/company.yaml b/state/company.yaml new file mode 100644 index 0000000..dbb8b50 --- /dev/null +++ b/state/company.yaml @@ -0,0 +1,50 @@ +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 new file mode 100644 index 0000000..34314ea --- /dev/null +++ b/tests/test_econ.py @@ -0,0 +1,84 @@ +"""Expected values copied from PRUNplanner's own test suite (ref/frontend/src/tests/features/planning/calculations).""" +import pytest +from puga import econ + +HAB_COSTS = {'HB1': 50283.96581293734, 'HB2': 48183.93380367031, 'HB3': 171236.78856075153, 'HB4': 478742.2978713045, + 'HB5': 881886.0033487707, 'HBB': 78602.69810873509, 'HBC': 191175.66459235144, + 'HBM': 759867.355670017, 'HBL': 1210568.4635058648} +REQ = {'pioneer': 100, 'settler': 390, 'technician': 490} + + +@pytest.mark.parametrize("cap,req,l1,l2,exp", [ + (100, 100, False, False, 0.7944444444444446), (50, 100, False, False, 0.3972222222222223), + (100, 50, False, False, 0.7944444444444446), (100, 100, True, False, 0.8666666666666668), + (100, 100, False, True, 0.9166666666666667), (100, 100, True, True, 1)]) +def test_tier_efficiency(cap, req, l1, l2, exp): + assert econ.tier_efficiency(cap, req, l1, l2) == pytest.approx(exp) + + +@pytest.mark.parametrize("n,exp", [(-5, 0), (100, 0), (1, 0.0306), (2, 0.0696), (3, 0.1248), (4, 0.1974), (5, 0.284)]) +def test_expert_bonus(n, exp): + assert econ.expert_bonus(n) == exp + + +def test_workforce_factor(): + b = dict(pioneers=40, settlers=30, technicians=20, engineers=10, scientists=5) + eff = dict(pioneer=.5, settler=.25, technician=.4, engineer=.1, scientist=1.5) + assert econ.workforce_factor(b, eff) == pytest.approx(0.419047619047619) + b = dict(pioneers=100, settlers=50, technicians=20, engineers=10, scientists=5) + eff = dict(pioneer=1.25, settler=1.625, technician=.4, engineer=.1, scientist=1.5) + assert econ.workforce_factor(b, eff) == pytest.approx(1.204054054054054) + + +def test_faction_bonus(): + assert econ.faction_multiplier("HORTUS", "AGRICULTURE", 1, 3) == pytest.approx(1.14) + assert econ.faction_multiplier("MORIA", "METALLURGY", 20, 21) == pytest.approx(1.0438095238095237) + assert econ.faction_multiplier("FOO", "AGRICULTURE", 1, 3) is None + assert econ.faction_multiplier("ANTARES", "METALLURGY", 1, 2) is None # Antares is electronics only + + +def test_hwp_without_technicians_under_metallurgy_cogc(): + """HWP 40 settlers + 10 technicians; technicians absent: workforce 0.8 x COGC 1.25 = 1.0 (before expert/HQ).""" + hwp = dict(settlers=40, technicians=10) + tier = dict(settler=econ.tier_efficiency(40, 40, True, True), technician=0.0) + total, el = econ.building_efficiency(hwp, tier, expertise="METALLURGY", cogc="METALLURGY") + assert el["WORKFORCE"] == pytest.approx(0.8) and total == pytest.approx(1.0) + + +def test_extraction_matches_live_deimos(): + """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) + assert econ.daily_extraction(0.3, "GASEOUS") == pytest.approx(18.0) + assert econ.daily_extraction(0.2, "LIQUID") == pytest.approx(14.0) + t, amt = econ.extraction_cycle("MINERAL", 28.0) + assert amt == 14 and t == pytest.approx(12 * 3600e3) + + +def test_production_io_flux_smelter(): + r = dict(time_ms=14.9167 * 3600e3, inputs={"ALO": 6, "FLX": 1, "C": 1, "O": 1}, outputs={"AL": 4}) + io = econ.production_io([r], efficiency=1.0, n_buildings=5) + assert io["out"]["AL"] == pytest.approx(4 * 5 * 24 / 14.9167, rel=1e-3) # ~32 AL/day for 5 SME at 100% + + +def test_workforce_consumption_luxury_gating(): + d = econ.workforce_consumption("pioneer", 100, 100, lux1=True, lux2=False) + assert d == {"DW": 4, "RAT": 4, "OVE": 0.5, "PWO": 0.2} + assert econ.workforce_consumption("pioneer", 100, 50, False, False)["DW"] == 2 + + +def test_hab_optimizer_matches_prunplanner(): + r = econ.optimize_habs(REQ, HAB_COSTS, "cost", max_area=135) + assert r["habs"] == {"HB1": 1, "HB2": 4, "HB3": 5} and r["cost"] == pytest.approx(1099203.64383138) + r = econ.optimize_habs(REQ, HAB_COSTS, "area") + assert r["area"] == 118 + + +def test_matches_live_fio_smelter_efficiency(): + """Deimos SME, 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", + experts={"METALLURGY": 2}, condition=0.9993602633476257) + # model 1.33614 vs FIO 1.33609: agrees to 0.004%; residual unexplained (rounding of expert/condition in game) + assert total == pytest.approx(1.3360869884490967, rel=1e-4) diff --git a/tests/test_market.py b/tests/test_market.py new file mode 100644 index 0000000..1d7526b --- /dev/null +++ b/tests/test_market.py @@ -0,0 +1,21 @@ +from puga import market, fio + + +def _book(monkeypatch): + monkeypatch.setattr(fio, "order_book", lambda m, c, r=False: { + "SellingOrders": [{"ItemCount": 10, "ItemCost": 110}, {"ItemCount": 5, "ItemCost": 100}], + "BuyingOrders": [{"ItemCount": 4, "ItemCost": 90}, {"ItemCount": 10, "ItemCost": 80}], + }) + + +def test_buy_walks_asks_ascending(monkeypatch): + _book(monkeypatch) + r = market.walk("X", "AI1", 10, "buy") + assert r["total"] == 5 * 100 + 5 * 110 and r["worst"] == 110 and not r["short"] + + +def test_sell_walks_bids_descending_and_reports_shortfall(monkeypatch): + _book(monkeypatch) + r = market.walk("X", "AI1", 20, "sell") + assert r["filled"] == 14 and r["short"] and r["worst"] == 80 + assert r["total"] == 4 * 90 + 10 * 80 diff --git a/tests/test_plan_push.py b/tests/test_plan_push.py new file mode 100644 index 0000000..8cb758b --- /dev/null +++ b/tests/test_plan_push.py @@ -0,0 +1,36 @@ +import importlib.util, sys +from pathlib import Path +import pytest + +spec = importlib.util.spec_from_file_location("plan_push", Path(__file__).resolve().parent.parent / "tools" / "plan_push.py") +pp_tool = importlib.util.module_from_spec(spec) +spec.loader.exec_module(pp_tool) + +R = [ + {"recipe_id": "SME#6xALO 1xO 1xC=>3xAL", "building_ticker": "SME", "inputs": [{"material_ticker": t} for t in ("ALO", "O", "C")], "outputs": [{"material_ticker": "AL"}]}, + {"recipe_id": "SME#6xALO 1xO 1xC 1xFLX=>4xAL", "building_ticker": "SME", "inputs": [{"material_ticker": t} for t in ("ALO", "O", "C", "FLX")], "outputs": [{"material_ticker": "AL"}]}, +] + + +def test_resolve_by_ticker_sets_distinguishes_flux(): + assert pp_tool.resolve_recipe("ALO,FLX,C,O=>AL", "SME", R).endswith("=>4xAL") + assert pp_tool.resolve_recipe("ALO,C,O=>AL", "SME", R).endswith("=>3xAL") + assert pp_tool.resolve_recipe("EXT#ALO", "EXT", R) == "EXT#ALO" + with pytest.raises(ValueError): + pp_tool.resolve_recipe("ALO=>AL", "SME", R) + + +def _spec(**kw): + s = dict(name="[PuGa] x", planet="ZV-759c", permits=1, cogc="METALLURGY", buildings=[{"building": "SME", "amount": 1, "recipes": ["ALO,C,O=>AL"]}]) + s.update(kw) + return s + + +def test_guardrail_requires_prefix_and_validates(): + ok = pp_tool.build_payload(_spec(), R, {"SME"}) + assert ok["plan_cogc"] == "METALLURGY" and len(ok["plan_data"]["experts"]) == 9 and len(ok["plan_data"]["workforce"]) == 5 + for bad in (dict(name="My plan"), dict(planet="ZV759c"), dict(permits=4), dict(cogc="FOO")): + with pytest.raises(ValueError): + pp_tool.build_payload(_spec(**bad), R, {"SME"}) + with pytest.raises(ValueError): + pp_tool.build_payload(_spec(buildings=[{"building": "ZZZ", "amount": 1}]), R, {"SME"}) diff --git a/tests/test_saturation.py b/tests/test_saturation.py new file mode 100644 index 0000000..064b057 --- /dev/null +++ b/tests/test_saturation.py @@ -0,0 +1,40 @@ +import math +from puga import saturation as s, market, fio + + +def test_tref_uses_min_and_poisson_floor(): + assert s.tref(60, 40) == 40 + assert s.tref(15, 13) < 13 # low volume gets a lower bound + assert s.tref(0, 0) == 0 + + +def test_n_out_penalises_queue_and_thin_demand(): + free = s.n_out(100, 0, 10_000, 5) # no queue, ample demand + assert math.isclose(free, 0.25 * 100 / 5) + assert s.n_out(100, 7000, 10_000, 5) < free # 70 days of queue cuts it + assert s.n_out(100, 0, 100, 5) < free # demand only 1 day of flow cuts it + assert s.n_out(0, 0, 100, 5) == 0 + + +def test_thin_flag(): + assert s.is_thin(1.3, 100, 5) and not s.is_thin(60, 500, 5) + + +def test_p_patient_clamps_and_falls_to_bid_when_oversupplied(): + asks = [(100, 50), (110, 50), (120, 50)] + assert s.p_patient(asks, 10, 20, bid=90, vwap7=105, vwap30=105, ask=100) == 90 # we out-produce flow: sell at bid + p = s.p_patient(asks, 30, 1, 90, 200, 200, 100) # ample flow: capped by ask level 100 + assert p == 100 + + +def test_walk_treats_mm_null_count_as_unlimited(monkeypatch): + monkeypatch.setattr(fio, "order_book", lambda m, c, r=False: { + "SellingOrders": [], "BuyingOrders": [{"ItemCount": 4, "ItemCost": 90}, {"ItemCount": None, "ItemCost": 80}]}) + r = market.walk("X", "AI1", 1000, "sell") + assert r["filled"] == 1000 and not r["short"] and r["worst"] == 80 + + +def test_effective_supply_ignores_stale_far_asks(): + asks = [(100, 10), (110, 20), (150, 1500)] + assert s.effective_supply(asks, 100) == 30 # 1500 units at 1.5x are not competition + assert s.effective_supply(asks, None) == 1530 diff --git a/tools/book.py b/tools/book.py new file mode 100755 index 0000000..8028a57 --- /dev/null +++ b/tools/book.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Order-book ladder for one material at one exchange: price levels with cumulative units and value. + tools/book.py BHP --cx AI1 --levels 8 +""" +import argparse, sys +from collections import defaultdict +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from puga import config, fio + + +def ladder(orders, reverse): + lv = defaultdict(int) + for o in orders: + if o.get("ItemCount") != 0: + lv[o["ItemCost"]] += 10**9 if o.get("ItemCount") is None else o["ItemCount"] # 10**9 = market maker, unlimited + cum, out = 0, [] + for price in sorted(lv, reverse=reverse): + cum += lv[price] + out.append((price, lv[price], cum)) + return out + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("ticker") + ap.add_argument("--cx", default=config.DEFAULT_CX) + ap.add_argument("--levels", type=int, default=8) + ap.add_argument("--refresh", action="store_true") + a = ap.parse_args() + t = a.ticker.upper() + ob = fio.order_book(t, a.cx, a.refresh) + asks, bids = ladder(ob["SellingOrders"], False), ladder(ob["BuyingOrders"], True) + print(f"{t}.{a.cx} ask {ob.get('Ask')} bid {ob.get('Bid')} supply {ob.get('Supply')} demand {ob.get('Demand')} " + f"MMBuy {ob.get('MMBuy')} MMSell {ob.get('MMSell')}") + print(f"{'ASKS':>8} {'units':>7} {'cum':>7} | {'BIDS':>8} {'units':>7} {'cum':>7}") + for i in range(a.levels): + l = f"{asks[i][0]:8.0f} {asks[i][1]:7d} {asks[i][2]:7d}" if i < len(asks) else " " * 24 + r = f"{bids[i][0]:8.0f} {bids[i][1]:7d} {bids[i][2]:7d}" if i < len(bids) else "" + print(f"{l} | {r}") + + +if __name__ == "__main__": + main() diff --git a/tools/chain.py b/tools/chain.py new file mode 100755 index 0000000..e8f31b7 --- /dev/null +++ b/tools/chain.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Make-vs-buy cost tree for a material at one exchange, plus sourcing depth for every raw input. +Cost to make = inputs at min(buy, make) per output unit; wages/capex excluded unless --wages. + tools/chain.py DEC # AI1 + tools/chain.py BHP --depth 3 --qty 13 +""" +import argparse, sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from puga import config, econ, fio, market, saturation as sat + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("ticker") + ap.add_argument("--cx", default=config.DEFAULT_CX) + ap.add_argument("--depth", type=int, default=2, help="levels of make-vs-buy expansion") + ap.add_argument("--qty", type=float, default=1.0, help="units/day you would make (sourcing check)") + ap.add_argument("--wages", action="store_true", help="add wage cost per output unit (both luxuries at ask)") + ap.add_argument("--cogc") + a = ap.parse_args() + + snap = market.snapshot() + Q = lambda t: snap.get((t, a.cx)) + blds = {b["Ticker"]: b for b in fio.buildings()} + by_out = {} + for r in fio.recipes(): + for o in r["Outputs"]: + by_out.setdefault(o["Ticker"], []).append(r) + + def wage_per_day(b): + tot = 0.0 + for t in econ.TIERS: + n = b[t.capitalize() + "s"] + if n: + tot += n * sum(need / 100 * (Q(tk).ask if Q(tk) and Q(tk).ask else 0) for tk, need, _, _ in econ.CONSUMPTION[t]) + return tot + + def buy(t): + q = Q(t) + return q.ask if q and q.ask else None + + memo = {} + + def make_cost(t, depth, stack=()): + """(best unit cost, how). Compares buy vs best recipe.""" + key = (t, depth) + if key in memo: + return memo[key] + b = buy(t) + best = (b if b is not None else float("inf"), "buy" if b is not None else "n/a", None) + if depth > 0 and t not in stack: + for r in by_out.get(t, []): + bld = blds.get(r["BuildingTicker"]) + if not bld: + continue + cost = 0.0 + for i in r["Inputs"]: + c, _, _ = make_cost(i["Ticker"], depth - 1, stack + (t,)) + cost += i["Amount"] * c + outs = sum(o["Amount"] for o in r["Outputs"] if o["Ticker"] == t) + if outs == 0: + continue + if a.wages: + eff, _ = econ.building_efficiency({x.lower() + "s": bld[x + "s"] for x in ("Pioneer", "Settler", "Technician", "Engineer", "Scientist")}, + {x: 1.0 for x in econ.TIERS}, expertise=bld["Expertise"] or None, cogc=a.cogc) + cost += wage_per_day(bld) * (r["TimeMs"] / 86400e3) / eff + unit = cost / outs + if unit < best[0]: + best = (unit, f"make {r['BuildingTicker']} {r['TimeMs']/3.6e6:.1f}h", r) + memo[key] = best + return best + + def show(t, depth, qty, indent=0): + c, how, r = make_cost(t, depth) + q = Q(t) + pad = " " * indent + mk = f"ask {q.ask:.0f} bid {q.bid:.0f} sup {q.supply:.0f} dem {q.demand:.0f} flow {sat.tref(q.traded7, q.traded30):.1f}/d" if q and q.ask and q.bid else "no market" + print(f"{pad}{t:5} x{qty:<8.1f} best {c:9.0f}/u via {how:18} | {mk}") + if r and how.startswith("make") and depth > 0: + outs = sum(o["Amount"] for o in r["Outputs"] if o["Ticker"] == t) + for i in r["Inputs"]: + show(i["Ticker"], depth - 1, qty * i["Amount"] / outs, indent + 1) + + show(a.ticker.upper(), a.depth, a.qty) + print("\n# sourcing check for buy-leaves at", a.qty, "output units/day: ask-walk price for one day of inputs") + q0 = Q(a.ticker.upper()) + _, _, r = make_cost(a.ticker.upper(), a.depth) + if r: + outs = sum(o["Amount"] for o in r["Outputs"] if o["Ticker"] == a.ticker.upper()) + for i in r["Inputs"]: + need = a.qty * i["Amount"] / outs + w = market.walk(i["Ticker"], a.cx, need, "buy") + x = Q(i["Ticker"]) + days = x.supply / need if x and need else 0 + print(f" {i['Ticker']:5} need {need:8.1f}/d ask {x.ask if x else None} walked avg {w['avg'] and round(w['avg'])} short={w['short']} standing supply = {days:.0f} days of your use, market flow {sat.tref(x.traded7, x.traded30):.1f}/d") + + +if __name__ == "__main__": + main() diff --git a/tools/plan_push.py b/tools/plan_push.py new file mode 100755 index 0000000..535d950 --- /dev/null +++ b/tools/plan_push.py @@ -0,0 +1,116 @@ +#!/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. + + 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 + +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"]}] +A recipe is an exact PRUNplanner recipe_id, an extraction id like EXT#ALO, or 'IN,IN=>OUT' (ticker sets, matched within the building). +""" +import argparse, json, sys +from pathlib import Path +import yaml +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from puga import prunplanner as pp + +EXPERTS = ["Agriculture", "Chemistry", "Construction", "Electronics", "Food_Industries", "Fuel_Refining", + "Manufacturing", "Metallurgy", "Resource_Extraction"] +COGC = {"AGRICULTURE", "CHEMISTRY", "CONSTRUCTION", "ELECTRONICS", "FOOD_INDUSTRIES", "FUEL_REFINING", "MANUFACTURING", + "METALLURGY", "RESOURCE_EXTRACTION", "PIONEERS", "SETTLERS", "TECHNICIANS", "ENGINEERS", "SCIENTISTS"} +TIERS = ["pioneer", "settler", "technician", "engineer", "scientist"] +PREFIX = "[PuGa]" + + +def resolve_recipe(spec: str, building: str, recipes: list[dict]) -> str: + rs = [r for r in recipes if r["building_ticker"] == building] + if any(r["recipe_id"] == spec for r in rs) or spec.startswith(building + "#") and "=>" not in spec: + return spec # exact id, or extraction id (EXT#ALO) + if "=>" not in spec: + raise ValueError(f"cannot parse recipe '{spec}'") + left, right = spec.split("=>") + want_in = {x.strip().split("x")[-1] if x.strip()[0].isdigit() and "x" in x else x.strip() for x in left.split(",") if x.strip()} + want_out = {x.strip().split("x")[-1] if x.strip()[0].isdigit() and "x" in x else x.strip() for x in right.split(",") if x.strip()} + # tolerate amount prefixes like '4AL' / '4xAL' + strip = lambda s: {"".join(ch for ch in x.lstrip("0123456789x")) for x in s} + want_in, want_out = strip(want_in), strip(want_out) + hits = [r for r in rs if {i["material_ticker"] for i in r["inputs"]} == want_in and {o["material_ticker"] for o in r["outputs"]} == want_out] + if len(hits) != 1: + raise ValueError(f"recipe '{spec}' at {building}: {len(hits)} matches" + (": " + ", ".join(h["recipe_id"] for h in hits) if hits else "")) + return hits[0]["recipe_id"] + + +def build_payload(spec: dict, recipes: list[dict], building_tickers: set[str]) -> dict: + name = spec["name"] + if not name.startswith(PREFIX): + raise ValueError(f"plan name must start with '{PREFIX}' (guardrail); got '{name}'") + planet = spec["planet"] + if len(planet) != 7: + raise ValueError("planet natural id must be 7 chars, e.g. ZV-759c") + permits = int(spec.get("permits", 1)) + if not 0 <= permits <= 3: + raise ValueError("permits must be 0..3") + cogc = spec.get("cogc") + if cogc is not None and cogc.upper() not in COGC: + raise ValueError(f"cogc '{cogc}' not one of {sorted(COGC)}") + experts = {k.upper(): v for k, v in (spec.get("experts") or {}).items()} + lux = spec.get("lux") or {} + buildings = [] + for b in spec["buildings"]: + tk = b["building"] + if tk not in building_tickers: + raise ValueError(f"unknown building {tk}") + recs = [{"recipeid": resolve_recipe(r, tk, recipes), "amount": 1} for r in b.get("recipes", [])] + buildings.append({"name": tk, "amount": int(b["amount"]), "active_recipes": recs}) + infra = [{"building": k, "amount": int(v)} for k, v in (spec.get("infrastructure") or {}).items()] + return { + "plan_name": name, "planet_natural_id": planet, "plan_permits_used": permits, + "plan_cogc": cogc.upper() if cogc else None, "plan_corphq": bool(spec.get("hq", False)), + "plan_data": { + "experts": [{"type": e, "amount": int(experts.get(e.upper(), 0))} for e in EXPERTS], + "workforce": [{"type": t, "lux1": bool(lux.get(t, [True, True])[0]), "lux2": bool(lux.get(t, [True, True])[1])} for t in TIERS], + "infrastructure": infra, "buildings": buildings}} + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("spec", help="plans/*.yaml or 'list'") + 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") + a = ap.parse_args() + + if a.spec == "list": + for p in pp.request("GET", "/planning/plan/"): + print(p["uuid"], p.get("plan_name"), p.get("planet_natural_id")) + 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"] + print(f"PLAN {payload['plan_name']} planet {payload['planet_natural_id']} permits {payload['plan_permits_used']} cogc {payload['plan_cogc']} hq {payload['plan_corphq']}") + print("experts:", {e["type"]: e["amount"] for e in d["experts"] if e["amount"]} or "-") + print("infrastructure:", {i["building"]: i["amount"] for i in d["infrastructure"]} or "-") + for b in d["buildings"]: + print(f" {b['amount']:>3} x {b['name']:4}", [r["recipeid"] for r in b["active_recipes"]]) + 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.") + return + if a.update: + cur = pp.request("GET", f"/planning/plan/{a.update}/") + if not str(cur.get("plan_name", "")).startswith(PREFIX): + sys.exit(f"REFUSED: existing plan '{cur.get('plan_name')}' is not a {PREFIX} plan") + out = pp.request("PUT", f"/planning/plan/{a.update}/", payload) + else: + out = pp.request("POST", "/planning/plan/", payload) + print("WRITTEN:", out.get("uuid") if isinstance(out, dict) else out) + + +if __name__ == "__main__": + main() diff --git a/tools/price.py b/tools/price.py new file mode 100755 index 0000000..1dd8631 --- /dev/null +++ b/tools/price.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Prices for materials across all exchanges, with VWAP and daily traded volume; optional fill price for a quantity. + tools/price.py BHP STL # all CX + tools/price.py BHP --cx AI1 --qty 200 # avg fill price to buy/sell 200 at AI1 (walks the live book) +""" +import argparse, sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from puga import config, market + + +def f(x, w=7): + return f"{x:{w}.0f}" if x is not None else f"{'-':>{w}}" + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("tickers", nargs="+") + ap.add_argument("--cx", help="limit to one exchange") + ap.add_argument("--qty", type=float, help="show avg fill price for this quantity (needs --cx or uses default CX)") + ap.add_argument("--refresh", action="store_true") + a = ap.parse_args() + snap = market.snapshot(a.refresh) + print(f"{'mat':5} {'cx':4} {'bid':>7} {'ask':>7} {'vwap7':>7} {'vwap30':>7} {'trd/d7':>7} {'trd/d30':>7} {'demand':>7} {'supply':>7}") + for t in map(str.upper, a.tickers): + for cx in market.CXS: + if a.cx and cx != a.cx: + continue + q = snap.get((t, cx)) + if q: + print(f"{t:5} {cx:4} {f(q.bid)} {f(q.ask)} {f(q.vwap7)} {f(q.vwap30)} {q.traded7:7.1f} {q.traded30:7.1f} {q.demand:7.0f} {q.supply:7.0f}") + if a.qty: + cx = a.cx or config.DEFAULT_CX + b, s = market.walk(t, cx, a.qty, "buy"), market.walk(t, cx, a.qty, "sell") + print(f" fill {a.qty:.0f} @ {cx}: buy avg {f(b['avg'])} (worst {f(b['worst'])}, filled {b['filled']:.0f}) | " + f"sell avg {f(s['avg'])} (worst {f(s['worst'])}, filled {s['filled']:.0f})") + + +if __name__ == "__main__": + main() diff --git a/tools/prun_cxarb.py b/tools/prun_cxarb.py new file mode 100644 index 0000000..8e93a10 --- /dev/null +++ b/tools/prun_cxarb.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Pure exchange arbitrage: for each material, cheapest ask at any CX vs best bid at any other CX. +Reports spread per unit, per ton and per m3 of cargo, and how many units the thinner side can absorb. + python prun_cxarb.py --from AI1 # only routes buying at AI1 + python prun_cxarb.py --to AI1 # only routes selling at AI1 + python prun_cxarb.py --minqty 200 --sort ton +""" +import argparse, json, urllib.request +FIO="https://rest.fnar.net" +def get(p): + with urllib.request.urlopen(FIO+p,timeout=60) as r: return json.load(r) +ap=argparse.ArgumentParser() +ap.add_argument("--from",dest="src",default=None); ap.add_argument("--to",dest="dst",default=None) +ap.add_argument("--minqty",type=float,default=100,help="min units both sides can absorb") +ap.add_argument("--sort",default="ton",choices=["unit","ton","m3","total","pct"]); ap.add_argument("--top",type=int,default=30) +a=ap.parse_args() +mats={m["Ticker"]:m for m in get("/material/allmaterials")} +px={} +for e in get("/exchange/all"): px.setdefault(e["MaterialTicker"],{})[e["ExchangeCode"]]=e +rows=[] +for t,cxs in px.items(): + for s,es in cxs.items(): + if a.src and s!=a.src: continue + if not es.get("Ask"): continue + for d,ed in cxs.items(): + if d==s or (a.dst and d!=a.dst) or not ed.get("Bid"): continue + # depth: units available at ask side / wanted at bid side (order book totals) + # /exchange/all has only book totals; use min(supply at source, demand at destination) as depth proxy + qty=min(es.get("Supply") or 0, ed.get("Demand") or 0) + if qty8} {'bid':>8} {'spread':>7} {'%':>5} {'/ton':>7} {'/m3':>7} {'qty':>7} {'total':>9}") +for r in rows[:a.top]: + print(f"{r['t']:4} {r['src']:4} {r['dst']:5} {r['ask']:8.0f} {r['bid']:8.0f} {r['unit']:7.0f} {r['pct']:5.0f} {r['ton']:7.0f} {r['m3']:7.0f} {r['qty']:7.0f} {r['total']:9.0f}") + diff --git a/tools/prun_scan.py b/tools/prun_scan.py new file mode 100644 index 0000000..8ee178c --- /dev/null +++ b/tools/prun_scan.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Scan every Prosperous Universe recipe for single-step profit: +buy all inputs at exchange ask, sell all outputs at exchange bid. + +Uses the public FIO REST API (rest.fnar.net), no auth needed. + +Examples: + python prun_scan.py # AI1, all tiers, top 40 by ROI + python prun_scan.py --cx NC1 --tier P # Moria, pioneer-only buildings + python prun_scan.py --sort net --depth 60 --top 20 + python prun_scan.py --cross # buy at cheapest CX, sell at best CX (ignores shipping) +""" +import argparse, json, urllib.request, sys + +FIO = "https://rest.fnar.net" +EXCH = ["AI1", "NC1", "CI1", "IC1", "NC2", "CI2"] +TIERS = ["Pioneers", "Settlers", "Technicians", "Engineers", "Scientists"] +TIER_CODE = {"Pioneers": "P", "Settlers": "S", "Technicians": "T", "Engineers": "E", "Scientists": "Sc"} +HAB = {"Pioneers": ("HB1", 100), "Settlers": ("HB2", 75), "Technicians": ("HB3", 75), + "Engineers": ("HB4", 75), "Scientists": ("HB5", 75)} + +def get(path): + with urllib.request.urlopen(f"{FIO}{path}", timeout=60) as r: + return json.load(r) + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--cx", default="AI1", help="exchange code (AI1 NC1 CI1 IC1 NC2 CI2)") + ap.add_argument("--cross", action="store_true", help="buy at cheapest ask across all CX, sell at best bid across all CX") + ap.add_argument("--tier", default=None, help="max workforce tier: P, S, T, E, Sc") + ap.add_argument("--depth", type=float, default=30, help="min days of open demand for one building's output") + ap.add_argument("--sort", default="roi", choices=["roi", "net", "area"]) + ap.add_argument("--top", type=int, default=40) + ap.add_argument("--mcg", type=float, default=4.0, help="MCG per area (4 on normal rocky planets)") + ap.add_argument("--understaff", action="store_true", help="also evaluate running without some worker tiers (no housing/wages for them)") + ap.add_argument("--penalty", type=float, default=1.0, help="efficiency = (staffed headcount share) ** penalty; 1 = proportional") + a = ap.parse_args() + + print("fetching FIO data...", file=sys.stderr) + recipes = get("/recipes/allrecipes") + buildings = {b["Ticker"]: b for b in get("/building/allbuildings")} + exch = get("/exchange/all") + needs = get("/global/workforceneeds") + + px = {} + for e in exch: + px.setdefault(e["MaterialTicker"], {})[e["ExchangeCode"]] = e + + def ask(t): + if a.cross: + v = [px[t][c]["Ask"] for c in px.get(t, {}) if px[t][c].get("Ask")] + return min(v) if v else None + return (px.get(t, {}).get(a.cx) or {}).get("Ask") + + def bid(t): + if a.cross: + v = [px[t][c]["Bid"] for c in px.get(t, {}) if px[t][c].get("Bid")] + return max(v) if v else None + return (px.get(t, {}).get(a.cx) or {}).get("Bid") + + def demand(t): + if a.cross: + return sum((px[t][c].get("Demand") or 0) for c in px.get(t, {})) + return (px.get(t, {}).get(a.cx) or {}).get("Demand") or 0 + + # wages per worker per day from consumable needs at ask + wage = {} + for w in needs: + wage[w["WorkforceType"]] = sum(n["Amount"] * (ask(n["MaterialTicker"]) or 0) for n in w["Needs"]) / 100 + + def bcost(tk): + b = buildings[tk] + c = sum(x["Amount"] * (ask(x["CommodityTicker"]) or 0) for x in b["BuildingCosts"]) + return c + a.mcg * b["AreaCost"] * (ask("MCG") or 0) + + hab_cost = {t: bcost(h) / n for t, (h, n) in HAB.items()} + hab_area = {t: buildings[h]["AreaCost"] / n for t, (h, n) in HAB.items()} + + tier_rank = {"P": 0, "S": 1, "T": 2, "E": 3, "Sc": 4} + rows = [] + for rec in recipes: + b = buildings.get(rec["BuildingTicker"]) + if not b or not rec["Outputs"]: + continue + top = "P" + for t in TIERS: + if b[t]: + top = TIER_CODE[t] + if a.tier and tier_rank[top] > tier_rank[a.tier] and not a.understaff: + continue + ins, outs = rec["Inputs"], rec["Outputs"] + if any(ask(i["Ticker"]) is None for i in ins) or any(bid(o["Ticker"]) is None for o in outs): + continue + per_day = 24 / (rec["TimeMs"] / 3.6e6) + cin = sum(i["Amount"] * ask(i["Ticker"]) for i in ins) * per_day + cout = sum(o["Amount"] * bid(o["Ticker"]) for o in outs) * per_day + outq = sum(o["Amount"] for o in outs) * per_day + dep = min(demand(o["Ticker"]) for o in outs) / outq + if dep < a.depth: + continue + used = [t for t in TIERS if b[t]] + total_heads = sum(b[t] for t in used) + import itertools + variants = [tuple(used)] + if a.understaff and len(used) > 1: + # drop any non-empty subset of tiers, but never drop all + for k in range(1, len(used)): + for keep in itertools.combinations(used, k): + variants.append(keep) + for keep in variants: + eff = (sum(b[t] for t in keep) / total_heads) ** a.penalty + wages = sum(b[t] * wage[t.upper()[:-1] if t != "Pioneers" else "PIONEER"] for t in keep) + net = (cout - cin) * eff - wages + capex = bcost(b["Ticker"]) + sum(b[t] * hab_cost[t] for t in keep) + area = b["AreaCost"] + sum(b[t] * hab_area[t] for t in keep) + staff = "".join(TIER_CODE[t] for t in keep) + ("" if len(keep) == len(used) else "-") + if a.tier and any(tier_rank[TIER_CODE[t]] > tier_rank[a.tier] for t in keep): + continue + rows.append(dict(roi=100 * net / capex, net=net, area=net / area, capex=capex, tier=staff, + bld=b["Ticker"], exp=(b["Expertise"] or "")[:5], + ins=" ".join(f"{i['Amount']}{i['Ticker']}" for i in ins), + outs=" ".join(f"{o['Amount']}{o['Ticker']}" for o in outs), + h=rec["TimeMs"] / 3.6e6, depth=dep, eff=eff)) + + rows.sort(key=lambda r: r[a.sort], reverse=True) + print(f"# {'cross-CX' if a.cross else a.cx} tier<={a.tier or 'any'} depth>={a.depth}d sorted by {a.sort}") + print(f"{'ROI%':>6} {'net/d':>8} {'net/area':>8} {'capex':>8} {'staff':6} {'eff':>4} {'bld':4} {'exp':5} {'h':>5} {'depth':>6} recipe") + for r in rows[:a.top]: + print(f"{r['roi']:6.1f} {r['net']:8.0f} {r['area']:8.0f} {r['capex']:8.0f} {r['tier']:6} {r['eff']*100:4.0f} {r['bld']:4} {r['exp']:5} " + f"{r['h']:5.1f} {r['depth']:6.0f} {r['ins']} -> {r['outs']}") + +if __name__ == "__main__": + main() diff --git a/tools/refresh_refs.sh b/tools/refresh_refs.sh new file mode 100755 index 0000000..353e05a --- /dev/null +++ b/tools/refresh_refs.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# Re-pull PRUNplanner source into ref/ (gitignored). Their code is the source of truth for game mechanics. +cd "$(dirname "$0")/.." || exit 1 +rm -rf ref/frontend ref/backend +git clone -q --depth 1 https://github.com/PRUNplanner/frontend.git ref/frontend +git clone -q --depth 1 https://github.com/PRUNplanner/backend.git ref/backend +rm -rf ref/frontend/.git ref/backend/.git +echo "refs updated $(date -I)" diff --git a/tools/scan.py b/tools/scan.py new file mode 100755 index 0000000..a2b39c0 --- /dev/null +++ b/tools/scan.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Depth-aware single-step recipe scan at one exchange (saturation model v1, see docs/saturation-design.md). +For each recipe: buildings the market can absorb (N*), and ROI/day at N=1 and N* using a patient sell price +(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 --sort total --min-n 3 # rank by absorbable profit/day +""" +import argparse, 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 + +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" + return yaml.safe_load(p.read_text()) if p.exists() else {} + + +def main(): + ap = argparse.ArgumentParser() + 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("--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("--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") + ap.add_argument("--mcg", type=float, default=4.0, help="MCG per area (4 on normal planets)") + ap.add_argument("--sort", default="roi", choices=["roi", "total", "roi1"]) + 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") + a = ap.parse_args() + + snap = market.snapshot() + Q = lambda t: snap.get((t, a.cx)) + ask = lambda t: (Q(t).ask if Q(t) else None) + 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) + + blds = {b["Ticker"]: b for b in fio.buildings()} + mcg = ask("MCG") or 0 + + def bcost(tk): + b = blds[tk] + parts = [(x["Amount"], ask(x["CommodityTicker"])) for x in b["BuildingCosts"]] + if any(p is None for _, p in parts): + return None + return sum(n * p for n, p in parts) + a.mcg * b["AreaCost"] * mcg + + hab_head = {} + for tier, hab in (("pioneer", "HB1"), ("settler", "HB2"), ("technician", "HB3"), ("engineer", "HB4"), ("scientist", "HB5")): + c = bcost(hab) + hab_head[tier] = None if c is None else c / econ.HAB_CAP[hab][tier] + + def wage(tier): # per worker per day, both luxuries supplied, at ask + tot = 0.0 + for tk, need, _, _ in econ.CONSUMPTION[tier]: + p = ask(tk) + if p is None: + return None + tot += need / 100 * p + return tot + + experts = {k.strip().upper(): int(v) for k, v in (x.split("=") for x in a.experts.split(",") if "=" in x)} + 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(): + 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): + 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 + 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)) + + cands.sort(key=lambda c: c["rough_roi"], reverse=True) + rows = [] + book = {} + + def asks_of(t): + if t not in book: + book[t] = sorted((o["ItemCost"], (o["ItemCount"] or 0)) for o in fio.order_book(t, a.cx)["SellingOrders"]) + return book[t] + + for c in cands[:a.k]: + io, mm = c["io"], c["mm"] + # stage 2: queue penalty from competing asks near market price only + n2 = float("inf") + for t, qo in io["out"].items(): + if t in mm: + continue + x = Q(t) + se = sat.effective_supply(asks_of(t), x.vwap7 or x.ask) + n2 = min(n2, sat.n_out(sat.tref(x.traded7, x.traded30), se, x.demand, qo)) + c["n_lim"] = float(a.max_n) if n2 == float("inf") else n2 + if c["n_lim"] < a.min_n and not a.show_thin: + continue + + def evaluate(N): + rev = 0.0 + for t, qo in io["out"].items(): + x = Q(t) + if t in mm: + p = mm[t] + else: + p = sat.p_patient(asks_of(t), sat.tref(x.traded7, x.traded30), N * qo, x.bid, x.vwap7, x.vwap30, x.ask) + rev += N * qo * p + cost = 0.0 + for t, qi in io["in"].items(): + w = market.walk(t, a.cx, N * qi, "buy") + if w["short"]: + return None + cost += w["total"] + return rev - cost - N * c["wcost"] + + 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"]))) + net1 = evaluate(1) + if net1 is None: + continue + netN = evaluate(n_star) + while netN is None and n_star > 1: + n_star = max(1, n_star // 2) + netN = evaluate(n_star) + if netN is None: + 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"], + 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) + 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") + 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} " + f"{r['h']:5.1f} {r['rec']}{' [thin]' if r['thin'] else ''}") + + +if __name__ == "__main__": + main() diff --git a/tools/state.py b/tools/state.py new file mode 100755 index 0000000..4fdfe6f --- /dev/null +++ b/tools/state.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Sync 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.""" +import sys, datetime +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 + +P = ROOT / "state" / "company.yaml" + + +def sync(): + u = fio.me() + 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) + 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) + 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 = [] + for s in sites: + b = old.get(s["PlanetIdentifier"], {}) + b.update(name=s["PlanetName"], planet=s["PlanetIdentifier"], buildings=dict(Counter(x["BuildingTicker"] for x in s["Buildings"])), + avg_condition=round(sum(x["Condition"] for x in s["Buildings"]) / len(s["Buildings"]), 4)) + b["production"] = [dict(type=l["Type"], count=l["Capacity"], efficiency=round(l["Efficiency"], 4), queued_orders=len(l["Orders"])) + for l in prod if l["SiteId"] == s["SiteId"]] + bases.append(b) + st["bases"] = bases + st["storage"] = [dict(type=x["Type"], name=x["Name"], weight=f"{x['WeightLoad']:.0f}/{x['WeightCapacity']:.0f}t", + items={i["MaterialTicker"]: i["MaterialAmount"] for i in x["StorageItems"] if i.get("MaterialTicker")}) + for x in stores if x["StorageItems"] or x["Type"] == "SHIP_STORE"] + st["ships"] = [dict(name=x["Name"], reg=x["Registration"], mass=x["Mass"], in_flight=bool(x.get("FlightId"))) for x in ships] + P.write_text(yaml.safe_dump(st, sort_keys=False, default_flow_style=None, width=140)) + print("synced", st["as_of"]) + + +if __name__ == "__main__": + cmd = sys.argv[1] if len(sys.argv) > 1 else "show" + if cmd == "sync": + sync() + print(P.read_text())