Skip to content

Gameplay Systems

Status: 🚧 Partial — Faction, RegionalGovernanceStack (6 tables), and Player ranking columns are committed; 5 major model classes (BountyClaim, MultiAccountCluster/Flag, AdminScopeGrant/ActionLog, ProcessedWebhookEvent … (impl audit 2026-06-16)

NPC factions and the regional governance / diplomacy stack. The turn system is implemented as plain integer counters on Player (turns, turn_reset_at) and Galaxy.default_turns_per_day rather than a dedicated entity, so it doesn't get its own model section.

Schema status

Per ADR-0066 D-V1, schema-level implementation status is consolidated here. Field descriptions describe the target schema.

Design-only entities (model classes target paths that do not yet exist):

  • BountyClaim (per ADR-0054).
  • MultiAccountCluster, MultiAccountFlag (per ADR-0056).
  • AdminScopeGrant, AdminActionLog, ProcessedWebhookEvent (per ADR-0058).
  • RegionalTreasuryEntry (per ADR-0059).
  • SectorFactionInfluence and the regional governance stack (RegionalMembership, InterRegionalTravel, RegionalTreaty, RegionalElection, RegionalVote, RegionalPolicy).

The Faction table is committed.


Faction

Source: services/gameserver/src/models/faction.py

Purpose: NPC political/economic entity. Controls territory, biases prices, and gates faction-locked sectors via player reputation.

Fields:

name type constraints notes
id UUID PK
name String(100) unique, not null, indexed
faction_type Enum factiontype (custom FactionTypeDB) not null Archetype slot for the lore faction. Nine values: FEDERATION (Terran Federation), MERCHANTS (Mercantile Guild), INDEPENDENTS (Frontier Coalition), MINING (Astral Mining Consortium — promoted per ADR-0033), EXPLORERS (Nova Scientific Institute), OUTLAWS (Fringe Alliance), SYNDICATE (Shadow Syndicate — reserved), PIRATES (hostile-only), CABAL (reserved — endgame antagonist singleton). Lore name lives on Faction.name; see FEATURES/gameplay/faction-lore.md for full lore-to-archetype mapping.
description Text nullable
territory_sectors ARRAY(UUID) default [] sector UUIDs under faction control
home_sector_id UUID nullable primary HQ sector (no FK constraint)
base_pricing_modifier Float default 1.0 0.8 = 20% discount, 1.2 = 20% markup
trade_specialties ARRAY(String) default [] commodities focus
aggression_level Integer default 5 1-10, drives NPC behavior
diplomacy_stance String(50) default neutral hostile/neutral/friendly
color_primary, color_secondary String(7) nullable hex
logo_url String(255) nullable

Relationships: - reputation_recordsReputation (1:many cascade).

Faction.get_pricing_modifier(player_reputation) and can_access_territory(player_reputation) encode price tiers and access gating.


Audit-row preservation across region deletion

Per ADR-0050 SK24, audit-trail tables in this domain (bounty_claim, aria_observation_log, pirate_kill_log, future cargo_wreck_log) gain a region_id_snapshot UUID column populated at row creation. The existing sector FK becomes ON DELETE SET NULL. On region regeneration (force=true) or termination cleanup, the sector rows cascade-delete but the audit rows persist with sector_id = NULL and region_id_snapshot retaining the original region pointer. Audit queries handle the NULL gracefully ("sector unknown — region was deleted").

BountyClaim — auto-bounty targets player, not ship

Per ADR-0054 X-V2, BountyClaim carries target_player_id (FK Player.id) as the canonical bounty target — not target_ship_id. The legacy target_ship_id column is retained as nullable for non-stolen-report bounty types (future) but is no longer used by the stolen-report flow.

| Column | Type | Notes |
| `id` | UUID PK | |
| `target_player_id` | UUID FK Player.id, not null | Per [ADR-0054](../ADR/0054-group-b-region-lifecycle-composition.md) — bounty stays on the thief regardless of ship state. |
| `target_ship_id` | UUID FK Ship.id, nullable | Legacy / reserved for future bounty types that target specific hulls. Null for stolen-report-driven auto-bounties. |
| `placer_player_id` | UUID FK Player.id | Who funded the bounty. |
| `amount` | Integer | Bounty pool amount (held in escrow on `placer_player_id`'s wallet at file-time). |
| `placed_at` | DateTime | |
| `collected_at` | DateTime nullable | Set when the bounty pays out (target killed). |
| `collector_player_id` | UUID FK Player.id, nullable | The hunter who killed the target and collected. |
| `region_id_snapshot` | UUID | Per SK24 audit-row preservation. |
| `sector_id` | UUID FK Sector.id, nullable, ON DELETE SET NULL | Per SK24. |

Migration: existing target_ship_id rows in production backfill to target_player_id from the ship's pilot_id at migration time. Rows where the pilot is unknown are dropped (rare; data-quality cleanup).

Indices (per ADR-0055 S-V3):

  • UNIQUE (placer_player_id, target_player_id) WHERE collected_at IS NULL — one active bounty per (placer, target) pair. Stops a single placer from stacking multiple bounties on the same target; multiple distinct placers still stack.
  • (target_player_id, collected_at) — used by the destruction handler's bounty-collection step (SELECT ... FOR UPDATE WHERE target_player_id = :pid AND collected_at IS NULL).
  • (collector_player_id, collected_at) — used by collector-side audit lookups.

Same-team collusion check (per ADR-0055 S-F1): bounty collection is rejected with ERR_COLLECTOR_SAME_TEAM_AS_PLACER if collector_player_id shares a team with placer_player_id at collection-time. The check is enforced at the application layer via a live team_members join — DB-level constraints can't reference dynamic team membership. Rejected rows leave the escrow held; placer can retract or wait for a non-team-mate kill.


AdminScopeGrant

Per ADR-0058 A-F2 (supersedes ADR-0027). Replaces the flat User.is_admin boolean as the authorization gate. One row per (admin user, scope) grant.

name type constraints notes
id UUID PK
user_id UUID FK users.id not null, CASCADE The admin user holding the scope.
scope String(64) not null One of the canonical 19 scopes (e.g., admin.players.suspend, admin.subscriptions.modify, admin.webhooks.replay). Full list in ADR-0058.
granted_by UUID FK users.id not null The admin who issued the grant. The bootstrap superadmin self-grants via a one-time migration.
granted_at DateTime not null
revoked_at DateTime nullable Set when the grant is revoked; the row persists for audit. Active grants have revoked_at IS NULL.
revoked_by UUID FK users.id nullable Who revoked it.

Indexes: - (user_id) WHERE revoked_at IS NULL — runtime authorization check ("does this admin have this scope?"). - UNIQUE (user_id, scope) WHERE revoked_at IS NULL — at most one active grant per (admin, scope) pair.

The legacy User.is_admin boolean becomes a derived view: is_admin = EXISTS (SELECT 1 FROM admin_scope_grants WHERE user_id = User.id AND revoked_at IS NULL). Code that previously checked User.is_admin continues to work; new code reads scopes directly.

AdminActionLog

Per ADR-0058 A-F2. Append-only audit trail for every admin action. Drives the daily review queue (high-impact actions surfaced for retrospective acknowledgement).

name type constraints notes
id UUID PK
admin_user_id UUID FK users.id not null Who acted.
scope_used String(64) not null The scope that authorized the action.
action String(128) not null Action name (e.g., subscription.create, region.terminate, webhook.replay).
target_type String(64) nullable Entity type acted upon (Player, Region, Subscription, etc.).
target_id UUID nullable Entity ID.
payload_snapshot JSONB not null Full request payload at action time, sanitized of secrets.
result Enum (ok, failed, partial) not null
failure_reason String nullable If result != ok.
reviewed_by UUID FK users.id nullable Set when another admin acknowledges this row in the review queue.
reviewed_at DateTime nullable
at DateTime not null When the action fired.

Indexes: - (at DESC) — chronological audit feed. - (admin_user_id, at DESC) — per-admin action history. - (action, at DESC) — review-queue filter by action class. - (reviewed_by) WHERE reviewed_at IS NULL — pending review queue.

Retention: 5 years (compliance default). Append-only; no in-place edits.

Review-queue scope: actions in admin.subscriptions.*, admin.webhooks.replay, admin.regions.terminate, and admin.scopes.* surface for retrospective review. Any holder of admin.audit.view can acknowledge.

ProcessedWebhookEvent

Per ADR-0058 A-D3. Idempotency table for PayPal (and future webhook providers). Inserted in the same transaction as the webhook-driven mutation, so a successful insert means the mutation also committed.

name type constraints notes
event_id String(64) PK The provider's event ID (PayPal event_id).
provider String(32) not null paypal at Launch; future providers use the same shape.
event_type String(64) not null The event class (BILLING.SUBSCRIPTION.ACTIVATED, etc.).
received_at DateTime not null When the gameserver received the webhook.
processed_at DateTime not null When the corresponding mutation committed (same transaction).
signature_valid Boolean not null Always true for committed rows (invalid signatures reject before insert). Kept for forensic queries.

PayPal's at-least-once delivery semantics produce duplicate events; the UNIQUE PK constraint ensures the second delivery returns HTTP 200 without re-applying the mutation.

MultiAccountCluster

Per ADR-0056. One row per detected cluster of accounts likely operated by the same human. Built and maintained by MultiAccountDetectionService per ../OPERATIONS/multi-account-detection.md.

name type constraints notes
id UUID PK
signal_summary JSONB not null {hard: ["payment_method", ...], soft: ["ip_24h", "device_fingerprint", "trade_correlation"], evidence: {...}} — the heuristics that fired and the supporting evidence.
severity Enum (hard, soft) not null The severity of the most-severe signal in this cluster. Drives the discount math.
all_paid_subscribers Boolean not null Cached: true if every member account has an active Galactic Citizen or Region Owner subscription at the most recent sweep. The discount layer skips clusters where this is true. Refreshed every sweep.
admin_decision Enum (pending, confirmed, overridden, escalated) default pending Admin review outcome. overridden clears all member flags permanently for this cluster ID; re-detection requires a new signal.
admin_decision_reason String nullable Free-text rationale for the admin decision.
admin_decision_at DateTime nullable When the admin acted.
admin_decision_by UUID FK users.id nullable Admin who decided.
created_at DateTime not null First detection.
updated_at DateTime not null Most recent re-evaluation.

MultiAccountFlag

Per ADR-0056. One row per (player, cluster) membership. Read by every gated participation surface (governance vote, station volume, beacon visibility, faction-rep gain) to compute the participation_weight.

name type constraints notes
id UUID PK
player_id UUID FK players.id not null, CASCADE The flagged account.
cluster_id UUID FK multi_account_clusters.id not null, CASCADE The cluster this player belongs to.
signal String not null The specific signal that ties this player to the cluster (e.g., payment_method, ip_24h, device_fingerprint).
severity Enum (hard, soft) not null Snapshot of the signal severity at flag-time.
created_at DateTime not null

Indexes: - (player_id) — single-row lookup by gated surfaces. - (cluster_id) — admin review queue and cluster-membership rollups. - UNIQUE (player_id, cluster_id, signal) — one row per (player, cluster, signal) triple; re-detections update created_at rather than insert.

Discount math (per ADR-0056 E-V5): the participation weight applied at gated surfaces is computed via MultiAccountDetectionService.participation_weight(player_id, surface)1.0 if no flag or cluster.all_paid_subscribers = true; 0.0 if the most-severe flag is hard; 0.5 if the most-severe flag is soft.


SectorFactionInfluence

Source: services/gameserver/src/models/sector_faction_influence.py (target path — does not yet exist).

Purpose: Per-(sector, faction) row holding the faction's current influence share, the derived territory-taxonomy tier, and the patrol-spawn weight consumed by npc-scheduler Loop B. Drives the territory taxonomy (Core / Controlled / Contested / Uncontrolled) per ADR-0021, feeds dynamic patrol density, and gates faction-aligned price drift at owned stations.

Schema:

name type constraints notes
id UUID PK
region_id UUID FK regions.id not null, indexed, CASCADE Scope per the compound sector identity (ADR-0005)
sector_number Integer not null, indexed Region-scoped sector number; with region_id identifies the sector
faction_code String(50) not null, indexed Matches Faction.faction_type enum (e.g. FEDERATION, MINING, PIRATES)
influence_pct Float not null, default 0.0, check [0.0, 1.0] Fractional share (0.0 = 0%, 1.0 = 100%); per-sector cross-faction sum ≤ 1.0
taxonomy_tier Enum (core, controlled, contested, uncontrolled) not null, default uncontrolled Denormalized for fast read; recomputed on every influence update
patrol_spawn_weight Float not null, default 0.0 influence_pct × faction.base_patrol_intensity × zone_modifier; clamped to [0.0, 2.0]
last_action_at DateTime server default now Last reputation-action that touched this sector; used by daily-decay sweep
created_at, updated_at DateTime server defaults

Indexes: - Composite UNIQUE on (region_id, sector_number, faction_code) — exactly one row per (sector, faction) tuple. - (region_id, sector_number) — sector-load read path (all factions in a sector). - (faction_code, region_id) — per-faction regional queries. - (last_action_at) — daily decay sweep.

Influence accumulation

Per-action deltas fire when emergent reputation actions touch a sector (ADR-0032, factions-and-teams.md#dynamic-influence):

Action Δ influence_pct
Defend a faction sector (drone defense survives) +0.01 to defending faction
Destroy a rival-faction ship −0.02 to rival faction
Build a warp gate +0.05 to builder's dominant-rep faction
Establish a colony +0.03 to dominant-rep faction
Run a fair-tariff (2–4%) player port for 30 days +0.02 to host sector's dominant faction

All updates clamp to [0.0, 1.0] and lock the row on (region_id, sector_number, faction_code). Multi-faction sums per sector are capped at 1.0; the residual is neutral / uncontrolled.

Territory taxonomy derivation

if influence_pct >= 0.95:        tier = core
elif influence_pct >= 0.75:      tier = controlled
elif influence_pct >= 0.40:
    if any rival faction has influence_pct >= 0.25:
                                 tier = contested
    else:                        tier = controlled
else:                            tier = uncontrolled

Recomputed on every influence update. If the tier changes, the service emits sector_taxonomy_changed for downstream consumers (UI, port pricing, pirate-spawn service).

Patrol spawn weight derivation

zone_mod_for(faction_code, region):
    FEDERATION:    1.5 in Federation Zone, 0.5 in Frontier
    MINING:        1.0 in Resource-Rich clusters, 0.0 elsewhere
    PIRATES:       0.0 in Federation Zone (worldgen invariant), 1.5 in Frontier
    default:       1.0

patrol_spawn_weight = clamp(
    influence_pct × faction.base_patrol_intensity × zone_mod_for(faction_code, region),
    0.0, 2.0
)

npc-scheduler Loop B reads patrol_spawn_weight to modulate spawn frequency for LAW_ENFORCEMENT, FACTION_PATROL, and STATION_SECURITY archetypes. Weight 1.0 = baseline; 0.5 = half; 1.5 = 1.5×. A weight change >10% signals the scheduler to reload affected sector rosters.

Daily decay sweep

A periodic service runs every UTC midnight:

for row where last_action_at < now − 7 days:
    influence_pct = max(0.0, influence_pct − 0.005)   // 0.5% / day
    recompute taxonomy_tier
    if taxonomy_tier changed → emit sector_taxonomy_changed

Bulk-updates committed in a single transaction. Prevents permanent territorial hold without active investment.


Player Ranking (in-place on Player)

Ranking is not a separate table. Player.military_rank (String, default Recruit) and Player.rank_points (Integer) hold progression. Personal alignment is Player.personal_reputation (-1000..+1000) with cached reputation_tier and name_color strings. See ./player.md for the full Player schema. Rank/reputation columns were added in migration fe22441146b1.


Regional Governance Stack

These models live in services/gameserver/src/models/region.py alongside Region itself (covered in ./galaxy.md). They implement the in-game political loop: membership → vote weight → elections, treaties, policies.

RegionalMembership

Purpose: A player's standing inside a specific region.

name type constraints notes
id UUID PK
player_id UUID FK players.id not null
region_id UUID FK regions.id not null
membership_type String(50) default visitor visitor/resident/citizen
reputation_score Integer default 0, -1000..1000 check constraint
local_rank String(50) nullable
voting_power DECIMAL(5,4) default 1.0, 0.0–5.0 check constraint
joined_at, last_visit TIMESTAMP server defaults
total_visits Integer default 0

Unique: (player_id, region_id). Relationships: player, region.

InterRegionalTravel

Purpose: Tracks a single cross-region travel job and any asset transfer.

Columns: id, player_id FK, source_region_id FK, destination_region_id FK (must differ — check), travel_method (platform_gate/player_gate/warp_jumper), travel_cost (≥0), assets_transferred JSONB, initiated_at, completed_at, status (in_transit/completed/failed/cancelled).

RegionalTreaty

Purpose: Bilateral region-to-region agreements.

Columns: region_a_id FK, region_b_id FK (must differ), treaty_type (trade_agreement/defense_pact/non_aggression/cultural_exchange), terms JSONB, signed_at, expires_at, status (default active). Unique on (region_a_id, region_b_id, treaty_type).

RegionalElection

Purpose: Election for a regional position (governor, council_member, ambassador).

Columns: region_id FK, position, candidates JSONB (array of {player_id, platform}), voting_opens_at, voting_closes_at (must be after open), results JSONB, status (pending/active/completed/cancelled).

Relationship: votesRegionalVote (1:many cascade).

RegionalVote

Columns: election_id FK, voter_id FK players.id, candidate_id FK players.id, weight DECIMAL(5,4) default 1.0 (0.0-5.0), cast_at. Unique on (election_id, voter_id).

RegionalPolicy

Purpose: Policy proposal / referendum within a region.

Columns: region_id FK, policy_type (tax_rate/pvp_rules/trade_policy/…), title, description, proposed_changes JSONB, proposed_by FK players.id, proposed_at, voting_closes_at (after proposed_at), votes_for (≥0), votes_against (≥0), status (voting/passed/rejected/implemented).

approval_percentage, is_passing are computed from votes against Region.voting_threshold.

RegionalTreasuryEntry

Per ADR-0059 N-I4. Append-only ledger of every balance-affecting event on Region.treasury_balance. Drives the daily reconciliation sweep that verifies the running balance matches the sum of entries.

Column Type Constraint Notes
id UUID PK
region_id UUID FK regions.id not null, CASCADE
before_balance Integer not null Treasury balance immediately before the event.
after_balance Integer not null Treasury balance immediately after the event.
delta Integer not null after_balance - before_balance; positive for inflow, negative for outflow.
cause_type Enum (policy_enactment, tax_collection, expenditure, transfer_in, transfer_out, manual_admin_adjustment) not null
cause_id UUID nullable RegionalPolicy.id, Tax.id, or other entity per cause_type. Null for manual_admin_adjustment.
reason String nullable Free-text snapshot of the event. For manual_admin_adjustment, captures the admin user's identity per ADR-0058.
at DateTime not null

Indexes: - (region_id, at DESC) — chronological treasury feed for a region. - (region_id) → SUM(delta) — reconciliation sweep aggregate.

Reconciliation sweep: a daily job (per ADR-0053) verifies SUM(treasury_entries.delta WHERE region_id = R) == Region.treasury_balance for every active region. Mismatches fire a non-blocking ops alert.

Retention: indefinite. No GDPR exposure (region-level balance changes only; no player-identifiable data).

Region governance config columns

Per ADR-0059 N-D5, Region gains a configurable quorum field:

Column Type Constraint Default Notes
governance_quorum_pct Decimal(3,2) 0.25 ≤ x ≤ 0.60 0.33 Fraction of eligible voters who must cast a vote for the result to count. Per-region admin-tunable; the 25–60% band keeps governance reachable while preventing impossible-quorum griefing. The 2-voter hard floor (when 2+ eligible) is non-configurable and lives in the quorum-evaluation logic, not as a column.

Game events

Source: services/gameserver/src/models/game_event.py (not detailed here — the file contains scheduled/active world events and is referenced by Galaxy.events JSONB and Sector.active_events). New code that touches event scheduling should refer to that model directly.


Notes

  • Faction membership and player faction standing live in Reputation (per-faction row, see ./player.md).
  • Region-level reputation lives in RegionalMembership.reputation_score. These two are independent: a player can be hated by Pirates faction (Reputation row) yet be a high-rep citizen of a player-owned region (RegionalMembership row).
  • Player.military_rank and Player.rank_points are the achievement-progression dimension that is independent of either reputation system.