@zhin.js/game-kit 2.0.1 → 2.0.2

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.
Files changed (116) hide show
  1. package/README.md +73 -16
  2. package/lib/base-session-service.d.ts +54 -0
  3. package/lib/base-session-service.d.ts.map +1 -0
  4. package/lib/base-session-service.js +139 -0
  5. package/lib/base-session-service.js.map +1 -0
  6. package/lib/command-alias.d.ts +3 -1
  7. package/lib/command-alias.d.ts.map +1 -1
  8. package/lib/command-alias.js.map +1 -1
  9. package/lib/command-message.d.ts +3 -0
  10. package/lib/command-message.d.ts.map +1 -1
  11. package/lib/command-message.js +0 -6
  12. package/lib/command-message.js.map +1 -1
  13. package/lib/define-game-plugin.d.ts +32 -0
  14. package/lib/define-game-plugin.d.ts.map +1 -0
  15. package/lib/define-game-plugin.js +87 -0
  16. package/lib/define-game-plugin.js.map +1 -0
  17. package/lib/deterministic-random.d.ts +16 -0
  18. package/lib/deterministic-random.d.ts.map +1 -0
  19. package/lib/deterministic-random.js +68 -0
  20. package/lib/deterministic-random.js.map +1 -0
  21. package/lib/game-events.d.ts +53 -0
  22. package/lib/game-events.d.ts.map +1 -0
  23. package/lib/game-events.js +26 -0
  24. package/lib/game-events.js.map +1 -0
  25. package/lib/game-interactive.d.ts +0 -22
  26. package/lib/game-interactive.d.ts.map +1 -1
  27. package/lib/game-interactive.js +1 -57
  28. package/lib/game-interactive.js.map +1 -1
  29. package/lib/game-records.d.ts +0 -1
  30. package/lib/game-records.d.ts.map +1 -1
  31. package/lib/game-records.js +0 -60
  32. package/lib/game-records.js.map +1 -1
  33. package/lib/game-session.d.ts +0 -59
  34. package/lib/game-session.d.ts.map +1 -1
  35. package/lib/game-session.js +0 -29
  36. package/lib/game-session.js.map +1 -1
  37. package/lib/index.d.ts +6 -9
  38. package/lib/index.d.ts.map +1 -1
  39. package/lib/index.js +6 -9
  40. package/lib/index.js.map +1 -1
  41. package/lib/memory-db.d.ts +6 -0
  42. package/lib/memory-db.d.ts.map +1 -1
  43. package/lib/memory-db.js +8 -0
  44. package/lib/memory-db.js.map +1 -1
  45. package/lib/session-coordinator.d.ts +27 -0
  46. package/lib/session-coordinator.d.ts.map +1 -0
  47. package/lib/session-coordinator.js +57 -0
  48. package/lib/session-coordinator.js.map +1 -0
  49. package/lib/versioned-session-service.d.ts +48 -0
  50. package/lib/versioned-session-service.d.ts.map +1 -0
  51. package/lib/versioned-session-service.js +116 -0
  52. package/lib/versioned-session-service.js.map +1 -0
  53. package/package.json +7 -6
  54. package/src/base-session-service.ts +210 -0
  55. package/src/command-alias.ts +3 -1
  56. package/src/command-message.ts +4 -0
  57. package/src/define-game-plugin.ts +162 -0
  58. package/src/deterministic-random.ts +81 -0
  59. package/src/game-events.ts +75 -0
  60. package/src/game-interactive.ts +1 -80
  61. package/src/game-records.ts +0 -73
  62. package/src/game-session.ts +0 -83
  63. package/src/index.ts +6 -9
  64. package/src/memory-db.ts +12 -0
  65. package/src/session-coordinator.ts +74 -0
  66. package/src/versioned-session-service.ts +162 -0
  67. package/lib/command-help.d.ts +0 -19
  68. package/lib/command-help.d.ts.map +0 -1
  69. package/lib/command-help.js +0 -91
  70. package/lib/command-help.js.map +0 -1
  71. package/lib/game-commands.d.ts +0 -3
  72. package/lib/game-commands.d.ts.map +0 -1
  73. package/lib/game-commands.js +0 -5
  74. package/lib/game-commands.js.map +0 -1
  75. package/lib/game-hub-feature.d.ts +0 -58
  76. package/lib/game-hub-feature.d.ts.map +0 -1
  77. package/lib/game-hub-feature.js +0 -102
  78. package/lib/game-hub-feature.js.map +0 -1
  79. package/lib/game-hub-flow.d.ts +0 -9
  80. package/lib/game-hub-flow.d.ts.map +0 -1
  81. package/lib/game-hub-flow.js +0 -90
  82. package/lib/game-hub-flow.js.map +0 -1
  83. package/lib/game-hub-menu-context.d.ts +0 -25
  84. package/lib/game-hub-menu-context.d.ts.map +0 -1
  85. package/lib/game-hub-menu-context.js +0 -64
  86. package/lib/game-hub-menu-context.js.map +0 -1
  87. package/lib/game-hub-menu.d.ts +0 -37
  88. package/lib/game-hub-menu.d.ts.map +0 -1
  89. package/lib/game-hub-menu.js +0 -162
  90. package/lib/game-hub-menu.js.map +0 -1
  91. package/lib/game-hub-mount.d.ts +0 -13
  92. package/lib/game-hub-mount.d.ts.map +0 -1
  93. package/lib/game-hub-mount.js +0 -89
  94. package/lib/game-hub-mount.js.map +0 -1
  95. package/lib/game-middleware.d.ts +0 -6
  96. package/lib/game-middleware.d.ts.map +0 -1
  97. package/lib/game-middleware.js +0 -9
  98. package/lib/game-middleware.js.map +0 -1
  99. package/lib/game-onboarding.d.ts +0 -8
  100. package/lib/game-onboarding.d.ts.map +0 -1
  101. package/lib/game-onboarding.js +0 -67
  102. package/lib/game-onboarding.js.map +0 -1
  103. package/lib/help-card.d.ts +0 -12
  104. package/lib/help-card.d.ts.map +0 -1
  105. package/lib/help-card.js +0 -129
  106. package/lib/help-card.js.map +0 -1
  107. package/src/command-help.ts +0 -117
  108. package/src/game-commands.ts +0 -4
  109. package/src/game-hub-feature.ts +0 -154
  110. package/src/game-hub-flow.ts +0 -114
  111. package/src/game-hub-menu-context.ts +0 -94
  112. package/src/game-hub-menu.ts +0 -223
  113. package/src/game-hub-mount.ts +0 -147
  114. package/src/game-middleware.ts +0 -14
  115. package/src/game-onboarding.ts +0 -71
  116. package/src/help-card.ts +0 -188
@@ -0,0 +1,57 @@
1
+ export class GameSessionConflictError extends Error {
2
+ requestedGameId;
3
+ activeGameId;
4
+ sessionId;
5
+ userId;
6
+ constructor(requestedGameId, activeGameId, sessionId, userId) {
7
+ super(`你在当前频道已有进行中的「${activeGameId}」对局,`
8
+ + `请先结束它,再开始「${requestedGameId}」。`);
9
+ this.requestedGameId = requestedGameId;
10
+ this.activeGameId = activeGameId;
11
+ this.sessionId = sessionId;
12
+ this.userId = userId;
13
+ this.name = 'GameSessionConflictError';
14
+ }
15
+ }
16
+ /**
17
+ * Cross-game active-session index. Registrations form per-game stacks so HMR
18
+ * can install a new generation before disposing the old one without a gap.
19
+ */
20
+ export class GameSessionCoordinator {
21
+ #providers = new Map();
22
+ register(provider) {
23
+ const providers = this.#providers.get(provider.gameId) ?? [];
24
+ providers.push(provider);
25
+ this.#providers.set(provider.gameId, providers);
26
+ return () => {
27
+ const current = this.#providers.get(provider.gameId);
28
+ if (!current)
29
+ return;
30
+ const index = current.lastIndexOf(provider);
31
+ if (index >= 0)
32
+ current.splice(index, 1);
33
+ if (current.length === 0)
34
+ this.#providers.delete(provider.gameId);
35
+ };
36
+ }
37
+ async assertAvailable(requestedGameId, channelKey, userIds) {
38
+ for (const [gameId, registrations] of this.#providers) {
39
+ if (gameId === requestedGameId)
40
+ continue;
41
+ const provider = registrations[registrations.length - 1];
42
+ if (!provider)
43
+ continue;
44
+ for (const userId of new Set(userIds.filter(Boolean))) {
45
+ const active = await provider.getActiveForUser(channelKey, userId);
46
+ if (active) {
47
+ throw new GameSessionConflictError(requestedGameId, gameId, active.id, userId);
48
+ }
49
+ }
50
+ }
51
+ }
52
+ clear() {
53
+ this.#providers.clear();
54
+ }
55
+ }
56
+ export const gameSessionCoordinator = new GameSessionCoordinator();
57
+ //# sourceMappingURL=session-coordinator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-coordinator.js","sourceRoot":"","sources":["../src/session-coordinator.ts"],"names":[],"mappings":"AAUA,MAAM,OAAO,wBAAyB,SAAQ,KAAK;IAEtC;IACA;IACA;IACA;IAJX,YACW,eAAuB,EACvB,YAAoB,EACpB,SAAiB,EACjB,MAAc;QAEvB,KAAK,CACH,gBAAgB,YAAY,MAAM;cAChC,aAAa,eAAe,IAAI,CACnC,CAAC;QARO,oBAAe,GAAf,eAAe,CAAQ;QACvB,iBAAY,GAAZ,YAAY,CAAQ;QACpB,cAAS,GAAT,SAAS,CAAQ;QACjB,WAAM,GAAN,MAAM,CAAQ;QAMvB,IAAI,CAAC,IAAI,GAAG,0BAA0B,CAAC;IACzC,CAAC;CACF;AAED;;;GAGG;AACH,MAAM,OAAO,sBAAsB;IACxB,UAAU,GAAG,IAAI,GAAG,EAAiC,CAAC;IAE/D,QAAQ,CAAC,QAA6B;QACpC,MAAM,SAAS,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;QAC7D,SAAS,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACzB,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;QAChD,OAAO,GAAG,EAAE;YACV,MAAM,OAAO,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;YACrD,IAAI,CAAC,OAAO;gBAAE,OAAO;YACrB,MAAM,KAAK,GAAG,OAAO,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC;YAC5C,IAAI,KAAK,IAAI,CAAC;gBAAE,OAAO,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;YACzC,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;gBAAE,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;QACpE,CAAC,CAAC;IACJ,CAAC;IAED,KAAK,CAAC,eAAe,CACnB,eAAuB,EACvB,UAAkB,EAClB,OAA0B;QAE1B,KAAK,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,IAAI,IAAI,CAAC,UAAU,EAAE,CAAC;YACtD,IAAI,MAAM,KAAK,eAAe;gBAAE,SAAS;YACzC,MAAM,QAAQ,GAAG,aAAa,CAAC,aAAa,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;YACzD,IAAI,CAAC,QAAQ;gBAAE,SAAS;YACxB,KAAK,MAAM,MAAM,IAAI,IAAI,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,EAAE,CAAC;gBACtD,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,gBAAgB,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC;gBACnE,IAAI,MAAM,EAAE,CAAC;oBACX,MAAM,IAAI,wBAAwB,CAChC,eAAe,EACf,MAAM,EACN,MAAM,CAAC,EAAE,EACT,MAAM,CACP,CAAC;gBACJ,CAAC;YACH,CAAC;QACH,CAAC;IACH,CAAC;IAED,KAAK;QACH,IAAI,CAAC,UAAU,CAAC,KAAK,EAAE,CAAC;IAC1B,CAAC;CACF;AAED,MAAM,CAAC,MAAM,sBAAsB,GAAG,IAAI,sBAAsB,EAAE,CAAC"}
@@ -0,0 +1,48 @@
1
+ import { BaseSessionService, type BaseGameSessionRow, type BaseSessionServiceOptions, type GameSessionDatabase } from './base-session-service.js';
2
+ export interface VersionedGameSessionRow extends BaseGameSessionRow {
3
+ revision: number;
4
+ /** JSON string containing the bounded action-id history. */
5
+ processed_actions: string;
6
+ }
7
+ export interface SessionMutationOptions<TRow extends VersionedGameSessionRow> {
8
+ readonly actionId: string;
9
+ readonly expectedRevision?: number;
10
+ readonly apply: Partial<TRow> | ((session: Readonly<TRow>) => Partial<TRow> | Promise<Partial<TRow>>);
11
+ }
12
+ export type SessionMutationResult<TRow extends VersionedGameSessionRow> = {
13
+ readonly kind: 'applied';
14
+ readonly session: TRow;
15
+ } | {
16
+ readonly kind: 'duplicate';
17
+ readonly session: TRow;
18
+ };
19
+ export declare class SessionNotFoundError extends Error {
20
+ readonly sessionId: string;
21
+ constructor(sessionId: string);
22
+ }
23
+ export declare class SessionRevisionConflictError extends Error {
24
+ readonly sessionId: string;
25
+ readonly expectedRevision: number;
26
+ readonly actualRevision: number;
27
+ constructor(sessionId: string, expectedRevision: number, actualRevision: number);
28
+ }
29
+ /**
30
+ * A keyed promise queue. It prevents lost updates inside one runtime while the
31
+ * persisted revision check protects deployments that share a database.
32
+ */
33
+ export declare class SessionActionGate {
34
+ #private;
35
+ run<TResult>(sessionId: string, action: () => TResult | Promise<TResult>): Promise<TResult>;
36
+ get pendingSessions(): number;
37
+ }
38
+ /**
39
+ * Adds serialized, optimistic and idempotent mutations to BaseSessionService.
40
+ * Action ids are intentionally persisted so adapter retries remain harmless
41
+ * after a process restart.
42
+ */
43
+ export declare abstract class VersionedSessionService<TRow extends VersionedGameSessionRow> extends BaseSessionService<TRow> {
44
+ #private;
45
+ protected constructor(database: GameSessionDatabase<TRow>, options: BaseSessionServiceOptions<TRow>);
46
+ mutateSession(id: string, options: SessionMutationOptions<TRow>): Promise<SessionMutationResult<TRow>>;
47
+ }
48
+ //# sourceMappingURL=versioned-session-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"versioned-session-service.d.ts","sourceRoot":"","sources":["../src/versioned-session-service.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAClB,KAAK,kBAAkB,EACvB,KAAK,yBAAyB,EAC9B,KAAK,mBAAmB,EACzB,MAAM,2BAA2B,CAAC;AAInC,MAAM,WAAW,uBAAwB,SAAQ,kBAAkB;IACjE,QAAQ,EAAE,MAAM,CAAC;IACjB,4DAA4D;IAC5D,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,sBAAsB,CAAC,IAAI,SAAS,uBAAuB;IAC1E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IACnC,QAAQ,CAAC,KAAK,EACV,OAAO,CAAC,IAAI,CAAC,GACb,CAAC,CAAC,OAAO,EAAE,QAAQ,CAAC,IAAI,CAAC,KACzB,OAAO,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;CAC7C;AAED,MAAM,MAAM,qBAAqB,CAAC,IAAI,SAAS,uBAAuB,IAClE;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAA;CAAE,GACpD;IAAE,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAA;CAAE,CAAC;AAE3D,qBAAa,oBAAqB,SAAQ,KAAK;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM;gBAAjB,SAAS,EAAE,MAAM;CAIvC;AAED,qBAAa,4BAA6B,SAAQ,KAAK;IAEnD,QAAQ,CAAC,SAAS,EAAE,MAAM;IAC1B,QAAQ,CAAC,gBAAgB,EAAE,MAAM;IACjC,QAAQ,CAAC,cAAc,EAAE,MAAM;gBAFtB,SAAS,EAAE,MAAM,EACjB,gBAAgB,EAAE,MAAM,EACxB,cAAc,EAAE,MAAM;CAQlC;AAED;;;GAGG;AACH,qBAAa,iBAAiB;;IAGtB,GAAG,CAAC,OAAO,EACf,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,GACvC,OAAO,CAAC,OAAO,CAAC;IAiBnB,IAAI,eAAe,IAAI,MAAM,CAE5B;CACF;AAED;;;;GAIG;AACH,8BAAsB,uBAAuB,CAC3C,IAAI,SAAS,uBAAuB,CACpC,SAAQ,kBAAkB,CAAC,IAAI,CAAC;;IAIhC,SAAS,aACP,QAAQ,EAAE,mBAAmB,CAAC,IAAI,CAAC,EACnC,OAAO,EAAE,yBAAyB,CAAC,IAAI,CAAC;IAM1C,aAAa,CACX,EAAE,EAAE,MAAM,EACV,OAAO,EAAE,sBAAsB,CAAC,IAAI,CAAC,GACpC,OAAO,CAAC,qBAAqB,CAAC,IAAI,CAAC,CAAC;CAgDxC"}
@@ -0,0 +1,116 @@
1
+ import { BaseSessionService, } from './base-session-service.js';
2
+ const DEFAULT_ACTION_HISTORY_LIMIT = 128;
3
+ export class SessionNotFoundError extends Error {
4
+ sessionId;
5
+ constructor(sessionId) {
6
+ super(`Game session "${sessionId}" does not exist.`);
7
+ this.sessionId = sessionId;
8
+ this.name = 'SessionNotFoundError';
9
+ }
10
+ }
11
+ export class SessionRevisionConflictError extends Error {
12
+ sessionId;
13
+ expectedRevision;
14
+ actualRevision;
15
+ constructor(sessionId, expectedRevision, actualRevision) {
16
+ super(`Game session "${sessionId}" changed: expected revision `
17
+ + `${expectedRevision}, received ${actualRevision}.`);
18
+ this.sessionId = sessionId;
19
+ this.expectedRevision = expectedRevision;
20
+ this.actualRevision = actualRevision;
21
+ this.name = 'SessionRevisionConflictError';
22
+ }
23
+ }
24
+ /**
25
+ * A keyed promise queue. It prevents lost updates inside one runtime while the
26
+ * persisted revision check protects deployments that share a database.
27
+ */
28
+ export class SessionActionGate {
29
+ #tails = new Map();
30
+ async run(sessionId, action) {
31
+ const previous = this.#tails.get(sessionId) ?? Promise.resolve();
32
+ let release = () => undefined;
33
+ const pending = new Promise((resolve) => {
34
+ release = resolve;
35
+ });
36
+ const tail = previous.catch(() => undefined).then(() => pending);
37
+ this.#tails.set(sessionId, tail);
38
+ await previous.catch(() => undefined);
39
+ try {
40
+ return await action();
41
+ }
42
+ finally {
43
+ release();
44
+ if (this.#tails.get(sessionId) === tail)
45
+ this.#tails.delete(sessionId);
46
+ }
47
+ }
48
+ get pendingSessions() {
49
+ return this.#tails.size;
50
+ }
51
+ }
52
+ /**
53
+ * Adds serialized, optimistic and idempotent mutations to BaseSessionService.
54
+ * Action ids are intentionally persisted so adapter retries remain harmless
55
+ * after a process restart.
56
+ */
57
+ export class VersionedSessionService extends BaseSessionService {
58
+ #gate = new SessionActionGate();
59
+ #actionHistoryLimit;
60
+ constructor(database, options) {
61
+ super(database, options);
62
+ this.#actionHistoryLimit = DEFAULT_ACTION_HISTORY_LIMIT;
63
+ }
64
+ mutateSession(id, options) {
65
+ if (!options.actionId.trim()) {
66
+ return Promise.reject(new TypeError('actionId must not be empty'));
67
+ }
68
+ return this.#gate.run(id, async () => {
69
+ const previous = await this.getById(id);
70
+ if (!previous)
71
+ throw new SessionNotFoundError(id);
72
+ const processedActions = parseActionHistory(previous.processed_actions);
73
+ if (processedActions.includes(options.actionId)) {
74
+ return { kind: 'duplicate', session: previous };
75
+ }
76
+ if (options.expectedRevision !== undefined
77
+ && options.expectedRevision !== previous.revision) {
78
+ throw new SessionRevisionConflictError(id, options.expectedRevision, previous.revision);
79
+ }
80
+ const patch = typeof options.apply === 'function'
81
+ ? await options.apply(previous)
82
+ : options.apply;
83
+ const revision = previous.revision + 1;
84
+ const nextActions = [
85
+ ...processedActions,
86
+ options.actionId,
87
+ ].slice(-this.#actionHistoryLimit);
88
+ const session = await this.commitSession(previous, {
89
+ ...patch,
90
+ revision,
91
+ processed_actions: JSON.stringify(nextActions),
92
+ }, { id, revision: previous.revision });
93
+ if (!session
94
+ || session.revision !== revision
95
+ || !parseActionHistory(session.processed_actions).includes(options.actionId)) {
96
+ const actual = session?.revision
97
+ ?? (await this.getById(id))?.revision
98
+ ?? previous.revision;
99
+ throw new SessionRevisionConflictError(id, previous.revision, actual);
100
+ }
101
+ return { kind: 'applied', session };
102
+ });
103
+ }
104
+ }
105
+ function parseActionHistory(value) {
106
+ try {
107
+ const parsed = JSON.parse(value);
108
+ if (!Array.isArray(parsed))
109
+ return [];
110
+ return parsed.filter((item) => typeof item === 'string');
111
+ }
112
+ catch {
113
+ return [];
114
+ }
115
+ }
116
+ //# sourceMappingURL=versioned-session-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"versioned-session-service.js","sourceRoot":"","sources":["../src/versioned-session-service.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/game-kit",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "Zhin.js 游戏插件通用工具包",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -13,8 +13,8 @@
13
13
  }
14
14
  },
15
15
  "peerDependencies": {
16
- "zhin.js": "^5.0.1",
17
- "@zhin.js/html-renderer": "2.0.1"
16
+ "zhin.js": "^5.0.2",
17
+ "@zhin.js/html-renderer": "2.0.2"
18
18
  },
19
19
  "peerDependenciesMeta": {
20
20
  "@zhin.js/html-renderer": {
@@ -22,13 +22,14 @@
22
22
  }
23
23
  },
24
24
  "dependencies": {
25
- "@zhin.js/core": "1.4.1",
26
- "@zhin.js/middleware": "1.0.3",
25
+ "@zhin.js/core": "1.4.2",
26
+ "@zhin.js/middleware": "1.0.4",
27
+ "@zhin.js/plugin-runtime": "1.1.1",
27
28
  "@zhin.js/satori": "1.0.17"
28
29
  },
29
30
  "devDependencies": {
30
31
  "typescript": "^6.0.3",
31
- "zhin.js": "5.0.1"
32
+ "zhin.js": "5.0.2"
32
33
  },
33
34
  "files": [
34
35
  "lib",
@@ -0,0 +1,210 @@
1
+ import {
2
+ gameEvents,
3
+ type GameEventSession,
4
+ type GameOutcome,
5
+ } from './game-events.js';
6
+ import { gameSessionCoordinator, type GameSessionProvider } from './session-coordinator.js';
7
+
8
+ export interface BaseGameSessionRow extends GameEventSession {
9
+ id: string;
10
+ channel_key: string;
11
+ status: string;
12
+ updated_at: number;
13
+ created_at: number;
14
+ turn?: unknown;
15
+ }
16
+
17
+ export interface GameSessionModel<TRow extends BaseGameSessionRow> {
18
+ findAll(query?: Record<string, unknown>): Promise<TRow[]>;
19
+ findOne(query?: Record<string, unknown>): Promise<TRow | null>;
20
+ create(row: TRow): Promise<unknown>;
21
+ updateWhere(
22
+ where: Record<string, unknown>,
23
+ patch: Record<string, unknown>,
24
+ ): Promise<unknown>;
25
+ }
26
+
27
+ export interface GameSessionDatabase<TRow extends BaseGameSessionRow> {
28
+ readonly models: {
29
+ get(name: string): GameSessionModel<TRow> | undefined;
30
+ };
31
+ }
32
+
33
+ export interface BaseSessionServiceOptions<TRow extends BaseGameSessionRow> {
34
+ readonly gameId: string;
35
+ readonly table: string;
36
+ readonly userFields: readonly (keyof TRow & string)[];
37
+ readonly activeStatus?: string;
38
+ readonly abortedStatus?: string;
39
+ readonly projectOutcomes?: (
40
+ session: Readonly<TRow>,
41
+ previous: Readonly<TRow>,
42
+ ) => readonly GameOutcome[];
43
+ }
44
+
45
+ export type SessionTimeoutListener<TRow extends BaseGameSessionRow> = (
46
+ session: TRow,
47
+ ) => void | Promise<void>;
48
+
49
+ /**
50
+ * Shared session lifecycle for every game. Subclasses own row construction and
51
+ * game-specific tables; this module owns persistence invariants and events.
52
+ */
53
+ export abstract class BaseSessionService<TRow extends BaseGameSessionRow>
54
+ implements GameSessionProvider {
55
+ readonly gameId: string;
56
+ readonly #activeStatus: string;
57
+ readonly #abortedStatus: string;
58
+ readonly #userFields: readonly (keyof TRow & string)[];
59
+ readonly #projectOutcomes?: BaseSessionServiceOptions<TRow>['projectOutcomes'];
60
+ readonly #model: GameSessionModel<TRow>;
61
+
62
+ protected constructor(
63
+ database: GameSessionDatabase<TRow>,
64
+ options: BaseSessionServiceOptions<TRow>,
65
+ ) {
66
+ const model = database.models.get(options.table);
67
+ if (!model) throw new Error(`${options.table} not registered`);
68
+ this.#model = model;
69
+ this.gameId = options.gameId;
70
+ this.#userFields = options.userFields;
71
+ this.#projectOutcomes = options.projectOutcomes;
72
+ this.#activeStatus = options.activeStatus ?? 'active';
73
+ this.#abortedStatus = options.abortedStatus ?? 'aborted';
74
+ }
75
+
76
+ async getById(id: string): Promise<TRow | null> {
77
+ return this.#model.findOne({ id });
78
+ }
79
+
80
+ async getActiveByChannel(channelKey: string): Promise<TRow | null> {
81
+ const rows = await this.#model.findAll({
82
+ channel_key: channelKey,
83
+ status: this.#activeStatus,
84
+ });
85
+ return rows[0] ?? null;
86
+ }
87
+
88
+ async getActiveForUser(channelKey: string, userId: string): Promise<TRow | null> {
89
+ const rows = await this.#model.findAll({
90
+ channel_key: channelKey,
91
+ status: this.#activeStatus,
92
+ });
93
+ return rows.find((row) =>
94
+ this.#userFields.some((field) => row[field] === userId)) ?? null;
95
+ }
96
+
97
+ async getLatestForUser(channelKey: string, userId: string): Promise<TRow | null> {
98
+ const rows = await this.#model.findAll({ channel_key: channelKey });
99
+ const matches = rows.filter((row) =>
100
+ this.#userFields.some((field) => row[field] === userId));
101
+ return matches.reduce<TRow | null>(
102
+ (latest, row) => !latest || row.updated_at >= latest.updated_at ? row : latest,
103
+ null,
104
+ );
105
+ }
106
+
107
+ async updateSession(id: string, patch: Partial<TRow>): Promise<void> {
108
+ const previous = await this.getById(id);
109
+ if (!previous) return;
110
+ await this.commitSession(previous, patch, { id });
111
+ }
112
+
113
+ /**
114
+ * Persist one transition and emit lifecycle events from the row that was
115
+ * actually stored. Versioned services use `where` for optimistic locking.
116
+ */
117
+ protected async commitSession(
118
+ previous: TRow,
119
+ patch: Partial<TRow>,
120
+ where: Record<string, unknown>,
121
+ ): Promise<TRow | null> {
122
+ const updatedAt = Date.now();
123
+ const changed = await this.#model.updateWhere(
124
+ where,
125
+ { ...patch, updated_at: updatedAt },
126
+ );
127
+ if (typeof changed === 'number' && changed === 0) return null;
128
+ const session = await this.getById(previous.id);
129
+ if (!session) return null;
130
+ if (patch.turn !== undefined && patch.turn !== previous.turn) {
131
+ await gameEvents.emit('turn:change', {
132
+ gameId: this.gameId,
133
+ session,
134
+ previousTurn: previous.turn,
135
+ turn: patch.turn,
136
+ });
137
+ }
138
+ if (patch.status !== undefined
139
+ && previous.status === this.#activeStatus
140
+ && patch.status !== this.#activeStatus) {
141
+ await gameEvents.emit('game:end', {
142
+ gameId: this.gameId,
143
+ session,
144
+ previousStatus: previous.status,
145
+ outcomes: this.#projectOutcomes?.(session, previous) ?? [],
146
+ });
147
+ }
148
+ return session;
149
+ }
150
+
151
+ async abortStale(
152
+ idleMs: number,
153
+ onTimeout?: SessionTimeoutListener<TRow>,
154
+ ): Promise<number> {
155
+ const cutoff = Date.now() - idleMs;
156
+ const rows = await this.#model.findAll({ status: this.#activeStatus });
157
+ let count = 0;
158
+ for (const row of rows) {
159
+ if (row.updated_at >= cutoff) continue;
160
+ // The timestamp condition prevents a cleanup scan from aborting a
161
+ // session that received a player action after this row was selected.
162
+ const session = await this.commitSession(
163
+ row,
164
+ { status: this.#abortedStatus } as Partial<TRow>,
165
+ {
166
+ id: row.id,
167
+ status: this.#activeStatus,
168
+ updated_at: row.updated_at,
169
+ },
170
+ );
171
+ if (!session) continue;
172
+ await gameEvents.emit('session:timeout', {
173
+ gameId: this.gameId,
174
+ session,
175
+ idleMs,
176
+ });
177
+ // A broken outbound adapter must not prevent later stale sessions from
178
+ // being finalized. Persistence and lifecycle events already succeeded.
179
+ if (onTimeout) {
180
+ try {
181
+ await onTimeout(session);
182
+ } catch {
183
+ // Notification is best-effort; the next cleanup pass stays healthy.
184
+ }
185
+ }
186
+ count += 1;
187
+ }
188
+ return count;
189
+ }
190
+
191
+ registerCoordinator(): () => void {
192
+ return gameSessionCoordinator.register(this);
193
+ }
194
+
195
+ protected async createRow(row: TRow): Promise<TRow> {
196
+ const userIds: string[] = [];
197
+ for (const field of this.#userFields) {
198
+ const value: unknown = row[field];
199
+ if (typeof value === 'string') userIds.push(value);
200
+ }
201
+ await gameSessionCoordinator.assertAvailable(this.gameId, row.channel_key, userIds);
202
+ await this.#model.create(row);
203
+ await gameEvents.emit('game:start', { gameId: this.gameId, session: row });
204
+ return row;
205
+ }
206
+
207
+ protected get model(): GameSessionModel<TRow> {
208
+ return this.#model;
209
+ }
210
+ }
@@ -1,6 +1,8 @@
1
1
  import { defineMiddleware, type MiddlewareContext } from '@zhin.js/middleware';
2
2
  import type { Message } from '@zhin.js/core/runtime';
3
3
 
4
+ type AliasReply = Parameters<Message['$reply']>[0];
5
+
4
6
  /**
5
7
  * 游戏 legacy 命令别名路由。
6
8
  *
@@ -20,7 +22,7 @@ export interface GameCommandAliasRoute {
20
22
  action: string,
21
23
  input: unknown,
22
24
  context: MiddlewareContext<Message>,
23
- ): Promise<string | null | undefined>;
25
+ ): Promise<AliasReply | null | undefined>;
24
26
  }
25
27
 
26
28
  export function defineGameCommandAliasMiddleware(route: GameCommandAliasRoute) {
@@ -4,6 +4,10 @@
4
4
  * Runtime Message: { adapter, target, sender?, metadata? }
5
5
  * Classic Message: { $adapter, $endpoint, $channel, $sender }
6
6
  */
7
+ import type { SendContent } from '@zhin.js/core';
8
+
9
+ /** A game returns semantic content; Runtime owns native rendering and text fallback. */
10
+ export type GameReply = SendContent;
7
11
 
8
12
  export interface GameMessageLike {
9
13
  $adapter: string;
@@ -0,0 +1,162 @@
1
+ import {
2
+ databaseHostToken,
3
+ definePlugin,
4
+ outboundHostToken,
5
+ scheduleHostToken,
6
+ type DatabaseHost,
7
+ type PluginMetadata,
8
+ type PluginSetupContext,
9
+ type Token,
10
+ } from '@zhin.js/plugin-runtime';
11
+ import {
12
+ createHostGameDb,
13
+ createInMemoryGameDb,
14
+ type InMemoryGameDb,
15
+ } from './memory-db.js';
16
+ import {
17
+ initGameRecordHost,
18
+ recordGameOutcome,
19
+ type GameRecordDatabaseHost,
20
+ } from './game-records.js';
21
+ import { gameEvents } from './game-events.js';
22
+ import {
23
+ DEFAULT_GAME_STALE_CRON,
24
+ DEFAULT_GAME_STALE_IDLE_MS,
25
+ registerRuntimeGame,
26
+ type RuntimeRegisteredGame,
27
+ } from './runtime-hub.js';
28
+ import type {
29
+ BaseGameSessionRow,
30
+ BaseSessionService,
31
+ } from './base-session-service.js';
32
+
33
+ export interface GamePluginSession<
34
+ TRow extends BaseGameSessionRow = BaseGameSessionRow,
35
+ > {
36
+ abortStale(
37
+ idleMs: number,
38
+ onTimeout?: (session: TRow) => void | Promise<void>,
39
+ ): Promise<number>;
40
+ registerCoordinator?(): () => void;
41
+ }
42
+
43
+ export interface DefineGamePluginOptions<
44
+ TServices,
45
+ TRow extends BaseGameSessionRow = BaseGameSessionRow,
46
+ TConfig = unknown,
47
+ > {
48
+ readonly name: string;
49
+ readonly metadata?: PluginMetadata;
50
+ readonly game: RuntimeRegisteredGame;
51
+ readonly tables: readonly string[];
52
+ readonly servicesToken: Token<TServices>;
53
+ readonly defineHostTables: (host: DatabaseHost) => void;
54
+ readonly createServices: (database: InMemoryGameDb) => TServices;
55
+ readonly session: (
56
+ services: TServices,
57
+ ) => GamePluginSession<TRow> | BaseSessionService<TRow>;
58
+ readonly stale?: {
59
+ readonly cron?: string;
60
+ readonly idleMs?: number;
61
+ readonly description?: string;
62
+ readonly message?: string | ((session: TRow) => string);
63
+ readonly onTimeout?: (
64
+ session: TRow,
65
+ context: PluginSetupContext<TConfig>,
66
+ ) => void | Promise<void>;
67
+ } | false;
68
+ readonly setup?: (
69
+ context: PluginSetupContext<TConfig>,
70
+ services: TServices,
71
+ ) => void | (() => void) | Promise<void | (() => void)>;
72
+ }
73
+
74
+ /**
75
+ * Declarative Plugin Runtime assembly for games. Database selection, service
76
+ * publication, Hub registration, stale cleanup and HMR disposal live here.
77
+ */
78
+ export function defineGamePlugin<
79
+ TServices,
80
+ TRow extends BaseGameSessionRow = BaseGameSessionRow,
81
+ TConfig = unknown,
82
+ >(
83
+ options: DefineGamePluginOptions<TServices, TRow, TConfig>,
84
+ ) {
85
+ return definePlugin<TConfig>({
86
+ name: options.name,
87
+ metadata: options.metadata,
88
+ async setup(context) {
89
+ let database: InMemoryGameDb;
90
+ if (context.resources.has(databaseHostToken)) {
91
+ const host = context.resources.use(databaseHostToken);
92
+ options.defineHostTables(host);
93
+ database = createHostGameDb(host, options.tables);
94
+ context.lifecycle.add(
95
+ initGameRecordHost(host as unknown as GameRecordDatabaseHost),
96
+ );
97
+ } else {
98
+ database = createInMemoryGameDb(options.tables);
99
+ }
100
+
101
+ const services = options.createServices(database);
102
+ context.resources.provide(options.servicesToken, services);
103
+
104
+ const session = options.session(services);
105
+ const disposeCoordinator = session.registerCoordinator?.();
106
+ if (disposeCoordinator) context.lifecycle.add(disposeCoordinator);
107
+ context.lifecycle.add(registerRuntimeGame(options.game));
108
+ context.lifecycle.add(gameEvents.on('game:end', async (event) => {
109
+ if (event.gameId !== options.game.id || event.outcomes.length === 0) return;
110
+ const row = event.session;
111
+ if (!row.adapter || !row.endpoint || !row.channel_type || !row.channel_id) return;
112
+ for (const outcome of event.outcomes) {
113
+ await recordGameOutcome({
114
+ $adapter: row.adapter,
115
+ $endpoint: row.endpoint,
116
+ $channel: { type: row.channel_type, id: row.channel_id },
117
+ $sender: {
118
+ id: outcome.userId,
119
+ name: outcome.userName ?? outcome.userId,
120
+ },
121
+ }, event.gameId, outcome.result, outcome.score);
122
+ }
123
+ }));
124
+
125
+ if (options.stale !== false && context.resources.has(scheduleHostToken)) {
126
+ const stale = options.stale ?? {};
127
+ const schedule = context.resources.use(scheduleHostToken);
128
+ context.lifecycle.add(schedule.register({
129
+ id: `${options.game.id}/abort-stale`,
130
+ cron: stale.cron ?? DEFAULT_GAME_STALE_CRON,
131
+ description: stale.description
132
+ ?? `Abort stale ${options.name} sessions`,
133
+ async execute() {
134
+ await session.abortStale(
135
+ stale.idleMs ?? DEFAULT_GAME_STALE_IDLE_MS,
136
+ async (row) => {
137
+ await stale.onTimeout?.(row, context);
138
+ if (!context.resources.has(outboundHostToken)) return;
139
+ if (!row.adapter || !row.endpoint
140
+ || !row.channel_type || !row.channel_id) return;
141
+ const outbound = context.resources.use(outboundHostToken);
142
+ await outbound.send({
143
+ adapter: row.adapter,
144
+ endpointId: row.endpoint,
145
+ channelType: row.channel_type,
146
+ channelId: row.channel_id,
147
+ content: typeof stale.message === 'function'
148
+ ? stale.message(row)
149
+ : stale.message
150
+ ?? `${options.game.icon} ${options.game.title}对局因长时间无操作已结束。`,
151
+ });
152
+ },
153
+ );
154
+ },
155
+ }));
156
+ }
157
+
158
+ const dispose = await options.setup?.(context, services);
159
+ if (dispose) context.lifecycle.add(dispose);
160
+ },
161
+ });
162
+ }