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_cappedfor 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 bypirate.daughter_spawn_cappedlog 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_idalready; 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.
Related¶
- ADR-0047 — pirate holdings (Stronghold/Outpost/Camp tiers).
- ADR-0048 — pirate ecosystem dynamics (cleansing, daughter spawn, evolution clock, formation prerequisites).
- ADR-0050 — region lifecycle states (referenced by X-I1 weekly-tick skip).
- ADR-0053 — periodic-service surface for the pirate weekly tick.
- ADR-0054 — transactional outbox pattern.
../SYSTEMS/pirate-ecosystem.md— ecosystem-tick mechanics; cap guards, zone affinity, evolution clock, weekly-tick skip.../SYSTEMS/pirate-holding-raid.md— raid lock, kill-log atomicity, abandoned re-seed predicate.../SYSTEMS/realtime-bus.md— five new pirate events.../FEATURES/galaxy/pirate-holdings.md— Bubble / Dead-End Bubble formation prerequisite clarification.../DATA_MODELS/pirate-holdings.md—combat_lock_team_snapshotcolumn; Stronghold-formation CHECK constraint.