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 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 23:00:21 +02:00
co-authored by Claude Sonnet 5
commit 7a538cb300
35 changed files with 1692 additions and 0 deletions
+7
View File
@@ -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 <key>"
DEFAULT_CX=AI1
DEFAULT_REGION=antares
+6
View File
@@ -0,0 +1,6 @@
.venv/
.env
data/cache/
ref/
__pycache__/
.pytest_cache/
+44
View File
@@ -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 <tier> --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.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+12
View File
@@ -0,0 +1,12 @@
<h1 align="center">
<br>
<img src='./PuGa.png' width="250px">
<br><br>
<b>PuGa</b>
<br><br>
</h1>
A set of tools to be used by humans and/or llms to plan for th egame Prosperous Universe.
Name origin:
Plutoniumgallium alloy (PuGa) is a specialized metallic blend of plutonium and gallium primarily used to stabilize the desirable delta (δ) phase of plutonium at room temperature.
+18
View File
@@ -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 <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.
+96
View File
@@ -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 <ship>).
- 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> base overview; BSC <planet> found/construct; BSL buildings list + demolish; WF <base> workforce; PROD <base> production lines (efficiency shown); BUI <ticker> building info (generic, no planet extras); PLI <planet> planet info; POPR <planet> population report; SYSI <system>; CX <ex>; CXOB <MAT.EX> order book; CXPC <MAT.EX> chart; CXPO place order; CXOS own orders; LM <planet/station> local market; CONTS contracts; CO <company code> company info incl. rating and faction reputation; USR <name> user profile; SFC <ship> 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).
+48
View File
@@ -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.
+22
View File
@@ -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 <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: <key>` (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.
+50
View File
@@ -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.
+13
View File
@@ -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"]}
+4
View File
@@ -0,0 +1,4 @@
"""PuGa: Prosperous Universe advisory toolkit."""
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
+17
View File
@@ -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
+26
View File
@@ -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"
+179
View File
@@ -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]}
+39
View File
@@ -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: <FIO_REST_KEY>` (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", "")
+53
View File
@@ -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)
+36
View File
@@ -0,0 +1,36 @@
"""api.prunplanner.org. Public data endpoints need no auth; planning endpoints use `Authorization: Api-Key <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
+56
View File
@@ -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)
+2
View File
@@ -0,0 +1,2 @@
[pytest]
testpaths = tests
+6
View File
@@ -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
+50
View File
@@ -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: {}
+84
View File
@@ -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)
+21
View File
@@ -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
+36
View File
@@ -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"})
+40
View File
@@ -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
Executable
+44
View File
@@ -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()
Executable
+100
View File
@@ -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()
+116
View File
@@ -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 <uuid>
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()
Executable
+40
View File
@@ -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()
+39
View File
@@ -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 qty<a.minqty: continue
spread=ed["Bid"]-es["Ask"]
if spread<=0: continue
w=mats[t]["Weight"]; v=mats[t]["Volume"]
rows.append(dict(t=t,src=s,dst=d,ask=es["Ask"],bid=ed["Bid"],unit=spread,pct=100*spread/es["Ask"],ton=spread/w if w else 0,m3=spread/v if v else 0,qty=qty,total=spread*qty))
rows.sort(key=lambda r:r[a.sort],reverse=True)
print(f"{'mat':4} {'buy@':4} {'sell@':5} {'ask':>8} {'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}")
+133
View File
@@ -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()
+8
View File
@@ -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)"
Executable
+196
View File
@@ -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()
Executable
+51
View File
@@ -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())