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).SectorFactionInfluenceand 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_records → Reputation (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: votes → RegionalVote (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_rankandPlayer.rank_pointsare the achievement-progression dimension that is independent of either reputation system.