Skip to content

Gameplay Systems

Status: 🚧 Partial — Faction, RegionalGovernanceStack (6 tables + RegionalPolicyVote), RegionalTreasuryEntry, AdminScopeGrant/AdminActionLog, Player ranking columns, BountyClaim, MultiAccountCluster/Flag, and ProcessedWebhookEvent are committed and migrated. SectorFactionInfluence schema + write path are on the feature tip; ADR-0021 read-side (taxonomy helper, patrol_spawn_weight, UTC idle decay + last_action_at) is implemented on LEG-INI-05 6d42bf2c / PR #603 (open — tip_HAS_land=NO vs origin/feat 46bce720; Fibril Soft-ORDER #1634 / LEG-1558 land path — not tip-shipped) — see § SectorFactionInfluence. Still open after that merge: Faction.base_patrol_intensity / zone_mod columns (identity defaults 1.0 in the WO tip). RBAC Phase-A1 + regional governance wiring still as noted 2026-08-06.

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: none remaining — the last holdouts (AdminScopeGrant/AdminActionLog, RegionalTreasuryEntry, the regional governance stack) all shipped; see the corrected status banner above. (re-verified 2026-08-06 against services/gameserver/src/models/region.py:287-565 + admin_action_log.py + admin_scope_grant.py + their alembic migrations and service/route wiring)

The Faction, BountyClaim, MultiAccountCluster/MultiAccountFlag, ProcessedWebhookEvent, AdminScopeGrant/AdminActionLog, RegionalTreasuryEntry, and the regional governance stack (RegionalMembership, InterRegionalTravel, RegionalTreaty, RegionalElection, RegionalVote, RegionalPolicy, RegionalPolicyVote) tables are all committed. SectorFactionInfluence write path is on the feature tip; ADR-0021 read-side (taxonomy / patrol weight / idle decay) is implemented on LEG-INI-05 6d42bf2c / PR #603 (open, not yet on feat/new-feature-development) — see § SectorFactionInfluence. Intensity/zone_mod columns remain identity-default residuals.


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. Ten 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), CONCORD (Galactic Concord — operator-managed, no player rep gain mechanics; canon-to-seed per ADR-0093 item 38, extending the MINING precedent). Lore name lives on Faction.name; see FEATURES/gameplay/faction-lore.md for full lore-to-archetype mapping and FEATURES/gameplay/police-forces.md for CONCORD's Nexus Sentinel Corps context.
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"). (folded from ADR-0050 SK24, re-verified 2026-08-07)

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. ✅ Live: participation_weight returns 1.0 for members of clusters where this is true (see Discount math). 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; shipped-vs-unbuilt SSOT: ../OPERATIONS/multi-account-detection.md):

  • ✅ Live on tip (46bce720+; LEG-256 / PR #668) — multi_account_service.participation_weight(db, player_id): no flag → 1.0; cluster.all_paid_subscribers1.0; HARD → 0.0; SOFT → 0.5. No surface argument.
  • 📐 Design-target / unbuilt — a surface parameter (the ADR E-V5 shape / former MultiAccountDetectionService.participation_weight(player_id, surface) sketch). Soft-tier 0.5× and all_paid_subscribers exemption are no longer gaps.

SectorFactionInfluence

Source: services/gameserver/src/models/sector_faction_influence.py (write-path columns on feature tip). Read-side on LEG-INI-05 tip 6d42bf2c / PR #603 (open as of 2026-08-21 — tip_HAS_land=NO vs 46bce720; Soft-ORDER #1634 tip-pending): faction_service.py (sector_territory_tier, compute_patrol_spawn_weight / apply_patrol_spawn_weight, apply_sector_influence_daily_decay); scheduler faction_influence_sweeps.py + core_loop.py / Loop B in npc_tick_loops.py. Decay formula from LEG-65.

Purpose: Per-(sector, faction) row holding the faction's stored influence percentage, derived patrol_spawn_weight, and (on the LEG-INI-05 tip) last_action_at activity clock. Write path: faction_service.adjust_sector_influence UPSERTs and clamps influence_percentage to [0, 100]; on the LEG-INI-05 tip it also stamps last_action_at and refreshes patrol_spawn_weight. ADR-0021 read-side is ✅ Implemented on LEG-INI-05 tip / pending merge — no longer Design-only, but not yet on feat/new-feature-development. 📐 Design-only residual: real Faction.base_patrol_intensity / zone_mod columns — WO tip uses identity defaults 1.0 × 1.0.

Schema (feature tip + LEG-INI-05 tip):

name type constraints notes
id UUID PK
sector_id UUID FK sectors.id not null, indexed, CASCADE Sector PK (not compound region_id + sector_number)
faction_id UUID FK factions.id not null, indexed, CASCADE Faction PK (not a faction_code string)
influence_percentage Float not null, default 0.0 0–100 percentage points (not a 0–1 fraction). Taxonomy thresholds key off this unit.
patrol_spawn_weight Float not null, default 0.0 On LEG-INI-05 tip: written by derivation (clamp((influence/100)×intensity×zone_mod, 0, 2)); Loop B reads max weight per sector. Feature tip still leaves default until merge.
last_action_at DateTime (tz) nullable LEG-INI-05 tip / migration a8c3e1f4b902. Set only by write-path adjust_sector_influence; decay never writes it. Not on feature tip until PR #603 merges.
created_at, updated_at DateTime server defaults Fallback activity clock for pre-last_action_at rows

Not on the model (still design / not columns): taxonomy_tier (computed at read time on LEG-INI-05 tip, not stored), region_id/sector_number/faction_code compound keys, influence_pct as a 0–1 fraction, Faction.base_patrol_intensity / zone_mod.

Indexes / constraints (shipped): - UNIQUE (sector_id, faction_id) — exactly one row per (sector, faction) tuple.

Influence accumulation (write path — ✅ Shipped; LEG-INI-05 tip also stamps activity + weight)

adjust_sector_influence applies a caller-supplied delta to influence_percentage and clamps to [0, 100]. On the LEG-INI-05 tip it also stamps last_action_at and refreshes patrol_spawn_weight. Which gameplay actions call it (and with what deltas) remains tied to ADR-0032 / factions-and-teams.md#dynamic-influence; the stored unit is percentage points, not the fractional influence_pct previously documented here.

Territory taxonomy derivation

✅ Implemented on LEG-INI-05 tip (sector_territory_tier) / pending PR #603 merge. No taxonomy_tier column. Thresholds (0–100 percentage points) match LEG-34:

if influence_percentage >= 95:        tier = core
elif influence_percentage >= 75:      tier = controlled
elif influence_percentage >= 40:
    if any rival faction has influence_percentage >= 25:
                                 tier = contested
    else:                        tier = controlled
else:                            tier = uncontrolled

Patrol spawn weight derivation

✅ Implemented on LEG-INI-05 tip (stored column + Loop B read) / pending PR #603 merge. Formula:

patrol_spawn_weight = clamp(
    (influence_percentage / 100.0) × faction.base_patrol_intensity × zone_mod,
    0.0, 2.0
)

📐 Design-only residual: Faction.base_patrol_intensity and zone_mod columns do not exist yet — LEG-INI-05 uses identity defaults 1.0 × 1.0 (flagged in code). Do not invent rates beyond that residual.

Daily decay sweep

✅ Implemented on LEG-INI-05 tip (scheduler/faction_influence_sweeps.py_run_sector_faction_influence_decay_sync, wired from the NPC scheduler core loop) / pending PR #603 merge. Numbers remain the LEG-65 provisional targets (Max may override async under the ratification-authority amendment).

Cadence: once per UTC calendar day (advisory lock + Galaxy.state day anchor).

Idle definition: a row is idle when its activity clock is older than 3 UTC days. Activity clock prefers last_action_at (set only by write-path adjust_sector_influence); falls back to updated_at for pre-column rows. Decay never writes last_action_at.

Decay rate (provisional): for each idle row,

influence_percentage = max(0.0, influence_percentage − 0.5)

0.5 influence_percentage points per idle UTC day (0–100 scale). After each change, refresh patrol_spawn_weight. Rows already at 0.0 are no-ops.

Worked example: a Contested row at 50.0 that stays idle decays to 49.5 after one midnight, 47.0 after six idle days, and reaches Uncontrolled (<40) after 21 idle days from 50.0 — slow enough that active sectors stay sticky, fast enough that abandoned holdings erode within a real-time month.


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 closed vocabulary — administrator / moderator / None — PATCH-validated server-side; not a free-form field (ADR-0093 item 20)
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.