@diffenderfer-games/hub 2.0.0-next.7 → 2.0.0-next.9
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 +43 -4
- package/dist/auto.d.ts +3 -3
- package/dist/auto.js +2 -2
- package/dist/avatar-pixi.d.ts +19 -1
- package/dist/avatar-pixi.d.ts.map +1 -1
- package/dist/avatar-pixi.js +37 -18
- package/dist/avatar-pixi.js.map +1 -1
- package/dist/avatar-svg.d.ts +1 -1
- package/dist/avatar-svg.js +1 -1
- package/dist/avatar-three.d.ts +1 -1
- package/dist/avatar.d.ts +3 -3
- package/dist/avatar.js +20 -7
- package/dist/avatar.js.map +1 -1
- package/dist/doctor/cli.mjs +58 -4
- package/dist/doctor/cli.mjs.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/shared/animator.d.ts +1 -1
- package/dist/shared/art.js +1 -1
- package/dist/shared/atlas.d.ts +5 -1
- package/dist/shared/atlas.d.ts.map +1 -1
- package/dist/shared/avatar.d.ts +10 -4
- package/dist/shared/avatar.d.ts.map +1 -1
- package/dist/shared/bridge.js +2 -2
- package/dist/shared/bridge.js.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.js +1 -1
- package/dist/shared/describe.d.ts +1 -1
- package/dist/shared/describe.d.ts.map +1 -1
- package/dist/shared/describe.js +1 -1
- package/dist/shared/index.d.ts +88 -11
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/live-canvas.js +1 -1
- package/dist/shared/overlays.js +1 -1
- package/dist/shared/overlays.js.map +1 -1
- package/dist/shared/pose-key.js +2 -2
- package/dist/shared/pose-key.js.map +1 -1
- package/dist/shared/social.d.ts +19 -4
- package/dist/shared/social.d.ts.map +1 -1
- package/dist/shared/src.js +178 -28
- package/dist/shared/src.js.map +1 -1
- package/dist/shared/svg-markup.js +430 -21
- package/dist/shared/svg-markup.js.map +1 -1
- package/dist/shared/svg.js +2 -2
- package/dist/shared/svg.js.map +1 -1
- package/dist/shared/types.d.ts +9 -1
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/social.d.ts +2 -2
- 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 +8 -2
- package/src/avatar/adapters/pixi-crowd.ts +45 -18
- package/src/avatar/atlas.ts +16 -6
- package/src/avatar/layout.ts +1 -1
- package/src/avatar/outputs/canvas.ts +2 -2
- package/src/avatar/outputs/svg.ts +2 -2
- package/src/avatar/render/body.ts +2 -2
- package/src/avatar/render/compose.ts +25 -8
- package/src/avatar/render/frame.ts +15 -2
- package/src/avatar/render/types.ts +9 -0
- package/src/avatar/render/walker-parts.ts +121 -0
- package/src/avatar/render/walker.ts +199 -0
- package/src/avatar.ts +2 -1
- package/src/core/rest/routes.gen.ts +1 -0
- 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/doctor/checks.ts +2 -2
- package/src/doctor/cli.ts +5 -2
- package/src/doctor/world-check.ts +61 -0
- package/src/index.ts +2 -1
- package/src/realtime/runtime-api.ts +5 -2
- package/src/realtime/types.ts +1 -0
- package/src/realtime/world-types.ts +20 -0
- package/src/turns-kit/check-rules.ts +116 -17
- package/src/turns-rules.ts +12 -0
- package/src/turns.ts +4 -3
package/src/doctor/checks.ts
CHANGED
|
@@ -15,8 +15,8 @@ export type FindingLevel = 'error' | 'warning' | 'info';
|
|
|
15
15
|
export interface Finding {
|
|
16
16
|
/** How serious it is. */
|
|
17
17
|
readonly level: FindingLevel;
|
|
18
|
-
/** Which check found it: `version`, `vendored` or `
|
|
19
|
-
readonly check: 'version' | 'vendored' | 'manifest';
|
|
18
|
+
/** Which check found it: `version`, `vendored`, `manifest` or `world`. */
|
|
19
|
+
readonly check: 'version' | 'vendored' | 'manifest' | 'world';
|
|
20
20
|
/** What to tell the developer, with the fix. */
|
|
21
21
|
readonly message: string;
|
|
22
22
|
}
|
package/src/doctor/cli.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* `/_api/version` (`--hub`, default `https://diffenderfer.games`): outside
|
|
12
12
|
* `supported` fails, `deprecated` warns (`--offline` skips this check);
|
|
13
13
|
* - a stale vendored `src/hub/` copy or `scripts/sync-hub.mjs` warns;
|
|
14
|
-
* - the `package.json` `game.*` block must match the contract's schema
|
|
14
|
+
* - the `package.json` `game.*` block must match the contract's schema;
|
|
15
|
+
* - a `game.world.map` file must exist and be a valid `dg-world@1` map within the size limit.
|
|
15
16
|
*
|
|
16
17
|
* Exits 1 when any check fails.
|
|
17
18
|
*/
|
|
@@ -21,6 +22,7 @@ import { parseArgs } from 'node:util';
|
|
|
21
22
|
import { HUB_CLIENT_VERSION } from '../core/version/client-version.gen';
|
|
22
23
|
import { checkClientVersion, checkManifest, checkVendoredCopies, type Finding } from './checks';
|
|
23
24
|
import { DEFAULT_HUB_TIMEOUT_MS, fetchHubVersion } from './hub-version-fetch';
|
|
25
|
+
import { checkWorld } from './world-check';
|
|
24
26
|
|
|
25
27
|
const DEFAULT_HUB_URL = 'https://diffenderfer.games';
|
|
26
28
|
|
|
@@ -79,7 +81,8 @@ async function runDoctor(options: DoctorOptions): Promise<Finding[]> {
|
|
|
79
81
|
}
|
|
80
82
|
findings.push(...checkVendoredCopies(options.gameDir));
|
|
81
83
|
try {
|
|
82
|
-
|
|
84
|
+
const packageJson = readJsonFile(join(options.gameDir, 'package.json'));
|
|
85
|
+
findings.push(...checkManifest(packageJson), ...checkWorld(options.gameDir, packageJson));
|
|
83
86
|
} catch (failure) {
|
|
84
87
|
findings.push({ level: 'error', check: 'manifest', message: `could not read package.json: ${failure instanceof Error ? failure.message : String(failure)}` });
|
|
85
88
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hub-doctor`'s world check (docs/plans/world.md §14.6): a `game.world.map`
|
|
3
|
+
* file must exist inside the game, be within the size limit and be a valid
|
|
4
|
+
* `dg-world@1` map (the same `parseWorldMapText` the hub runs at load); a
|
|
5
|
+
* `game.world.generate.type` the hub doesn't know is a warning.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { GamePackageJson } from '@diffenderfer-games/hub-contract';
|
|
10
|
+
import { BUILT_IN_WORLD_TYPES, WORLD_LIMITS, parseWorldMapText } from '@diffenderfer-games/world';
|
|
11
|
+
import type { Finding } from './checks';
|
|
12
|
+
|
|
13
|
+
/** The most map problems listed (the rest are counted). */
|
|
14
|
+
const MAX_LISTED_PROBLEMS = 20;
|
|
15
|
+
|
|
16
|
+
/** Checks the map file and world type `game.world` names. A manifest that doesn't parse is left to `checkManifest`. */
|
|
17
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: the game's package.json, parsed by GamePackageJson next
|
|
18
|
+
export function checkWorld(gameDir: string, packageJson: unknown): Finding[] {
|
|
19
|
+
const parsed = GamePackageJson.safeParse(packageJson);
|
|
20
|
+
const world = parsed.success ? parsed.data.game.world : undefined;
|
|
21
|
+
if (world === undefined) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
const findings: Finding[] = [];
|
|
25
|
+
const type = world.generate?.type;
|
|
26
|
+
if (type !== undefined && !BUILT_IN_WORLD_TYPES.some((known) => known.id === type)) {
|
|
27
|
+
findings.push({ level: 'warning', check: 'world', message: `game.world.generate.type: the hub has no world type "${type}"; use one of ${BUILT_IN_WORLD_TYPES.map((known) => known.id).join(', ')}` });
|
|
28
|
+
}
|
|
29
|
+
return world.map === undefined ? findings : [...findings, ...checkMapFile(gameDir, world.map)];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One map file's findings. */
|
|
33
|
+
export function checkMapFile(gameDir: string, mapFile: string): Finding[] {
|
|
34
|
+
const path = join(gameDir, mapFile);
|
|
35
|
+
const error = (message: string): Finding => ({ level: 'error', check: 'world', message: `game.world.map (${mapFile}): ${message}` });
|
|
36
|
+
if (!existsSync(path) || !statSync(path).isFile()) {
|
|
37
|
+
return [error('the file does not exist (build or commit it)')];
|
|
38
|
+
}
|
|
39
|
+
const bytes = statSync(path).size;
|
|
40
|
+
if (bytes > WORLD_LIMITS.bytes) {
|
|
41
|
+
return [error(`${String(bytes)} bytes is over the ${String(WORLD_LIMITS.bytes)}-byte limit (save layers as "rle")`)];
|
|
42
|
+
}
|
|
43
|
+
const result = parseWorldMapText(readFileSync(path, 'utf8'));
|
|
44
|
+
const listed = result.problems.slice(0, MAX_LISTED_PROBLEMS).map((problem): Finding => ({
|
|
45
|
+
level: problem.severity === 'error' ? 'error' : 'warning',
|
|
46
|
+
check: 'world',
|
|
47
|
+
message: `game.world.map (${mapFile}): ${problem.message}`,
|
|
48
|
+
}));
|
|
49
|
+
const more = result.problems.length - listed.length;
|
|
50
|
+
if (more > 0) {
|
|
51
|
+
listed.push(error(`and ${String(more)} more problems`));
|
|
52
|
+
}
|
|
53
|
+
if (!result.ok) {
|
|
54
|
+
return listed;
|
|
55
|
+
}
|
|
56
|
+
const { map } = result;
|
|
57
|
+
return [...listed, {
|
|
58
|
+
level: 'info', check: 'world',
|
|
59
|
+
message: `game.world.map (${mapFile}) is a valid dg-world@1 map: ${map.title}, ${String(map.width)} × ${String(map.height)}, ${String(map.objects.length)} objects, ${String(bytes)} bytes`,
|
|
60
|
+
}];
|
|
61
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -46,7 +46,8 @@ export { HubSaved, SAVED_CHANGE_QUIET_MS, type SavedChangeListener, type SavedOp
|
|
|
46
46
|
export { SAVED_CHANGE_EVENT, SAVED_KIND_PARAM, SAVED_OPEN_EVENT, SAVED_OPEN_PARAM, savedGameUrl } from './core/saved/saved-open';
|
|
47
47
|
export type * from './core/saved/saved-types';
|
|
48
48
|
export { HubTurns, TURN_LINK_PARAM, type TurnCreateOptions } from './core/turns/hub-turns';
|
|
49
|
-
export {
|
|
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';
|
|
50
51
|
export type { OpenTurnGame, TurnChatLine, TurnDeadline, TurnGameView } from '@diffenderfer-games/hub-contract';
|
|
51
52
|
export type { QueueOp, QueueOpInput, QueuedScoreOptions, ProfilePatch } from './core/offline/queue';
|
|
52
53
|
export { OfflineApi } from './core/offline/offline-api';
|
|
@@ -8,14 +8,15 @@ import type { HubMpApi, InviteLinkMethods } from './multiplayer-types';
|
|
|
8
8
|
import type { HubRaceApi } from './race-types';
|
|
9
9
|
import type { HubRoomsApi, RoomReclaimMethods } from './room-types';
|
|
10
10
|
import type { HubNotifyApi, HubPresenceApi, HubSocialApi, PlayOnlineMethods } from './social-types';
|
|
11
|
+
import type { RuntimeWorldApi } from './world-types';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* The realtime feature version the runtime reports in {@link RealtimeApi.version}.
|
|
14
15
|
* 2 added races; 3 added invite links and taking back a held seat; 4 added
|
|
15
|
-
* "Play online" (tournaments); 5 added avatars (the designer and `ev:avatar`).
|
|
16
|
+
* "Play online" (tournaments); 5 added avatars (the designer and `ev:avatar`); 6 added the world (`world`).
|
|
16
17
|
* A client feature-detects newer additions with it (or by the optional method).
|
|
17
18
|
*/
|
|
18
|
-
export const REALTIME_FEATURE_VERSION =
|
|
19
|
+
export const REALTIME_FEATURE_VERSION = 6;
|
|
19
20
|
|
|
20
21
|
/** `hub.social` as a runtime installs it: version 4's `openPlayOnline` is missing on older runtimes. */
|
|
21
22
|
export type RuntimeSocialApi = Omit<HubSocialApi, keyof PlayOnlineMethods> & Partial<PlayOnlineMethods>;
|
|
@@ -68,4 +69,6 @@ export interface RealtimeApi {
|
|
|
68
69
|
race?: HubRaceApi;
|
|
69
70
|
/** Avatars: the designer and live changes (feature version 5 and later). */
|
|
70
71
|
avatar?: RuntimeAvatarApi;
|
|
72
|
+
/** The world's ops and events (feature version 6 and later). */
|
|
73
|
+
world?: RuntimeWorldApi;
|
|
71
74
|
}
|
package/src/realtime/types.ts
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The world over the runtime's socket (docs/plans/world.md §8.3): the
|
|
3
|
+
* `world.*` ops and events, typed from the contract. The hub's world client
|
|
4
|
+
* calls it; games don't need it.
|
|
5
|
+
*/
|
|
6
|
+
import type { EventData, EventName, events, OpAck, OpInput, OpName, ops } from '@diffenderfer-games/hub-contract';
|
|
7
|
+
|
|
8
|
+
/** A `world.*` op name. */
|
|
9
|
+
export type WorldOpName = Extract<OpName, `world.${string}`>;
|
|
10
|
+
|
|
11
|
+
/** A `world.*` event name. */
|
|
12
|
+
export type WorldEventName = Extract<EventName, `world.${string}`>;
|
|
13
|
+
|
|
14
|
+
/** `rt.world` (realtime feature version 6 and later). */
|
|
15
|
+
export interface RuntimeWorldApi {
|
|
16
|
+
/** Send a world op on the page's socket; rejects with the hub's refusal. */
|
|
17
|
+
op<Name extends WorldOpName>(name: Name, input: OpInput<(typeof ops)[Name]>): Promise<OpAck<(typeof ops)[Name]>>;
|
|
18
|
+
/** Hear a world event; returns the unsubscribe. */
|
|
19
|
+
on<Name extends WorldEventName>(name: Name, listener: (payload: EventData<(typeof events)[Name]>) => void): () => void;
|
|
20
|
+
}
|
|
@@ -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';
|