Pirate Ecosystem System¶
Status: ๐ง Partial โ Population score, target computation, the weekly growth/evolution tick, and
PirateKillLogbookkeeping are shipped and wired into the scheduler's weekly tick (pirate_ecosystem_service.run_weekly_tick/evolution_tick); daughter-spawn zone-affinity weighting, holding composition, andparent_holding_idlineage remain deferred to the raid/capture work order per thePirateHoldingmodel's own docstring. Adjacent NPC lodging schema (NPCBarracks,OutlawBase, sector flags) is shipped โ see../DATA_MODELS/npc-lodging.md; do not read that as outlaw/barracks raid runtime being live. (re-verified 2026-08-21 vs origin/feat46bce720)
๐ง Partial. The population/growth/evolution machinery below is shipped in pirate_ecosystem_service.py, wired into the scheduler's weekly tick; this page is also the prescriptive spec for the daughter-spawn and raid/capture pieces still design-only. 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+ (โ Shipped) | linear extrapolation beyond the 1,200โ35 anchor (ratified 2026-08-09, DECISIONS.md no-canon-magnitudes-batch-remainder) |
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, nullable |
The player who triggered the capture or final NPC KIA. NULL for an NPC-vs-NPC or environmental final KIA with no credited player โ medal-attribution reads treat a NULL attacker as "no medal eligible" rather than failing the feed. |
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-0093 item 8 (consumer-states): 'active' and 'grace' are both
# alive for simulation purposes; 'generation_corrupt' and
# 'attachment_pending' are dormant. suspended/terminated/pending stay
# dormant. See SYSTEMS/region-lifecycle.md ยง Consumer-states.
if region.status not in ('active', 'grace'):
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¶
๐ Prescriptive, pending raid runtime. This snippet's trigger point โ a damage_event against holding.citadel โ does not exist in code today: PirateHolding has no citadel field, current_strength is never decremented by combat, and combat_service.py has no PirateHolding awareness at all. It depends on the combat-damage-to-citadel path specified in pirate-holding-raid.md, which that page's own banner marks design-only/no raid runtime committed. Treat the function below as the intended wiring once that runtime lands, not as shipped behavior (impl audit 2026-08-04).
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/npc-lodging.mdโ outlaw/barracks lodging tables + sector flags (schema shipped; spawn/roster wiring and raid capture remain partial).../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.