@flayerlabs/gamemode-spec 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/live.ts ADDED
@@ -0,0 +1,173 @@
1
+ import type { PlayerId } from './index.js';
2
+
3
+ /** Static launch facts that are safe for every game and spectator to render. */
4
+ export interface LaunchContext {
5
+ readonly roundId: string;
6
+ readonly poolId: string;
7
+ readonly coinAddress: string | null;
8
+ readonly name: string;
9
+ readonly symbol: string;
10
+ readonly imageUrl: string | null;
11
+ readonly opensAt: number;
12
+ readonly closesAt: number;
13
+ /** The chart/standings settlement tail may outlive gameplay and claim issuance. */
14
+ readonly booksCloseAt: number;
15
+ }
16
+
17
+ const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
18
+
19
+ /** Check an untrusted launch response before using it to choose a room or trusted service. */
20
+ export function isLaunchContext(input: unknown): input is LaunchContext {
21
+ if (typeof input !== 'object' || input === null) return false;
22
+ const value = input as Record<string, unknown>;
23
+ return (
24
+ typeof value.roundId === 'string' &&
25
+ value.roundId.length > 0 &&
26
+ value.roundId.length <= 128 &&
27
+ typeof value.poolId === 'string' &&
28
+ value.poolId.length > 0 &&
29
+ value.poolId.length <= 128 &&
30
+ (value.coinAddress === null || (typeof value.coinAddress === 'string' && ADDRESS.test(value.coinAddress))) &&
31
+ typeof value.name === 'string' &&
32
+ value.name.length <= 256 &&
33
+ typeof value.symbol === 'string' &&
34
+ value.symbol.length <= 64 &&
35
+ (value.imageUrl === null || (typeof value.imageUrl === 'string' && value.imageUrl.length <= 2_048)) &&
36
+ Number.isSafeInteger(value.opensAt) &&
37
+ Number.isSafeInteger(value.closesAt) &&
38
+ Number.isSafeInteger(value.booksCloseAt) &&
39
+ (value.opensAt as number) < (value.closesAt as number) &&
40
+ (value.closesAt as number) <= (value.booksCloseAt as number)
41
+ );
42
+ }
43
+
44
+ export type ConnectionState = 'connecting' | 'connected' | 'reconnecting' | 'superseded' | 'ended';
45
+
46
+ /**
47
+ * Who is currently connected. Telemetry, and deliberately nothing more.
48
+ *
49
+ * This carries no readiness, because the platform has no opinion about what "ready" means. A game
50
+ * that gates its lobby on players being ready defines a ready Action of its own: it then travels
51
+ * the same authenticated, ordered, persisted path as every other action and arrives in `decide()`,
52
+ * where it can actually change the round.
53
+ *
54
+ * Readiness modelled here instead was a rule the rules module could not see. It could not be
55
+ * replayed, it could not decide anything, and it imposed one lobby policy on every kind of game —
56
+ * a live quiz, a shooter and a turn-based game do not agree about when to start, and should not
57
+ * have to.
58
+ */
59
+ export interface PresenceState {
60
+ connectedCount: number;
61
+ }
62
+
63
+ export interface PlayerIdentity {
64
+ player: PlayerId;
65
+ displayName: string | null;
66
+ avatarUrl: string | null;
67
+ }
68
+
69
+ export interface Reaction {
70
+ player: PlayerId;
71
+ id: string;
72
+ }
73
+
74
+ export interface MarketPrice {
75
+ at: number;
76
+ /** Display price of one whole coin in ETH. Market data never participates in scoring. */
77
+ priceEth: number;
78
+ }
79
+
80
+ export interface MarketTrade {
81
+ /** Stable across reconnects, normally transaction hash plus log index. */
82
+ id: string;
83
+ at: number;
84
+ player: PlayerId | null;
85
+ side: 'buy' | 'sell';
86
+ spendWei: bigint;
87
+ priceEth: number | null;
88
+ transactionHash?: string;
89
+ }
90
+
91
+ export interface MarketState {
92
+ status: 'unavailable' | 'loading' | 'live' | 'stale';
93
+ prices: readonly MarketPrice[];
94
+ trades: readonly MarketTrade[];
95
+ marketCapUsd: number | null;
96
+ }
97
+
98
+ export interface WireMarketTrade extends Omit<MarketTrade, 'spendWei'> {
99
+ spendWei: string;
100
+ }
101
+
102
+ export interface WireMarketState extends Omit<MarketState, 'trades'> {
103
+ trades: readonly WireMarketTrade[];
104
+ }
105
+
106
+ /** Authoritative ledger state. A signed but unsettled authorisation remains held. */
107
+ export interface EconomyBalance {
108
+ /** Immutable entitlement rate for this round. This is wei, not a live dollar quote. */
109
+ weiPerPoint: bigint;
110
+ earnedWei: bigint;
111
+ heldWei: bigint;
112
+ spentWei: bigint;
113
+ availableWei: bigint;
114
+ /**
115
+ * When the earliest outstanding hold expires, in epoch ms, or null when nothing is held.
116
+ *
117
+ * A player who opens the buy dialog and then declines keeps that allowance held until this
118
+ * passes, which is correct — the authorisation is still live and could still be spent. But
119
+ * without the deadline on the balance, a game cannot explain the gap between what a player
120
+ * earned and what they can spend, so it either tracks the deadline itself or shows a number
121
+ * that is wrong for five minutes.
122
+ */
123
+ holdExpiresAt: number | null;
124
+ }
125
+
126
+ /** Stable player-facing reasons a platform buy did not happen. */
127
+ export type BuyFailure =
128
+ | 'nothing-to-spend'
129
+ | 'declined'
130
+ | 'not-enough-for-fees'
131
+ | 'window-closed'
132
+ | 'try-again';
133
+
134
+ /**
135
+ * JSON-safe form used on the wire.
136
+ *
137
+ * The wei fields cross as decimal strings because JSON has no bigint. `holdExpiresAt` is an
138
+ * ordinary millisecond number and stays one.
139
+ */
140
+ export type WireEconomyBalance = {
141
+ [Key in keyof Omit<EconomyBalance, 'holdExpiresAt'>]: string;
142
+ } & { holdExpiresAt: number | null };
143
+
144
+ /** Keep cosmetic reaction identifiers bounded and safe to use as asset keys. */
145
+ export function isReactionId(id: string): boolean {
146
+ return /^[A-Za-z0-9._:-]{1,64}$/.test(id);
147
+ }
148
+
149
+ export const LIVE_PROTOCOL_VERSION = 1 as const;
150
+
151
+ export type LiveServerFrame<PublicView, PlayerView> =
152
+ | {
153
+ v: typeof LIVE_PROTOCOL_VERSION;
154
+ type: 'snapshot';
155
+ sequence: number;
156
+ now: number;
157
+ view: PublicView;
158
+ you: PlayerView | null;
159
+ economy: WireEconomyBalance;
160
+ launch: LaunchContext;
161
+ presence: PresenceState;
162
+ }
163
+ | { v: typeof LIVE_PROTOCOL_VERSION; type: 'reaction'; reaction: Reaction }
164
+ | { v: typeof LIVE_PROTOCOL_VERSION; type: 'market'; market: WireMarketState }
165
+ | { v: typeof LIVE_PROTOCOL_VERSION; type: 'presence'; presence: PresenceState };
166
+
167
+ /**
168
+ * Everything a client may say over the socket.
169
+ *
170
+ * Reactions only. Actions go over HTTP so they are ordered and persisted with the round, and
171
+ * readiness is an ordinary action for whichever games want one.
172
+ */
173
+ export type LiveClientFrame = { v: typeof LIVE_PROTOCOL_VERSION; type: 'reaction'; id: string };
package/src/round.ts ADDED
@@ -0,0 +1,141 @@
1
+ import type { Award, Command, Decision, GameModule, PlayerId, Refusal, RoundWindow } from './index.js';
2
+ import { isRefusal } from './index.js';
3
+
4
+ /**
5
+ * Drives a game module through commands. Pure, in-memory, no I/O.
6
+ *
7
+ * This is the only implementation of "how to run a round". The server persists around it and the
8
+ * browser mock runs it directly, so a game cannot behave one way offline and another way live —
9
+ * there is no second copy to drift. The reference games this platform replaces had exactly that
10
+ * bug: two copies of the same scoring rules, disagreeing about a constant, with the mechanism
11
+ * meant to pin them not covering the part that moved.
12
+ *
13
+ * Ordering is the platform's job, not the game's: commands are applied one at a time, in the
14
+ * order they arrive here.
15
+ */
16
+ export class Round<Config, State, Event, Action, PublicView, PlayerView> {
17
+ private readonly points = new Map<PlayerId, number>();
18
+
19
+ /**
20
+ * @param onAward Called for every award as it happens, including ones produced by a wake that
21
+ * `send` ran on the way to a command. A durable caller needs this: it has to write the state and
22
+ * the money in one transaction, and it cannot see inside a wake any other way.
23
+ */
24
+ private constructor(
25
+ private readonly game: GameModule<Config, State, Event, Action, PublicView, PlayerView>,
26
+ private state: State,
27
+ readonly window: RoundWindow,
28
+ private readonly onAward?: (award: Award) => void,
29
+ ) {}
30
+
31
+ /** Begin a new round. */
32
+ static start<Config, State, Event, Action, PublicView, PlayerView>(
33
+ game: GameModule<Config, State, Event, Action, PublicView, PlayerView>,
34
+ config: Config,
35
+ seed: number,
36
+ window: RoundWindow,
37
+ onAward?: (award: Award) => void,
38
+ ): Round<Config, State, Event, Action, PublicView, PlayerView> {
39
+ return new Round(game, game.initRound(config, seed, window), window, onAward);
40
+ }
41
+
42
+ /**
43
+ * Pick a round back up from a stored state.
44
+ *
45
+ * Nothing is replayed and nothing recomputed: the state is the game's own and the platform never
46
+ * inspects it, so resuming is a straight substitution. That is what makes a restart cheap.
47
+ */
48
+ static resume<Config, State, Event, Action, PublicView, PlayerView>(
49
+ game: GameModule<Config, State, Event, Action, PublicView, PlayerView>,
50
+ state: State,
51
+ window: RoundWindow,
52
+ onAward?: (award: Award) => void,
53
+ ): Round<Config, State, Event, Action, PublicView, PlayerView> {
54
+ return new Round(game, state, window, onAward);
55
+ }
56
+
57
+ /**
58
+ * Apply one command. Fires any wakes that fall due at or before it first, so a game never sees
59
+ * an action from after a deadline it asked to be woken for.
60
+ */
61
+ send(command: Command<Action>): Decision<Event> | Refusal {
62
+ this.runWakesUpTo(command.at);
63
+ return this.apply(command);
64
+ }
65
+
66
+ /**
67
+ * Fire every wake due at or before `time`, and report how many fired.
68
+ *
69
+ * The count is what a durable caller needs: zero means nothing changed and there is nothing to
70
+ * write. Never send a wake through {@link send} to achieve this — `send` runs due wakes on the
71
+ * way to the command, so a synthetic wake command fires the same deadline twice and collapses
72
+ * whatever phase sat between them.
73
+ */
74
+ advanceTo(time: number): number {
75
+ return this.runWakesUpTo(time);
76
+ }
77
+
78
+ private runWakesUpTo(time: number): number {
79
+ // A wake may schedule the next one. Bounded by each wake having to move strictly forward.
80
+ let previous = -Infinity;
81
+ let fired = 0;
82
+ for (;;) {
83
+ const at = this.game.nextWakeAt(this.state);
84
+ if (at === null) return fired;
85
+ if (!Number.isFinite(at)) {
86
+ throw new Error(`${this.game.id}: nextWakeAt returned a non-finite time`);
87
+ }
88
+ if (!Number.isSafeInteger(at)) {
89
+ throw new Error(`${this.game.id}: nextWakeAt returned a non-safe-integer time`);
90
+ }
91
+ if (at > this.window.closesAt) {
92
+ throw new Error(`${this.game.id}: nextWakeAt ${at} is after the round closes at ${this.window.closesAt}`);
93
+ }
94
+ if (at > time) return fired;
95
+ if (at <= previous) {
96
+ throw new Error(`${this.game.id}: nextWakeAt did not advance past ${at}`);
97
+ }
98
+ previous = at;
99
+ this.apply({ kind: 'wake', at });
100
+ fired += 1;
101
+ }
102
+ }
103
+
104
+ private apply(command: Command<Action>): Decision<Event> | Refusal {
105
+ const result = this.game.decide(this.state, command);
106
+ if (isRefusal(result)) return result;
107
+
108
+ for (const event of result.events) {
109
+ this.state = this.game.evolve(this.state, event);
110
+ }
111
+ for (const award of result.awards ?? []) {
112
+ this.credit(award);
113
+ }
114
+ return result;
115
+ }
116
+
117
+ private credit(award: Award): void {
118
+ if (!Number.isFinite(award.points) || award.points < 0) {
119
+ throw new Error(`${this.game.id}: award of ${award.points} points to ${award.player}`);
120
+ }
121
+ this.points.set(award.player, (this.points.get(award.player) ?? 0) + award.points);
122
+ this.onAward?.(award);
123
+ }
124
+
125
+ pointsFor(player: PlayerId): number {
126
+ return this.points.get(player) ?? 0;
127
+ }
128
+
129
+ publicView(): PublicView {
130
+ return this.game.publicView(this.state);
131
+ }
132
+
133
+ playerView(player: PlayerId): PlayerView {
134
+ return this.game.playerView(this.state, player);
135
+ }
136
+
137
+ /** For replay assertions. Structural equality of this is what determinism means. */
138
+ snapshot(): State {
139
+ return this.state;
140
+ }
141
+ }
@@ -0,0 +1,58 @@
1
+ import type { RoundWindow } from './index.js';
2
+
3
+ export interface ScheduleStep<Value> {
4
+ value: Value;
5
+ durationMs: number;
6
+ }
7
+
8
+ export interface ScheduledStep<Value> {
9
+ value: Value;
10
+ startsAt: number;
11
+ endsAt: number;
12
+ }
13
+
14
+ export class ScheduleWindowError extends RangeError {
15
+ constructor(readonly requiredMs: number, readonly availableMs: number) {
16
+ super(`schedule needs ${requiredMs}ms but the round window has ${availableMs}ms`);
17
+ this.name = 'ScheduleWindowError';
18
+ }
19
+ }
20
+
21
+ /** Turn relative authored durations into the absolute timestamps a round can replay forever. */
22
+ export function scheduleWithin<Value>(
23
+ window: RoundWindow,
24
+ steps: readonly ScheduleStep<Value>[],
25
+ ): ScheduledStep<Value>[] {
26
+ if (
27
+ !Number.isSafeInteger(window.opensAt) ||
28
+ !Number.isSafeInteger(window.closesAt) ||
29
+ window.closesAt <= window.opensAt
30
+ ) {
31
+ throw new RangeError('schedule needs safe-integer timestamps whose close is after its open');
32
+ }
33
+ const availableMs = window.closesAt - window.opensAt;
34
+ if (!Number.isSafeInteger(availableMs)) {
35
+ throw new RangeError('schedule round-window duration is outside the safe-integer range');
36
+ }
37
+ let cursor = window.opensAt;
38
+ const scheduled = steps.map((step, index) => {
39
+ if (!Number.isSafeInteger(step.durationMs) || step.durationMs <= 0) {
40
+ throw new RangeError(`schedule phase ${index} must have a positive safe-integer duration`);
41
+ }
42
+ const endsAt = cursor + step.durationMs;
43
+ if (!Number.isSafeInteger(endsAt)) {
44
+ throw new RangeError(`schedule phase ${index} ends outside the safe-integer timestamp range`);
45
+ }
46
+ const scheduledStep = { value: step.value, startsAt: cursor, endsAt };
47
+ cursor = scheduledStep.endsAt;
48
+ return scheduledStep;
49
+ });
50
+ if (cursor > window.closesAt) {
51
+ const requiredMs = cursor - window.opensAt;
52
+ if (!Number.isSafeInteger(requiredMs)) {
53
+ throw new RangeError('schedule duration is outside the safe-integer range');
54
+ }
55
+ throw new ScheduleWindowError(requiredMs, availableMs);
56
+ }
57
+ return scheduled;
58
+ }