Skip to content

0060 — Pirate ecosystem and holdings hardening (Group A)

Status

Accepted.

Context

Eleven audit findings cluster around the pirate-ecosystem and holding-raid surfaces specified in ADR-0047 and ADR-0048. Most are post-launch hardening items: cap guards that need to fire earlier, atomicity gaps between subsystems, lock semantics that don't fit team-coordinated play, and several "stated as design intent but not enforced" gaps in the schema or scheduler.

Three of the eleven are critical (population burst, team-raid blocker, kill-log atomicity); the rest are correctness/edge-case fixes that compose cleanly with the criticals.

Decision

G-F1 — Cleansing-regrowth burst exceeds population cap

The cap check would_exceed_max_population() runs inside both entry points, not after them:

  • seed_spawn_camp() evaluates the cap before inserting a new Camp. If the regional ecosystem is at cap, no Camp is seeded that tick.
  • The daughter-spawn path evaluates the cap before spawning each daughter. Daughters that would push the population past cap are dropped (logged with pirate.daughter_spawn_capped for ops visibility).

Cleansing followed by regrowth no longer bursts: the cleanse zeroes the regional pirate population, the next tick seeds at most one Camp, daughter spawning then proceeds turn-by-turn until cap is reached. The previous "burst from 0 → 5+ holdings in one tick before cap kicks in" no longer happens.

G-F2 — Team-coordinated raid lock with snapshotted membership

The combat lock on a pirate Stronghold (or any holding requiring team raids) expands to permit team-mate engagement:

def can_engage(player_id, holding):
    if holding.combat_lock_held_by is None:
        return True
    return holding.combat_lock_held_by in (
        player_id,
        *holding.combat_lock_team_snapshot,  # frozen at first engagement
    )

Snapshot semantics (per the user pick): when the first team-mate engages the holding, the lock acquisition captures the engaging player's team.member_ids at that instant into combat_lock_team_snapshot (UUID[] column). Subsequent team-mates check against the snapshot, not live team membership. This closes the late-join exploit — a player who joins the team mid-raid is not added to the snapshot and cannot bypass the lock.

The snapshot clears when the lock releases (combat ends, raid completes, or timeout fires). A subsequent re-engagement on the same holding rebuilds a fresh snapshot from the new acquirer's team.

G-V1 — Kill-log atomicity with capture

The PirateKillLog insert and the holding-capture state mutation share one DB transaction. The transactional outbox pattern from ADR-0054 carries any post-commit realtime events (pirate.holding_captured per R-I1 below). A network failure that previously left capture committed without the kill-log row no longer occurs — both succeed together or both roll back.

The suppression-modifier read path (which gates daughter-spawn rate) sees a consistent kill-log; daughters no longer spawn faster than they should because of a missing log row.

G-D1 — Stronghold formation-prerequisite clarification

Per ADR-0048 design intent, both Bubble and Dead-End Bubble formations qualify as Stronghold prerequisites. The tier-composition tables in ../FEATURES/galaxy/pirate-holdings.md and ../SYSTEMS/pirate-ecosystem.md are corrected to match. No code-side change implied — this is a doc-vs-doc reconciliation.

G-V2 — Abandoned-holding re-seeding race

Re-seeding eligibility gains an explicit combat_lock_held_by IS NULL predicate. The OutlawBase ↔ NPCBarracks conversion path runs only when no active combat is in progress on the abandoned holding's sector. If combat is active, re-seeding waits until the lock clears (next weekly tick re-evaluates).

G-V3 — Roving Fleet Camp reachability

Spawn-site selection for Roving Fleet Camps validates standard warp-tunnel reachability from at least one Frontier-adjacent sector. The QJ-only path is rejected as a spawn site:

def is_valid_roving_fleet_camp_site(sector):
    return any(
        graph.has_warp_tunnel_path(sector, src)
        for src in galaxy.frontier_adjacent_sectors
    )

Sectors reachable only via Quantum Jump are skipped during spawn-site selection (logged as pirate.spawn_skipped_unreachable for ops visibility).

G-I1 — Evolution clock reset threshold

The evolution clock (which advances toward Stronghold tier) resets only on single-combat-event damage ≥ 5% of citadel max HP. Trivial scratches no longer reset the timer:

def maybe_reset_evolution_clock(holding, damage_event):
    if damage_event.damage >= 0.05 * holding.citadel.max_hp:
        holding.evolution_clock_started_at = utcnow()

A 1-hull-point scratch on day 29 no longer drops the clock to day 0. Single-shot harassment can't grief the timer.

G-I2 — Daughter-spawn zone weighting

Per the user pick, daughter spawn-site selection picks from all reachable sectors with a zone-affinity weight, not a hard zone restriction:

def daughter_spawn_weight(parent_holding, candidate_sector):
    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
    return 0.25       # distant

Camps from a Border Outpost prefer Border sectors but can spill into Frontier (or Federation, at quarter rate) given enough generations. The ecosystem is dynamic: pirates encroach over time without being hard-zoned. Daughter-spawn realtime events carry the chosen sector's zone for ops visibility.

X-I1 — Weekly tick skips non-active regions

The pirate-ecosystem weekly tick adds an early skip on Region.status != 'active':

def run_pirate_weekly_tick(region):
    if region.status != 'active':
        return  # suspended, grace, terminated, generation_corrupt — no ecosystem state change
    ...

Suspended / grace / terminated regions don't accumulate ecosystem state during their lifecycle wind-down. The cleanup orchestrator (per ADR-0050) handles holding cleanup during termination separately.

R-I1 — Realtime event taxonomy additions

Five new events join the realtime-bus taxonomy:

Event Room Payload
pirate.holding_evolved sector + region holding_id, from_tier, to_tier, formation_id, at
pirate.region_cleansed region region_id, holdings_destroyed_count, cleansed_by_player_ids, at
pirate.daughter_spawned sector + region parent_holding_id, daughter_holding_id, parent_zone, daughter_zone, at
pirate.holding_captured sector + region + personal (capturer) holding_id, tier, captured_by_player_id, captured_by_team_id, at
pirate.fleet_destroyed sector + region holding_id, fleet_size_destroyed, by_player_ids, at

All five are emitted via the transactional outbox per ADR-0054.

R-F1 — Stronghold-without-formation CHECK constraint

A DB CHECK constraint enforces the Stronghold-formation invariant per ADR-0048:

ALTER TABLE pirate_holdings
ADD CONSTRAINT pirate_holdings_stronghold_requires_formation
CHECK (tier != 'stronghold' OR formation_id IS NOT NULL);

The constraint is enforced at insert + update time. Code paths that promote a holding to Stronghold tier set formation_id in the same statement; rejection at the DB layer is the canonical defense.

Consequences

  • The cap-guard inside seed_spawn_camp() makes the pirate-ecosystem weekly tick more deterministic — no more bursts requiring post-tick cleanup. Operations dashboards lose the "regrowth burst" alert pattern; it's replaced by pirate.daughter_spawn_capped log events that are informational, not alerting.
  • The team-snapshot raid lock requires a new column pirate_holdings.combat_lock_team_snapshot UUID[]. The column is null when no lock is held; populated on lock acquisition; cleared on release.
  • Kill-log atomicity composes with the transactional outbox introduced by ADR-0054. The outbox is now used by region-lifecycle cleanup (B), ship destruction (D), and pirate-ecosystem capture (A) — three consumers; the pattern is well-validated.
  • The 5%-HP evolution-clock threshold means a holding that takes only minor harassment for 30 days will evolve to Stronghold. This is a feature: dedicated low-intensity cleanup work has to clear holdings, not just nibble at them.
  • The zone-affinity daughter-spawn rule lets pirate ecosystems drift between zones over generations. Border-zone pirates can eventually establish a foothold in Frontier; Frontier pirates can leak into Federation. This is intentional ecosystem dynamism — the affinity weighting (1.0 / 0.5 / 0.25) makes drift slow but possible.
  • The CHECK constraint catches code-path bugs at the DB layer. The two existing tier-promotion paths (capture-evolution and worldgen-pre-seed) both set formation_id already; the constraint is defensive.

Alternatives considered

  • Live team membership for the raid lock. Rejected per user pick — opens a late-join exploit. Snapshot is the safer default.
  • Lower evolution-clock reset threshold (1%, or per-shot). Rejected per user pick. The 5% threshold trades off fine-grained reactivity for grief resistance; that's the right trade for a long-cycle evolution mechanic.
  • Hard zone restriction on daughter spawns (same zone only). Rejected per user pick — too static. Zone affinity preserves the predominant character of each zone while allowing slow ecosystem drift.
  • Application-layer enforcement of the Stronghold-formation invariant instead of a DB CHECK. Rejected — invariants that are stated as design intent should be enforced at the lowest layer that can.
  • Cap-check after the tick instead of inside the spawn entry points. Rejected — that was the original behaviour and it's exactly what allowed the burst.