Pirate Ecosystem System¶
Status: 📐 Design-only — Entirely unbuilt: no PirateHolding/PirateKillLog models, no pirate_ecosystem_service, no spawn/evolution/cleansed logic … (impl audit 2026-06-16)
📐 Design-only. No ecosystem service is committed yet; this page is the prescriptive system spec. Canonical decision in ADR-0048. Pirate-holding placement and raid mechanics live in ADR-0047, ./pirate-holding-raid.md, and ../FEATURES/galaxy/pirate-holdings.md.
The runtime that makes pirate populations behave — spread, grow, evolve, and react to player pressure. The galaxy generator's Phase 12.6 places the initial 9–17 holdings per region; this ecosystem service takes over from there. Three coupled mechanisms produce the self-regulating behavior: a weighted population score with a suppression-modulated target, virus-like daughter spawning from existing parents, and tier evolution where untouched holdings strengthen over time.
Architecture¶
The ecosystem is implemented as one scheduled service plus a kill-log feeder. The service runs weekly; the kill-log is updated synchronously every time a holding is captured or cleared.
┌─────────────────────────────┐
│ Player captures holding │
└──────────────┬──────────────┘
│ (synchronous)
▼
┌─────────────────────────────┐
│ PirateKillLog row insert │
│ (region_id, tier, weight, │
│ timestamp, attacker_id) │
└─────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ │
│ Weekly UTC Sunday 00:00 — pirate_ecosystem_service │
│ │
│ 1. Compute kill_weight_last_30_days per region │
│ 2. Compute target_population per region │
│ 3. For each region: │
│ a. Check Cleansed state (population = 0 for 7+ days)│
│ b. Run growth tick (spawn daughters if needed) │
│ c. Run evolution tick (promote eligible holdings) │
│ 4. Emit telemetry events on the realtime bus │
│ │
└──────────────────────────────────────────────────────────┘
Population score¶
Each pirate-controlled PirateHolding contributes weighted points to its region's score:
| Tier | Population weight |
|---|---|
| Camp | 1 |
| Outpost | 3 |
| Stronghold | 10 |
def compute_population_score(region):
holdings = db.query(PirateHolding).filter(
region_id=region.id,
owner_player_id=None, # only pirate-controlled, not player-captured
).all()
return sum(TIER_WEIGHT[h.tier] for h in holdings)
Player-captured holdings are not counted — the ecosystem responds to pirate-controlled presence only. A holding the player owns doesn't suppress the target, and doesn't grow or spawn daughters.
Target population¶
def compute_target_population(region):
base_target = REGION_BASELINE_TARGET[region.size_tier] # Standard: 35
# Suppression: kills in the last 30 days lower the target
kill_weight = sum_kill_weights(region, days=30)
suppression_modifier = max(0.20, 1.0 - 0.05 * kill_weight)
# Cleansed state: extra reduction if region was empty for 7+ days
if region.pirate_ecosystem_state.cleansed_at:
days_cleansed = (now - region.pirate_ecosystem_state.cleansed_at).days
if days_cleansed < 30:
suppression_modifier *= 0.50
return base_target * suppression_modifier
REGION_BASELINE_TARGET table:
| Region size (sectors) | Base target |
|---|---|
| 100–300 | 12 |
| 301–600 | 22 |
| 601–800 | 30 |
| 801–1,200 (Standard) | 35 |
| 1,201+ (📐 future tiers) | scale up proportionally |
PirateKillLog¶
A new table tracks each holding-clear event for the rolling-30-day window. Source: services/gameserver/src/models/pirate_kill_log.py (target).
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID | PK | |
region_id |
UUID | FK regions.id, not null, indexed |
|
holding_id |
UUID | FK pirate_holdings.id (nullable, since the holding may be deleted in some flows) |
|
tier |
Enum (camp, outpost, stronghold) |
not null | Snapshot at kill time |
kill_weight |
Integer | not null | Same as tier weight: 1, 3, or 10 |
attacker_player_id |
UUID | FK players.id |
The player who triggered the capture or final NPC KIA |
attacker_team_id |
UUID | FK teams.id, nullable |
If a team raid |
disposition |
Enum (captured, cleared) |
not null | captured = player took ownership; cleared = all NPCs KIA but no capture (rare; defenses still up but population zero) |
created_at |
DateTime | server default now |
Indexes: (region_id, created_at DESC) for the rolling-window aggregate. Rows older than 90 days are auto-pruned by a separate maintenance job (the 30-day window plus a buffer).
def sum_kill_weights(region, days=30):
cutoff = now - timedelta(days=days)
return db.query(func.coalesce(func.sum(PirateKillLog.kill_weight), 0)).filter(
PirateKillLog.region_id == region.id,
PirateKillLog.created_at >= cutoff,
).scalar()
Weekly growth tick¶
def pirate_growth_tick(region):
# Per ADR-0060 X-I1: skip non-active regions entirely.
# Suspended/grace/terminated regions don't accumulate ecosystem state
# during their lifecycle wind-down; cleanup orchestrator (ADR-0050)
# handles holding cleanup during termination separately.
if region.status != 'active':
return {'action': 'skipped', 'reason': f'region_status={region.status}'}
current = compute_population_score(region)
target = compute_target_population(region)
delta = target - current
# Tolerance band: don't bother spawning for small deltas
if delta < 3:
return {'action': 'no_growth', 'current': current, 'target': target}
sites_to_spawn = min(5, math.ceil(delta / 3)) # cap at +5/week per region
spawned = []
for _ in range(sites_to_spawn):
result = spawn_daughter_holding(region)
if result:
spawned.append(result)
return {'action': 'growth', 'spawned': spawned, 'current': current, 'target': target}
Daughter spawning algorithm¶
def spawn_daughter_holding(region):
# Per ADR-0060 G-F1: cap check fires inside the spawn entry point.
# A daughter that would push the regional population past cap is
# dropped (logged as pirate.daughter_spawn_capped for ops visibility).
if would_exceed_max_population(region):
log.info('pirate.daughter_spawn_capped', region_id=region.id)
return None
# Pick a parent, weighted by tier
parents = db.query(PirateHolding).filter(
region_id=region.id,
owner_player_id=None,
).all()
if not parents:
# Fully cleansed — fall back to seed spawning
return seed_spawn_camp(region)
parent = weighted_choice(parents, weights=[
SPAWN_PARENT_WEIGHT[p.tier] for p in parents # Camp 1, Outpost 2, Stronghold 4
])
# Pick the daughter's tier from the parent's spawn distribution
distribution = SPAWN_DISTRIBUTION[parent.tier]
daughter_tier = weighted_choice(*zip(*distribution.items()))
if daughter_tier == 'skip':
return None # propagation failed this attempt
# Pick the daughter's anchor sector with zone-affinity weighting
# (per ADR-0060 G-I2): same zone weight 1.0, adjacent 0.5, distant 0.25.
# Daughters can drift across zones over generations but heavily prefer
# the parent zone.
radius = SPAWN_RADIUS[daughter_tier] # Camp 1-3, Outpost 2-5, Stronghold any-Frontier
candidates = find_eligible_sectors(
region, parent.anchor_sector_id, radius,
avoid_starter_cluster=True,
avoid_phase11_anchors=True,
avoid_existing_holding_interiors=True,
prefer_low_patrol_density=True,
# zone_filter dropped — replaced by zone-affinity weighting below
)
if not candidates:
return None
weighted = [
(sector, daughter_spawn_weight(parent, sector))
for sector in candidates
]
anchor = weighted_random_choice(weighted)
composition = roll_composition(daughter_tier)
# Roving Fleet Camps (per ADR-0060 G-V3) require standard warp-tunnel
# reachability from a Frontier-adjacent sector — QJ-only sites are skipped.
if daughter_tier == 'roving_fleet_camp' and not is_warp_reachable_from_frontier(anchor):
log.info('pirate.spawn_skipped_unreachable', region_id=region.id, sector_id=anchor.id)
return None
# Create the daughter holding
holding = create_holding(
region=region, tier=daughter_tier, anchor_sector_id=anchor,
composition=composition, parent_holding_id=parent.id,
)
return holding
def daughter_spawn_weight(parent_holding, candidate_sector):
# ADR-0060 G-I2 — zone-affinity weighting for daughter spawn-site selection.
if candidate_sector.zone == parent_holding.zone:
return 1.0 # native zone
if candidate_sector.zone in adjacent_zones(parent_holding.zone):
return 0.5 # adjacent zone — slow drift
return 0.25 # distant zone — rare encroachment
Spawn distribution table:
| Parent tier | Camp daughter | Outpost daughter | Stronghold daughter | Skip |
|---|---|---|---|---|
| Camp | 70% | 0% | 0% | 30% |
| Outpost | 50% | 30% | 0% | 20% |
| Stronghold | 50% | 30% | 20% | 0% |
Spawn parent weight (per parent in the random pick):
| Parent tier | Pick weight |
|---|---|
| Camp | 1 |
| Outpost | 2 |
| Stronghold | 4 |
Stronghold parents are most likely to spawn — they've got the resources to seed daughters — and they're the only tier that can spawn another Stronghold.
Seed spawning (fallback)¶
When the region is fully cleansed and has no parent holdings, the growth tick still needs to bootstrap regrowth. Seed spawning creates a single Camp at a random eligible Frontier sector with parent_holding_id = NULL:
def seed_spawn_camp(region):
# Per ADR-0060 G-F1: cap check fires inside the seed entry point.
# If the regional ecosystem is at cap, no Camp is seeded that tick.
# Combined with the daughter-spawn cap check, this closes the
# cleansing-regrowth-burst window entirely.
if would_exceed_max_population(region):
return None
candidates = find_eligible_sectors(
region, anchor_sector_id=None, radius=None,
zone_filter=['frontier'],
avoid_starter_cluster=True,
avoid_phase11_anchors=True,
avoid_existing_holding_interiors=True,
)
if not candidates:
return None
anchor = rng.choice(candidates)
composition = roll_camp_composition() # 6 profiles per ADR-0047
return create_holding(
region=region, tier='camp', anchor_sector_id=anchor,
composition=composition, parent_holding_id=None,
)
After the seed Camp is placed, subsequent ticks resume normal daughter spawning (the seed is the new parent for future daughters).
Evolution tick¶
Runs in the same weekly service after growth. For each holding still pirate-controlled:
def evolution_tick(holding):
if holding.tier == 'stronghold':
return # top tier; no further promotion
# Track when the holding hit "untouched at full strength" status
if holding.current_strength >= 0.95 and not holding.last_damage_at:
# never been damaged; untouched-at-full clock starts from creation
threshold_met_at = holding.created_at
elif holding.current_strength >= 0.95 and holding.last_damage_at:
# was damaged once; clock starts from last damage event
threshold_met_at = holding.last_damage_at
else:
return # not at full strength
days_untouched = (now - threshold_met_at).days
required = EVOLUTION_THRESHOLD[holding.tier] # Camp 30, Outpost 60
if days_untouched < required:
return
# Cap check: would promotion exceed regional max?
if would_exceed_max_population(holding.region, holding.tier):
return
# Stronghold prerequisite: must be inside a Bubble or Dead-End Bubble
if holding.tier == 'outpost':
formation = holding.special_formation
if not formation or formation.type not in ('BUBBLE', 'DEAD_END_BUBBLE'):
# No qualifying formation; suppress promotion and reset the clock
holding.last_damage_at = now()
return
# Roll the promotion chance
chance = EVOLUTION_CHANCE[holding.tier] # Camp 20%, Outpost 10%
if rng.random() < chance:
promote_holding_tier(holding)
When promote_holding_tier fires:
- The holding's
tieris incremented (Camp → Outpost, Outpost → Stronghold). - The composition profile re-rolls per the new tier's table.
- New
NPCRosterslots are added (Outpost gains 2–4 enforcers; Stronghold adds Lord + 1–2 captains). - The recovery service ticks up planetary citadels over subsequent days (L1 → L2 → L3 in stages, paced at the per-tier recovery rate).
holding.last_damage_at = now()resets the evolution clock.- A
pirate.holding_evolvedevent fires on the realtime bus per ADR-0060 R-I1 — players in the region see "the Carrion Court has fortified" / "pirate activity in sector 4173 has consolidated into a stronghold."
Evolution clock damage threshold¶
Per ADR-0060 G-I1, only single-combat-event damage ≥ 5% of citadel max HP resets holding.last_damage_at:
def maybe_reset_evolution_clock(holding, damage_event):
if damage_event.damage >= 0.05 * holding.citadel.max_hp:
holding.last_damage_at = utcnow()
# otherwise: damage applied, but evolution clock unchanged
Trivial scratches (1–2 hull-point harassment) no longer drop the clock to day 0. A real engagement — 5%+ damage in a single combat event — resets the timer. Single-shot harassment cannot grief the evolution timeline; sustained low-intensity pressure that never crosses the threshold lets the holding evolve on schedule.
Cleansed-region detection¶
Runs at the start of the weekly tick before growth:
def update_cleansed_state(region):
current = compute_population_score(region)
state = region.pirate_ecosystem_state
if current == 0:
if not state.zero_population_since:
state.zero_population_since = now()
elif (now - state.zero_population_since).days >= 7 and not state.cleansed_at:
# Region just qualified for Cleansed status
state.cleansed_at = now()
emit_event('region_cleansed', {
'region_id': region.id,
'cleansed_at': state.cleansed_at,
'recent_attackers': top_attackers_by_kill_weight(region, days=30),
})
award_pirate_hunter_medal(region) # to top recent attackers
else:
# Population non-zero; reset Cleansed eligibility
state.zero_population_since = None
if state.cleansed_at and (now - state.cleansed_at).days < 30:
# Pirates returned during the Cleansed window; the bonus applies until day 30 then expires
pass # cleansed_at stays; the 50% modifier will expire at +30 days
elif state.cleansed_at and (now - state.cleansed_at).days >= 30:
# Cleansed window expired; clear the marker
state.cleansed_at = None
db.session.add(state)
The Pirate Hunter — {region_name} medal is awarded to the top 3 attackers (by aggregate kill weight in the 30-day window) at Cleansed-state declaration. Multiple players can earn the medal for the same region (one cleansed event = up to 3 medal grants).
Region.pirate_ecosystem_state JSONB¶
A new column on the Region row (per ../DATA_MODELS/galaxy.md):
{
"base_target": 35,
"current_population_score": 22,
"current_target": 28,
"suppression_modifier": 0.80,
"kill_weight_last_30_days": 4,
"zero_population_since": null,
"cleansed_at": null,
"last_growth_tick_at": "2026-05-09T00:00:00Z",
"last_growth_action": "spawn_3_daughters",
"last_evolution_tick_at": "2026-05-09T00:00:00Z",
"evolutions_since_creation": 2
}
The current_* fields are denormalized snapshots updated by the weekly tick; queries for "what's the pirate state in region X" hit this row instead of recomputing scores. Authoritative source for the scores is the live PirateHolding and PirateKillLog tables; the JSONB cache is a fast-path read.
Caps¶
MAX_POPULATION_PER_REGION = base_target * 1.5
def would_exceed_max_population(region, tier_being_added):
new_score = current_population_score + TIER_WEIGHT[tier_being_added]
return new_score > MAX_POPULATION_PER_REGION
For Standard regions: 35 × 1.5 = 52.5 (effectively 50 weighted points). The growth tick respects this cap on both daughter spawning and evolution promotion.
Telemetry and observability¶
The weekly tick emits realtime events:
region_pirate_growth— spawn count, target, current score, sites spawned.holding_evolved— pre/post tier, location, region.region_cleansed— region ID, attacker leaderboard.region_suppression_high— whensuppression_modifier < 0.30(an admin-visible warning that this region is under heavy player pressure; useful for balancing telemetry).
ARIA reads these events to compose player-facing narration ("pirate activity has flared up in your home region — three new sites spawned overnight"). See ../FEATURES/gameplay/aria-companion.md for the dialogue surface.
Failure modes¶
| Mode | Detection | Handling |
|---|---|---|
| Weekly tick missed (service down) | Lock-extension on Region.pirate_ecosystem_state.last_growth_tick_at |
On next successful tick, compute days-elapsed and apply growth proportionally (multiplied by elapsed weeks, capped at 4 weeks of catch-up) |
| Eligible-sector candidate exhausted | find_eligible_sectors returns empty |
Skip this spawn attempt; log under-budget event; growth tick advances. Re-attempt on the next tick. |
| Growth + evolution combined would exceed cap | Cap check inside evolution_tick |
Suppress promotion this tick; retry on subsequent ticks once growth is offset by player kills |
| Concurrent player capture during the tick | Capture transaction is atomic; tick reads stale data and may attempt to promote a holding that just flipped | The promote operation is wrapped in SELECT ... FOR UPDATE on the holding row; if the row is now player-owned, the tick skips and logs a benign race |
Region.pirate_ecosystem_state missing (regions created before this feature lands) |
Tick checks for missing JSONB | Initialize default state on first tick; emit ecosystem_initialized event |
Source map¶
| Concern | Path (target) |
|---|---|
PirateKillLog model |
services/gameserver/src/models/pirate_kill_log.py |
| Weekly tick service | services/gameserver/src/services/pirate_ecosystem_service.py:run_weekly_tick |
| Daughter spawning | pirate_ecosystem_service.py:spawn_daughter_holding |
| Seed spawning | pirate_ecosystem_service.py:seed_spawn_camp |
| Evolution tick | pirate_ecosystem_service.py:evolution_tick |
| Cleansed-state detection | pirate_ecosystem_service.py:update_cleansed_state |
| Eligible-sector finder | pirate_ecosystem_service.py:find_eligible_sectors |
| Kill-log feeder | services/gameserver/src/services/pirate_holding_service.py:attempt_capture (extends — appends PirateKillLog row on success) |
| Cron / scheduler | services/gameserver/src/scheduling/cron_jobs.py (target — adds pirate_ecosystem_weekly job) |
| API: read region ecosystem state | GET /api/v1/regions/{id}/pirate-ecosystem |
Related¶
../ADR/0048-pirate-ecosystem-dynamics.md— canonical decision.../ADR/0047-pirate-holdings.md— pirate-holding tier/composition that this ecosystem operates on../pirate-holding-raid.md— capture transaction that feeds the kill log../galaxy-generator-design.md— Phase 12.6 initial seeding handed off to this service.../FEATURES/galaxy/pirate-holdings.md— player-facing reference (cross-link to the ecosystem behavior).../DATA_MODELS/pirate-holdings.md—PirateHoldingschema; gainsparent_holding_idand evolution-tracking fields.../DATA_MODELS/galaxy.md—Region.pirate_ecosystem_stateJSONB.../FEATURES/gameplay/medals.md— Pirate Hunter medal awarded on Cleansed declaration.../FEATURES/gameplay/aria-companion.md— ARIA narration of growth, evolution, cleansing events.