Skip to content

JSONB schemas

Status: 🚧 Partial — Galaxy, Cluster, WarpTunnel, and SpecialFormation JSONB shapes fully match code. Ship.cargo is now documented as the live shipped shape (WO-CANON-DRAFT-SHIP-CARGO-JSONB-SHAPE-DRIFT, 2026-08-05): {capacity, used, contents} — never the abandoned max_capacity / used_capacity rename, and not a flat commodity→quantity map. Other Ship JSONB shapes on this page (maintenance, combat, upgrades, equipment_slots) remain launch-target sketches pending their own re-audit.

Most domain models lean on PostgreSQL JSONB columns rather than fully-relational sub-tables. This page documents the authoritative shape of each JSONB column so consumers can write to it without guessing.

Ship JSONB except cargo — still a migration sketch. Ship.maintenance, Ship.combat, Ship.upgrades, Ship.equipment_slots, and the ShipSpecification shapes for special_abilities, acquisition_methods, faction_requirements may still diverge from live writers; treat those sections as launch targets until re-audited. Ship.cargo below is live truth, not a target.

Conventions used below:

  • Source. The model file that owns the column.
  • Shape. Canonical JSON skeleton. Where a key's value is a primitive, the type is annotated as int, str, float, bool, or iso8601. Where it's a collection, the inner element shape follows.
  • Defaults. The literal default a freshly-inserted row receives. {} means "empty object"; [] means "empty array"; null means SQL NULL.
  • Constraints. Application-level validation. The database does not enforce these; the gameserver does.

The contracts in this page apply to both JSONB (the explicit Postgres type) and the handful of columns declared as plain JSON (e.g. Fleet.battle_log).


Player

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

Player.reputation

{
  "<faction_code>": {
    "score": int,
    "tier": str,
    "last_updated": iso8601
  }
}

Defaults: {} (empty; populated lazily on first interaction with a faction).

Constraints: - Keys must match Faction.code. - score clamped to [-1000, 1000]. - tier is the cached tier name derived from score; the source of truth is Reputation rows in reputations. The JSONB cache exists for fast reads on the player record.

Player.settings

{
  "ui": {
    "theme": str,
    "compact_layout": bool,
    "minimap_position": str
  },
  "language": {
    "code": str,
    "manual_override": bool
  },
  "audio": {
    "master_volume": float,
    "music_volume": float,
    "sfx_volume": float,
    "voice_volume": float,
    "muted": bool
  },
  "accessibility": {
    "colorblind_mode": str,
    "reduced_motion": bool,
    "screen_reader_hints": bool,
    "subtitle_size": str
  },
  "gameplay": {
    "auto_refuel": bool,
    "confirm_destructive_actions": bool,
    "default_trade_quantity": int,
    "show_aria_suggestions": bool
  },
  "privacy": {
    "show_on_leaderboards": bool,
    "show_personal_reputation_publicly": bool,
    "allow_team_invites": bool,
    "allow_dms": str
  },
  "notification_preferences": {
    "combat": bool,
    "trade": bool,
    "social": bool,
    "system": bool,
    "medals": bool
  },
  "medal_privacy": {
    "visibility": str,
    "pinned_medal_id": "str | null",
    "curated_medal_ids": [str],
    "show_hidden": bool,
    "show_count_publicly": bool,
    "broadcast_to_team": bool,
    "unviewed_awards": [str]
  },
  "bounties": [
    {
      "id": str,
      "placed_by": str,
      "placed_by_name": str,
      "amount": int,
      "placed_at": iso8601,
      "type": str
    }
  ]
}

Defaults: {} — keys are populated lazily as the player interacts with the relevant feature. The gameserver supplies sane defaults at read time when a key is missing (e.g. ui.theme = "dark", language.code = inherit-from-region, audio.master_volume = 0.8, notification_preferences.medals = true, medal_privacy.visibility = "all", medal_privacy.show_hidden = false, medal_privacy.broadcast_to_team = true, medal_privacy.show_count_publicly = true).

Constraints: - bounties is appended to by the bounty service (services/gameserver/src/services/bounty_service.py); each entry is one active bounty placed against this player. - notification_preferences must contain only known channel keys (combat, trade, social, system, medals). - accessibility.colorblind_mode{"off", "deuteranopia", "protanopia", "tritanopia"}. - accessibility.subtitle_size{"small", "medium", "large", "x-large"}. - privacy.allow_dms{"everyone", "team_only", "none"}. - medal_privacy.visibility{"all", "curated", "none"}. - medal_privacy.pinned_medal_id and entries in curated_medal_ids / unviewed_awards reference Medal.id (string PK on the medals table — see ../FEATURES/gameplay/medals.md). - audio.*_volume clamped to [0.0, 1.0].

Status:ui / audio / accessibility / privacy / gameplay blocks are the target schema; the live model only enforces notification_preferences and bounties today.

Player.first_login

{
  "completed": bool,
  "session_id": str,
  "ship_chosen": str,
  "completed_at": iso8601
}

Defaults: {"completed": false}.

Constraints: Once completed flips to true it must not flip back. Admin reset goes through DELETE /first-login/session, which resets the entire row, not this column.

Player.insurance

{
  "type": "NONE | BASIC | STANDARD | PREMIUM",
  "purchased_at": iso8601,
  "expires_at": iso8601,
  "claims_remaining": int
}

Defaults: null — the column is nullable; an absent row means no insurance.

Constraints: type must match the InsuranceType enum in services/gameserver/src/models/ship.py.


Ship

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

Ship.maintenance

{
  "current_integrity": float,
  "max_integrity": float,
  "last_serviced_at": iso8601,
  "decay_rate_per_turn": float,
  "next_failure_check_at": iso8601,
  "failure_history": [
    {"type": "MINOR | MAJOR | CATASTROPHIC", "at": iso8601, "repaired_at": iso8601}
  ]
}

Defaults: Set on ship creation by services/gameserver/src/services/ship_service.py from the matching ShipSpecification.

Constraints: current_integrity must be in [0, max_integrity]. When it hits 0, the ship is destroyed (is_destroyed = true).

Ship.cargo

Live shape (seeded by first_login_service / NPC traders; mutated by contract, NPC trade, contraband, construction, fuel-delivery, beacon, abandonment paths):

{
  "capacity": int,
  "used": int,
  "contents": {
    "ore": int,
    "organics": int,
    "gourmet_food": int,
    "fuel": int,
    "equipment": int,
    "exotic_technology": int,
    "luxury_goods": int,
    "colonists": int,
    "precious_metals": int,
    "lumen_crystals": int,
    "quantum_shards": int
  },
  "_capacity_bonus_percent": float
}

Canonical keys (2026-08-05): capacity / used / contents — not max_capacity / used_capacity, and not top-level commodity keys. _capacity_bonus_percent is an optional meta-key written by ShipUpgradeService (Cargo-Hold); readers must use effective_cargo_capacity(ship) rather than raw capacity when applying the bonus.

Defaults: {capacity: ShipSpecification.max_cargo, used: 0, contents: {}} on creation.

Constraints: used ≤ capacity (before bonus). used must equal sum(contents.values()). Commodity keys inside contents match the canonical commodity catalog at ../FEATURES/definitions.md#resource-types plus colonists.

Known legacy writer: ~~enhanced_websocket_service still mutates a flat {commodity: qty} map on some websocket trade paths.~~ Fixed 2026-08-05 (WO-FIX-ENHANCED-WEBSOCKET-CARGO-SHAPE-MISMATCH) — websocket buy/sell now writes nested contents + used/capacity and flag_modified, matching REST trading.py.

Mining-drop slots (per ADR-0065 M-E1): lumen_crystals and quantum_shards live under contents. Both are integer counts; default absent/0; produced as mining drops per ../FEATURES/economy/mining.md; extracted at TradeDocks for refining (per ADR-0009).

Ship.combat

{
  "attack_rating": int,
  "defense_rating": int,
  "shields_current": int,
  "shields_max": int,
  "shields_recharge_rate": float,
  "shield_resistance": float,
  "armor_rating": float,
  "evasion": int,
  "weapons": [
    {"type": str, "damage": int, "range": int, "ammo": int}
  ]
}

Defaults: Populated from ShipSpecification at creation time. shield_resistance and armor_rating default to 0.0 (no damage absorbed); ship class and upgrades raise them — see the canonical damage stack in ../SYSTEMS/combat-resolver.md#damage-stack-order-of-operations.

Constraints: - shields_current ≤ shields_max. - shield_resistance ∈ [0.0, 1.0] — fraction of shield-damage absorbed (0.0 = no resistance, 1.0 = total absorption). - armor_rating ∈ [0.0, 1.0] — fraction of hull-damage absorbed (post-shield residual). Same units as shield_resistance for symmetry.

Ship.upgrades

[
  {
    "type": "ENGINE | CARGO_HOLD | SHIELD | HULL | SENSOR | DRONE_BAY | GENESIS_CONTAINMENT | MAINTENANCE_SYSTEM",
    "level": int,
    "installed_at": iso8601,
    "credits_spent": int
  }
]

Defaults: [].

Constraints: At most one entry per type. level must be ≤ ShipSpecification.max_upgrade_levels[type].

Ship.equipment_slots

{
  "<slot_name>": {
    "equipment_id": str,
    "installed_at": iso8601,
    "condition": float
  }
}

Defaults: {}.

Constraints: Slot names are validated against ShipSpecification.special_abilities.

Ship.insurance

Same shape as Player.insurance. null when uninsured.

ShipSpecification.max_upgrade_levels

{
  "ENGINE": int,
  "CARGO_HOLD": int,
  "SHIELD": int,
  "HULL": int,
  "SENSOR": int,
  "DRONE_BAY": int,
  "GENESIS_CONTAINMENT": int,
  "MAINTENANCE_SYSTEM": int
}

Defaults: Required; populated from the ship-spec seed data.

Constraints: Keys must match the UpgradeType enum.

ShipSpecification.special_abilities

[
  {"name": str, "description": str, "slot": str, "compatible_with": [str]}
]

Defaults: [].

ShipSpecification.acquisition_methods

[
  {"method": "purchase | quest_reward | faction_unlock | event_drop", "cost": int, "requirement": str}
]

Defaults: [].

ShipSpecification.faction_requirements

{
  "<faction_code>": {"min_reputation": int, "tier": str}
}

Defaults: null (no requirement).


Sector

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

Sector.resources

{
  "has_asteroids": bool,
  "asteroid_yield": {
    "ore": int,
    "precious_metals": int,
    "quantum_shards": int
  },
  "gas_clouds": [
    {"type": str, "density": int}
  ],
  "has_scanned": bool
}

Defaults:

{
  "has_asteroids": false,
  "asteroid_yield": {"ore": 0, "precious_metals": 0, "quantum_shards": 0},
  "gas_clouds": [],
  "has_scanned": false
}

Constraints: asteroid_yield values are non-negative. Live key is quantum_shards (bang SectorResources / gameserver harvest contract); radioactives is a retired slug and must not appear as current schema.

Sector.players_present

[
  {
    "player_id": str,
    "ship_id": str,
    "ship_type": str,
    "joined_at": iso8601,
    "last_seen": iso8601,
    "is_cloaked": bool
  }
]

Defaults: [].

Constraints: Maintained by the movement service; player_id must be a valid Player.id UUID. The fat-dict shape supports presence-aware features (sector-presence encounter checks per SYSTEMS/sector-presence.md). Stale entries are cleaned up when a player moves out or last_seen exceeds the presence-decay window. 📐 is_cloaked is a schema placeholder only — zero gameserver writers/readers as of 2026-08-04; do not treat it as live cloaking. Presence-hide design pending Max ruling (same DECISION as Ship.has_cloaking).

Sector.ships_present

[
  {"ship_id": str, "owner_id": str, "type": str, "is_cloaked": bool}
]

Defaults: [].

Constraints: Mirrors Ships rows whose sector_id equals this sector's sector_id. The JSONB is the read-fast denormalization; the relational column is canonical.

Sector.defenses

{
  "drone_blocks": [
    {
      "count": int,
      "owner_player_id": str,
      "owner_team_id": "str | null",
      "faction_code": "str | null",
      "deployed_at": iso8601,
      "is_hostile_to_passers": bool,
      "weapon_profile": "attack | defense"
    }
  ],
  "dominant_owner_id": "uuid | null",
  "dominant_owner_name": "str | null",
  "dominant_team_id": "uuid | null",
  "mines": int,
  "mine_owner_id": "uuid | null",
  "police_patrol_ships": [
    {
      "patrol_id": str,
      "faction_code": str,
      "squad_kind": "frontier_militia | corporate_security | terran_navy | cabal_skirmisher | mercenary_company | federation_marshal | nexus_sentinel",
      "npc_character_ids": [str],
      "ship_count": int,
      "wanted_threshold": int,
      "deployed_at": iso8601,
      "scheduled_clear_at": "iso8601 | null",
      "shift_handoff_state": {
        "outgoing_npc_id": "str | null",
        "incoming_npc_id": "str | null",
        "overlap_started_at": "iso8601 | null",
        "threat_notes": [{"player_id": str, "sector_id": int, "note": str, "timestamp": iso8601}],
        "engaged_player_ids": [str],
        "coverage_gap_started_at": "iso8601 | null"
      }
    }
  ],
  "pirate_patrol_ships": [
    {
      "patrol_id": str,
      "faction_code": str,
      "squad_kind": str,
      "npc_character_ids": [str],
      "ship_count": int,
      "deployed_at": iso8601,
      "scheduled_clear_at": "iso8601 | null"
    }
  ],
  "docked_npc_ships": [
    {
      "npc_id": str,
      "ship_id": str,
      "status": "docked_off_duty | docked_maintenance | docked_standby",
      "docked_at": iso8601
    }
  ]
}

Defaults:

{
  "drone_blocks": [],
  "dominant_owner_id": null,
  "dominant_owner_name": null,
  "dominant_team_id": null,
  "mines": 0,
  "mine_owner_id": null,
  "police_patrol_ships": [],
  "pirate_patrol_ships": [],
  "docked_npc_ships": []
}

Shipped as two dedicated keys, not a single patrol_ships list. police_patrol_ships (Federation Marshal / Nexus Sentinel squads) and pirate_patrol_ships (ADR-0047 pirate rosters) are separate lists rather than one unified patrol_ships key — src/services/npc_spawn_service.py's POLICE_PATROL_DEFENSES_KEY / PIRATE_PATROL_DEFENSES_KEY. patrol_ships itself is not a Sector.defenses key — it's an unrelated key on Station.defenses (see below), a per-station scalar-int garrison count that never collides with these lists (different model), but the split was chosen defensively under the belief that it would. This is the shipped mechanism movement_service.py's sector-entry encounter check and npc_engagement_service.py's ADR-0042 dispatch/sweep engine are built against.

docked_npc_ships semantics. Off-duty NPC ships parked at this sector (typically the host sector of an NPCBarracks or OutlawBase). The combat-resolver checks this array during attack-validation: ships listed with status = "docked_off_duty" at a sector flagged as the NPC's home_barracks_id host are shielded from player attack (ERR_NPC_SHIP_AT_BARRACKS). Ships at docked_standby (e.g., a pirate captain's ship parked at a public Fringe-Alliance port that isn't his outlaw base) are not shielded — players who infiltrate the port can attack them. docked_maintenance is a middle state — auto-repair queued during the NPC's sleep block; ships in this state are also shielded as a side effect of being at a barracks.

dominant_* fields are denormalized read-side conveniences for the UI — derived from the largest drone_blocks entry. Multiple players can stack defenses in the same sector via separate drone_blocks entries; each block tracks its own owner, team, faction (if NPC patrol), deploy timestamp, hostility flag (drives whether it engages passers-by or only attackers), and weapon profile.

police_patrol_ships / pirate_patrol_ships are NPC-only per SD2 — these are faction patrol squads spawned by the world generator and zone-policing system, never player-deployable. Each entry carries faction_code and squad_kind so sector-presence._check_for_encounters can compare a passing ship's Wanted status (Player.personal_reputation and any active stolen-ship reports) against the patrol's wanted_threshold to decide whether to fire pursuit (pirate_patrol_ships entries carry no wanted_threshold — pirate aggression is unconditional presence-based, not a Wanted-status gate). scheduled_clear_at is non-NULL on temporary surge patrols (e.g., zone-event spawned squads); NULL means the patrol persists until the sector's policing rules say otherwise. Player-deployable defenses live in drone_blocks, not here.

Constraints: - When any drone_blocks[].count > 0 or mines > 0, the corresponding owner field must not be null. - police_patrol_ships[].faction_code / pirate_patrol_ships[].faction_code must reference a valid faction (terran_federation, cabal, corporate, frontier, etc.); never NULL. - police_patrol_ships[].owner_player_id / pirate_patrol_ships[].owner_player_id is disallowed — the schema enforces NPC-only by omitting the field entirely.

Sector.active_events

[
  {
    "event_id": str,
    "type": str,
    "started_at": iso8601,
    "expires_at": iso8601,
    "payload": {}
  }
]

Defaults: [].

Sector.nav_hazards

{
  "<hazard_type>": {
    "severity": int,
    "duration_remaining": int
  }
}

Defaults: {}.

Sector.nav_beacons

[
  {"beacon_id": str, "owner_id": str, "message": str, "placed_at": iso8601}
]

Defaults: [].


Planet

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

Planet.resources

{
  "fuel_ore": int,
  "organics": int,
  "equipment": int,
  "rare_minerals": int,
  "extraction_modifier": float
}

Defaults: {}.

Constraints: Quantities are non-negative. Specialization can boost extraction_modifier.

Planet.economy

{
  "trade_balance": int,
  "tax_rate": float,
  "currency_reserves": int,
  "active_treaties": [str],
  "trade_routes": [
    {"partner_planet_id": str, "volume": int, "established_at": iso8601}
  ]
}

Defaults: {}.

Constraints: tax_rate in [0.0, 1.0].

Planet.production

{
  "fuel": int,
  "organics": int,
  "equipment": int,
  "research": int
}

Defaults: {"fuel": 0, "organics": 0, "equipment": 0, "research": 0}.

Constraints: Each value is non-negative; the per-tick rate is computed by _calculate_production_rates in services/gameserver/src/services/planetary_service.py.

Planet.active_events

Same shape as Sector.active_events.


Galaxy

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

Galaxy.statistics

{
  "total_sectors": int,
  "discovered_sectors": int,
  "station_count": int,
  "planet_count": int,
  "player_count": int,
  "team_count": int,
  "warp_tunnel_count": int,
  "genesis_count": int
}

Defaults: All zeros.

Constraints: Counts are non-negative; discovered_sectors ≤ total_sectors. Maintained by Galaxy.update_statistics().

Galaxy.density

{
  "station_density": int,
  "planet_density": int,
  "one_way_warp_percentage": int,
  "resource_distribution": {
    "ore": int,
    "organics": int,
    "equipment": int,
    "luxury_goods": int,
    "medical_supplies": int,
    "technology": int
  }
}

Defaults: station_density=10, planet_density=3, one_way_warp_percentage=5, distribution shown in source.

Constraints: Density percentages are validated against the canonical ranges (5–15 for stations, 10–25 for planets, ~10 for one-way warps). resource_distribution values should sum to 100.

Galaxy.faction_influence

{
  "terran_federation": int,
  "mercantile_guild": int,
  "frontier_coalition": int,
  "astral_mining_consortium": int,
  "nova_scientific_institute": int,
  "fringe_alliance": int,
  "player_controlled": int,
  "contested": int
}

Defaults: Shown in source.

Constraints: Values sum to 100.

Galaxy.state

{
  "age_in_days": int,
  "resource_depletion": int,
  "economic_health": int,
  "exploration_percentage": int,
  "player_wealth_distribution": {
    "top_10_percent": float,
    "middle_40_percent": float,
    "bottom_50_percent": float
  }
}

Defaults: Shown in source.

Constraints: Wealth distribution percentages sum to 100.

Galaxy.events

{
  "active_events": [
    {"event_id": str, "started_at": iso8601, "expires_at": iso8601}
  ],
  "scheduled_events": [
    {"event_id": str, "scheduled_for": iso8601}
  ]
}

Defaults: {"active_events": [], "scheduled_events": []}.

Galaxy.combat_penalties

{
  "federation": "high | medium | low | none",
  "border": "high | medium | low | none",
  "frontier": "high | medium | low | none"
}

Defaults: {"federation": "high", "border": "medium", "frontier": "none"}.

Galaxy.economic_modifiers

{
  "global_price_multiplier": float,
  "<commodity_key>": {"multiplier": float, "expires_at": iso8601}
}

Defaults: {}.


Cluster

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

Cluster.stats

{
  "total_sectors": int,
  "populated_sectors": int,
  "empty_sectors": int,
  "resource_value": int,
  "danger_level": int,
  "development_index": int,
  "exploration_percentage": int
}

Defaults: Shown in source. All numeric fields are 0-100.

Cluster.resources

{
  "primary_resources": [str],
  "resource_distribution": {
    "ore": int,
    "organics": int,
    "equipment": int,
    "luxury_goods": int,
    "medical_supplies": int,
    "technology": int
  },
  "special_resources": [str]
}

Defaults: Shown in source. resource_distribution sums to 100.

Cluster.faction_influence

{
  "terran_federation": int,
  "mercantile_guild": int,
  "frontier_coalition": int,
  "astral_mining_consortium": int,
  "nova_scientific_institute": int,
  "fringe_alliance": int,
  "dominant_faction": str
}

Defaults: Shown in source.

Constraints: Numeric values sum to 100; dominant_faction is the key with the highest value. Roster: only the six "territorial" allyable factions appear in cluster influence — Terran Federation, Mercantile Guild, Frontier Coalition, Astral Mining Consortium, Nova Scientific Institute, Fringe Alliance. Shadow Syndicate is intentionally excluded because per FEATURES/gameplay/faction-lore.md Syndicate territory is operational (it hides inside other factions' space) rather than geographical, so cluster-level influence percentages don't apply. Pirates and Cabal are hostile-only and similarly absent — Pirates have no fixed territory; Cabal occupies a single Terran-Space HQ sector (Border Zone, randomized 101–200 per ../FEATURES/gameplay/faction-lore.md#the-cabal) without cluster-level influence.

Cluster.resource_modifiers

{
  "<resource_key>": float
}

Defaults: {}. Each value is a multiplier applied to extraction in this cluster.

Cluster.discovery_requirement

{
  "min_player_rank": str,
  "min_sectors_explored": int,
  "required_quest": str
}

Defaults: null (no requirement, cluster starts visible).


WarpTunnel

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

WarpTunnel.properties

{
  "length": float,
  "stability_rating": int,
  "expected_lifetime": "iso8601 | null",
  "age": int,
  "traversal_cost": int,
  "cool_down": int,
  "discovered": bool,
  "discoverer_id": "uuid | null",
  "discovery_date": "iso8601 | null",
  "affected_by_storms": bool
}

Defaults: Shown in source. Natural tunnels have expected_lifetime: null; artificial tunnels carry an explicit collapse time.

WarpTunnel.tunnel_status

{
  "is_active": bool,
  "disruption": "null | { reason: str, until: iso8601 }",
  "traffic_level": int,
  "last_traversal": "iso8601 | null",
  "maintenance_status": "null | { state: str, scheduled_for: iso8601 }"
}

Defaults: Shown in source.

WarpTunnel.source_endpoint / destination_endpoint

Same shape on both:

{
  "sector_id": "uuid | null",
  "cluster_id": "uuid | null",
  "region_id": "uuid | null",
  "coordinates": {"x": int, "y": int, "z": int},
  "controlling_faction": "str | null",
  "is_secured": bool,
  "access_requirements": "null | { min_rank: str, min_reputation: int }"
}

Defaults: Shown in source.

Constraints: sector_id, cluster_id, region_id should be populated when the endpoint is real. access_requirements is null on public tunnels.

WarpTunnel.artificial_data

{
  "creator_id": str,
  "created_at": iso8601,
  "fuel_cost": int,
  "decay_per_day": float,
  "anchor_method": str,
  "owner_team_id": "uuid | null"
}

Defaults: null (only populated for type = ARTIFICIAL).

WarpTunnel.traversal_history

[
  {"ship_id": str, "owner_id": str, "at": iso8601, "direction": "outgoing | incoming"}
]

Defaults: []. Capped to the last 100 entries; older entries are evicted by the traversal handler.

WarpTunnel.access_requirements

{
  "min_rank": str,
  "min_reputation": int,
  "required_faction": str,
  "required_quest": str
}

Defaults: null.

WarpTunnel.special_effects

{
  "<effect_name>": {"magnitude": float, "duration_turns": int}
}

Defaults: {}.


SpecialFormation

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

SpecialFormation.properties

Type-specific generation parameters. The shape varies by the row's SpecialFormationType; keys present in a row must match the row's type. A BUBBLE row never carries target_formation_id; a BACKDOOR row never carries interior_size.

{
  // BUBBLE / DEAD_END_BUBBLE
  "interior_size": int,
  "link_tunnel_depth": int,
  "branching": "chain | tree | cyclic",

  // GOLD_BUBBLE
  "gateway_count": int,
  "interior_size_min": int,

  // TUNNEL
  "length": int,
  "one_way_bias": float,

  // DEAD_END
  "parent_kind": "open | tunnel | bubble",
  "is_unfigged_only": bool,

  // WARP_SINK
  "entry_count": int,
  "recovery_method": "slipdrive | distress | scheduled_clear | none",

  // BACKDOOR
  "target_formation_id": str,
  "entry_distance": int,

  // BLISTER
  "interior_size": int,
  "bypass_distance": int,

  // ESCAPE_HATCH
  "surprise_source_distance": int,

  // LOST_SECTOR
  "exitWarp": null | { "destinationSectorId": int },

  // LOST_CLUSTER
  "interiorClusterId": str,
  "exitWarp": null | { "sourceSectorId": int, "destinationSectorId": int },

  // ARCHIPELAGO
  "memberClusterIds": [str],
  "crossWarpCount": int,
  "exitWarps": [{ "sourceSectorId": int, "destinationSectorId": int }]
}

Defaults: {} — only the keys relevant to the row's type are populated by the generator.

Constraints: - interior_size, length, entry_count, gateway_count, interior_size_min, entry_distance, bypass_distance, surprise_source_distance are positive integers. - link_tunnel_depth ≥ 0. - one_way_bias[0.0, 1.0]. - branching{"chain", "tree", "cyclic"}. - parent_kind{"open", "tunnel", "bubble"}. - recovery_method{"slipdrive", "distress", "scheduled_clear", "none"}. WARP_SINK rows set this to a non-"none" value unless they are deliberate traps. - target_formation_id for BACKDOOR rows references a SpecialFormation.id that exists in the same region. - entry_distance and surprise_source_distance are graph hops; min 1, max 32 (typical region diameter). - gateway_count for GOLD_BUBBLE[1, 3]. - exitWarp on LOST_SECTOR and LOST_CLUSTER is either null or an object — never an array. destinationSectorId must reference a sector in the main connected sub-graph (not inside any lost formation and not in fedspace). - exitWarps on ARCHIPELAGO is an array (may be empty); each entry's sourceSectorId must belong to a different member cluster (per ADR-0070). - interiorClusterId (LOST_CLUSTER) references a Cluster row whose sector set equals the formation's interior_sector_ids. memberClusterIds (ARCHIPELAGO) references 2–6 Cluster rows whose union of sector sets equals the formation's interior_sector_ids.

SpecialFormation.discovery_requirement

{
  "ship_class": str,
  "min_reputation": {"<faction_code>": int},
  "warp_jumper_required": bool,
  "min_player_rank": str
}

Defaults: null — most formations are discoverable on first visit and scan with no gating.

Constraints: All keys optional. The discovery service evaluates each present key against the visiting player; a missing key means the requirement does not apply. Used for GOLD_BUBBLE story sites and other operator-placed rare formations.


Station

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

Station.commodities

{
  "<commodity_key>": {
    "quantity": int,
    "capacity": int,
    "base_price": int,
    "current_price": int,
    "production_rate": int,
    "price_variance": int,
    "buys": bool,
    "sells": bool
  }
}

Defaults: Shown in source — eight commodity keys populated with class-appropriate defaults.

Constraints: quantity ≤ capacity. current_price is recomputed by the market service on each price-update tick.

Station.trader_personality

Drives haggling behavior for the station's NPC trader: difficulty, preferred persuasion approach, memory of past interactions, and per-player trust.

{
  "type": "Federation | Border | Frontier | Luxury | Black Market",
  "haggling_difficulty": int,
  "preferred_appeal_types": [str],
  "memory_duration_days": int,
  "trust_level": int,
  "quirks": [str]
}

Defaults: Populated at station creation from the personality archetype keyed off the station's class. Archetype defaults:

type haggling_difficulty preferred_appeal_types memory_duration_days
Federation 3 procedural, compliance 14
Border 5 economic, personal 30
Frontier 7 personal, risk 60
Luxury 8 cultural, aesthetic 21
Black Market 9 risk, discretion 90

trust_level defaults to 0; quirks defaults to [].

Constraints: - type must be one of the five archetypes above. - haggling_difficulty is in [1, 10]; higher values are harder to haggle against. - preferred_appeal_types entries are drawn from procedural, compliance, economic, personal, cultural, aesthetic, risk, discretion. A player using a preferred appeal type receives a +20% success modifier (see ../FEATURES/economy/haggling.md). - memory_duration_days is in [7, 90] and controls how long the station remembers a given player's haggling history. - trust_level is in [-1000, 1000], accumulated per player via repeated successful trades and eroded by failed haggling or hostile actions; high trust eases haggling difficulty. - quirks are free-form behavior modifiers consumed by the haggling resolver. Examples: "always_haggles_under_500_units", "responds_to_humor", "refuses_pirates", "prefers_carrier_owners".

Station.services

{
  "ship_dealer": bool,
  "ship_repair": bool,
  "ship_maintenance": bool,
  "ship_upgrades": bool,
  "insurance": bool,
  "drone_shop": bool,
  "genesis_dealer": bool,
  "mine_dealer": bool,
  "diplomatic_services": bool,
  "storage_rental": bool,
  "market_intelligence": bool,
  "refining_facility": bool,
  "luxury_amenities": bool
}

Defaults: Shown in source.

Station.defenses

{
  "defense_drones": int,
  "max_defense_drones": int,
  "auto_turrets": bool,
  "defense_grid": bool,
  "shield_strength": int,
  "patrol_ships": int,
  "military_contract": bool
}

Defaults: Shown in source.

Station.ownership

{
  "owner_id": str,
  "owner_type": "player | team | syndicate | faction",
  "acquired_at": iso8601,
  "acquisition_price": int,
  "acquisition_method": "purchase | trade_volume | faction_standing | takeover",
  "tariff_rate": float,
  "docking_fee": int,
  "service_charge_multiplier": float,
  "price_adjustment_pct": float,
  "preferred_traders": [str],
  "shareholders": [
    {
      "player_id": str,
      "stake_pct": float,
      "joined_at": iso8601,
      "voting_weight": float
    }
  ],
  "treasury_balance": int,
  "treasury_pending_payouts": int,
  "withdrawal_schedule": "monthly | quarterly | manual",
  "upgrade_levels": {
    "extended_storage": int,
    "market_intelligence": int,
    "automated_trading": int,
    "shipyard": int,
    "refining": int,
    "luxury_amenities": int,
    "defense_grid": int,
    "security_patrol": bool,
    "military_contract": iso8601
  },
  "lifetime_revenue": int,
  "monthly_revenue_history": [
    {
      "period": "YYYY-MM",
      "tariff_revenue": int,
      "docking_revenue": int,
      "service_revenue": int,
      "storage_revenue": int,
      "info_revenue": int,
      "maintenance_costs": int,
      "net_profit": int
    }
  ]
}

Defaults: null until first acquired. Field semantics:

  • tariff_rate — 0.02–0.08 (range 2%–8%); applied to every player buy/sell at the station.
  • docking_fee — 50–500 credits per docking; flat charge, owner-set.
  • service_charge_multiplier — 0.8–2.0 (×0.8 loss-leader to ×2.0 premium); applied to repair/refueling/drone/refining services.
  • price_adjustment_pct — −0.10 to +0.10 (−10% to +10%); owner can nudge buy and sell prices within this band, applied after the standard supply/demand formula.
  • shareholders — for owner_type = syndicate; max 10 stakeholders; sum of stake_pct ≤ 100; voting threshold 50% by default. See ../FEATURES/economy/port-ownership.md#syndicate.
  • withdrawal_schedule — when treasury auto-distributes profits to owner / shareholders.
  • upgrade_levels — integer level per upgrade type (Extended Storage L1-3, Market Intelligence L1-3, Auto Trading L1-3, Shipyard L1-3, Refining L1-3, Luxury Amenities L1-3, Defense Grid L1-3); booleans for Security Patrol (active subscription) and Military Contract (binding-until timestamp).
  • monthly_revenue_history — rolling 12-month accounting; older periods rolled into lifetime_revenue.

Status: — Station ownership ships only the 5-field stub at launch (owner_id, owner_type, acquired_at, acquisition_price, tariff_rate). The full shape above is the launch-target home for syndicate stakes, treasury accounting, upgrade levels, and price adjustments described in ../FEATURES/economy/port-ownership.md.

Station.acquisition_requirements

{
  "min_trade_volume": int,
  "min_faction_standing": str,
  "base_price": int
}

Defaults: Shown in source.

Station.price_modifiers, service_prices, active_events

price_modifiers and service_prices are owner-set {<key>: int} maps; default {}. active_events follows the same shape as Sector.active_events.


Reputation (Faction reputation cache)

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

Reputation.history (player-faction)

[
  {"delta": int, "reason": str, "at": iso8601, "source_event": str, "sector_id": "uuid | null"}
]

Defaults: []. Capped at the most-recent 100 entries. sector_id is the Sector.id where the triggering event occurred (combat, port transaction, sector-defense event); null for non-spatial events (admin grant, decay tick, server-initiated).

TeamReputation.faction_reputation

{"<faction_code>": int}

Defaults: {}.

TeamReputation.history

Same shape as Reputation.history.

TeamReputation.pending_notifications

[
  {"id": str, "type": str, "message": str, "created_at": iso8601}
]

Defaults: [].


Team

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

Team.join_requirements

{
  "min_player_rank": str,
  "min_personal_reputation": int,
  "required_faction_standing": {"<faction_code>": int},
  "invitation_only": bool
}

Defaults: {}.

Team.member_roles

{
  "<player_uuid>": {"role": str, "since": iso8601}
}

Defaults: {}.

Team.resource_sharing

{
  "auto_share_credits": bool,
  "auto_share_cargo": bool,
  "share_percent": int
}

Defaults: {}.

Team.invitation_codes

[
  {"code": str, "created_by": str, "created_at": iso8601, "expires_at": iso8601, "max_uses": int, "uses": int}
]

Defaults: [].

TeamMember.permissions

TeamMember has explicit boolean columns (can_invite, can_kick, can_manage_treasury, can_manage_alliances) for the canonical permission set; the permissions JSONB is a free-form extension surface for per-team overrides and future flags. Recommended shape mirrors the column names so role-resolution can fall back uniformly:

{
  "can_invite": bool,
  "can_kick": bool,
  "can_manage_treasury": bool,
  "can_manage_alliances": bool,
  "<custom_flag>": bool
}

Defaults: {}. Custom flags are scoped to the owning team and have no game-side meaning unless team-specific code reads them.

TeamMember.contribution_credits

{
  "credits_donated": int,
  "resources_donated": {"<commodity_key>": int},
  "ships_destroyed": int,
  "contracts_completed": int
}

Defaults: {}.


Other notable JSONB columns

  • Drone and DroneDeployment — see services/gameserver/src/models/drone.py. No JSONB columns; deployment state is fully relational.
  • Fleet.battle_log (JSON) — append-only log of round-by-round battle events. [] default.
  • Fleet.resources_looted (JSON) — {<commodity_key>: int}, default {}.
  • MarketTransaction — relational, no JSONB.
  • AIRecommendation.recommendation_data, AIRecommendation.contextual_data — opaque payload owned by the AI service. See services/gameserver/src/services/ai_trading_service.py for shape; treat as service-private.
  • ARIA* models — services/gameserver/src/models/aria_personal_intelligence.py. Multiple JSON columns documented inline in the model file; treat their shape as private to the ARIA service.
  • Region.language_pack, Region.aesthetic_theme, Region.traditions, Region.social_hierarchy, Region.trade_bonuses — see OPERATIONS/multi-regional.md. Each is owner-controlled metadata used to differentiate regions; the gameserver does not validate inner shapes.
  • RegionalElection.candidates, RegionalElection.results — election scaffolding documented in OPERATIONS/multi-regional.md.

Migrations

JSONB shape changes go through Alembic migrations under services/gameserver/alembic/versions/. Adding a key is non-breaking; renaming or removing a key requires a backfill step. Reads that depend on a key existing should default-fall-back rather than assume the row was migrated.