Skip to content

0060 — Pirate ecosystem and holdings hardening (Group A)

Status

Distributed-fold — 10/11 shipped (2026-08-07): G-F1 cap-guard, X-I1 weekly-tick region-skip, G-D1 doc reconciliation (2026-08-04, pre-existing); G-I2 daughter-spawn zone-affinity weighting (PR #262, live — weights eligible sectors by parent/candidate zone on the Federation-Border-Frontier ladder); G-F2 team-raid combat lock w/ team snapshot, G-V1 kill-log/capture atomicity, R-F1 Stronghold-formation CHECK constraint (PR #264 — additive migration + 4 library functions, shipped dormant: correct and fully unit-tested but with zero live callers, since no player-facing raid/capture entry point exists anywhere in the codebase yet); G-I1 evolution-clock reset gate, R-I1 realtime-event taxonomy (holding_evolved/region_cleansed live since PR #264, daughter_spawned wired live in PR #265, fleet_destroyed/holding_captured dormant per the same no-live-caller reasoning as the capture kernel). G-V3 (roving-fleet reachability) is retired, not deferredPirateHoldingTier only ever modeled CAMP/OUTPOST/STRONGHOLD, no ROVING_FLEET_CAMP tier was ever built, so a reachability check has nothing to gate.

1/11 remains a genuine gap: G-V2 (abandoned-holding re-seed race, combat_lock_held_by IS NULL predicate). No OutlawBase↔NPCBarracks re-seed mechanism exists anywhere in the codebase to attach the predicate to — confirmed via full-tree grep, not merely unwired. Blocked on a future "lodging slice" (per existing npc_character.py/npc_tick_loops.py docstrings) that hasn't been scoped yet; banked as its own future backlog item rather than folded into this ADR's completion.

The 5 "dormant" items above (G-F2/G-V1/R-F1/G-I1/2 of R-I1's events) are real, tested, correct code with no caller — activation is a separate future WO (raid/capture player-facing entry point) that doesn't exist yet either. Design brief: audit/design-briefs/pirate-raid-capture-epic.md. (re-verified 2026-08-07)

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. Post-commit realtime for pirate.holding_captured is designed to follow ADR-0054's outbox pattern; as of 2026-08-07 the dormant kernel emits via _broadcast_pirate_event and RealtimeOutbox is not yet imported (see R-I1 honesty note + WO-BUILD-WIRE-REALTIME-OUTBOX-PIRATE-CAPTURE). 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 event types are defined here. Emission path honesty (2026-08-07): live helpers in pirate_ecosystem_service.py use _broadcast_pirate_event (direct bus), not ADR-0054 RealtimeOutbox (zero imports). holding_captured / fleet_destroyed remain dormant emit helpers awaiting ECO-3. Outbox cutover tracked as WO-BUILD-WIRE-REALTIME-OUTBOX-PIRATE-CAPTURE.

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 is in place in the dormant capture kernel. ADR-0054 RealtimeOutbox is live for region-lifecycle cleanup (B); ship-destruction (D) and pirate-capture (A) outbox consumers remain deferred (see WO-BUILD-WIRE-REALTIME-OUTBOX-*).
  • 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.