@masonf539/game-sdk 0.0.0-stage → 1.0.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/LICENSE +25 -0
- package/README.md +30 -3
- package/dist/gameDefinition.d.ts +57 -0
- package/dist/gameDefinition.js +2 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +9 -0
- package/dist/manifest.d.ts +28 -0
- package/dist/manifest.js +4 -0
- package/dist/plugin.d.ts +120 -0
- package/dist/plugin.js +2 -0
- package/dist/registry.d.ts +14 -0
- package/dist/registry.js +165 -0
- package/package.json +43 -6
package/LICENSE
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
GameHub SDK License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mason Fong
|
|
4
|
+
|
|
5
|
+
Required Notice: Copyright (c) 2026 Mason Fong
|
|
6
|
+
Required Notice: The GameHub SDK is licensed under the PolyForm Noncommercial License 1.0.0.
|
|
7
|
+
|
|
8
|
+
The GameHub SDK, including its source code, compiled forms, documentation,
|
|
9
|
+
examples, tests, and associated files, is licensed under the PolyForm
|
|
10
|
+
Noncommercial License 1.0.0.
|
|
11
|
+
|
|
12
|
+
The complete license terms are available at:
|
|
13
|
+
|
|
14
|
+
https://polyformproject.org/licenses/noncommercial/1.0.0
|
|
15
|
+
|
|
16
|
+
You may use, copy, modify, and distribute the GameHub SDK only for purposes
|
|
17
|
+
permitted by the PolyForm Noncommercial License 1.0.0.
|
|
18
|
+
|
|
19
|
+
Commercial use requires a separate written license from the copyright holder.
|
|
20
|
+
|
|
21
|
+
Third-party dependencies and materials remain governed by their respective
|
|
22
|
+
licenses.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OR CONDITION, TO THE EXTENT
|
|
25
|
+
PERMITTED BY LAW.
|
package/README.md
CHANGED
|
@@ -1,3 +1,30 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# GameHub Game SDK
|
|
2
|
+
|
|
3
|
+
Versioned TypeScript contracts used by GameHub and its trusted game packages.
|
|
4
|
+
Game packages depend on this package instead of importing GameHub's private
|
|
5
|
+
server source.
|
|
6
|
+
|
|
7
|
+
This package is reusable GameHub platform infrastructure, not a playable game.
|
|
8
|
+
It lives under `packages/` and remains owned by the GameHub repository when
|
|
9
|
+
individual games move into their own repositories. Egyptian War and future
|
|
10
|
+
games consume an exact SDK release from the public npm registry.
|
|
11
|
+
|
|
12
|
+
The SDK defines package manifests, authoritative `GameSession` lifecycle
|
|
13
|
+
hooks, viewer-safe state and event envelopes, browser-module platform
|
|
14
|
+
services, and optional named keybind actions. A game declares stable action
|
|
15
|
+
IDs and defaults; GameHub owns the remapping UI, local persistence, and input
|
|
16
|
+
routing through `GameClientContext.input`. GameHub treats installed packages
|
|
17
|
+
as trusted code and does not expose its private room implementation through
|
|
18
|
+
these contracts.
|
|
19
|
+
|
|
20
|
+
## Releasing
|
|
21
|
+
|
|
22
|
+
The package is configured for public npm publication. From the repository
|
|
23
|
+
root, first update its semantic version and verify the full suite, then run:
|
|
24
|
+
|
|
25
|
+
```powershell
|
|
26
|
+
npm publish --workspace @masonf539/game-sdk
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Publishing requires npm access to the `@masonf539` scope. Game packages should
|
|
30
|
+
pin a released SDK version so their supported GameHub API is reproducible.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type BooleanGameSetting = {
|
|
2
|
+
type?: "boolean";
|
|
3
|
+
key: string;
|
|
4
|
+
label: string;
|
|
5
|
+
description: string;
|
|
6
|
+
defaultValue: boolean;
|
|
7
|
+
};
|
|
8
|
+
export type SelectNumberGameSetting = {
|
|
9
|
+
type: "number";
|
|
10
|
+
control?: "select";
|
|
11
|
+
key: string;
|
|
12
|
+
label: string;
|
|
13
|
+
description: string;
|
|
14
|
+
defaultValue: number;
|
|
15
|
+
options: number[];
|
|
16
|
+
unit?: string;
|
|
17
|
+
};
|
|
18
|
+
export type RangeNumberGameSetting = {
|
|
19
|
+
type: "number";
|
|
20
|
+
control: "range";
|
|
21
|
+
key: string;
|
|
22
|
+
label: string;
|
|
23
|
+
description: string;
|
|
24
|
+
defaultValue: number;
|
|
25
|
+
min: number;
|
|
26
|
+
max: number;
|
|
27
|
+
step: number;
|
|
28
|
+
unit?: string;
|
|
29
|
+
};
|
|
30
|
+
export type NumberGameSetting = SelectNumberGameSetting | RangeNumberGameSetting;
|
|
31
|
+
export type GameSetting = BooleanGameSetting | NumberGameSetting;
|
|
32
|
+
export type GameSettingValue = boolean | number;
|
|
33
|
+
export type GamePreview = {
|
|
34
|
+
videoPath: string;
|
|
35
|
+
posterPath: string;
|
|
36
|
+
};
|
|
37
|
+
export type GameKeybind = {
|
|
38
|
+
/** Stable action id used by the game client, such as "play-card". */
|
|
39
|
+
id: string;
|
|
40
|
+
label: string;
|
|
41
|
+
description?: string;
|
|
42
|
+
/** KeyboardEvent.code value, so defaults follow physical key positions. */
|
|
43
|
+
defaultKey: string;
|
|
44
|
+
};
|
|
45
|
+
export type GameDefinition = {
|
|
46
|
+
id: string;
|
|
47
|
+
name: string;
|
|
48
|
+
description: string;
|
|
49
|
+
isPlayable: boolean;
|
|
50
|
+
chatEnabled: boolean;
|
|
51
|
+
rules: string[];
|
|
52
|
+
minPlayers: number;
|
|
53
|
+
maxPlayers: number;
|
|
54
|
+
settings: GameSetting[];
|
|
55
|
+
keybinds?: GameKeybind[];
|
|
56
|
+
preview?: GamePreview;
|
|
57
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type { BooleanGameSetting, GameDefinition, GameKeybind, GamePreview, GameSetting, GameSettingValue, NumberGameSetting, RangeNumberGameSetting, SelectNumberGameSetting } from "./gameDefinition.js";
|
|
2
|
+
export { gameHubGameApiVersion, type EmbeddedGameClient, type GameClientDelivery, type GamePluginManifest, type GamePluginPackage, type ModuleGameClient } from "./manifest.js";
|
|
3
|
+
export type { GameActionEnvelope, GameActionResult, GameAudioApi, GameClientContext, GameClientInstance, GameClientModule, GameCompletion, GameEvent, GameEventEnvelope, GameLifecycleState, GameMember, GameMemberRole, GameInputApi, GamePlatformEvent, GameServerPlugin, GameSession, GameSessionContext, GameStateEnvelope, GameViewer } from "./plugin.js";
|
|
4
|
+
export { GamePluginRegistry, GamePluginValidationError, validateGamePluginManifest } from "./registry.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.validateGamePluginManifest = exports.GamePluginValidationError = exports.GamePluginRegistry = exports.gameHubGameApiVersion = void 0;
|
|
4
|
+
var manifest_js_1 = require("./manifest.js");
|
|
5
|
+
Object.defineProperty(exports, "gameHubGameApiVersion", { enumerable: true, get: function () { return manifest_js_1.gameHubGameApiVersion; } });
|
|
6
|
+
var registry_js_1 = require("./registry.js");
|
|
7
|
+
Object.defineProperty(exports, "GamePluginRegistry", { enumerable: true, get: function () { return registry_js_1.GamePluginRegistry; } });
|
|
8
|
+
Object.defineProperty(exports, "GamePluginValidationError", { enumerable: true, get: function () { return registry_js_1.GamePluginValidationError; } });
|
|
9
|
+
Object.defineProperty(exports, "validateGamePluginManifest", { enumerable: true, get: function () { return registry_js_1.validateGamePluginManifest; } });
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { GameDefinition } from "./gameDefinition.js";
|
|
2
|
+
import type { GameServerPlugin } from "./plugin.js";
|
|
3
|
+
export declare const gameHubGameApiVersion: 1;
|
|
4
|
+
export type EmbeddedGameClient = {
|
|
5
|
+
delivery: "embedded";
|
|
6
|
+
markupPath?: string;
|
|
7
|
+
entryPaths?: string[];
|
|
8
|
+
stylePaths?: string[];
|
|
9
|
+
};
|
|
10
|
+
export type ModuleGameClient = {
|
|
11
|
+
delivery: "module";
|
|
12
|
+
entryPath: string;
|
|
13
|
+
markupPath?: string;
|
|
14
|
+
entryPaths?: string[];
|
|
15
|
+
stylePaths?: string[];
|
|
16
|
+
};
|
|
17
|
+
export type GameClientDelivery = EmbeddedGameClient | ModuleGameClient;
|
|
18
|
+
export type GamePluginManifest = {
|
|
19
|
+
apiVersion: typeof gameHubGameApiVersion;
|
|
20
|
+
packageVersion: string;
|
|
21
|
+
definition: GameDefinition;
|
|
22
|
+
client: GameClientDelivery;
|
|
23
|
+
};
|
|
24
|
+
export type GamePluginPackage = {
|
|
25
|
+
manifest: GamePluginManifest;
|
|
26
|
+
publicDirectory: string;
|
|
27
|
+
server: GameServerPlugin;
|
|
28
|
+
};
|
package/dist/manifest.js
ADDED
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { GameSettingValue } from "./gameDefinition.js";
|
|
2
|
+
import type { GamePluginManifest } from "./manifest.js";
|
|
3
|
+
export type GameMemberRole = "player" | "spectator";
|
|
4
|
+
export type GameMember = {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
avatar: string;
|
|
8
|
+
isConnected: boolean;
|
|
9
|
+
role: GameMemberRole;
|
|
10
|
+
};
|
|
11
|
+
export type GameViewer = {
|
|
12
|
+
memberId: string;
|
|
13
|
+
role: GameMemberRole;
|
|
14
|
+
};
|
|
15
|
+
export type GameActionEnvelope = {
|
|
16
|
+
type: string;
|
|
17
|
+
payload?: unknown;
|
|
18
|
+
};
|
|
19
|
+
export type GameActionResult = {
|
|
20
|
+
success: boolean;
|
|
21
|
+
message?: string;
|
|
22
|
+
};
|
|
23
|
+
export type GameEvent = {
|
|
24
|
+
type: string;
|
|
25
|
+
payload?: unknown;
|
|
26
|
+
};
|
|
27
|
+
export type GameStateEnvelope = {
|
|
28
|
+
gameId: string;
|
|
29
|
+
state: unknown;
|
|
30
|
+
};
|
|
31
|
+
export type GameEventEnvelope = {
|
|
32
|
+
gameId: string;
|
|
33
|
+
event: GameEvent;
|
|
34
|
+
};
|
|
35
|
+
export type GameCompletion = {
|
|
36
|
+
winnerId: string | null;
|
|
37
|
+
reason: string;
|
|
38
|
+
};
|
|
39
|
+
export type GameSessionContext = {
|
|
40
|
+
roomCode: string;
|
|
41
|
+
members: () => readonly GameMember[];
|
|
42
|
+
broadcastState: () => void;
|
|
43
|
+
emitEvent: (event: GameEvent) => void;
|
|
44
|
+
finish: (completion: GameCompletion) => void;
|
|
45
|
+
now: () => number;
|
|
46
|
+
randomInteger(maxExclusive: number): number;
|
|
47
|
+
};
|
|
48
|
+
export type GameLifecycleState = {
|
|
49
|
+
isPaused: boolean;
|
|
50
|
+
isBusy: boolean;
|
|
51
|
+
};
|
|
52
|
+
export interface GameSession {
|
|
53
|
+
start?(): void;
|
|
54
|
+
getPublicState(viewer: GameViewer): unknown;
|
|
55
|
+
getLifecycleState(): GameLifecycleState;
|
|
56
|
+
handleAction(memberId: string, action: GameActionEnvelope): GameActionResult | Promise<GameActionResult>;
|
|
57
|
+
pause?(): GameActionResult;
|
|
58
|
+
resume?(): GameActionResult;
|
|
59
|
+
memberDisconnected?(memberId: string): void;
|
|
60
|
+
memberReconnected?(memberId: string, previousMemberId?: string): void;
|
|
61
|
+
/**
|
|
62
|
+
* A member is leaving the game. `voluntary` is true when they left on
|
|
63
|
+
* their own (they may still be connected) and false when the host removed
|
|
64
|
+
* a disconnected player.
|
|
65
|
+
*/
|
|
66
|
+
memberRemoved?(memberId: string, options?: {
|
|
67
|
+
voluntary?: boolean;
|
|
68
|
+
}): GameActionResult;
|
|
69
|
+
dispose(): void;
|
|
70
|
+
}
|
|
71
|
+
export interface GameServerPlugin {
|
|
72
|
+
manifest: GamePluginManifest;
|
|
73
|
+
createSession(context: GameSessionContext, settings: Readonly<Record<string, GameSettingValue>>): GameSession;
|
|
74
|
+
}
|
|
75
|
+
export type GameAudioApi = {
|
|
76
|
+
playEffect(name: string): void;
|
|
77
|
+
registerEffect(name: string, options: {
|
|
78
|
+
source: string;
|
|
79
|
+
category?: "music" | "join" | "game";
|
|
80
|
+
volume?: number;
|
|
81
|
+
}): void;
|
|
82
|
+
registerMusicScene(name: string, source: string): void;
|
|
83
|
+
setScene(scene: string | null): void;
|
|
84
|
+
};
|
|
85
|
+
export type GameInputApi = {
|
|
86
|
+
/** Subscribe to a named action declared by this game's manifest. */
|
|
87
|
+
onAction(actionId: string, handler: () => void): () => void;
|
|
88
|
+
};
|
|
89
|
+
export type GameClientContext = {
|
|
90
|
+
root: HTMLElement;
|
|
91
|
+
gameId: string;
|
|
92
|
+
memberId: string;
|
|
93
|
+
role: GameMemberRole;
|
|
94
|
+
isHost: boolean;
|
|
95
|
+
launchData: unknown;
|
|
96
|
+
audio: GameAudioApi;
|
|
97
|
+
input: GameInputApi;
|
|
98
|
+
submitAction(action: GameActionEnvelope): Promise<GameActionResult>;
|
|
99
|
+
requestPause(isPaused: boolean): Promise<GameActionResult>;
|
|
100
|
+
sendChat(message: string): Promise<GameActionResult>;
|
|
101
|
+
removeMember(memberId: string): Promise<GameActionResult>;
|
|
102
|
+
/** Opens the confirmation flow for leaving the active game but staying in its room. */
|
|
103
|
+
requestLeave(): void;
|
|
104
|
+
notify(message: string): void;
|
|
105
|
+
requestExit(): void;
|
|
106
|
+
};
|
|
107
|
+
export type GamePlatformEvent = {
|
|
108
|
+
type: "spectators" | "chat-message";
|
|
109
|
+
payload: unknown;
|
|
110
|
+
};
|
|
111
|
+
export interface GameClientInstance {
|
|
112
|
+
receiveState(state: unknown): void;
|
|
113
|
+
receiveEvent(event: GameEvent): void;
|
|
114
|
+
receivePlatformEvent?(event: GamePlatformEvent): void;
|
|
115
|
+
setPaused?(isPaused: boolean): void;
|
|
116
|
+
destroy(): void;
|
|
117
|
+
}
|
|
118
|
+
export interface GameClientModule {
|
|
119
|
+
mount(context: GameClientContext): GameClientInstance;
|
|
120
|
+
}
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { GameDefinition } from "./gameDefinition.js";
|
|
2
|
+
import { type GamePluginManifest } from "./manifest.js";
|
|
3
|
+
export declare class GamePluginValidationError extends Error {
|
|
4
|
+
constructor(message: string);
|
|
5
|
+
}
|
|
6
|
+
export declare function validateGamePluginManifest(manifest: GamePluginManifest): void;
|
|
7
|
+
export declare class GamePluginRegistry {
|
|
8
|
+
#private;
|
|
9
|
+
constructor(manifests?: Iterable<GamePluginManifest>);
|
|
10
|
+
register(manifest: GamePluginManifest): void;
|
|
11
|
+
getManifest(gameId: string): GamePluginManifest | null;
|
|
12
|
+
getDefinition(gameId: string): GameDefinition | null;
|
|
13
|
+
listDefinitions(): GameDefinition[];
|
|
14
|
+
}
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GamePluginRegistry = exports.GamePluginValidationError = void 0;
|
|
4
|
+
exports.validateGamePluginManifest = validateGamePluginManifest;
|
|
5
|
+
const manifest_js_1 = require("./manifest.js");
|
|
6
|
+
const identifierPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
7
|
+
const settingKeyPattern = /^[A-Za-z][A-Za-z0-9]*$/;
|
|
8
|
+
const actionIdPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
9
|
+
const semanticVersionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
10
|
+
class GamePluginValidationError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "GamePluginValidationError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
exports.GamePluginValidationError = GamePluginValidationError;
|
|
17
|
+
function requireNonEmpty(value, field) {
|
|
18
|
+
if (value.trim().length === 0) {
|
|
19
|
+
throw new GamePluginValidationError(`${field} cannot be empty.`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function validatePublicPath(value, field) {
|
|
23
|
+
requireNonEmpty(value, field);
|
|
24
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value) ||
|
|
25
|
+
value.startsWith("/") ||
|
|
26
|
+
value.includes("\\") ||
|
|
27
|
+
value.split("/").includes("..")) {
|
|
28
|
+
throw new GamePluginValidationError(`${field} must be package-relative.`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Preview media is addressed by absolute site path inside the game's own
|
|
32
|
+
// folder, e.g. /games/<id>/assets/preview.mp4. It ends up in a CSS url(), so
|
|
33
|
+
// only plain path characters are allowed.
|
|
34
|
+
function validatePreviewPath(value, gameId, field) {
|
|
35
|
+
requireNonEmpty(value, field);
|
|
36
|
+
const prefix = `/games/${gameId}/`;
|
|
37
|
+
if (!value.startsWith(prefix) ||
|
|
38
|
+
!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value.slice(prefix.length)) ||
|
|
39
|
+
value.split("/").includes("..")) {
|
|
40
|
+
throw new GamePluginValidationError(`${field} must be a path inside ${prefix}.`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function validateSetting(setting, gameId) {
|
|
44
|
+
const settingKey = setting.key;
|
|
45
|
+
const defaultValue = setting.defaultValue;
|
|
46
|
+
if (!settingKeyPattern.test(settingKey)) {
|
|
47
|
+
throw new GamePluginValidationError(`${gameId} has an invalid setting key: ${settingKey}.`);
|
|
48
|
+
}
|
|
49
|
+
requireNonEmpty(setting.label, `${gameId}.${settingKey} label`);
|
|
50
|
+
requireNonEmpty(setting.description, `${gameId}.${settingKey} description`);
|
|
51
|
+
if (setting.type !== "number") {
|
|
52
|
+
if (typeof defaultValue !== "boolean") {
|
|
53
|
+
throw new GamePluginValidationError(`${gameId}.${settingKey} must have a boolean default.`);
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (typeof defaultValue !== "number" || !Number.isFinite(defaultValue)) {
|
|
58
|
+
throw new GamePluginValidationError(`${gameId}.${settingKey} must have a finite numeric default.`);
|
|
59
|
+
}
|
|
60
|
+
if (setting.control === "range") {
|
|
61
|
+
if (!Number.isFinite(setting.min) ||
|
|
62
|
+
!Number.isFinite(setting.max) ||
|
|
63
|
+
!Number.isFinite(setting.step) ||
|
|
64
|
+
setting.min > setting.max ||
|
|
65
|
+
setting.step <= 0 ||
|
|
66
|
+
defaultValue < setting.min ||
|
|
67
|
+
defaultValue > setting.max) {
|
|
68
|
+
throw new GamePluginValidationError(`${gameId}.${settingKey} has an invalid numeric range.`);
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (setting.options.length === 0 ||
|
|
73
|
+
setting.options.some((option) => !Number.isFinite(option)) ||
|
|
74
|
+
new Set(setting.options).size !== setting.options.length ||
|
|
75
|
+
!setting.options.includes(defaultValue)) {
|
|
76
|
+
throw new GamePluginValidationError(`${gameId}.${settingKey} has invalid select options.`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function validateGamePluginManifest(manifest) {
|
|
80
|
+
if (manifest.apiVersion !== manifest_js_1.gameHubGameApiVersion) {
|
|
81
|
+
throw new GamePluginValidationError(`Unsupported GameHub game API version: ${manifest.apiVersion}.`);
|
|
82
|
+
}
|
|
83
|
+
if (!semanticVersionPattern.test(manifest.packageVersion)) {
|
|
84
|
+
throw new GamePluginValidationError(`Invalid game package version: ${manifest.packageVersion}.`);
|
|
85
|
+
}
|
|
86
|
+
const game = manifest.definition;
|
|
87
|
+
if (!identifierPattern.test(game.id)) {
|
|
88
|
+
throw new GamePluginValidationError(`Invalid game id: ${game.id}.`);
|
|
89
|
+
}
|
|
90
|
+
requireNonEmpty(game.name, `${game.id} name`);
|
|
91
|
+
requireNonEmpty(game.description, `${game.id} description`);
|
|
92
|
+
if (!Number.isInteger(game.minPlayers) ||
|
|
93
|
+
!Number.isInteger(game.maxPlayers) ||
|
|
94
|
+
game.minPlayers < 1 ||
|
|
95
|
+
game.maxPlayers < game.minPlayers) {
|
|
96
|
+
throw new GamePluginValidationError(`${game.id} has invalid player limits.`);
|
|
97
|
+
}
|
|
98
|
+
if (game.preview) {
|
|
99
|
+
validatePreviewPath(game.preview.videoPath, game.id, `${game.id} preview video path`);
|
|
100
|
+
validatePreviewPath(game.preview.posterPath, game.id, `${game.id} preview poster path`);
|
|
101
|
+
}
|
|
102
|
+
const settingKeys = new Set();
|
|
103
|
+
for (const setting of game.settings) {
|
|
104
|
+
if (settingKeys.has(setting.key)) {
|
|
105
|
+
throw new GamePluginValidationError(`${game.id} has a duplicate setting key: ${setting.key}.`);
|
|
106
|
+
}
|
|
107
|
+
settingKeys.add(setting.key);
|
|
108
|
+
validateSetting(setting, game.id);
|
|
109
|
+
}
|
|
110
|
+
const keybindIds = new Set();
|
|
111
|
+
const defaultKeys = new Set();
|
|
112
|
+
for (const keybind of game.keybinds ?? []) {
|
|
113
|
+
if (!actionIdPattern.test(keybind.id)) {
|
|
114
|
+
throw new GamePluginValidationError(`${game.id} has an invalid keybind action id: ${keybind.id}.`);
|
|
115
|
+
}
|
|
116
|
+
if (keybindIds.has(keybind.id)) {
|
|
117
|
+
throw new GamePluginValidationError(`${game.id} has a duplicate keybind action id: ${keybind.id}.`);
|
|
118
|
+
}
|
|
119
|
+
requireNonEmpty(keybind.label, `${game.id}.${keybind.id} keybind label`);
|
|
120
|
+
requireNonEmpty(keybind.defaultKey, `${game.id}.${keybind.id} default keybind`);
|
|
121
|
+
if (defaultKeys.has(keybind.defaultKey)) {
|
|
122
|
+
throw new GamePluginValidationError(`${game.id} assigns ${keybind.defaultKey} to multiple actions.`);
|
|
123
|
+
}
|
|
124
|
+
keybindIds.add(keybind.id);
|
|
125
|
+
defaultKeys.add(keybind.defaultKey);
|
|
126
|
+
}
|
|
127
|
+
if (manifest.client.delivery === "module") {
|
|
128
|
+
validatePublicPath(manifest.client.entryPath, `${game.id} module entry path`);
|
|
129
|
+
}
|
|
130
|
+
if (manifest.client.markupPath) {
|
|
131
|
+
validatePublicPath(manifest.client.markupPath, `${game.id} client markup path`);
|
|
132
|
+
}
|
|
133
|
+
for (const entryPath of manifest.client.entryPaths ?? []) {
|
|
134
|
+
validatePublicPath(entryPath, `${game.id} client entry path`);
|
|
135
|
+
}
|
|
136
|
+
for (const stylePath of manifest.client.stylePaths ?? []) {
|
|
137
|
+
validatePublicPath(stylePath, `${game.id} client style path`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
class GamePluginRegistry {
|
|
141
|
+
#manifests = new Map();
|
|
142
|
+
constructor(manifests = []) {
|
|
143
|
+
for (const manifest of manifests) {
|
|
144
|
+
this.register(manifest);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
register(manifest) {
|
|
148
|
+
validateGamePluginManifest(manifest);
|
|
149
|
+
const gameId = manifest.definition.id;
|
|
150
|
+
if (this.#manifests.has(gameId)) {
|
|
151
|
+
throw new GamePluginValidationError(`A game with id ${gameId} is already registered.`);
|
|
152
|
+
}
|
|
153
|
+
this.#manifests.set(gameId, manifest);
|
|
154
|
+
}
|
|
155
|
+
getManifest(gameId) {
|
|
156
|
+
return this.#manifests.get(gameId) ?? null;
|
|
157
|
+
}
|
|
158
|
+
getDefinition(gameId) {
|
|
159
|
+
return this.getManifest(gameId)?.definition ?? null;
|
|
160
|
+
}
|
|
161
|
+
listDefinitions() {
|
|
162
|
+
return Array.from(this.#manifests.values(), (manifest) => manifest.definition);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
exports.GamePluginRegistry = GamePluginRegistry;
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@masonf539/game-sdk",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@masonf539/game-sdk",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Versioned contracts for GameHub game plugins.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"require": "./dist/index.js",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/MasonF539/GameHub.git",
|
|
23
|
+
"directory": "packages/game-sdk"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
27
|
+
"clean:test": "node -e \"require('node:fs').rmSync('dist-test',{recursive:true,force:true})\"",
|
|
28
|
+
"build": "npm run clean && tsc",
|
|
29
|
+
"build:test": "npm run clean:test && tsc -p tsconfig.test.json",
|
|
30
|
+
"check": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
|
|
31
|
+
"test": "npm run build:test && node --test dist-test/*.test.js",
|
|
32
|
+
"prepack": "npm run build"
|
|
33
|
+
},
|
|
34
|
+
"license": "PolyForm-Noncommercial-1.0.0",
|
|
35
|
+
"type": "commonjs",
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=20"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^26.6.4",
|
|
41
|
+
"typescript": "^7.0.2"
|
|
42
|
+
}
|
|
43
|
+
}
|