Pirate Holding Raid System¶
Status: π§ Partial β Effectively unbuilt: no PirateHolding/OutlawBase models, no capture/lock/abandonment/recovery services, and no layered engagement β¦ (impl audit 2026-06-16)
π Design-only. No raid runtime is committed yet; this page is the prescriptive system spec. Canonical decision in ADR-0047. Player-facing reference in ../FEATURES/galaxy/pirate-holdings.md.
The runtime that governs pirate-holding combat β engagement order, damage composition with the canonical combat resolver, organic recovery between sessions, capture atomicity, ownership transfer, and concurrent-attacker arbitration. The holding's defensive layers compose with the existing combat-resolver, npc-scheduler, and station/planet defense surfaces; this page documents how the layers fit together and how raid state persists when no player is engaged.
Engagement order¶
When a player enters a sector belonging to a PirateHolding.interior_sector_ids (or the anchor_sector_id), defensive layers engage in this canonical sequence. Each layer is a separate combat-resolver invocation β no damage carryover within a single resolver run, but ship state (hull/shields) persists between layers via the standard Ship row.
| Layer | Trigger | Combat phase | Defenders | Notes |
|---|---|---|---|---|
| Sector defenses (perimeter) | Player enters any holding sector | combat-resolver Phase 1 (Initiation) β Phase 2 (Drone screen) |
Deployed pirate drones, mines | Per Sector.defenses.deployed_drones and mines. Mines apply on entry; drones engage the entering ship. |
| Pirate ship patrols | Patrol ship in the sector reacts to a hostile player on the realtime bus | combat-resolver Phase 3 (ship-vs-ship) |
Patrolling HOSTILE_RAIDER NPCs from Sector.defenses.pirate_patrol_ships |
Squad leader is a named captain; KIA respawn per the per-tier recovery rate. |
| Station defense | Player approaches a pirate-owned station | Station drone screen (Phase 1β2) β Port combat (Phase 3) | Station drones (4β50 by class), STATION_SECURITY archetype guards, tractor beam |
Per ../FEATURES/economy/station-protection.md. Each station is a separate engagement. |
| Planetary defense (orbital) | Player orbits a pirate-controlled planet | Combined Phase 1 + Phase 3 of a single planet-assault resolver invocation | Orbital platforms (range 2 sectors), rail guns (range 1 sector), drones, shields | Per ../FEATURES/planets/defense.md β all defense weapons fire in the same combat instance. |
| Citadel siege | Player attacks the planet's citadel structure after orbital defenses are down | Phase 3 continued | Citadel hull (L1βL5), shields, garrison HOSTILE_RAIDER NPCs |
Per ../FEATURES/planets/citadels.md and ADR-0040 does not apply (this is planet siege, not QJ). |
| OutlawBase clearing (terminal) | All planets / stations / patrol ships in the holding's interior are neutralized; pirate Lord/captains living at the OutlawBase are the last threat | Phase 3 ship-vs-ship against the named Lord/captains | Named NPCs in their personal flagships, with any final escort | Capture trigger fires when these are KIA. |
The order is observed, not enforced. If a player burns straight to the planet without clearing the perimeter sector defenses first, the planet's orbital platforms will engage, but so will the perimeter drones (the ship is now in two combat states simultaneously β Phase 3 against the planet, Phase 1β2 with the perimeter from prior to entry). The combat resolver runs each engagement atomically; the player's ship state persists across.
Damage composition¶
Each engagement runs the canonical 5-step damage stack from ./combat-resolver.md. Holding-specific compositions:
Per-station / per-planet¶
A pirate-owned station or planet runs the combat resolver once per encounter. The defending side's combat_bonus is taken from the pirate captain commanding the station/planet (typically EnsignβCaptain rank, providing +10β15% modifier on outgoing damage), not from the holding's Lord.
base_damage (player attacking station)
Γ player.attack_drones_modifier
Γ SHIP_COMBAT_MODIFIERS[player.ship.type, "STATION"]
Γ sector.modifier (nebula, radiation, etc.)
Γ (1 + player.rank.combat_bonus / 100)
-> shield_hit applied to station drones first
-> residual applies to station structure
(parallel, same round)
station_drone_damage
Γ pirate_captain.combat_bonus_factor
-> applied to player.shields, then hull
Citadel siege specifically¶
When a pirate-controlled planet has orbital platforms + rail guns + a citadel, all three fire in the same combat-resolver invocation:
Round N:
orbital_platform.fire(target=attacker, base_damage=500, multiplier=1.0)
rail_gun.fire(target=attacker, base_damage=1500 Γ ship_class_multiplier[attacker.ship.type])
citadel_drones.fire(target=attacker)
attacker.weapons.fire(target=orbital_platform OR rail_gun OR citadel)
Per the integration spec in ../FEATURES/planets/defense.md#combat-resolver-integration, defense weapons fire before attacker weapons in each round. The defending pirate captain's rank-bonus applies. Suppressed weapons (EMP'd) skip their turn; destroyed weapons stay offline.
Sector layer at planet-siege time¶
If the planet is inside a Bubble interior and sector drones / mines are still alive in the same sector as the planet, the combat resolver treats them as part of the planet engagement (Phase 2 drone screen of the planet-assault) rather than a separate invocation. This avoids "fight the drones, dock at the planet, fight the platforms separately" rhythm-breaking.
Current-strength state model¶
The holding's defensive state is the source of truth β there is no per-player RaidProgress table. Each defense layer carries:
current_strength: Float [0.0, 1.0]β defaults to 1.0, decreases on player damage.last_damage_at: DateTime nullableβ set on each player damage event.tier_recovery_rate: Floatβ sourced from the holding's tier (Camp 0.25/day, Outpost 0.10/day, Stronghold 0.03/day).
Strength schema lives on the entity that carries the defense:
| Defense surface | Where strength lives |
|---|---|
| Sector drones / mines | Sector.defenses.deployed_drones.current_strength, mines[*].current_strength |
| Pirate patrol ships | Sector.defenses.pirate_patrol_ships[*].current_strength (for the squad as a unit) |
| Station structure | Station.defense_state.current_strength (π β new sub-shape on existing column) |
| Planet orbital platforms / rail guns | per-platform / per-battery row in the existing planet-defense schema, with a new current_strength column |
| Citadel | Planet.citadel_state.current_strength |
| OutlawBase / NPC presence | OutlawBase.current_strength (logical-anchor health: 1.0 when at full roster, scales down with KIA proportion) |
Recovery service¶
A daily-tick service runs at UTC midnight (alongside the existing planetary-production tick and influence-decay tick). For each pirate-controlled PirateHolding:
def recover_holding(holding):
rate = TIER_RECOVERY_RATE[holding.tier] # 0.25 / 0.10 / 0.03 per day
days_idle = (now - holding.last_pirate_recovery_at).days
for layer in holding.defense_layers():
days_since_damage = (now - layer.last_damage_at).days if layer.last_damage_at else float('inf')
delta = rate * min(days_since_damage, days_idle)
layer.current_strength = min(1.0, layer.current_strength + delta)
holding.last_pirate_recovery_at = now
Player-owned holdings skip the recovery service β there is no pirate faction rebuilding them. Damage from prior raids stays as-is until the player chooses to repair via the standard repair surfaces (station treasury, planet construction queue, drone manufacturing).
NPC respawn¶
KIA pirate NPCs follow the standard NPC scheduler KIA cooldown (per ./npc-scheduler.md) modified by tier:
| Tier | Lord respawn | Captain respawn | Enforcer respawn |
|---|---|---|---|
| Camp | n/a | 2 days | 1 day |
| Outpost | n/a | 5 days | 3 days |
| Stronghold | 14 days | 7 days | 5 days |
NPC scheduler Loop B (cadence: 10 min) reads each holding's OutlawBase roster targets and spawns replacement NPCs when their respawn-eligible time elapses. Player-owned holdings: KIA pirates do not respawn β the holding's faction is the player, not pirates.
Capture trigger¶
Capture flips the holding to player ownership when all HOSTILE_RAIDER NPCs anchored to the holding's OutlawBase are KIA, atomically.
def attempt_capture(holding_id, player_id, team_id=None):
with transaction():
holding = db.query(PirateHolding).filter(id=holding_id).with_for_update().one()
if holding.owner_player_id is not None:
return # already owned
active_npcs = db.query(NPCCharacter).filter(
home_outlaw_base_id=holding.outlaw_base_id,
status__in=['on_duty', 'off_duty', 'engaged', 'engaged_pending_arrival']
).count()
if active_npcs > 0:
return # not all KIA yet
# Atomic capture
holding.owner_player_id = player_id
holding.owner_team_id = team_id
holding.captured_at = now()
for planet_id in holding.planet_ids():
db.query(Planet).filter(id=planet_id).update(controlling_faction='player', owner_player_id=player_id)
for station_id in holding.station_ids():
db.query(Station).filter(id=station_id).update(
owner_player_id=player_id,
security_level='basic', # reset to basic; player rebuilds defenses
)
if holding.special_formation_id:
db.query(SpecialFormation).filter(id=holding.special_formation_id).update(
owner_player_id=player_id, owner_team_id=team_id
)
# Convert OutlawBase to NPCBarracks
convert_outlawbase_to_barracks(holding.outlaw_base_id, owner_player_id=player_id)
# Per ADR-0060 G-V1: PirateKillLog insert is atomic with capture,
# inside the same transaction. Realtime events fire post-commit
# via the transactional outbox per ADR-0054.
db.session.add(PirateKillLog(
region_id=holding.region_id,
holding_id=holding_id,
attacker_player_id=player_id,
tier_at_kill=holding.tier,
disposition='captured',
killed_at=now(),
))
outbox.queue('pirate.holding_captured', {
'holding_id': holding_id,
'tier': holding.tier,
'name': holding.name,
'captured_by_player_id': player_id,
'captured_by_team_id': team_id,
})
award_capture_rewards(player_id, team_id, holding)
The with_for_update() row lock prevents two concurrent capture attempts. The cross-table updates plus the kill-log insert run inside a single transaction (per ADR-0060 G-V1); failure rolls everything back, leaving the holding in its current pirate-controlled state. A network failure that previously left the capture committed without the PirateKillLog row no longer occurs β the suppression-modifier read path that gates daughter-spawn rate sees a consistent log.
Concurrent-attacker arbitration¶
For Outposts and Strongholds, when a player initiates combat against any holding defense layer, the holding row is lock-claimed for that attacker:
def claim_holding_for_combat(holding_id, attacker_player_id):
with transaction():
holding = db.query(PirateHolding).filter(id=holding_id).with_for_update().one()
if holding.tier == 'camp':
return True # camps are permissive; anyone can engage
if holding.combat_lock_held_by and holding.combat_lock_held_until > now():
# Per ADR-0060 G-F2: team-mate engagement is permitted via the
# snapshotted team set captured at lock acquisition. Live team
# changes don't grant access β late-joiners cannot bypass the lock.
if attacker_player_id == holding.combat_lock_held_by or \
attacker_player_id in (holding.combat_lock_team_snapshot or []):
holding.combat_lock_held_until = now() + timedelta(minutes=15)
return True
raise HoldingUnderAttack(holding_id, holding.combat_lock_held_by, holding.combat_lock_held_until)
# Fresh lock acquisition β capture the attacker's current team set
# as the snapshot. Team-mates within this snapshot can engage in
# parallel; players who join the team after this instant cannot.
attacker = db.query(Player).filter(id=attacker_player_id).one()
team_snapshot = (
[m.player_id for m in attacker.team.members if m.player_id != attacker_player_id]
if attacker.team_id else []
)
holding.combat_lock_held_by = attacker_player_id
holding.combat_lock_team_snapshot = team_snapshot
holding.combat_lock_held_until = now() + timedelta(minutes=15)
return True
A second attacker hitting the same holding while the lock is active is permitted only if they are the lock holder or in the snapshotted team set. Other attackers receive ERR_HOLDING_UNDER_ATTACK with the lock holder + expiry timestamp. The lock auto-expires after 15 minutes of inactivity (no combat resolver invocations against the holding) β covering disconnects, idle pauses, and deliberate withdrawal. On expiry, both combat_lock_held_by and combat_lock_team_snapshot clear; a fresh acquisition rebuilds the snapshot from the new acquirer's current team.
The snapshot semantics close the late-join exploit: a player who joins a team mid-raid cannot bypass the lock just by joining the team β they must wait for the lock to release before engaging.
Camps have no lock β anyone can roll in. Two attackers at the same Camp race to capture; whoever clears the last NPC wins the atomic flip.
Abandonment cascade¶
For a player-owned holding inactive for 30 real-time days (no player visit, no team activity, subscription not lapsed), the abandonment service runs:
def process_abandonment(holding):
if holding.last_visit_at > now - timedelta(days=30):
return
# Per ADR-0060 G-V2: re-seeding waits for the combat lock to clear.
# If the holding's sector is in active combat, the OutlawBase β
# NPCBarracks conversion is deferred to the next abandonment-service
# tick β a player engaged in combat doesn't suddenly find their
# half-conquered holding repopulated with fresh pirates mid-fight.
if holding.combat_lock_held_by is not None and \
holding.combat_lock_held_until > now():
return
# Defenses repopulate at 50% strength (anti-grief)
for layer in holding.defense_layers():
layer.current_strength = min(0.5, max(layer.current_strength, 0.0))
# Spawn fresh pirate Lord/captain
new_lord = spawn_npc(
archetype=NPCArchetype.HOSTILE_RAIDER,
role='pirate_lord' if holding.tier == 'stronghold' else 'pirate_captain',
home_outlaw_base_id=holding.outlaw_base_id,
# name pool randomized; not the same name as the prior Lord
)
# Convert NPCBarracks back to OutlawBase
convert_barracks_to_outlawbase(holding.outlaw_base_id)
# Flip ownership
holding.owner_player_id = None
holding.owner_team_id = None
holding.captured_at = None
holding.last_pirate_recovery_at = now
# Flip planets / stations back to pirate
for planet_id in holding.planet_ids():
db.query(Planet).filter(id=planet_id).update(controlling_faction='pirates', owner_player_id=None)
for station_id in holding.station_ids():
db.query(Station).filter(id=station_id).update(owner_player_id=None, controlling_faction='pirates')
emit_event('holding_abandoned', {'holding_id': holding.id, 'new_lord_npc_id': new_lord.id})
After 90 days untouched, the recovery service ticks the defenses up to 100%; the holding is fully re-fortified.
Insurance, wreck, and rep interactions¶
- Insured player ships destroyed in raid: payout per the standard
Insurancepolicy in../FEATURES/gameplay/ship-insurance.md. PvE pirate combat carries no insurance penalty. - Pirate NPC kills: standard destruction handler creates a
CargoWreckrow; salvage is open to any player after the standard 1-hour grace. - Personal reputation on raid failure: no penalty. Dying to pirates in a known pirate site is the expected attempt cost.
- Faction rep on raid clear: per the table in
../FEATURES/galaxy/pirate-holdings.md#reward-tiers. - Pirate vendetta bounty on raid failure (Outpost / Stronghold): 5,000 cr base, scaling with attacker rank, posted via Fringe Alliance, visible 72 hours.
Source map¶
| Concern | Path (target) |
|---|---|
PirateHolding model |
services/gameserver/src/models/pirate_holding.py |
| Holding placement (Phase 12.6) | services/gameserver/src/services/galaxy_service.py:GalaxyGenerator._seed_pirate_holdings |
| Daily recovery service | services/gameserver/src/services/pirate_holding_recovery_service.py |
| Capture trigger | services/gameserver/src/services/pirate_holding_service.py:attempt_capture |
| Abandonment service | services/gameserver/src/services/pirate_holding_service.py:process_abandonment |
| Concurrent-attacker lock | pirate_holding_service.py:claim_holding_for_combat |
| OutlawBase β NPCBarracks conversion | pirate_holding_service.py:convert_outlawbase_to_barracks / convert_barracks_to_outlawbase |
| Engagement routing for pirate patrols | services/gameserver/src/services/npc_scheduler.py:route_engagement (existing β extends to pirate_patrol_ships) |
| API: list holdings (admin / player intel) | services/gameserver/src/api/routes/pirate_holdings.py |
| API: capture status query | GET /api/v1/pirate-holdings/{id} |
| Realtime events | holding_engagement_started, holding_captured, holding_abandoned, holding_recovery_complete |
Related¶
../ADR/0047-pirate-holdings.mdβ canonical decision.../FEATURES/galaxy/pirate-holdings.mdβ player-facing reference../combat-resolver.mdβ canonical 5-step damage stack../npc-scheduler.mdβ engagement routing, KIA processing, NPC respawn../galaxy-generator-design.mdβ Phase 12.6 placement.../FEATURES/economy/station-protection.mdβ station defense layers.../FEATURES/planets/defense.mdβ planet orbital + rail-gun integration with the combat resolver.../FEATURES/planets/citadels.mdβ citadel structure.../FEATURES/gameplay/ship-insurance.mdβ insurance payout.