Documentation
¶
Overview ¶
Package sessiondir is the server-side session directory for multiplayer (v0.27 S3, ADR 0034): a versioned session.json holding the player roster, outstanding invite codes, and the body-catalog hash, plus one save envelope per enrolled player (current save.SchemaVersion). The local single-player save.json and saves/ directory are never touched by anything here.
The Store serialises in-process mutations behind a mutex — every ssh session lives in the host's process (the ssh-only MVP has no wire), so this is the whole concurrency story. A `serve invite` CLI run against a live server is a separate process and races last-write-wins; acceptable while sessions are friends-hosted.
Index ¶
- Constants
- Variables
- func DefaultDir() (string, error)
- type DockLink
- type FleetResetEntry
- type Invite
- type Meta
- type Player
- type Store
- func (s *Store) ConsumePendingNote(fingerprint string) (string, error)
- func (s *Store) DemoteAdmin(fingerprint string) error
- func (s *Store) Enroll(code, fingerprint, handle string) (Player, error)
- func (s *Store) EnsureHost(handle string) (Player, error)
- func (s *Store) FindPlayer(fingerprint string) (Player, error)
- func (s *Store) HasPayload(fingerprint string) bool
- func (s *Store) LatestSimTime() (time.Time, bool)
- func (s *Store) LoadPlayer(fingerprint string) (*sim.World, error)
- func (s *Store) MayAdminister(fingerprint string) bool
- func (s *Store) MayDelegate(fingerprint string) bool
- func (s *Store) MayRemove(actor, target string) bool
- func (s *Store) Meta() (Meta, error)
- func (s *Store) MintInvite(handle string) (Invite, error)
- func (s *Store) Peek(code string) (Invite, error)
- func (s *Store) PromoteAdmin(fingerprint string) error
- func (s *Store) RemovePlayer(fingerprint string) error
- func (s *Store) ResetFleet(epoch time.Time) ([]FleetResetEntry, error)
- func (s *Store) RevokeInvite(code string) error
- func (s *Store) SavePlayer(fingerprint string, w *sim.World) error
- func (s *Store) SetDocks(docks []DockLink) error
Constants ¶
const ( // FleetResetAltitudeM is the shared ring's altitude: 500 km, the // same circular orbit a fresh enrollment's default vessel spawns // into (spacecraft.NewInLEO), so a reset slate is indistinguishable // from a first join apart from the phase slot. FleetResetAltitudeM = 500e3 // FleetResetMinSeparationM is the guaranteed along-track distance // between ring neighbours: 50 km. It MUST exceed the co-warp // DECOUPLE gate (coWarpDecoupleRangeM = 42 km in // internal/sim/cowarp.go) — the wider hysteresis bound above the // 35 km couple gate and far above the docking range — so no two // reset vessels can spawn coupled, in docking range, or even // inside the couple neighbourhood. At the 500 km ring's ~43,171 km // circumference, even spacing keeps this for up to 863 players; // ResetFleet refuses larger rosters rather than shrink the gap. FleetResetMinSeparationM = 50_000.0 )
const ( RoleHost = "host" RoleAdmin = "admin" RoleGuest = "guest" )
Roster roles. Host is the session's root operator (ADR 0034): the player whose machine runs the session, with invite/removal authority and the sole power to delegate administration. Admin is a guest the host has promoted — it carries the invite/removal capability but not delegation (single-rooted escalation, v0.30 S2). Everyone else is a guest. The role is a plain persisted string on the roster entry, so adding admin is additive: no MetaVersion bump, no migration.
const HostFingerprint = "local"
HostFingerprint marks the host's roster entry: the host plays in-process over local stdio, so there is no ssh key to print.
const MetaVersion = 3
MetaVersion is the session.json schema version. Bump + migrate on shape changes, mirroring the save-envelope discipline.
v1 (v0.27): roster + invites + catalog hash. v2 (v0.28 S5): adds Docks — the cross-player-dock cross-ref, so a guest riding another player's stack (whose craft therefore isn't in its own payload) resumes docked-as-guest on reconnect. A v1 session.json migrates forward (Docks defaults empty) via migrateMetaV1ToV2; live v0.27 sessions never break. v3 (ADR 0040, #311/#313): each DockLink gains the in-flight half of its record — the parked craft payloads and the request flags. v2 persisted a dock's identity but not its substance, so a restart under a handover destroyed the craft and left the record pointing at nothing. A v2 session.json migrates forward via migrateMetaV2ToV3: its docks simply carry no payloads, which is exactly what they did.
Variables ¶
var ( ErrUnknownInvite = errors.New("sessiondir: unknown or already-used invite code") ErrNotEnrolled = errors.New("sessiondir: fingerprint not in roster") )
Functions ¶
func DefaultDir ¶
DefaultDir is $XDG_STATE_HOME/terminal-space-program/session (falling back to ~/.local/state), sibling of save.json and saves/.
Types ¶
type DockLink ¶ added in v0.28.0
type DockLink struct {
ID uint64 `json:"id"`
Owner string `json:"owner"`
OwnerHandle string `json:"owner_handle,omitempty"`
DockerCraftID uint64 `json:"docker_craft_id"`
CompositeID uint64 `json:"composite_id,omitempty"`
GuestOwner string `json:"guest_owner"`
GuestHandle string `json:"guest_handle,omitempty"`
GuestCraftID uint64 `json:"guest_craft_id"`
Phase int `json:"phase"`
// In-flight handoffs (v3+). Omitted entirely for a settled dock, so a
// session.json for a session that never parks a payload looks exactly
// like its v2 self.
GuestPayload *save.Craft `json:"guest_payload,omitempty"`
ReturnPayload *save.Craft `json:"return_payload,omitempty"`
TransferPayload *save.Craft `json:"transfer_payload,omitempty"`
UndockAsk bool `json:"undock_ask,omitempty"`
UndockRefused bool `json:"undock_refused,omitempty"`
TransferTo string `json:"transfer_to,omitempty"`
Aborted bool `json:"aborted,omitempty"`
ReleaseAsk bool `json:"release_ask,omitempty"`
ReleaseAsParcel bool `json:"release_as_parcel,omitempty"`
Parcel bool `json:"parcel,omitempty"`
ParcelAtNano int64 `json:"parcel_at_unix_nano,omitempty"`
ReclaimNotice bool `json:"reclaim_notice,omitempty"`
// ReclaimAtNano is the absent owner's sim-time when an empty-seat
// reclaim's stack was lifted out of their persisted program (review
// finding on ADR 0040 §4) — carried so a restart between the reclaim
// and its delivery doesn't lose the stamp the Kepler-advance at
// delivery needs.
ReclaimAtNano int64 `json:"reclaim_at_unix_nano,omitempty"`
// 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 `json:"dock_notice,omitempty"`
// 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 `json:"return_at_unix_nano,omitempty"`
}
DockLink is one cross-player dock's durable cross-ref (v0.28 S5, ADR 0034 §6). It is the persisted, serialisable subset of the live relay.DockRecord — enough for a reconnecting session to resume: the stack owner + composite, and the guest player + their craft riding in it. The transient in-flight payloads (the craft handoffs) are NOT persisted; a dock that was mid-handshake at shutdown resolves fresh. Phase is the relay.DockPhase int (0 pending / 1 active); sessiondir stays below relay so it carries the raw int rather than importing it.
v3 (ADR 0040) adds the in-flight half. A craft parked on a dock record — a migrating stack mid-[J], a Parcel awaiting an absent guest — exists NOWHERE else, so it is persisted here as the save package's per-craft wire form (the same shape a save carries). The request flags ride along for the same reason: a keypress in flight at shutdown must still be waiting when the recipient comes back.
type FleetResetEntry ¶ added in v0.32.2
type FleetResetEntry struct {
Fingerprint string
Handle string
Role string
// Host marks the in-process host entry: it holds ring slot 0 but no
// payload is written (the host's world lives in-process; the caller
// aligns it).
Host bool
// OldCraftCount is the craft count of the player's previous payload:
// 0 when they had none, -1 when it existed but could not be read
// (it is still backed up and replaced).
OldCraftCount int
// PhaseDeg is the player's slot on the shared ring, degrees prograde
// from the fresh-start seed spot.
PhaseDeg float64
// BackupPath is where the previous payload was copied, empty when
// there was none.
BackupPath string
}
FleetResetEntry reports what ResetFleet did for one enrolled player.
type Invite ¶
type Invite struct {
Code string `json:"code"`
Handle string `json:"handle"`
CreatedAt time.Time `json:"created_at"`
}
Invite is one outstanding (unredeemed) invite code. The handle is pre-bound at mint and editable at enroll (ADR 0034 addendum).
type Meta ¶
type Meta struct {
Version int `json:"version"`
BodyCatalogHash string `json:"body_catalog_hash"`
Roster []Player `json:"roster"`
Invites []Invite `json:"invites"`
// Docks is the cross-player-dock cross-ref (v2+, v0.28 S5). Empty
// in a fresh or non-docking session; the serve layer syncs it from
// the live relay ledger on change.
Docks []DockLink `json:"docks,omitempty"`
}
Meta is the session.json shape.
type Player ¶
type Player struct {
Fingerprint string `json:"fingerprint"`
Handle string `json:"handle"`
Role string `json:"role"`
EnrolledAt time.Time `json:"enrolled_at"`
Calibrated bool `json:"calibrated"`
// PendingNote is a durable, deliver-once session note (#274):
// surfaced to the player as a toast the next time they connect,
// then cleared via ConsumePendingNote. Additive field — an
// existing roster row without it deserialises to "", a no-op, so
// no MetaVersion bump is needed (same additive precedent as Role
// gaining RoleAdmin). Used today for the legacy handle-collision
// auto-rename: both the renamed player and the collision
// counterpart who kept their name get one, so both learn a
// previously-ambiguous pair is now distinct.
PendingNote string `json:"pending_note,omitempty"`
}
Player is one roster entry. Fingerprint is the ssh public-key SHA256 fingerprint (the stable identity — handles are editable).
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store owns one session directory. All mutations re-read session.json under the lock, so a CLI mint between server reads is picked up on the next connect.
func Open ¶
Open creates the directory if needed and initialises session.json on first use, stamping the current body-catalog hash. Opening an EXISTING session also repairs any legacy roster handle collision (#274, see dedupeRosterHandles) — a one-shot fix-up run at load, mirroring the versioned migration ladder but keyed on content rather than a version number, since case-insensitive uniqueness wasn't the persisted shape changing, just a rule that used to be unenforced.
func (*Store) ConsumePendingNote ¶ added in v0.35.0
ConsumePendingNote returns and clears fingerprint's pending session note (#274), if any — empty string and no error when there is none. Clearing is persisted immediately so the note is delivered exactly once, even if the process restarts between the read and the next mutation.
func (*Store) DemoteAdmin ¶ added in v0.30.0
DemoteAdmin returns an admin to guest by fingerprint. Idempotent for a player already a guest; rejects the host and unknown fingerprints.
func (*Store) Enroll ¶
Enroll redeems the code (one-time) and adds the player to the roster in a single locked step. The code is re-validated here, so a Peek that raced another enrollment fails cleanly instead of double-spending. Calibrated is stamped true — the enroll flow runs behind the calibration card.
func (*Store) EnsureHost ¶
EnsureHost auto-enrolls the serving player as roster entry #1 with the Host role on first --serve (idempotent — an existing host entry is returned untouched, so a renamed handle survives restarts).
func (*Store) FindPlayer ¶
FindPlayer looks a fingerprint up in the roster.
func (*Store) HasPayload ¶
HasPayload reports whether the player has a persisted world.
func (*Store) LatestSimTime ¶
LatestSimTime scans every persisted player payload for the maximum stored subspace time — offline players hold the frontier (v0.27 S4, ADR 0034: you can never start in someone's past, online or not). ok is false when no payload parses. Unreadable files are skipped: frontier is a floor, not an integrity check.
func (*Store) LoadPlayer ¶
LoadPlayer restores the player's world. fs.ErrNotExist means no payload yet (first session); save.ErrCatalogMismatch propagates so the connect path can reject rather than corrupt (ADR 0034 — reuses the existing save mechanism).
func (*Store) MayAdminister ¶ added in v0.30.0
MayAdminister reports whether the given fingerprint may perform session-admin actions — minting/revoking invites and removing players. It is the single authorization predicate the serve-layer handler consults before acting (v0.30 S1, #222): authorization is a capability decided here, in the store, not a UI-presentation detail. Today only the host qualifies; the admin role (v0.30 S2) extends roleMayAdminister without touching any caller. An unknown or unenrolled fingerprint may not administer.
func (*Store) MayDelegate ¶ added in v0.30.0
MayDelegate reports whether the fingerprint may promote a guest to admin or demote an admin back to guest. Single-rooted escalation (v0.30 S2): only the host delegates administration — an admin can neither create nor remove another admin. Kept distinct from MayAdminister so the escalation tree stays single-rooted.
func (*Store) MayRemove ¶ added in v0.30.0
MayRemove reports whether actor may remove target from the roster (v0.30 S3, #224) — the guardrail matrix for the first admin power that can lock someone out. The rules keep escalation single-rooted:
- the actor must carry the admin capability (host or admin);
- nobody removes themselves (avoids a self-inflicted lockout);
- nobody removes the host (the session's root);
- an admin may not remove another admin — only the host may (mirrors promotion being host-only).
Both fingerprints must be enrolled. The store's RemovePlayer still guards the host independently; this predicate is the actor-aware gate the serve handler consults before calling it.
func (*Store) MintInvite ¶
MintInvite creates a one-time code pre-bound to handle. The handle must be case-insensitively unique against both the roster and any other outstanding invite (#274) — refused here rather than left to surface only once the guest tries to enroll, so the host learns about a collision before ever handing out a code that can't land.
func (*Store) Peek ¶
Peek validates an invite code without consuming it — the enroll flow shows the pre-bound handle for editing before committing.
func (*Store) PromoteAdmin ¶ added in v0.30.0
PromoteAdmin grants the admin role to an enrolled guest by fingerprint. Idempotent — re-promoting an admin is a no-op. The host is rejected (already root) and an unknown fingerprint returns ErrNotEnrolled. Only the host should call this (enforced at the handler via MayDelegate); the store guards the host-role invariant.
func (*Store) RemovePlayer ¶
RemovePlayer drops a guest from the roster: their key no longer resumes and they'd need a fresh invite. The persisted payload stays on disk — a re-invited player finds their program intact. The host entry can't be removed. A live session isn't kicked (MVP): removal gates the NEXT connect.
func (*Store) ResetFleet ¶ added in v0.32.2
func (s *Store) ResetFleet(epoch time.Time) ([]FleetResetEntry, error)
ResetFleet wipes every enrolled player's slate to one full-tanks default vessel on the shared 500x500 km ring, evenly phased, with every written payload's subspace clock set to epoch. Payloads are produced by the real save machinery and verified to load back through the real load path before the reset is considered done. It refuses — before touching anything — a roster too large to keep FleetResetMinSeparationM between neighbours.
func (*Store) RevokeInvite ¶
RevokeInvite deletes an unredeemed code. ErrUnknownInvite when the code doesn't exist (already redeemed, already revoked, or a typo).
func (*Store) SavePlayer ¶
SavePlayer persists the player's world as a save envelope at the package's current SchemaVersion (the existing save machinery, arbitrary path).