Research & Tech Tree¶
Purpose¶
Research is the empire's progression currency. A player accrues research points (RP) passively from the research labs on their colonized planets, banks them on a per-player ledger, and spends them to unlock nodes in a static tech tree. Each node, once unlocked, switches on a capability that the rest of the game reads at the moment it needs it: a new building becomes placeable, a tool becomes available, a ceiling rises, or a curve bends. The tree is the single gate currency that ties colony output (labs make RP) back into colony capability (RP unlocks content).
The defining discipline is that research is a leaf in the call graph. Effects are read at point-of-use — production, combat, terraform, and citadel code query the tree and the player's ledger when they compute a value. A research effect is never written onto the entity it buffs, so unlocking a node mutates no planet, ship, or combat column. The tree grows by appending catalog rows, not by migrating buffed systems.
The research-point currency¶
RP is a flat, integer currency banked on the per-player research ledger (Player.research_ledger, a nullable JSONB column). The ledger shape:
{
"rp": 0, // banked, spendable research points
"insight": 0, // reserved second/third currencies; read 0 today
"doctrine": 0,
"unlocked": ["t.root.0"], // node ids the player has unlocked — the durable spend record
"swept_at": "2026-..." // first-sweep stamp; gates the one-time wipe+refund (below)
}
A null column means a never-researched player and reads as the cold-start default {rp:0, insight:0, doctrine:0, unlocked:["t.root.0"]} — the free root is always present. The ledger is read lazily: a pure read of a null column returns a fresh default without persisting it; only the mutating paths (unlock_node, the faucet sweep) assign the seeded dict back to the column and flag it modified. unlocked is the only durable record of what RP bought — it stores node ids, so appending new catalog rows never invalidates an existing ledger.
The faucet that fills the ledger is the shipped per-planet active_events['research_points'] balance, accrued at 25 RP per lab level per day (RESEARCH_POINTS_PER_LAB_LEVEL_PER_DAY). The planetary tick drains that faucet into the owner's ledger (see the faucet sweep below).
The tech-tree node catalog¶
The catalog is a module-level static dict (tech_tree.TECH_NODES), mirroring the shape of CITADEL_LEVELS / DEFENSE_BUILDINGS: the game queries it and never mutates it. A CATALOG_VERSION constant is bumped when the catalog's shape or contents change; because the ledger stores unlocked node ids, appending rows is forward-compatible.
Each node:
{
"id": "t.defense.railgun.1", // stable dotted id, unique
"branch": "defense", // one of BRANCHES
"tier": 1, // 0 = root
"name": "Rail Gun Emplacements", // display
"cost": { "rp": 50 }, // flat RP only
"prereqs": ["t.root.0"], // node ids that must be unlocked first (DAG edges)
"effect": { "kind": "content_unlock", "key": "rail_gun" }
}
Branches and the free root¶
Five branches share a single Tier-0 root: production, defense, ships, exploration, terraforming. The branch vocabulary is pinned by the BRANCHES constant so later rows validate against it.
The free root is t.root.0 ("Applied Science"), tier 0, cost 0 RP, with no prerequisites. It is the only prereq-less node, and every other node is reachable from it by following prereq edges. A cold-start ledger lazy-seeds unlocked: ["t.root.0"], so every player starts already holding the root.
Effect kinds¶
Each node carries an effect.kind from EFFECT_KINDS:
| Kind | Meaning | Point-of-use reader |
|---|---|---|
root |
the free origin; no effect | — |
content_unlock |
makes a catalog entry (e.g. a defense building) placeable | the placement gate |
tool |
unlocks a capability flag | has_tool |
gate |
raises a stage / intensity ceiling | gate_value |
modifier |
bends a numeric curve | tech_modifier |
The shipped nodes¶
| Node id | Branch | Tier | Cost (RP) | Prereqs | Effect |
|---|---|---|---|---|---|
t.root.0 |
production | 0 | 0 | — | root |
t.defense.railgun.1 |
defense | 1 | 50 | t.root.0 |
content_unlock → rail_gun |
t.defense.grid.1 |
defense | 2 | 120 | t.defense.railgun.1 |
content_unlock → planetary_defense_grid |
t.exploration.survey.1 |
exploration | 1 | 30 | t.root.0 |
tool → grid_survey |
t.terraforming.hazard_clear.1 |
terraforming | 1 | 60 | t.root.0 |
tool → hazard_clear |
t.terraforming.plot_clear.1 |
terraforming | 1 | 40 | t.root.0 |
tool → plot_clear |
t.terraforming.intensity.1 |
terraforming | 2 | 90 | t.terraforming.plot_clear.1 |
gate → terraform_intensity (gate 2) |
t.production.yield.1 |
production | 1 | 45 | t.root.0 |
modifier → production_rate (+0.05) |
t.ships.efficiency.1 |
ships | 1 | 45 | t.root.0 |
modifier → turn_cost (−0.05) |
The two content_unlock nodes are the live payload: unlocking one makes the matching defense building placeable through the existing build flow. The remaining tool / gate / modifier nodes carry their eventual effect shape so future consumers wire to them without reshaping the catalog; their readers recognize them, but no live game system consults those readers yet.
DAG invariants¶
assert_dag_reachable() validates the catalog as a well-formed DAG and is safe to call from CI, tests, or startup (pure, no DB):
- Exactly one prereq-less root, and it is
t.root.0with cost 0. - Every node's
prereqsreference existing node ids; no node lists itself. - No cycles (DFS three-colour detection over the prereq edges).
- Every node is reachable from the free root by following its prereq chain.
A catalog edit that strands a node — an unknown branch, an unknown effect kind, a missing prereq, a cycle, or an unreachable node — fails this assertion.
CONTENT_UNLOCK_BY_KEY indexes the content_unlock nodes by the content key they unlock ("rail_gun" → "t.defense.railgun.1"), and node_id_for_content(content_key) returns the node id that gates a given building type, or None for ungated content.
Point-of-use reads¶
Downstream systems call these pure readers at the moment they consume a value. Each is a read of the player's ledger against the static catalog — safe to call anywhere, hot-path cheap, and mutating nothing:
player_has_tech(player, node_id) -> bool— true iffnode_idis in the player'sunlockedset.has_tool(player, tool_key) -> bool— true iff the player has unlocked anytoolnode whosekeymatches.gate_value(player, gate_key, floor=1) -> int— the highest unlockedgateceiling forgate_key, elsefloor. A gate raises a ceiling up, never out.tech_modifier(player, modifier_key, base=0.0) -> float— the summedmodifiermagnitude formodifier_key, additive onbase(e.g.rate = base_rate * (1 + tech_modifier(player, "production_rate"))).
These readers are the only contract a buffed system depends on. They never call into the rest of research, and research is never called from them — the zero-migration guarantee for every buffed system.
Unlocking a node¶
can_unlock(player, node_id) is a pure read returning {ok, reason}. It fails when the node is unknown, already unlocked, missing prerequisites, or the player lacks the banked RP; on success it also returns rp_cost.
unlock_node(db, player_id, node_id) spends banked RP to unlock a node. It locks the player row, re-checks can_unlock under the lock to prevent a concurrent double-spend, deducts the RP cost, appends the node to unlocked, and flushes. The caller commits (the deduct-and-flush, commit-at-route pattern). On success it returns the remaining RP and the updated unlocked set.
The faucet sweep¶
The faucet sweep drains each planet's accrued active_events['research_points'] into its owner's ledger. It runs as step 5 of the planetary settle tick (structures.settle()), after production accrual has written the planet's faucet balance. The planet row is already locked by the tick; the sweep acquires the owner's player row in the same transaction (planet-then-player lock order) before touching credits or the ledger.
Per call:
- Read the planet's
active_events['research_points'](the banked faucet balance). - Lock the owning player; lazy-seed a
nullledger. - Steady state (the player has been swept before, i.e.
swept_atis present): add this planet's faucet balance to the ledger'srp, then zero the planet's faucet so RP is never counted twice. RP becomes spendable research currency. - First-ever sweep (no
swept_at): apply the one-time wipe + refund. The sweep aggregates the faucet balance across every planet the player owns (locking those rows ordered by id, then the player row last — deadlock-safe against the player-facing planet→player build path), zeroes each of those faucets, refunds the summed RP toplayer.creditsat 10 credits per RP, and stampsswept_atonce. Pre-tick banked RP is therefore converted to credits rather than dumped on the ledger as a spendable windfall.
The swept_at stamp gates the whole first-sweep path so a re-tick or a restart never double-refunds — the sweep is idempotent. An unowned planet's orphaned faucet is defensively zeroed. The sweep returns whether any state changed, so the caller commits only on a real change.
CRT-4 economy — governor, copay, decay pressure, and directives¶
The CRT-4 tranche layers a closed-loop economy over the bare faucet-and-unlock kernel: a per-empire governor that compresses runaway RP, a per-day credit copay on banked RP, a per-plot decay-pressure model that creates ongoing demand, and a perishable Research-Directive contract set that sells relief from that demand. Each mechanism ships with a reproduce-exactly off-switch whose off value reproduces today's behavior byte-for-byte, so the live default is neutral — neutral equals today. The mechanisms are dormant under those off-switches and only the magnitudes below turn them on. Every magnitude in this section is NO-CANON, pending Max's ratification; until then the off-switches hold and the loop is a no-op. (lifecycle.md:215 carries the matching NO-CANON flag for the §3.1 net-economy ledger-drain rows.)
Governed RP — the per-empire S-curve soft cap¶
governed_rp(raw_daily_rp, soft_cap) compresses an empire's raw daily RP above a per-empire soft cap with a logarithmic taper. With excess = raw - soft_cap:
governed = soft_cap + soft_cap × GOV_TAPER × log1p(excess / soft_cap)
Below the cap the function is identity (no excess, no compression); above it, each further unit of raw RP yields progressively less governed RP. The governor is per-EMPIRE — it caps the player's RP aggregated across all their planets, not each planet independently — so a wide empire feels the taper the same as a tall one of equal output.
Constants (all NO-CANON pending Max):
| Constant | Value | Role |
|---|---|---|
GOV_BASE_SOFT_CAP |
1500.0 RP/day |
per-empire, empire-anchored threshold where the taper begins (Max-ruled) |
GOV_TAPER |
0.5 |
excess-compression strength (Max-ruled) |
GOV_SOFT_CAP_OFF |
inf |
reproduce-exactly off value |
The off value is GOV_SOFT_CAP_OFF = inf: with soft_cap == inf the excess is never positive and governed_rp(raw, inf) == raw for every input, so the governor is a pure no-op. The governor is neutral until Max blesses a finite cap.
Faucet credit copay¶
faucet_copay(governed_rp_banked) charges a per-day credit cost to bank RP — a slice of the value the banked RP represents, paid in credits:
copay = FAUCET_CREDIT_COPAY × governed_rp × RP_TO_CREDIT_RATE (cr/day)
FAUCET_CREDIT_COPAY is the headline E3 number, defaulting to 0.05 (the Orchestrator default), against the same RP_TO_CREDIT_RATE = 10 cr/RP the first-sweep refund uses. The reproduce-exactly off-switch is FAUCET_CREDIT_COPAY = 0.0, which makes the copay zero and debits nothing. Both magnitudes are NO-CANON pending Max.
Decay pressure — per-plot multiplier tiers¶
decay_pressure() scales the flat per-plot terraform decay rate (TERRA_DECAY_RATE) by a per-plot multiplier keyed to the plot's situation, so a finished peaceful world decays far more slowly than a contested frontier. This is a planet-structures-grid pressure mechanic, tied to the CRT terraform/decay model, and it is the demand engine the directive contracts sell relief from.
The multiplier tiers (all NO-CANON pending Max):
| Tier | Multiplier | Situation |
|---|---|---|
| Done | 0.1× |
capstoned, in-band, uncontested |
| Banded | 0.3× |
in-band, peaceful, not yet capstoned |
| Frontier | 1.0× |
not-yet-in-band peaceful — the reproduce-exactly off value |
| Contested | 2.0× |
under siege, recent siege, or claimed border |
The reproduce-exactly off value is the Frontier tier at 1.0×: with every plot treated as frontier (the default whenever no planet context is threaded in), the multiplier is uniformly 1.0× and decay matches the shipped flat rate exactly.
A coupled term lets the directive economy feed back into decay: the shared JSONB key terraform_meta.instability is read here as a small additive decay bump (1 + INSTAB_DECAY_COEFF × instability, clamped), so a more unstable world decays faster. An absent or non-positive instability value reads as today's behavior — the bump is neutral until something writes instability, which is what the Stabilize directive bleeds back off. The coefficient and clamp are NO-CANON pending Max.
Terrain mutation¶
mutate_terrain() mutates a plot's terrain on the planet grid when its terraform axes cross a terrain boundary, re-evaluated each grid tick off the live axes. It is gated by the MUTATION_ENABLED flag, whose off value disables all mutation so terrain never changes — equal to today. The flag is off by default and the boundary thresholds are NO-CANON pending Max.
Research-Directive contracts¶
Research-Directive contracts are time-boxed, credit-plus-RP-priced directive offers a player buys per-planet. The RP price is the gate (recoverable from the faucet, paces how often a player can buy); the credit price is the sink (the bottomless credit drain). RP is never refunded — a contract cannot re-mint banked RP into anything spendable elsewhere. An empire that buys no contract spends nothing and writes nothing, so an empty contract set is byte-identical to today; that is the reproduce-exactly baseline for this strand.
The kernel contract set (all prices NO-CANON pending Max):
| Directive | RP (gate) | Credits (sink) | Effect |
|---|---|---|---|
| Overclock | 300 | 50,000 | a temporary production surge on one planet (a multi-day effect that then reverts) |
| Rush | 200 | 30,000 | collapses an in-progress build/terraform timer (instant) |
| Stabilize | 150 | 20,000 | bleeds instability off a contested or slipping world (instant) |
Stabilize is the one designed credit↔instability touch-point: on settle it decrements the shared terraform_meta.instability key (clamped at zero), which the decay-pressure reader above consumes as faster-decay relief. At most one active Overclock is allowed per planet so the production surge cannot stack.
Offers are generated, never browsed: the faucet sweep raises at most one perishable offer per empire for a frontier or contested world on a band crossing, rate-limited by a per-empire cooldown, so a healthy or finished empire raises roughly zero per day. An offer perishes free if it expires unaccepted — accepting it is the only spend. ARIA narrates a generated offer and an expired/settled directive as a pure read surfaced when the player looks (narrate_* helpers); ARIA has no proactive emit path, so the narration is a read surface, not a push. Offer-generation magnitudes (expiry window, cooldown) and the offer-generation enable flag are NO-CANON pending Max, with the generation off-switch reproducing today's no-offer baseline.
Source map additions¶
| Concern | Path |
|---|---|
| Governor, faucet copay, directive contracts + offers, ARIA narration | services/gameserver/src/services/research_service.py |
| Decay-pressure tiers, instability decay term, terrain mutation | services/gameserver/src/services/structures.py |
| Planet-grid decay/terraform context | services/gameserver/src/services/planetary_service.py |
| Directive offer + start/cancel routes | services/gameserver/src/api/routes/research_cockpit.py |
Research-gated defense buildings¶
The two content_unlock nodes gate the two research-only defense buildings at the existing building-placement path — the single live cross-system dependency the kernel ships:
| Building | Gate node | Effect when locked |
|---|---|---|
| Rail gun emplacement | t.defense.railgun.1 |
placement rejected until unlocked |
| Planetary defense grid | t.defense.grid.1 |
placement rejected until unlocked |
A building spec that carries a research_node key is placeable through the existing build flow only if the owning player has unlocked that node. The placement path reads player_has_tech(owner, spec["research_node"]) before the citadel-level and credit checks; on a miss it returns a message naming the required research. This is the point-of-use read in practice — citadel placement calls into research, never the reverse, and no new placement path is introduced. The grid chains off the rail gun (a real Tier-2 prerequisite edge), so a player unlocks rail guns first, then the grid.
The content key planetary_defense_grid is the chosen name for the grid's unlock key, distinct from the unrelated Station.defense_grid boolean.
Invariants¶
- The free root
t.root.0costs 0 RP, has no prereqs, and is the only prereq-less node. - Every cold-start ledger holds
t.root.0inunlocked. - The catalog is an acyclic DAG, every node reachable from the free root (
assert_dag_reachable). - Unlocking re-checks affordability and prerequisites under a player row lock before spending.
unlockedonly ever grows; bankedrpnever goes negative.- The faucet sweep zeroes each drained faucet, so RP is never counted twice.
- The first-sweep wipe+refund happens at most once per player, gated by
swept_at(idempotent across re-ticks and restarts). - Point-of-use readers are pure: they mutate no ledger, no catalog, and no buffed entity.
Source map¶
| Concern | Path |
|---|---|
| Static node catalog + DAG check | services/gameserver/src/services/tech_tree.py |
| Ledger readers, unlock pipeline, faucet sweep | services/gameserver/src/services/research_service.py |
| Ledger column | services/gameserver/src/models/player.py (research_ledger) |
| Settle step 5 (sweep call site) | services/gameserver/src/services/structures.py (_step5_research) |
| Placement gate (defense buildings) | services/gameserver/src/services/citadel_service.py (build_defense_building) |
| Faucet accrual rate | RESEARCH_POINTS_PER_LAB_LEVEL_PER_DAY = 25 |
Related¶
planetary-production-tick.md— the production accrual that fills the per-planet faucet.../FEATURES/planets/defense.md— the rail gun and defense grid this tree gates.../DATA_MODELS/player.md— the player entity carrying the research ledger.