relay

package
v0.36.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 6, 2026 License: MIT Imports: 10 Imported by: 0

Documentation

Overview

Package relay is the multiplayer store (v0.27 S4, ADR 0034): a no-physics, no-clock report/subscribe hub. Sessions report their craft as messages; subscribers read the latest report per player and evaluate ghosts at their own sim-time. Everything crossing this interface is a plain serialisable value — the ssh-only MVP keeps the store in shared memory, and the v2 WebSocket layer must be a pure transport swap over these same messages (store discipline, ADR 0034 addendum).

Index

Constants

View Source
const Heartbeat = 5 * time.Second

Heartbeat is the maximum quiet interval (wall clock) between a session's reports. Change detection below catches every orbit change on the tick it happens, so the heartbeat's only job is bounding subspace-time staleness for the roster's Δt display — a coasting ghost's ORBIT is exact regardless (Kepler evaluation). 5s keeps Δt readable without meaningful traffic; a playtest may move it (v0.27 plan: tunable const with rationale).

Variables

This section is empty.

Functions

func CoWarpPeersFrom added in v0.28.0

func CoWarpPeersFrom(w *sim.World, reports []CraftReport, handles map[string]string, viewerFP string, live, away map[string]bool) []sim.CoWarpPeer

CoWarpPeersFrom adapts the store's reports into the sim-level co-warp input (v0.28 S1, ADR 0034 §5) — the relay-side twin of GhostsFor. Each remote craft's last-reported primary-relative state is Kepler-stepped across the subspace gap to the viewer's sim-time (forward OR backward), so range + |v_rel| against the viewer's active craft are geometrically exact for a coasting peer within the same-subspace tolerance. Gating per ADR 0015: only craft in the viewer's active system contribute; landed craft carry no orbit and are skipped. The peer's Owner/Handle, SubspaceTime, and reported EffWarp travel through so ComputeCoWarp can apply the same-subspace gate and the min-over-Effective clamp.

Honest staleness matches ghosts: KeplerStep neither sees a peer's burn after the report nor an SOI exit — but a burning peer reports every tick (elements change), so the propagation gap it feeds co-warp with is one tick, not one heartbeat.

live is the serve layer's session-liveness input: owner fingerprint → has a live session right now (attended or reprieved-away — an Away session is still simulating and still counts; that is the point of the Reprieve). It gates the RENDEZVOUS ARM fields only, not the peer (#252 review, finding 1): the store never scrubs reports, so a partner who disconnects for good leaves a frozen report with RendezvousTarget still set — an immortal arm that would hold the survivor's standing intent (and its 0×-hold / dead-orbit coast) forever. A rendezvous intent requires a live SESSION, not a live report; suppressing the arm here makes the sim's normal retract path fire, with its normal cancel chip. A live session's report gaps are unaffected — the arm rides through however stale the report is, so a silent reprieved partner is held for, never cancelled. A nil map means no owner is live (the safe default for callers with no liveness source).

away is the serve layer's per-owner Away verdict (#253): reports carry what a peer's WORLD is doing, not whether anyone is at its controls, so the caller supplies Server.isAway's answer and it rides the peer as standing state. Orthogonal to live — an away session is still live (that is the Reprieve), so its arm survives while Away marks it unattended. nil means nobody is away (solo / tests).

func DetectGuestContact added in v0.28.0

func DetectGuestContact(w *sim.World, coupled map[string]bool) (ghostOwner string, ghostCraftID uint64, ok bool)

DetectGuestContact returns a guest ghost the viewer's active craft has closed to within the docking gates, among owners the viewer is co-warp coupled to (coupled) — the cross-player analogue of checkDocking, which can't fire because a ghost isn't in the local slate. The viewer must not already be flying a cross-player stack or riding as a guest. ok is false when there's no contact. The serve layer turns a hit into a ledger Claim.

func GhostsFor

func GhostsFor(w *sim.World, reports []CraftReport, handles map[string]string) []sim.Ghost

GhostsFor evaluates every stored report against the viewer's world (v0.27 S5, ADR 0034): each remote craft's last-reported primary- relative state is propagated analytically (KeplerStep, forward OR backward) across the subspace gap to the viewer's sim-time, then rebased onto the primary's position at that time. Gating per ADR 0015: only craft in the viewer's active system appear. Landed craft carry no orbit and are skipped (roster surfaces still count them). handles joins owner fingerprints to display names.

Honest staleness: KeplerStep neither detects SOI exits nor knows about burns after the report — the ghost is "where they'd be if they kept coasting", exactly the ADR contract.

Types

type CraftReport

type CraftReport struct {
	Owner        string       `json:"owner"`
	SubspaceTime time.Time    `json:"subspace_time"`
	Crafts       []CraftState `json:"crafts"`

	// ActiveCraftID names which of Crafts the reporter is actually
	// flying (#288). Without it a consumer can only read a fixed slot,
	// which told partners a four-craft pilot was at the Sun for a whole
	// session because slot 0 held a heliocentric craft. Zero (omitted)
	// means "unmarked" — ActiveCraft falls back to the first slot, the
	// pre-#288 behaviour.
	ActiveCraftID uint64 `json:"active_craft_id,omitempty"`

	// EffWarp is the reporter's current Effective warp — the post-clamp
	// rate its World actually stepped this report (v0.28 S1, ADR 0034 §5).
	// Proximity co-warp reads it to take the min over coupled players, so
	// a partner's 10× burn cap propagates. A plain float, serialisable
	// like everything else crossing this interface (store discipline).
	EffWarp float64 `json:"eff_warp,omitempty"`

	// RendezvousTarget / RendezvousTau carry the reporter's outgoing
	// Rendezvous Warp intent (v0.29 S1, ADR 0034 v0.29 addendum): the
	// fingerprint they have Engaged toward and the committed encounter
	// sim-time. Empty when not armed. CoWarpPeersFrom turns a report whose
	// RendezvousTarget names the viewer into CoWarpPeer.ArmedTowardViewer,
	// so the mutual-arm couple trigger can fire; the responder reads the
	// τ to adopt the initiator's authoritative encounter time. Plain
	// serialisable values — store discipline preserved.
	RendezvousTarget string    `json:"rendezvous_target,omitempty"`
	RendezvousTau    time.Time `json:"rendezvous_tau,omitempty"`
	RendezvousCA     float64   `json:"rendezvous_ca,omitempty"` // committed predicted approach at τ (m) — the responder's adopted baseline

	// RendezvousInitiator / RendezvousRate / RendezvousBurning carry the
	// reporter's SEAT and its contribution to the pair's rate in a
	// rendezvous agreement's terminal phase (ADR 0037 §2). The initiator
	// publishes their selected warp, the copilot their brake, either folded
	// with their own burn cap; 0 means "this seat imposes no ceiling".
	//
	// The role must be unambiguous under reconnect, so it rides the wire
	// explicitly rather than being inferred from who Engaged first — a
	// reconnecting session rebuilds its arm from its own state, and two
	// sides guessing from report order could disagree about who is in
	// command. A peer that publishes neither bit (an older build) leaves
	// the seats unresolved, and the pair keeps min-wins.
	//
	// RendezvousRate is a SELECTION, never a post-clamp rate: the receiving
	// side's own rate is derived from it, so relaying a derived value back
	// would close the #248 loop and ratchet the pair to 1×.
	RendezvousInitiator bool    `json:"rendezvous_initiator,omitempty"`
	RendezvousRate      float64 `json:"rendezvous_rate,omitempty"`
	RendezvousBurning   bool    `json:"rendezvous_burning,omitempty"`

	// Paused marks a deliberately paused reporter (Clock.Paused), as
	// opposed to an EffWarp of 0 from a hold or clamp — the rendezvous
	// hold-the-leader logic keys on it (v0.29 review).
	Paused bool `json:"paused,omitempty"`
}

CraftReport is one player's full craft set at a moment of their subspace time — set-replace semantics, so a vanished craft (staged away, ended flight) disappears by omission. Identity is the ssh key fingerprint; handles live in the session roster (sessiondir) and are joined by the UI, not duplicated onto the wire.

func (CraftReport) ActiveCraft added in v0.32.4

func (r CraftReport) ActiveCraft() (CraftState, bool)

ActiveCraft returns the craft the reporter is flying — the one a partner means by "where are they" (#288) and the one any verb aimed at the player acts through. Falls back to the first slot when the report carries no marker or names a craft that has since left the set, so an unmarked report reads exactly as it did before the marker existed. ok=false only for a genuinely empty slate.

type CraftState

type CraftState struct {
	ID      uint64       `json:"id"`
	Name    string       `json:"name"`
	Glyph   string       `json:"glyph,omitempty"`
	System  string       `json:"system"`
	Primary string       `json:"primary"`
	R       orbital.Vec3 `json:"r"`
	V       orbital.Vec3 `json:"v"`
	Landed  bool         `json:"landed,omitempty"`
}

CraftState is one vessel on the wire: primary-relative state vector at the owner's subspace time (the exact representation the save envelope uses, and what physics.KeplerStep propagates for ghost evaluation), plus the addressing a viewer needs (system, SOI primary) and display identity. Landed craft carry no meaningful orbit — flagged so renderers skip them while rosters still count them.

type DockChip added in v0.28.0

type DockChip struct {
	Kind   sim.SessionEventKind
	Handle string
	// Detail is the reason, in the player's words, for the kinds that render
	// one verbatim (the refusal chips). Empty on every other moment.
	Detail string
}

DockChip is a moment the reconcile surfaces for the caller to turn into a session chip (docked / undocked / control transfer).

type DockLedger added in v0.28.0

type DockLedger struct {
	// contains filtered or unexported fields
}

DockLedger is the shared, in-process ledger of live cross-player docks. Every mutation takes the lock; a v2 wire keeps the same call surface with the store behind it.

func NewDockLedger added in v0.28.0

func NewDockLedger() *DockLedger

NewDockLedger builds an empty ledger.

func (*DockLedger) ActiveGuestDock added in v0.28.0

func (l *DockLedger) ActiveGuestDock(fp string) (*DockRecord, bool)

ActiveGuestDock returns the active record in which fp is the guest, if any — the tui reads it to route the Undock key to RequestUndock and to show the docked-as-guest status.

func (*DockLedger) Claim added in v0.28.0

func (l *DockLedger) Claim(owner, ownerHandle string, dockerCraftID uint64, guestOwner, guestHandle string, guestCraftID uint64) (*DockRecord, bool)

Claim opens a cross-player dock: the docker (owner) claims a guest craft it has closed on. Refused (ok=false) when either craft is already engaged in a dock — the guard that keeps a simultaneous mutual approach from opening two crossed records (the ledger mutex serialises, so the first writer wins; the passive-station MVP posture means only one side is actively claiming) — or when this exact PAIR is still holding an ADR 0038 §5 re-arm latch.

The two guards are deliberately different widths (#326). An engaged record (Pending/Active) means a craft is party to a live dock, and it must refuse on EITHER endpoint: a craft already fused into someone's stack cannot also be claimed by a third player. A Cooldown record is not a dock at all — it is a statement about ONE pair ("you two backed apart yet?") — so it may only ever refuse that same pair. Refusing on either endpoint made one couple's latch disable docking for both of their craft against everybody, which is how a vessel ended up permanently un-dockable.

func (*DockLedger) FullRecords added in v0.33.0

func (l *DockLedger) FullRecords() []DockSnapshot

FullRecords snapshots every live dock in full, converting the parked craft under the ledger lock. Records() remains the identity-only view for callers that only want the cross-ref.

func (*DockLedger) GrantReclaim added in v0.33.0

func (l *DockLedger) GrantReclaim(recordID uint64, guestOwner string, stack *spacecraft.Spacecraft, capturedAtNano int64) bool

GrantReclaim completes an empty-seat reclaim: the migrating stack (lifted out of the absent owner's payload by the caller) is parked for delivery, the ownership tags are flipped, and the roles swap so the reclaimer owns the dock and the absent player becomes its guest. The returning owner is owed an explanation, which rides the record until they connect.

capturedAtNano is the absent owner's sim-time when their payload was loaded — the caller's w.Clock.SimTime at that moment (review finding on ADR 0040 §4). reconcileOwner uses it to Kepler-advance the stack to the reclaimer's current sim-time before adopting it, so it doesn't arrive however many hours stale the owner's absence made the payload.

ok is false when the record moved under the caller — a reclaim that raced something else must not be applied to whatever the record became.

func (*DockLedger) IsGuest added in v0.28.0

func (l *DockLedger) IsGuest(fp string) bool

IsGuest reports whether fp is the guest in any active cross-player dock — the source for the Session roster's Docked-as-Guest marker (v0.28 S5).

func (*DockLedger) ReclaimTarget added in v0.33.0

func (l *DockLedger) ReclaimTarget(guestOwner string, live func(string) bool) (DockRecord, string)

ReclaimTarget resolves the dock a guest is asking to take control of from an empty seat (ADR 0040 §4), returning the record as it stands plus a player-facing refusal ("" when the reclaim may go ahead).

The rule is the mirror of §2 and deliberately asymmetric: GIVING someone the stick needs a live recipient, because delivery runs on their tick; TAKING it back from a seat nobody is sitting in needs nobody's permission. Making the ask wait durably for the owner instead would be #312's stranding wearing a different hat — a player riding a stack they cannot fly and cannot leave, for as long as the other person stays away.

Resolving and granting are separate calls because the migration in between is not the ledger's to do: the stack is sitting in the absent owner's persisted payload, which only the serve layer can open.

func (*DockLedger) Reconcile added in v0.28.0

func (l *DockLedger) Reconcile(w *sim.World, owner string, reports map[string]CraftReport) []DockChip

Reconcile advances every dock touching owner against owner's World w for one tick, moving craft across the World seam through the ledger payloads, and returns any chips to surface. reports supplies the current per-owner CraftReport (for the guest's warp coupling to the stack owner). w.DockGuest is rebuilt each call — set when this player is Docked-as-Guest, nil otherwise.

func (*DockLedger) RecordCount added in v0.35.1

func (l *DockLedger) RecordCount() int

RecordCount is how many docks the ledger currently holds. The serve layer samples it either side of a Reconcile to notice a record ENDING (#326): a re-arm latch clearing, or any other teardown, produces no chip, no craft movement and no successful Claim, so without this the flush that persists it never fires and the record comes back from disk on the next restart.

func (*DockLedger) Records added in v0.28.0

func (l *DockLedger) Records() []DockRecord

Records returns a durable-field snapshot of every live dock — the session directory persists this as the reconnect cross-ref.

func (*DockLedger) RequestRelease added in v0.33.0

func (l *DockLedger) RequestRelease(owner string, live func(string) bool) (bool, string)

RequestRelease is the docker-side release (ADR 0040 §3): the owner of a cross-player stack asks for the guest's component to be handed back, and the answer does not depend on the guest being there. Before this, the only path out of a composite was guest-initiated, so a guest who disconnected while docked stranded the docker indefinitely — no in-game recourse, and on 2026-08-02 recovery took a server-side --reset-fleet (#312).

live reports session liveness. A live guest gets the ordinary handback on the owner's next reconcile; an absent one gets a Parcel: safed, placed across the subspace gap, and delivered with an explanation when they next connect. Deciding which at ASK time (rather than at reconcile) keeps the ledger's own reconcile free of session knowledge; a guest who reconnects in the gap simply receives a Parcel that is also correct.

ok is false with the player-facing reason. The structural refusal — the guest's components sitting under the owner's after a control transfer (#314, ADR 0040 §5) — is decided against the World by GuestReleaseRefusal at the seat, which is where the composite can actually be inspected.

func (*DockLedger) RequestTransfer added in v0.28.0

func (l *DockLedger) RequestTransfer(owner string, live func(string) bool) (bool, string)

RequestTransfer flags the owner's active stack for a control handover to the guest (2-party: the recipient is unambiguous — ADR 0034 addendum). The docker's next reconcile migrates the stack unless it's mid-burn (refused, retried). live reports whether a fingerprint has a live Session.

ok is false with the player-facing reason from TransferRefusal, which it consults rather than re-deriving — the two cannot drift, so a refused [J] can never be silent (#308's lesson at the second cross-player verb).

func (*DockLedger) RequestUndock added in v0.28.0

func (l *DockLedger) RequestUndock(guestOwner string, guestCraftID uint64) bool

RequestUndock flags the guest's active dock for a split (guest-initiated, any time — ADR 0034 §6). The docker's next reconcile performs the actual UndockGuest and hands the craft back. ok is false when no active dock matches (nothing to undock).

func (*DockLedger) RestoreRecord added in v0.33.0

func (l *DockLedger) RestoreRecord(recordID uint64, snapshot DockRecord) bool

RestoreRecord overwrites the record at recordID with snapshot verbatim — the undo side of GrantReclaim, used when the migration's persist fails after the grant already landed in memory (ADR 0040 §4 review). ok is false when the record is no longer there to restore (it moved or ended under the caller); the caller owns deciding what that means.

func (*DockLedger) Seed added in v0.28.0

func (l *DockLedger) Seed(records []DockRecord)

Seed installs durable records (from the session directory on server start) so a dock that outlived a restart resumes. Only the durable fields are carried; the in-flight payload handoffs were transient and are gone.

func (*DockLedger) SeedFull added in v0.33.0

func (l *DockLedger) SeedFull(snaps []DockSnapshot, systems []bodies.System)

SeedFull installs full records (from the session directory on server start) so a dock — and anything it was mid-way through delivering — resumes. A payload that can't be rehydrated against the loaded catalog is dropped rather than failing the whole restore: losing one craft is bad, refusing to start the session is worse, and the #309 reaper still bounds the damage.

func (*DockLedger) TransferRefusal added in v0.33.0

func (l *DockLedger) TransferRefusal(owner string, live func(string) bool) string

TransferRefusal says, in the player's words, why RequestTransfer(owner) will refuse — or "" when the stack will change hands. Exhaustive over the synchronous refusals by construction (RequestTransfer consults it); the mid-burn refusal is deliberately NOT here, because it is a wait rather than a no — the reconcile retries it every tick until the burn ends.

type DockPhase added in v0.28.0

type DockPhase int

DockPhase is a cross-player dock's lifecycle stage.

const (
	// DockPending: the docker has claimed a guest craft; the guest hasn't
	// yet handed its craft over (first the guest's tick removes it from its
	// World and parks it on the record, then the docker's tick fuses it).
	DockPending DockPhase = iota
	// DockActive: the guest craft is fused into the docker's stack and the
	// ride is live — the guest is Docked-as-Guest, warp-coupled to the stack.
	DockActive
	// DockCooldown: the pair just undocked (a live handback, not a Parcel)
	// and the record is held open purely as the ADR 0038 §5 re-arm-by-
	// leaving latch — Claim refuses a fresh dock between this SAME pair of
	// craft (isPair, both endpoints — never a third party, #326) until
	// reconcileCooldown observes them past ReArmDistM, or sim.ReArmCeiling of
	// sim-time passes with the range unreadable, and drops the record.
	// Nothing else reads a Cooldown record: RequestUndock/RequestRelease/
	// RequestTransfer/ReclaimTarget all gate on DockActive, so a cooling-down
	// pair is invisible to every other verb.
	DockCooldown
)

type DockRecord added in v0.28.0

type DockRecord struct {
	ID            uint64
	Owner         string // current stack owner fingerprint (flips on transfer)
	OwnerHandle   string
	DockerCraftID uint64 // the owner's craft that leads the fused stack
	CompositeID   uint64 // the fused stack's craft ID in the owner's World (0 until fused)
	GuestOwner    string // the guest player fingerprint
	GuestHandle   string
	GuestCraftID  uint64 // the guest's craft riding in the stack (returned on undock)
	Phase         DockPhase
	// contains filtered or unexported fields
}

DockRecord is one cross-player dock. The exported fields are the cross-ref (owner, composite/guest IDs, phase); the unexported fields are the in-flight handoffs consumed on the reconciling side's tick. ADR 0040: BOTH halves are durable now — FullRecords/SeedFull round-trip the payloads and request flags through the session directory, so a handover in flight when the server restarts completes on the recipient's next connect instead of destroying the craft (#311). Nothing on a record is transient any more.

type DockSnapshot added in v0.33.0

type DockSnapshot struct {
	ID            uint64
	Owner         string
	OwnerHandle   string
	DockerCraftID uint64
	CompositeID   uint64
	GuestOwner    string
	GuestHandle   string
	GuestCraftID  uint64
	Phase         DockPhase

	// In-flight handoffs. Nil payloads / false flags are the common case
	// (a settled dock); a non-nil payload is a craft that exists nowhere
	// else and must be delivered.
	GuestPayload    *save.Craft
	ReturnPayload   *save.Craft
	TransferPayload *save.Craft
	UndockAsk       bool
	UndockRefused   bool
	TransferTo      string
	Aborted         bool
	ReleaseAsk      bool
	ReleaseAsParcel bool
	Parcel          bool
	ParcelAtNano    int64
	ReclaimNotice   bool
	ReclaimAtNano   int64
	// DockNotice (ADR 0038 S1) is the absorbed guest's pending "you just got
	// docked" chip, owed at fuse — round-tripped so a restart between the
	// fuse and the guest's next tick doesn't drop it.
	DockNotice bool
	// ReturnAtNano (ADR 0038 S2) is the release-time stamp a live undock's
	// subspace-gap placement reads at delivery, generalising ParcelAtNano to
	// every return, not just Parcels.
	ReturnAtNano int64
}

DockSnapshot is one dock record in full, serialisable form — the durable cross-ref plus every in-flight handoff. The session directory persists these; a restart seeds them straight back and delivery resumes where it stopped.

type Reporter

type Reporter struct {
	Owner string
	// contains filtered or unexported fields
}

Reporter watches one session's World and reports its craft set on element-changing events (burn end, staging, SOI transition — all of which move the derived elements) plus the heartbeat. It carries no goroutine: the session's own tick loop drives Tick, so reports happen at tick boundaries with no cross-goroutine World access.

func NewReporter

func NewReporter(store *Store, owner string) *Reporter

func (*Reporter) Tick

func (r *Reporter) Tick(w *sim.World, now time.Time)

Tick inspects the world and reports if anything orbit-shaped changed or the heartbeat elapsed. now is wall clock (heartbeat cadence must not warp with sim time).

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store holds the latest report per owner and fans new reports out to subscribers. It never inspects craft physics and holds no clock of its own (ADR 0034: the server stores and relays, nothing else).

func NewStore

func NewStore() *Store

func (*Store) Frontier

func (s *Store) Frontier() (time.Time, bool)

Frontier is the maximum subspace time across every stored report — where a new player joins (you can never start in someone's past, ADR 0034). ok is false while the store is empty.

func (*Store) Report

func (s *Store) Report(r CraftReport)

Report replaces the owner's craft set and notifies subscribers. A subscriber that has fallen behind misses intermediate reports, not the latest state — Snapshot always has that.

func (*Store) Snapshot

func (s *Store) Snapshot(excludeOwner string) []CraftReport

Snapshot returns the latest report per owner, excluding one (a viewer never ghosts itself). Reports are copied; callers may hold them across frames.

func (*Store) Subscribe

func (s *Store) Subscribe() (<-chan CraftReport, func())

Subscribe returns a channel of future reports and a cancel func. The channel is buffered; a subscriber that stalls drops messages rather than blocking reporters.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL