@diffenderfer-games/hub 2.0.0-next.7 → 2.0.0-next.8
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/CHANGELOG.md +24 -4
- package/dist/auto.d.ts +2 -2
- package/dist/auto.js +2 -2
- package/dist/doctor/cli.mjs +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.js +1 -1
- package/dist/shared/index.d.ts +87 -10
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/src.js +172 -28
- package/dist/shared/src.js.map +1 -1
- package/dist/turns-rules.d.ts +2 -0
- package/dist/turns-rules.js +1 -0
- package/dist/turns.d.ts +23 -1
- package/dist/turns.d.ts.map +1 -1
- package/dist/turns.js +79 -13
- package/dist/turns.js.map +1 -1
- package/package.json +7 -2
- package/src/core/turns/hub-turns.ts +25 -4
- package/src/core/turns/turn-game.ts +143 -21
- package/src/core/turns/your-turn-buzz.ts +69 -0
- package/src/core/version/client-version.gen.ts +1 -1
- package/src/index.ts +68 -67
- package/src/turns-kit/check-rules.ts +116 -17
- package/src/turns-rules.ts +12 -0
- package/src/turns.ts +4 -3
package/src/index.ts
CHANGED
|
@@ -1,67 +1,68 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `@diffenderfer-games/hub`: the hub client for games on diffenderfer.games.
|
|
3
|
-
*
|
|
4
|
-
* ```ts
|
|
5
|
-
* import { hub } from '@diffenderfer-games/hub';
|
|
6
|
-
*
|
|
7
|
-
* const { user } = await hub.me();
|
|
8
|
-
* await hub.putSave('auto', { level: 7, gold: 120 });
|
|
9
|
-
* await hub.submitScore('highscore', 9001, { title: 'High Score' });
|
|
10
|
-
* ```
|
|
11
|
-
*
|
|
12
|
-
* In a hosted game the slug and API base are detected from the page; use
|
|
13
|
-
* {@link createHub} for others. Every call rejects with a {@link HubError}
|
|
14
|
-
* when it fails, except the fire-and-forget analytics (`recordPlay`,
|
|
15
|
-
* `heartbeat`, `event`). When the hub can't be reached, reads come from an
|
|
16
|
-
* offline cache and writes are queued in an outbox until it is back; games
|
|
17
|
-
* queue their own operations with `hub.offline` (docs: "Offline and replay").
|
|
18
|
-
*
|
|
19
|
-
* The page-wide namespaces hang off `hub` as lazy getters (`hub.input`,
|
|
20
|
-
* `hub.daily`, `hub.resume`, `hub.saved`, `hub.social`, `hub.mp`, `hub.rooms`, `hub.race`), and each has
|
|
21
|
-
* its own entry (`@diffenderfer-games/hub/input`, ...) for games that want
|
|
22
|
-
* only that part.
|
|
23
|
-
*
|
|
24
|
-
* @packageDocumentation
|
|
25
|
-
*/
|
|
26
|
-
export { createHub, Hub, hub } from './core/hub';
|
|
27
|
-
export {
|
|
28
|
-
ContentRejectedError,
|
|
29
|
-
HubError,
|
|
30
|
-
OfflineError,
|
|
31
|
-
SAVE_UNKNOWN_CODE,
|
|
32
|
-
UNSUPPORTED_BY_HUB,
|
|
33
|
-
type HubErrorCode,
|
|
34
|
-
type HubErrorDetails,
|
|
35
|
-
} from './core/errors';
|
|
36
|
-
export type { SyncConflict, SyncResult } from './core/connection';
|
|
37
|
-
export type { AuthResponse, ProfileUpdate, StatResult } from './core/hub-progress';
|
|
38
|
-
export type { GrantBundle, TradeBundle, TradeId, TradeListOptions, TradeNoteOptions } from './core/hub-economy';
|
|
39
|
-
export type { HubMenuControl } from './core/menu-control';
|
|
40
|
-
export { HubResume, RESUME_DEBOUNCE_MS, RESUME_MAX_STATE_BYTES } from './core/resume/hub-resume';
|
|
41
|
-
export { HubAvatar, type AvatarHost, type AvatarModule } from './core/avatar/hub-avatar';
|
|
42
|
-
export type { AvatarChange, AvatarRef, DesignerResult, OpenDesignerOptions, RuntimeAvatarApi } from './core/avatar/avatar-types';
|
|
43
|
-
export type { CarriesLook } from './core/avatar/avatar-ref';
|
|
44
|
-
export type { ResumeBlocker, ResumeEntry, ResumeLoadOptions, ResumeProgress, ResumeRun, ResumeSaveInput } from './core/resume/resume-types';
|
|
45
|
-
export { HubSaved, SAVED_CHANGE_QUIET_MS, type SavedChangeListener, type SavedOpenDetail, type SavedOpenHandler } from './core/saved/hub-saved';
|
|
46
|
-
export { SAVED_CHANGE_EVENT, SAVED_KIND_PARAM, SAVED_OPEN_EVENT, SAVED_OPEN_PARAM, savedGameUrl } from './core/saved/saved-open';
|
|
47
|
-
export type * from './core/saved/saved-types';
|
|
48
|
-
export { HubTurns, TURN_LINK_PARAM, type TurnCreateOptions } from './core/turns/hub-turns';
|
|
49
|
-
export {
|
|
50
|
-
export
|
|
51
|
-
export type {
|
|
52
|
-
export {
|
|
53
|
-
export {
|
|
54
|
-
export type
|
|
55
|
-
export {
|
|
56
|
-
export
|
|
57
|
-
export type {
|
|
58
|
-
export {
|
|
59
|
-
export
|
|
60
|
-
export {
|
|
61
|
-
export
|
|
62
|
-
export * from './
|
|
63
|
-
export * from './
|
|
64
|
-
export
|
|
65
|
-
export type * from './
|
|
66
|
-
export type * from './
|
|
67
|
-
export type * from './
|
|
1
|
+
/**
|
|
2
|
+
* `@diffenderfer-games/hub`: the hub client for games on diffenderfer.games.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { hub } from '@diffenderfer-games/hub';
|
|
6
|
+
*
|
|
7
|
+
* const { user } = await hub.me();
|
|
8
|
+
* await hub.putSave('auto', { level: 7, gold: 120 });
|
|
9
|
+
* await hub.submitScore('highscore', 9001, { title: 'High Score' });
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* In a hosted game the slug and API base are detected from the page; use
|
|
13
|
+
* {@link createHub} for others. Every call rejects with a {@link HubError}
|
|
14
|
+
* when it fails, except the fire-and-forget analytics (`recordPlay`,
|
|
15
|
+
* `heartbeat`, `event`). When the hub can't be reached, reads come from an
|
|
16
|
+
* offline cache and writes are queued in an outbox until it is back; games
|
|
17
|
+
* queue their own operations with `hub.offline` (docs: "Offline and replay").
|
|
18
|
+
*
|
|
19
|
+
* The page-wide namespaces hang off `hub` as lazy getters (`hub.input`,
|
|
20
|
+
* `hub.daily`, `hub.resume`, `hub.saved`, `hub.social`, `hub.mp`, `hub.rooms`, `hub.race`), and each has
|
|
21
|
+
* its own entry (`@diffenderfer-games/hub/input`, ...) for games that want
|
|
22
|
+
* only that part.
|
|
23
|
+
*
|
|
24
|
+
* @packageDocumentation
|
|
25
|
+
*/
|
|
26
|
+
export { createHub, Hub, hub } from './core/hub';
|
|
27
|
+
export {
|
|
28
|
+
ContentRejectedError,
|
|
29
|
+
HubError,
|
|
30
|
+
OfflineError,
|
|
31
|
+
SAVE_UNKNOWN_CODE,
|
|
32
|
+
UNSUPPORTED_BY_HUB,
|
|
33
|
+
type HubErrorCode,
|
|
34
|
+
type HubErrorDetails,
|
|
35
|
+
} from './core/errors';
|
|
36
|
+
export type { SyncConflict, SyncResult } from './core/connection';
|
|
37
|
+
export type { AuthResponse, ProfileUpdate, StatResult } from './core/hub-progress';
|
|
38
|
+
export type { GrantBundle, TradeBundle, TradeId, TradeListOptions, TradeNoteOptions } from './core/hub-economy';
|
|
39
|
+
export type { HubMenuControl } from './core/menu-control';
|
|
40
|
+
export { HubResume, RESUME_DEBOUNCE_MS, RESUME_MAX_STATE_BYTES } from './core/resume/hub-resume';
|
|
41
|
+
export { HubAvatar, type AvatarHost, type AvatarModule } from './core/avatar/hub-avatar';
|
|
42
|
+
export type { AvatarChange, AvatarRef, DesignerResult, OpenDesignerOptions, RuntimeAvatarApi } from './core/avatar/avatar-types';
|
|
43
|
+
export type { CarriesLook } from './core/avatar/avatar-ref';
|
|
44
|
+
export type { ResumeBlocker, ResumeEntry, ResumeLoadOptions, ResumeProgress, ResumeRun, ResumeSaveInput } from './core/resume/resume-types';
|
|
45
|
+
export { HubSaved, SAVED_CHANGE_QUIET_MS, type SavedChangeListener, type SavedOpenDetail, type SavedOpenHandler } from './core/saved/hub-saved';
|
|
46
|
+
export { SAVED_CHANGE_EVENT, SAVED_KIND_PARAM, SAVED_OPEN_EVENT, SAVED_OPEN_PARAM, savedGameUrl } from './core/saved/saved-open';
|
|
47
|
+
export type * from './core/saved/saved-types';
|
|
48
|
+
export { HubTurns, TURN_LINK_PARAM, type TurnCreateOptions } from './core/turns/hub-turns';
|
|
49
|
+
export { isNewlyDue, readYourTurnBuzz, saveYourTurnBuzz, YOUR_TURN_BUZZ, YOUR_TURN_BUZZ_KEY } from './core/turns/your-turn-buzz';
|
|
50
|
+
export { TURN_CHAT_EVENT, TURN_EVENT, TURN_POLL_MS, TURN_QUIET_MS, TURN_RESYNC_EVENT, TurnGame, type TurnGameEvents, type TurnGameOptions, type TurnMoveEvent, type TurnOutcome } from './core/turns/turn-game';
|
|
51
|
+
export type { OpenTurnGame, TurnChatLine, TurnDeadline, TurnGameView } from '@diffenderfer-games/hub-contract';
|
|
52
|
+
export type { QueueOp, QueueOpInput, QueuedScoreOptions, ProfilePatch } from './core/offline/queue';
|
|
53
|
+
export { OfflineApi } from './core/offline/offline-api';
|
|
54
|
+
export { OUTBOX_FULL_CODE, type OfflineEventDetails, type OfflineEventName, type OfflineListener, type OfflineOp } from './core/offline/outbox';
|
|
55
|
+
export type { OfflineHandler, OfflineKindDefinition, ReplayContext } from './core/offline/outbox-kinds';
|
|
56
|
+
export { OUTBOX_MAX_CHARS, OUTBOX_MAX_ENTRIES } from './core/offline/outbox-store';
|
|
57
|
+
export type { BufferedEvent } from './core/offline/events';
|
|
58
|
+
export type { EndpointFields, EndpointName, EndpointReply } from './core/rest/endpoint-types';
|
|
59
|
+
export { CLIENT_VERSION_HEADER } from './core/rest/rest-client';
|
|
60
|
+
export type { ClientSupport } from './core/version/hub-version';
|
|
61
|
+
export { HUB_CLIENT_VERSION } from './core/version/client-version.gen';
|
|
62
|
+
export type * from './core/types';
|
|
63
|
+
export * from './daily';
|
|
64
|
+
export * from './input';
|
|
65
|
+
export type * from './social';
|
|
66
|
+
export type * from './mp';
|
|
67
|
+
export type * from './race';
|
|
68
|
+
export type * from './rooms';
|
|
@@ -4,18 +4,24 @@
|
|
|
4
4
|
* module the way the hub does and throws on the first broken promise:
|
|
5
5
|
*
|
|
6
6
|
* - every game reaches an outcome within `maxMoves`, and `toAct` is never
|
|
7
|
-
* empty before it;
|
|
7
|
+
* empty before it; every seat in `toAct` gets to move (not only the
|
|
8
|
+
* first), and so do the seats in `mayAct` (out of turn: a jump-in, a
|
|
9
|
+
* catch) whose `autoReact` (or `options.reactMove`) has a move;
|
|
10
|
+
* - `toAct` and `mayAct` only name seats that exist;
|
|
8
11
|
* - the state survives a JSON round trip after every move, and the same seed
|
|
9
12
|
* plays the same game (no `Math.random`, no hidden globals);
|
|
10
|
-
* - `autoMove` (when the rules have
|
|
13
|
+
* - `autoMove` and `autoReact` (when the rules have them) are always legal;
|
|
11
14
|
* - no seat's `view` or `eventView` shows another seat's secrets: markers
|
|
12
|
-
* planted with `options.plant` never appear in anyone else's view
|
|
15
|
+
* planted with `options.plant` never appear in anyone else's view until
|
|
16
|
+
* the rules reveal them (`options.revealed`: a card played face up);
|
|
17
|
+
* - with `options.shuffleHidden` (the "twin table"): shuffling everything a
|
|
18
|
+
* seat may not see never changes that seat's view;
|
|
13
19
|
* - stored fixtures from older versions `migrate` to a state that views;
|
|
14
20
|
* - views, events and moves stay within the hub's size caps.
|
|
15
21
|
*
|
|
16
22
|
* It needs no hub: run it in a game's own unit tests (vitest, node:test).
|
|
17
23
|
*/
|
|
18
|
-
import { createTurnRng, turnRngStateFromSeed, type TurnRng, type TurnRules } from '@diffenderfer-games/hub-contract';
|
|
24
|
+
import { createTurnRng, turnRngStateFromSeed, type TurnRng, type TurnRules } from '@diffenderfer-games/hub-contract/turns-rules';
|
|
19
25
|
import { parseJsonText } from '../core/storage/json-guards';
|
|
20
26
|
|
|
21
27
|
/** The hub's caps. */
|
|
@@ -35,6 +41,8 @@ export interface TurnRulesCheckOptions<State, Move, Options = never> {
|
|
|
35
41
|
readonly mode?: string | null;
|
|
36
42
|
/** A legal move for `seat` (default: the rules' own `autoMove`). At least one of the two is needed. */
|
|
37
43
|
readonly randomMove?: (state: State, seat: number, rng: TurnRng) => Move;
|
|
44
|
+
/** A legal out-of-turn move for `seat` while it is in `mayAct`, or `null` to pass (default: the rules' own `autoReact`). */
|
|
45
|
+
readonly reactMove?: (state: State, seat: number, rng: TurnRng) => Move | null;
|
|
38
46
|
/**
|
|
39
47
|
* Put `marker` somewhere only `seat` may see (a card in their hand, their
|
|
40
48
|
* secret goal) and return the state; the kit then checks no other seat's
|
|
@@ -42,6 +50,23 @@ export interface TurnRulesCheckOptions<State, Move, Options = never> {
|
|
|
42
50
|
* information.
|
|
43
51
|
*/
|
|
44
52
|
readonly plant?: (state: State, seat: number, marker: string) => State;
|
|
53
|
+
/**
|
|
54
|
+
* Whether a planted `marker` is now public by the rules (the card was
|
|
55
|
+
* played or discarded face up, a secret was shown at the end): from then
|
|
56
|
+
* on the kit stops checking it in that game, so a card that is rightly
|
|
57
|
+
* revealed isn't a leak. Leave it out when planted markers never become
|
|
58
|
+
* public.
|
|
59
|
+
*/
|
|
60
|
+
readonly revealed?: (state: State, marker: string) => boolean;
|
|
61
|
+
/**
|
|
62
|
+
* The "twin table": a copy of `state` with everything `seat` may not see
|
|
63
|
+
* shuffled or redealt (the deck's order, the other hands' cards, keeping
|
|
64
|
+
* their counts), drawing from `rng`. After every move the kit checks
|
|
65
|
+
* that each seat's `view` of the twin is exactly its view of the real
|
|
66
|
+
* state: a view that changes depends on something that seat must not
|
|
67
|
+
* know. Catches leaks `plant` can't (a count, an order, a derived hint).
|
|
68
|
+
*/
|
|
69
|
+
readonly shuffleHidden?: (state: State, seat: number, rng: TurnRng) => State;
|
|
45
70
|
/** States saved by older rules versions, which `migrate` must bring up to date. */
|
|
46
71
|
// eslint-disable-next-line no-restricted-syntax -- trust boundary: states stored by older rules versions
|
|
47
72
|
readonly fixtures?: readonly { readonly version: number; readonly state: unknown }[];
|
|
@@ -52,6 +77,8 @@ export interface TurnRulesReport {
|
|
|
52
77
|
readonly games: number;
|
|
53
78
|
readonly moves: number;
|
|
54
79
|
readonly longestGame: number;
|
|
80
|
+
/** Moves made out of turn (by a seat in `mayAct`, not `toAct`). */
|
|
81
|
+
readonly outOfTurn: number;
|
|
55
82
|
}
|
|
56
83
|
|
|
57
84
|
/** The UTF-8 bytes of a JSON text. */
|
|
@@ -90,50 +117,120 @@ function pickMove<State, Move, View, Event, Options>(rules: TurnRules<State, Mov
|
|
|
90
117
|
return rules.autoMove(state, seat, rng);
|
|
91
118
|
}
|
|
92
119
|
|
|
120
|
+
/** An out-of-turn move for `seat`: the check's own chooser, else the rules' autoReact, else none. */
|
|
121
|
+
function pickReaction<State, Move, View, Event, Options>(rules: TurnRules<State, Move, View, Event, Options>, options: TurnRulesCheckOptions<State, Move, Options>, state: State, seat: number, rng: TurnRng): Move | null {
|
|
122
|
+
if (options.reactMove !== undefined) {
|
|
123
|
+
return options.reactMove(state, seat, rng);
|
|
124
|
+
}
|
|
125
|
+
return rules.autoReact?.(state, seat, rng) ?? null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Fail unless every seat in `list` exists. */
|
|
129
|
+
function checkSeats(list: readonly number[], seats: number, what: string, seed: number): void {
|
|
130
|
+
if (list.some((seat) => !Number.isInteger(seat) || seat < 0 || seat >= seats)) {
|
|
131
|
+
fail(`${what} named a seat that doesn't exist: [${list.join(', ')}] (seed ${String(seed)})`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The next move of a game: half the time a seat in `mayAct` that has an
|
|
137
|
+
* out-of-turn move, else a seat in `toAct` (any of them, not only the
|
|
138
|
+
* first). The kit's own `pick` RNG chooses, so the game's RNG sees only
|
|
139
|
+
* the rules' own draws.
|
|
140
|
+
*/
|
|
141
|
+
function nextMove<State, Move, View, Event, Options>(
|
|
142
|
+
rules: TurnRules<State, Move, View, Event, Options>, options: TurnRulesCheckOptions<State, Move, Options>, state: State, toAct: readonly number[], rng: TurnRng, pick: TurnRng,
|
|
143
|
+
): { seat: number; move: Move; by: string } | null {
|
|
144
|
+
const optional = (rules.mayAct?.(state) ?? []).filter((seat) => !toAct.includes(seat));
|
|
145
|
+
if (optional.length > 0 && pick.int(2) === 0) {
|
|
146
|
+
const seat = pick.pick(optional);
|
|
147
|
+
const move = pickReaction(rules, options, state, seat, rng);
|
|
148
|
+
if (move !== null) {
|
|
149
|
+
return { seat, move, by: options.reactMove === undefined ? 'autoReact' : 'reactMove' };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const seat = toAct.length === 0 ? undefined : pick.pick(toAct);
|
|
153
|
+
return seat === undefined ? null : { seat, move: pickMove(rules, options, state, seat, rng), by: options.randomMove === undefined ? 'autoMove' : 'randomMove' };
|
|
154
|
+
}
|
|
155
|
+
|
|
93
156
|
/** Play one seeded game; answers its moves and a trace for the determinism check. */
|
|
94
157
|
function playGame<State, Move, View, Event, Options>(
|
|
95
158
|
rules: TurnRules<State, Move, View, Event, Options>, seats: number, seed: number, options: TurnRulesCheckOptions<State, Move, Options>,
|
|
96
|
-
): { moves: number; trace: string } {
|
|
159
|
+
): { moves: number; outOfTurn: number; trace: string } {
|
|
97
160
|
const rng = createTurnRng(turnRngStateFromSeed(seed));
|
|
161
|
+
const pick = createTurnRng(turnRngStateFromSeed(seed ^ 0x5bd1e995));
|
|
162
|
+
const twinRng = createTurnRng(turnRngStateFromSeed(seed ^ 0x27d4eb2f));
|
|
98
163
|
const marker = `MARKER-${String(seed)}-`;
|
|
164
|
+
const markerOf = (owner: number): string => `${marker}${String(owner)}`;
|
|
99
165
|
let state = rules.setup({ seats, options: options.setupOptions ?? null, mode: options.mode ?? null, rng });
|
|
100
166
|
if (options.plant !== undefined) {
|
|
101
167
|
for (let seat = 0; seat < seats; seat++) {
|
|
102
|
-
state = options.plant(state, seat,
|
|
168
|
+
state = options.plant(state, seat, markerOf(seat));
|
|
103
169
|
}
|
|
104
170
|
}
|
|
105
171
|
const maxMoves = options.maxMoves ?? 2000;
|
|
106
172
|
const trace: string[] = [];
|
|
173
|
+
let outOfTurn = 0;
|
|
174
|
+
/** The twin table: each seat's view is the same after what it can't see is shuffled. */
|
|
175
|
+
const checkTwin = (): void => {
|
|
176
|
+
if (options.shuffleHidden === undefined) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
for (let viewer = 0; viewer < seats; viewer++) {
|
|
180
|
+
const real = JSON.stringify(rules.view(state, viewer));
|
|
181
|
+
const twin = JSON.stringify(rules.view(roundTrip(options.shuffleHidden(roundTrip(state), viewer, twinRng)), viewer));
|
|
182
|
+
if (twin !== real) {
|
|
183
|
+
fail(`seat ${String(viewer)}'s view changed when what it can't see was shuffled: it shows something hidden (seed ${String(seed)})`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
/** The seats whose planted marker is still secret (the rules haven't revealed it). */
|
|
188
|
+
const secret = new Set(options.plant === undefined ? [] : Array.from({ length: seats }, (_, seat) => seat));
|
|
107
189
|
const checkViews = (events: readonly Event[]): void => {
|
|
190
|
+
for (const owner of secret) {
|
|
191
|
+
if (options.revealed?.(state, markerOf(owner)) === true) {
|
|
192
|
+
secret.delete(owner);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
108
195
|
for (let viewer = 0; viewer < seats; viewer++) {
|
|
109
196
|
const seen = JSON.stringify(rules.view(state, viewer)) + JSON.stringify(events.map((event) => rules.eventView?.(event, viewer) ?? null));
|
|
110
|
-
for (
|
|
111
|
-
if (owner !== viewer &&
|
|
197
|
+
for (const owner of secret) {
|
|
198
|
+
if (owner !== viewer && seen.includes(markerOf(owner))) {
|
|
112
199
|
fail(`seat ${String(viewer)} can see seat ${String(owner)}'s secret (seed ${String(seed)})`);
|
|
113
200
|
}
|
|
114
201
|
}
|
|
115
202
|
}
|
|
116
|
-
|
|
117
|
-
|
|
203
|
+
const shown = JSON.stringify(rules.view(state, null));
|
|
204
|
+
for (const owner of secret) {
|
|
205
|
+
if (shown.includes(markerOf(owner))) {
|
|
206
|
+
fail(`the public view shows seat ${String(owner)}'s secret (seed ${String(seed)})`);
|
|
207
|
+
}
|
|
118
208
|
}
|
|
209
|
+
checkTwin();
|
|
119
210
|
};
|
|
120
211
|
checkViews([]);
|
|
121
212
|
for (let moves = 0; moves < maxMoves; moves++) {
|
|
122
213
|
if (rules.outcome(state) !== null) {
|
|
123
|
-
return { moves, trace: trace.join('|') };
|
|
214
|
+
return { moves, outOfTurn, trace: trace.join('|') };
|
|
124
215
|
}
|
|
125
216
|
const toAct = rules.toAct(state);
|
|
126
|
-
|
|
127
|
-
|
|
217
|
+
checkSeats(toAct, seats, 'toAct', seed);
|
|
218
|
+
checkSeats(rules.mayAct?.(state) ?? [], seats, 'mayAct', seed);
|
|
219
|
+
if (toAct.length === 0) {
|
|
128
220
|
fail(`toAct is empty but there is no outcome yet (seed ${String(seed)}, move ${String(moves)})`);
|
|
129
221
|
}
|
|
130
|
-
const
|
|
222
|
+
const next = nextMove(rules, options, state, toAct, rng, pick);
|
|
223
|
+
if (next === null) {
|
|
224
|
+
return fail(`no seat could move (seed ${String(seed)}, move ${String(moves)})`);
|
|
225
|
+
}
|
|
226
|
+
const { seat, move } = next;
|
|
227
|
+
outOfTurn += toAct.includes(seat) ? 0 : 1;
|
|
131
228
|
if (bytesOf(JSON.stringify(move)) > MOVE_MAX_BYTES) {
|
|
132
229
|
fail(`a move is over ${String(MOVE_MAX_BYTES)} bytes`);
|
|
133
230
|
}
|
|
134
231
|
const result = rules.apply(roundTrip(state), seat, roundTrip(move), rng);
|
|
135
232
|
if ('error' in result) {
|
|
136
|
-
fail(`${
|
|
233
|
+
fail(`${next.by} made an illegal move for seat ${String(seat)}: ${result.error} (seed ${String(seed)}, move ${String(moves)})`);
|
|
137
234
|
}
|
|
138
235
|
state = roundTrip(result.state);
|
|
139
236
|
if (bytesOf(JSON.stringify(state)) > STATE_MAX_BYTES) {
|
|
@@ -141,7 +238,7 @@ function playGame<State, Move, View, Event, Options>(
|
|
|
141
238
|
}
|
|
142
239
|
checkViews(result.events ?? []);
|
|
143
240
|
rules.progress(state, seat);
|
|
144
|
-
trace.push(JSON.stringify(move));
|
|
241
|
+
trace.push(`${String(seat)}:${JSON.stringify(move)}`);
|
|
145
242
|
}
|
|
146
243
|
return fail(`a game did not end within ${String(maxMoves)} moves (seed ${String(seed)})`);
|
|
147
244
|
}
|
|
@@ -156,6 +253,7 @@ export function checkTurnRules<State, Move, View, Event, Options>(
|
|
|
156
253
|
let games = 0;
|
|
157
254
|
let moves = 0;
|
|
158
255
|
let longestGame = 0;
|
|
256
|
+
let outOfTurn = 0;
|
|
159
257
|
for (const seats of options.seats ?? [2]) {
|
|
160
258
|
for (let game = 0; game < (options.games ?? 25); game++) {
|
|
161
259
|
const seed = seats * 100_003 + game;
|
|
@@ -166,6 +264,7 @@ export function checkTurnRules<State, Move, View, Event, Options>(
|
|
|
166
264
|
games += 1;
|
|
167
265
|
moves += played.moves;
|
|
168
266
|
longestGame = Math.max(longestGame, played.moves);
|
|
267
|
+
outOfTurn += played.outOfTurn;
|
|
169
268
|
}
|
|
170
269
|
}
|
|
171
270
|
for (const fixture of options.fixtures ?? []) {
|
|
@@ -178,5 +277,5 @@ export function checkTurnRules<State, Move, View, Event, Options>(
|
|
|
178
277
|
rules.toAct(migrated);
|
|
179
278
|
}
|
|
180
279
|
}
|
|
181
|
-
return { games, moves, longestGame };
|
|
280
|
+
return { games, moves, longestGame, outOfTurn };
|
|
182
281
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@diffenderfer-games/hub/turns-rules`: what an online turn game's rules
|
|
3
|
+
* module imports (docs/multiplayer.md §16): `defineTurnRules` to type the
|
|
4
|
+
* rules object, the rules contract's types, and the seeded RNG the hub
|
|
5
|
+
* hands the rules. Nothing else comes with it (no zod, no client), so a
|
|
6
|
+
* rules bundle that imports it stays a few KB. The conformance kit for
|
|
7
|
+
* tests is `@diffenderfer-games/hub/turns`.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
export { createTurnRng, defineTurnRules, turnRngStateFromSeed } from '@diffenderfer-games/hub-contract/turns-rules';
|
|
12
|
+
export type { TurnApplyResult, TurnOutcome, TurnProgress, TurnRng, TurnRngState, TurnRules, TurnSetupContext } from '@diffenderfer-games/hub-contract/turns-rules';
|
package/src/turns.ts
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* rules contract, `defineTurnRules` to type a rules object, the seeded RNG
|
|
5
5
|
* the hub hands the rules (to replay a game in tests), and the conformance
|
|
6
6
|
* kit, `checkTurnRules`, for the game's own CI. The page-side API is
|
|
7
|
-
* `hub.turns` on the main entry.
|
|
7
|
+
* `hub.turns` on the main entry. A rules module itself imports
|
|
8
|
+
* `@diffenderfer-games/hub/turns-rules` (the same `defineTurnRules` and
|
|
9
|
+
* types, nothing else), so its bundle stays a few KB.
|
|
8
10
|
*
|
|
9
11
|
* @packageDocumentation
|
|
10
12
|
*/
|
|
11
|
-
export
|
|
12
|
-
export type { TurnApplyResult, TurnOutcome, TurnProgress, TurnRng, TurnRngState, TurnRules, TurnSetupContext } from '@diffenderfer-games/hub-contract';
|
|
13
|
+
export * from './turns-rules';
|
|
13
14
|
export { checkTurnRules, type TurnRulesCheckOptions, type TurnRulesReport } from './turns-kit/check-rules';
|