Skip to content

Latest commit

 

History

History
291 lines (194 loc) · 23.1 KB

File metadata and controls

291 lines (194 loc) · 23.1 KB
title Integration Notes: Live Data Shapes
description Hard-won notes for building against the live chain and Guild API: numeric fields arrive as JSON strings, event detail has two shapes, and more.

Integration Notes: Live Data Shapes

Purpose: Hard-won, verified notes for anyone building against the live Structs chain and Guild API (bots, MCP servers, dashboards). These are the data-shape and endpoint details that cause silent integration bugs. Each note is tagged with where it was verified.

Scope: this page is about the game API / proto / data shapes. For the proof-of-work mechanism see hashing.md; for combat math see combat.md.


Live Endpoints (public testnet)

The public testnet exposes the standard Cosmos surfaces over TLS. There is no port 1317 on the public host — that port is local-devnet only.

Surface URL Notes
REST (LCD) https://public.testnet.structs.network Standard HTTPS, no port. http://...:1317 is dead (connection refused).
Tendermint RPC https://public.testnet.structs.network:26657 Block, tx, status
gRPC public.testnet.structs.network:9090 If exposed for your tooling
Local devnet LCD http://localhost:1317 Only when you run your own node (see local-devnet.md)

Caveat — a 501 is often a wrong URL, not an unimplemented query. The gRPC-gateway returns gRPC code 12 ("Not Implemented") for routes that do not match a registered annotation. Verified on both local LCD (http://localhost:1317) and public testnet:

Wrong (501) Correct (200)
/structs/params /structs/structs/params (the only route that keeps the doubled structs)
/structs/structs/struct_type/{id} /structs/struct_type/{id}
/structs/guild_rank_permission_by_object/{objectId} /structs/guild_rank_permission/object/{object_id}
/structs/guild_rank_permission_by_object_and_guild/{objectId}/{guildId} /structs/guild_rank_permission/object/{object_id}/guild/{guild_id}
/structs/player_halted (no such query — removed from the module)

Authoritative path list: option (google.api.http).get annotations in .references/structsd/proto/structs/structs/query.proto. When an LCD route 501s, check that file before assuming the feature is missing. Fall back to the CLI (structsd query structs ...) or the Guild Stack PostgreSQL mirror if the annotation really is absent.

The query docs under api/queries/ use http://localhost:1317 as a generic base — substitute the live HTTPS base above for testnet.


Guild API: numeric fields are JSON strings

The Guild API serializes numeric fields as JSON strings, not numbers. Naive numeric use (a + b, comparisons) silently misbehaves.

{ "health": "6", "last_action_block_height": "1337217", "ore": "5", "load": "25000", "capacity": "1000000" }

Always coerce: parseInt(x, 10) for counts/heights, and BigInt for precise energy/capacity values (the client divides load/capacity/structs_load by an energy precision factor). Verified in webapp src/js/factories/PlayerFactory.js (Number(BigInt(numberString) / BigInt(PRECISION))) and src/js/api/GuildAPI.js (parseInt(...) on block heights and counts).

Blanket rule: treat every Guild API numeric as a string at the wire and convert explicitly.

Note this also applies to chain LCD responses: protobuf JSON serializes uint64 fields as strings (e.g. address permissions below comes back as "1").


Event detail has two representations

The same activity event reaches you in two different encodings depending on the transport:

Source detail encoding How to read
Guild API planet-activity catalog (/all, /planet/, /category/) JSON-encoded string JSON.parse(row.detail) before use
Guild API GET /api/planet-activity/player/{id}/page/{n} string and object row.detail is still the string; row.detail_json is the parsed object (or null)
NATS / GRASS realtime stream already-parsed object use message.detail.* directly

Verified: GRASS frames are parsed once via message.json() in webapp src/js/framework/GrassManager.js, and listeners access messageData.detail.* as an object. The REST planet-activity rows come straight from a PostgreSQL detail column and arrive as a JSON string. An integrator consuming both must branch on the source.

GRASS grid/planet subjects carry the owner player_id (added 2026-07-07). Subjects are structs.grid.{object_type}.{object_id}.{player_id} and structs.planet.{planet_id}.{player_id} (literal noPlayer when the owner can't be resolved), and every such payload includes a top-level player_id field — so you can filter by owner from the subject without parsing. Because NATS * matches exactly one token, subscribe with the trailing wildcard: structs.planet.{planet_id}.* (one planet) or structs.planet.> / structs.grid.> (all). A bare structs.planet.* matches nothing now. Verified in structs-pg deploy/trigger-grass-planet-activity-20260707-add-owner-player-id.sql and deploy/trigger-grass-grid-20260707-add-owner-player-id.sql; the webapp listener matches the suffixed subjects in src/js/framework/GrassManager.js.

There is also a field-casing split inside detail: most struct events use snake_case keys (struct_id, defender_struct_id), but struct_attack uses camelCase (see next section).


struct_attack event detail schema

Attacker context is flat at the top of detail; the per-shot outcomes are in a nested array eventAttackShotDetail[]. (Verified against webapp src/js/ts/structs.structs/types/structs/structs/events.ts EventAttackDetail / EventAttackShotDetail.)

// detail (flat attacker block)
{
  "attackerStructId": "5-1",
  "attackerStructType": 2,
  "attackerStructOperatingAmbit": 4,        // ambit ENUM (space=4) — see Ambit note below
  "weaponSystem": 0,
  "weaponControl": 1,
  "recoilDamage": 0,
  "recoilDamageToAttacker": false,
  "planetaryDefenseCannonDamage": 0,
  "attackerPlayerId": "1-11",
  "targetPlayerId": "1-22",
  "eventAttackShotDetail": [                 // nested: one entry PER PROJECTILE
    {
      "targetStructId": "5-3",
      "targetStructType": 3,
      "evaded": false,
      "evadedCause": 0,
      "blocked": false,
      "blockedByStructId": "",
      "damageDealt": 2,
      "damage": 2,
      "damageReduction": 0,
      "targetDestroyed": false,
      "targetCountered": false,
      "targetCounteredDamage": 0
    }
  ]
}

Notes:

  • Chain EventAttack does include health on the proto: attackerHealthBefore / After / Max on eventAttackDetail, and per-shot targetHealth* / blockerHealth* on eventAttackShotDetail. Catalog: chain-events.md.
  • The Guild API / GRASS activity feed may still runtime-enrich the same names for animation. Health values may arrive as strings (coerce).
  • Evasion is per-target (the whole volley); hit/miss is per-projectile (per eventAttackShotDetail entry).
  • A struct counters at most once per struct-attack invocation; defender counters nest under eventAttackDefenderCounterDetail[] inside each shot.

See combat.md for the resolution order, chain-events.md for the Tendermint event, and api/streaming/event-schemas.md for the GRASS catalog.


Where struct HP and status live

Struct health and the numeric status bitmask are NOT on the base struct row. They live in struct_attribute rows and are only joined by some endpoints.

Source Returns health? Returns numeric status?
Guild API catalog GET /api/struct/list/{all|owner|location}/... No No
Guild API bespoke GET /api/struct/player/{id}, GET /api/struct/{id} Yes (joined, default 0) Yes (joined; missing attribute is 32 if is_destroyed, else 0)
Chain LCD struct entity (GET /structs/struct/{id}) Yes (structAttributes.health) Yes (status)

The catalog list endpoints return only base columns: id, index, type, creator, owner, location_type, location_id, operating_ambit, slot, is_destroyed, destroyed_block, created_at, updated_at (verified in webapp TableReadManager::structListAll/ByOwner/ByLocation). To get HP, built-state, or the build clock you must use a bespoke endpoint or the chain entity, where structAttributes exposes health, isBuilt, blockStartBuild, and status.

The numeric status is a StructState bit-flag, not an enum. Decode it with the canonical table in building.md — Status field (numeric) (Online = status & 4, Destroyed = status & 32; e.g. 35 is a destroyed struct). The catalog list's is_destroyed boolean is the only destruction signal on the base row. Bespoke reads keep recently destroyed structs until STRUCT_SWEEP_DELAY elapses.

See api/webapp/struct.md for full response shapes.


/structs/address/{addr} response shape

The chain LCD address query returns a flat object with playerId top-level (camelCase) — not nested under an Address wrapper:

{ "address": "structs1...", "playerId": "1-11", "permissions": "1" }

Verified in proto/structs/structs/query.proto (QueryAddressResponse { string address; string playerId; uint64 permissions; }, route /structs/address/{address}). permissions is a uint64 → serialized as a string. A common polling bug is looking for the player id under a nested key; it is top-level.


List queries omit grid attributes (single-entity queries do not)

Every live number about an object — capacity, load, ore, power, lastAction — is a grid attribute, stored separately from the object record. The single-entity query joins them; the *All list query does not.

// single: record + grid attributes
gridAttributes := k.GetGridAttributesByObject(ctx, req.Id)
return &types.QueryGetAllocationResponse{Allocation: allocation, GridAttributes: &gridAttributes}, nil

// *All: bare records only
return &types.QueryAllAllocationResponse{Allocation: allocations, Pagination: pageRes}, nil

Verified in x/structs/keeper/query_*.go for player, struct, substation, planet, reactor, provider, and allocation: GetGridAttributesByObject appears in the single-entity handler and in no *All handler.

The practical consequences:

  • allocation-all never reports power. Allocation power is a grid attribute, so a list row looks like a zero-power allocation. Fetch the individual allocation for its real value. (This is why Structs Desktop's guild_power.rs::find_dynamic_allocation lists to find the ID, then GETs it.)
  • You cannot read charge from player-all. Charge is derived from the lastAction grid attribute — see Charge is per-player.
  • Do not build a scan-and-decide loop on list output. Listing is for discovering IDs; commit only after a single-entity read. For galaxy-wide numeric scans use the Guild Stack, where grid attributes are already projected into columns — see database-schema.md.

Compounding this: grid attributes are uint64 with omitempty, so a zero-valued attribute is absent from the JSON entirely. Treat a missing key as 0 rather than as an error or an unsupported field.


Field-name traps

  • The rate infix. Weapon shot-success fields are primaryWeaponShotSuccessRate{Numerator,Denominator} / secondaryWeaponShotSuccessRate{Numerator,Denominator} — note Rate in the middle. Mis-keying as primaryWeaponShotSuccess{Numerator,Denominator} reads undefined/null and silently zeroes your combat math. Verified in proto/structs/structs/struct.proto (fields 22/23, 34/35).
  • Casing varies by source. The chain LCD/CLI proto JSON uses camelCase (primaryWeaponShotSuccessRateNumerator). The Guild Stack PostgreSQL / Guild API uses snake_case column names (primary_weapon_shot_success_rate_numerator). Key your parser to the source you are reading.
  • Guaranteed shots is omitempty. primaryWeaponGuaranteedShots / secondaryWeaponGuaranteedShots exist on StructType (fields 67/68) but are 0 for every type except the Starfighter secondary (= 1), and zero values are omitted from JSON. A struct_type payload with no guaranteed-shots key does not mean the field was removed — it means the value is 0. See combat.md.

Ambit: enum vs reach bitmask

There are two different ambit numbering schemes; conflating them produces an invalid int32 error on build.

Scheme Values Used by
Enum none=0, water=1, land=2, air=3, space=4, local=5 Transaction messages: MsgStructBuildInitiate.operatingAmbit, MsgStructMove.ambit; a struct's stored operatingAmbit
Reach bitmask none=1, water=2, land=4, air=8, space=16, local=32 StructType.possibleAmbit, primaryWeaponAmbits, secondaryWeaponAmbits (weapon reach masks)

When you build or move, pass the enum (the CLI accepts the lowercase name space|air|land|water, which maps to enum 4/3/2/1). The bitmask values (2/4/8/16) are only for interpreting possibleAmbit and weapon-reach fields. Verified in proto/structs/structs/keys.proto (enum) and x/structs/types/keys.go (Ambit_flag bitmask). See building.md.


Charge is not a pool

charge = currentBlock - lastActionBlock, per-player, and any charge-consuming action resets it to 0. The per-action "cost" is a minimum threshold, not a balance you draw down or bank. You cannot stockpile charge to burst several expensive actions; idling past your next action's cost gains nothing. Plan combat as single actions spaced ~1 block per charge apart. A dashboard "Charge: N" is the current currentBlock - lastActionBlock, not a wallet. See building.md.


capacity_exceeded covers two different build checks

struct-build-initiate raises one error string — cannot handle new load requirements (required: X, available: Y) (structured error key capacity_exceeded) — for two unrelated gates. The numbers tell them apart:

required / available look like Gate Meaning
Tiny integers, often equal (e.g. 1 / 1) Per-player build limit required is the struct type's build limit, available is how many you already own. Most planet structs and the Command Ship cap at 1; only Orbital Shield Generator, Ore Bunker, and fleet combat structs stack.
Large values (hundreds of thousands to millions) Power capacity (milliwatts) required is the struct's BuildDraw, available is remaining capacity (capacity + capacitySecondary) - (load + structsLoad).

Do not assume the error is always about power — a 1/1 is the build-count limit, not a power shortage. Verified in x/structs/keeper/msg_server_struct_build_initiate.go (build-count and CanSupportLoadAddition checks) and x/structs/types/errors_structured.go (capacity_exceeded). See building.md.


Proxy signup is idempotent

The guild proxy signup flow (sign GUILD{id}ADDRESS{addr}NONCE0 → POST /api/auth/signup → guild fronts MsgGuildMembershipJoinProxy → poll /structs/address/{addr} for the player id) is safe to re-run. Re-running for an address that already joined returns {resource_already_exists}. Treat this as success (adopt the existing player) rather than a hard failure. See structs-onboarding.


Guild API auth scope (public vs authenticated)

Almost everything under /api/ requires an authenticated session (the PlayerAuthenticator checks player_id in the PHP session; missing → 401 {"authentication_error":"Login required"}). Only these prefixes are public (firewall security: false), verified in webapp config/packages/security.yaml:

Public prefix Purpose
/api/auth/ signup, login, logout, and the two /api/auth/player-address* onboarding routes
/api/guild/this This guild's metadata
/api/timestamp Server time
/api/setting Live tunables

Correction to a common assumption: struct reads and the planet-activity feed are not public — /api/struct/list/..., /api/struct/{id}, /api/planet-activity/..., etc. all require a session. Authenticate first (see api/webapp/auth.md) before any catalog or bespoke read.


Energy and grid data shapes

Power/grid integrations trip on where the numbers live and how the messages are shaped.

  • Power fields live in a gridAttributes sub-object, not the entity wrapper. For player, substation, and reactor LCD entities, capacity, load, structsLoad, connectionCapacity, and connectionCount are under gridAttributes. There is no capacitySecondary key on the wire — secondary capacity is the connected substation’s connectionCapacity (see energy.md). Top-level wrapper fields are the non-grid ones — reactor defaultCommission and validator, and owner on Reactor/Substation/Guild.
  • LCD numerics are JSON strings ("1512960000"), including everything in gridAttributes. Parse before doing math — a unit is milliwatts (see energy.md — Units).
  • guild entity carries the power entry points: primaryReactorId (field 9) and entrySubstationId (field 10) — start here to resolve a player's power infrastructure. Verified in proto/structs/structs/guild.proto.
  • Build-state is LCD-only. The Guild API struct list exposes location_type, operating_ambit, slot, is_destroyed, type/type_name but not isBuilt/blockStartBuild — read the chain struct entity (structAttributes) for those (see "Where struct HP and status live" above).

Message field shapes (verified in proto/structs/structs/tx.proto):

Message Fields Gotcha
MsgReactorInfuse creator, delegatorAddress, validatorAddress, amount amount is a single Coin (not a list); infusing is a delegation to the reactor's validator address.
MsgAllocationCreate creator, controller, sourceObjectId, allocationType, power No destinationId — the destination is set later via connect.
MsgAllocationUpdate creator, allocationId, power Only dynamic allocations are updatable. Growing an existing allocation releases its own current power before the capacity check — an increase on a live allocation no longer false-errors as capacity_exceeded (create-vs-update asymmetry, allocation_cache.go SetDynamicPower).
MsgSubstationAllocationConnect creator, allocationId, destinationId The substation id goes in destinationId — there is no substationId field.

See energy.md for the grid mechanics behind these shapes.


struct_type DB field shapes

Two struct_type shape changes in the Guild Stack (structs-pg) that tools read:

  • generating_rate is a formatted column. The raw chain value now lives in generating_rate_p, and generating_rate is a generated column equal to generating_rate_p * 1000 (same _p/formatted split as ledger/infusion precision columns — not structs.grid, which stores a single val). For the raw per-gram rate the gameplay docs quote (Field Generator 2, Continental Power Plant 5, World Engine 10), read generating_rate_p; generating_rate is the scaled representation. Verified in structs-pg deploy/table-struct-type-20260602-add-generating-rate-precision.sql.
  • Armour-piercing is explicit booleans. struct_type exposes primary_weapon_armour_piercing and secondary_weapon_armour_piercing (chain StructType.primaryWeaponArmourPiercing / secondaryWeaponArmourPiercing, structsd v0.18.0) — read these rather than inferring from the struct class. Only the Battleship sets them today. Verified in structs-pg deploy/table-struct-type-20260612-add-armour-piercing.sql. See combat.md.

Player UGC: PFP is a first-class attribute

Profile pictures are a moderated player UGC attribute alongside username, and the pending-player signup flow now threads pfp render hints too. PLAYER_PENDING_JOIN_PROXY builds the signed guild-membership-join-proxy ugc argument from up to three keys: player-name (from username), player-pfp (from pfp), and player-pfp-client-render-attributes (from the pfp_client_render_attributes column — the column was renamed from pfp_cr_attributes, and the ugc key was lengthened from the short player-pfp-cr-attributes form to match). Committed username/pfp surface on structs.player via GRASS player_consensus. Verified in structs-pg deploy/trigger-player-pending-20260612-pfp-cr-attributes-inclusion.sql, …-20260617-rename-pfp-cr-attributes.sql, …-20260617-ugc-key-long-name.sql. The pfpClientRenderAttributes field is self-service (not guild-moderatable); moderation of name/pfp follows the ugc_moderated path — see ugc-moderation.md.


Permission-denied errors always say "administrate"

Every permission failure renders the same string regardless of which flag was actually checked:

...calling player (1-1) has no administrate permission on object (1-61)...

The word "administrate" is hardcoded — it is a fixed literal passed to NewPermissionError on every denial path, not a report of the bit that failed (verified: PermissionCheck in x/structs/keeper/permissions_context.go passes "administrate" in all four return-error branches). So an "administrate" error does not mean PermAdmin (2) was required. To find the real missing bit, map the failing message handler to its required permission via the handler permission table. Example: a substation-player-migrate denial on a player object is a missing PermSubstationConnection (1024) on that player, not PermAdmin — see energy.md.


See Also