Skip to content

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:

  1. The holding's tier is incremented (Camp → Outpost, Outpost → Stronghold).
  2. The composition profile re-rolls per the new tier's table.
  3. New NPCRoster slots are added (Outpost gains 2–4 enforcers; Stronghold adds Lord + 1–2 captains).
  4. The recovery service ticks up planetary citadels over subsequent days (L1 → L2 → L3 in stages, paced at the per-tier recovery rate).
  5. holding.last_damage_at = now() resets the evolution clock.
  6. A pirate.holding_evolved event 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 — when suppression_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