@roster-lock/match-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/LICENSE.md +11 -0
  2. package/dist/authentication.js +53 -0
  3. package/dist/authentication.js.map +1 -0
  4. package/dist/config/constants.js +12 -0
  5. package/dist/config/constants.js.map +1 -0
  6. package/dist/config/findFile.js +32 -0
  7. package/dist/config/findFile.js.map +1 -0
  8. package/dist/config/index.js +29 -0
  9. package/dist/config/index.js.map +1 -0
  10. package/dist/config/manage.js +41 -0
  11. package/dist/config/manage.js.map +1 -0
  12. package/dist/cors.js +20 -0
  13. package/dist/cors.js.map +1 -0
  14. package/dist/editor-support/index.js +247 -0
  15. package/dist/editor-support/index.js.map +1 -0
  16. package/dist/handle-room/version-1/file-routes.js +53 -0
  17. package/dist/handle-room/version-1/file-routes.js.map +1 -0
  18. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/MultiAbort.js +76 -0
  19. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/MultiAbort.js.map +1 -0
  20. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/ensurePieceExists.js +218 -0
  21. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/ensurePieceExists.js.map +1 -0
  22. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/getVersions.js +18 -0
  23. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/getVersions.js.map +1 -0
  24. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/index.js +16 -0
  25. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/index.js.map +1 -0
  26. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/schema.js +370 -0
  27. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/schema.js.map +1 -0
  28. package/dist/handle-room/version-1/globals/FolderDB/index.js +18 -0
  29. package/dist/handle-room/version-1/globals/FolderDB/index.js.map +1 -0
  30. package/dist/handle-room/version-1/globals/FolderDB/types.js +3 -0
  31. package/dist/handle-room/version-1/globals/FolderDB/types.js.map +1 -0
  32. package/dist/handle-room/version-1/globals/Room/WebsocketRoom.js +95 -0
  33. package/dist/handle-room/version-1/globals/Room/WebsocketRoom.js.map +1 -0
  34. package/dist/handle-room/version-1/globals/Room/index.js +18 -0
  35. package/dist/handle-room/version-1/globals/Room/index.js.map +1 -0
  36. package/dist/handle-room/version-1/globals/Room/room.js +3 -0
  37. package/dist/handle-room/version-1/globals/Room/room.js.map +1 -0
  38. package/dist/handle-room/version-1/globals/index.js +20 -0
  39. package/dist/handle-room/version-1/globals/index.js.map +1 -0
  40. package/dist/handle-room/version-1/globals/types.js +3 -0
  41. package/dist/handle-room/version-1/globals/types.js.map +1 -0
  42. package/dist/handle-room/version-1/handleDownloads/DownloadTracker.js +83 -0
  43. package/dist/handle-room/version-1/handleDownloads/DownloadTracker.js.map +1 -0
  44. package/dist/handle-room/version-1/handleDownloads/index.js +35 -0
  45. package/dist/handle-room/version-1/handleDownloads/index.js.map +1 -0
  46. package/dist/handle-room/version-1/handleDownloads/types.js +3 -0
  47. package/dist/handle-room/version-1/handleDownloads/types.js.map +1 -0
  48. package/dist/handle-room/version-1/handleRoomSelections/encryption.js +21 -0
  49. package/dist/handle-room/version-1/handleRoomSelections/encryption.js.map +1 -0
  50. package/dist/handle-room/version-1/handleRoomSelections/random.js +12 -0
  51. package/dist/handle-room/version-1/handleRoomSelections/random.js.map +1 -0
  52. package/dist/handle-room/version-1/index.js +28 -0
  53. package/dist/handle-room/version-1/index.js.map +1 -0
  54. package/dist/handle-room/version-1/piece-sort.js +71 -0
  55. package/dist/handle-room/version-1/piece-sort.js.map +1 -0
  56. package/dist/handle-room/version-1/room-handler-bridge/exchange-and-download-selections.js +89 -0
  57. package/dist/handle-room/version-1/room-handler-bridge/exchange-and-download-selections.js.map +1 -0
  58. package/dist/handle-room/version-1/room-handler-bridge/http-handler.js +19 -0
  59. package/dist/handle-room/version-1/room-handler-bridge/http-handler.js.map +1 -0
  60. package/dist/handle-room/version-1/room-handler-bridge/steps.js +170 -0
  61. package/dist/handle-room/version-1/room-handler-bridge/steps.js.map +1 -0
  62. package/dist/handle-room/version-1/room-handler-bridge/ws-handler.js +28 -0
  63. package/dist/handle-room/version-1/room-handler-bridge/ws-handler.js.map +1 -0
  64. package/dist/handle-room/version-1/schema/count.js +16 -0
  65. package/dist/handle-room/version-1/schema/count.js.map +1 -0
  66. package/dist/handle-room/version-1/schema/lock.js +46 -0
  67. package/dist/handle-room/version-1/schema/lock.js.map +1 -0
  68. package/dist/handle-room/version-1/schema/selected.js +13 -0
  69. package/dist/handle-room/version-1/schema/selected.js.map +1 -0
  70. package/dist/handle-room/version-1/select.js +94 -0
  71. package/dist/handle-room/version-1/select.js.map +1 -0
  72. package/dist/index.js +180 -0
  73. package/dist/index.js.map +1 -0
  74. package/dist/server.js +88 -0
  75. package/dist/server.js.map +1 -0
  76. package/dist/utils/fs.js +32 -0
  77. package/dist/utils/fs.js.map +1 -0
  78. package/dist/utils/http-router.js +49 -0
  79. package/dist/utils/http-router.js.map +1 -0
  80. package/dist/utils/pagination.js +23 -0
  81. package/dist/utils/pagination.js.map +1 -0
  82. package/dist/utils/websocket-router.js +15 -0
  83. package/dist/utils/websocket-router.js.map +1 -0
  84. package/package.json +66 -0
  85. package/src/authentication.ts +48 -0
  86. package/src/config/constants.ts +7 -0
  87. package/src/config/findFile.ts +34 -0
  88. package/src/config/index.ts +17 -0
  89. package/src/config/manage.ts +48 -0
  90. package/src/cors.ts +23 -0
  91. package/src/editor-support/index.ts +289 -0
  92. package/src/handle-room/version-0/piece-download/availableGamePiece/index.ts +109 -0
  93. package/src/handle-room/version-0/piece-download/availableGamePiece/isUpToDate.ts +78 -0
  94. package/src/handle-room/version-0/piece-download/downloadPieceAssets/downloadSingleAsset.ts +129 -0
  95. package/src/handle-room/version-0/piece-download/downloadPieceAssets/example.ts +116 -0
  96. package/src/handle-room/version-0/piece-download/downloadPieceAssets/index.ts +144 -0
  97. package/src/handle-room/version-0/piece-download/downloadPieceAssets/types.ts +32 -0
  98. package/src/handle-room/version-0/piece-download/downloadPieceAssets/validateAssetFile.ts +54 -0
  99. package/src/handle-room/version-0/piece-download/index.ts +5 -0
  100. package/src/handle-room/version-0/piece-selection/constants.ts +39 -0
  101. package/src/handle-room/version-0/piece-selection/handleRoomSelections.ts +202 -0
  102. package/src/handle-room/version-0/piece-selection/index.ts +31 -0
  103. package/src/handle-room/version-0/piece-selection/roomErrors.ts +25 -0
  104. package/src/handle-room/version-0/piece-selection/steps/encrypton.ts +70 -0
  105. package/src/handle-room/version-0/piece-selection/steps/final-selection.ts +72 -0
  106. package/src/handle-room/version-0/piece-selection/steps/player-selection.ts +100 -0
  107. package/src/handle-room/version-1/constants.ts +44 -0
  108. package/src/handle-room/version-1/file-routes.ts +68 -0
  109. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/MultiAbort.ts +83 -0
  110. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/ensurePieceExists.ts +317 -0
  111. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/getVersions.ts +31 -0
  112. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/index.ts +13 -0
  113. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/schema.ts +547 -0
  114. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/types.ts +6 -0
  115. package/src/handle-room/version-1/globals/FolderDB/index.ts +3 -0
  116. package/src/handle-room/version-1/globals/FolderDB/types.ts +72 -0
  117. package/src/handle-room/version-1/globals/Room/WebsocketRoom.ts +118 -0
  118. package/src/handle-room/version-1/globals/Room/index.ts +3 -0
  119. package/src/handle-room/version-1/globals/Room/room.ts +8 -0
  120. package/src/handle-room/version-1/globals/index.ts +4 -0
  121. package/src/handle-room/version-1/globals/types.ts +7 -0
  122. package/src/handle-room/version-1/handleDownloads/DownloadTracker.ts +98 -0
  123. package/src/handle-room/version-1/handleDownloads/index.ts +41 -0
  124. package/src/handle-room/version-1/handleDownloads/types.ts +9 -0
  125. package/src/handle-room/version-1/handleRoomSelections/encryption.ts +29 -0
  126. package/src/handle-room/version-1/handleRoomSelections/random.ts +9 -0
  127. package/src/handle-room/version-1/index.ts +33 -0
  128. package/src/handle-room/version-1/piece-sort.ts +96 -0
  129. package/src/handle-room/version-1/room-handler-bridge/exchange-and-download-selections.ts +125 -0
  130. package/src/handle-room/version-1/room-handler-bridge/http-handler.ts +16 -0
  131. package/src/handle-room/version-1/room-handler-bridge/server.pseudo.ts +85 -0
  132. package/src/handle-room/version-1/room-handler-bridge/steps.ts +255 -0
  133. package/src/handle-room/version-1/room-handler-bridge/ws-handler.ts +30 -0
  134. package/src/handle-room/version-1/room-handler-state/RoomState.ts +132 -0
  135. package/src/handle-room/version-1/room-handler-state/bind-to-websocket.ts +93 -0
  136. package/src/handle-room/version-1/room-handler-state/handleRoomSelections.ts +71 -0
  137. package/src/handle-room/version-1/room-handler-state/handleRoomWithErrors.ts +55 -0
  138. package/src/handle-room/version-1/room-handler-state/roomErrors.ts +35 -0
  139. package/src/handle-room/version-1/room-handler-state/user-selection.ts +98 -0
  140. package/src/handle-room/version-1/schema/count.ts +13 -0
  141. package/src/handle-room/version-1/schema/lock.ts +47 -0
  142. package/src/handle-room/version-1/schema/selected.ts +14 -0
  143. package/src/handle-room/version-1/select.ts +130 -0
  144. package/src/handle-room/version-1/validateUnknown.ts +11 -0
  145. package/src/index.old.ts +71 -0
  146. package/src/index.ts +231 -0
  147. package/src/server.ts +85 -0
  148. package/src/utils/fs.ts +43 -0
  149. package/src/utils/http-router.ts +55 -0
  150. package/src/utils/pagination.ts +17 -0
  151. package/src/utils/websocket-router.ts +23 -0
  152. package/tests/integration/editor-pieces-search.test.ts +147 -0
  153. package/tests/integration/game-complete.test.ts +72 -0
  154. package/tests/integration/handle-full-selection.test.ts +143 -0
  155. package/tests/integration/helpers/bridge.ts +22 -0
  156. package/tests/integration/helpers/fakeFolderDB.ts +37 -0
  157. package/tests/integration/helpers/fakePluginRuntime.ts +46 -0
  158. package/tests/integration/helpers/lockConfig.ts +71 -0
  159. package/tests/integration/helpers/make-tar-gz.ts +41 -0
  160. package/tests/integration/helpers/piece.ts +98 -0
  161. package/tests/integration/helpers/plugin-dir.ts +26 -0
  162. package/tests/integration/helpers/room.ts +42 -0
  163. package/tests/integration/helpers/server.ts +51 -0
  164. package/tests/integration/helpers/validLockConfig.ts +80 -0
  165. package/tests/integration/piece-download-and-list.test.ts +194 -0
  166. package/tests/integration/piece-ensure-ws.test.ts +256 -0
  167. package/tests/integration/piece-files.test.ts +139 -0
  168. package/tests/integration/piece-sort-list.test.ts +113 -0
  169. package/tests/integration/protocol.test.ts +129 -0
  170. package/tests/integration/root-and-auth.test.ts +100 -0
  171. package/tests/integration/sync-dl.test.ts +62 -0
  172. package/tests/tsconfig.json +14 -0
  173. package/tsconfig.json +22 -0
  174. package/vitest.config.ts +10 -0
@@ -0,0 +1,85 @@
1
+ import { MessageBridge } from "../../../utils/MessageBridge";
2
+ import { isDeepStrictEqual } from "node:util";
3
+
4
+ type UserPublicKey = string;
5
+
6
+ export async function runRooms(users: Array<{ bridge: MessageBridge, publicKey: UserPublicKey }>){
7
+ const pingAbortController = new AbortController();
8
+
9
+ try {
10
+ await Promise.race([
11
+ runRoom(users),
12
+ Promise.race(users.map(user=>keepPing(user.bridge, pingAbortController.signal))),
13
+ Promise.race(users.map(user=>waitForError(user.bridge))),
14
+ ])
15
+ }catch(e){
16
+ for(const user of users){
17
+ user.bridge.sendEvent("error", (e as Error).message);
18
+ }
19
+ throw e;
20
+ }finally{
21
+ pingAbortController.abort();
22
+ }
23
+ }
24
+
25
+ async function waitForError(user: MessageBridge){
26
+ return new Promise((resolve, reject)=>{
27
+ user.onEvent("error", (error)=>{
28
+ reject(new Error(error.toString()));
29
+ });
30
+ });
31
+ }
32
+
33
+
34
+ async function keepPing(user: MessageBridge, abortSignal: AbortSignal){
35
+ while(!abortSignal.aborted){
36
+ const result = await user.sendRequest("ping", {});
37
+ if(result !== "pong") throw new Error("Invalid Response");
38
+ await delay(1000);
39
+ }
40
+ }
41
+
42
+ function delay(ms: number){
43
+ return new Promise(resolve=>setTimeout(resolve, ms));
44
+ }
45
+
46
+ const UNSET_FINAL = Symbol("unset-final");
47
+ const INVALID_FINAL = Symbol("invalid-final");
48
+ async function runRoom(users: Array<{ bridge: MessageBridge, publicKey: UserPublicKey }>){
49
+ const userCommits: Record<UserPublicKey, any> = {};
50
+ await Promise.all(users.map(async (user)=>{
51
+ const commit = await user.bridge.sendRequest("user-selection", {});
52
+ userCommits[user.publicKey] = commit;
53
+ }));
54
+ const userReveals: Record<UserPublicKey, any> = {};
55
+ await Promise.all(users.map(async (user)=>{
56
+ const reveal = await user.bridge.sendRequest("all-selection-for-user-decryption", userCommits);
57
+ userReveals[user.publicKey] = reveal;
58
+ }));
59
+ let userFinals: typeof UNSET_FINAL | typeof INVALID_FINAL | Record<string, any> = UNSET_FINAL;
60
+ await Promise.all(users.map(async (user)=>{
61
+ const final = await user.bridge.sendRequest("all-decryption-for-user-final", userReveals);
62
+ if(userFinals === INVALID_FINAL) return;
63
+ if(userFinals === UNSET_FINAL){
64
+ userFinals = final;
65
+ return;
66
+ }
67
+ if(!isDeepStrictEqual(userFinals, final)){
68
+ userFinals = INVALID_FINAL;
69
+ throw new Error("Final Selection Mismatch");
70
+ }
71
+ }));
72
+ for(const user of users){
73
+ user.bridge.onEvent("download-progress", (data)=>{
74
+ for(const otherUser of users){
75
+ otherUser.bridge.sendEvent("download-progress", { ...data, user: user.publicKey });
76
+ }
77
+ })
78
+ }
79
+ await Promise.all(users.map(async (user)=>{
80
+ await user.bridge.sendRequest("user-download", {});
81
+ }));
82
+ for(const user of users){
83
+ user.bridge.sendEvent("all-download", {});
84
+ }
85
+ }
@@ -0,0 +1,255 @@
1
+
2
+ import { MessageBridge } from "@roster-lock/utils";
3
+ import {
4
+ FinalSelection, RosterLockV1Config, SelectedPiece, UserSelection, PlayerId,
5
+ RosterLockV1SyncDLResult
6
+ } from "@roster-lock/types";
7
+ import { runSelection } from "@roster-lock/shared";
8
+ import { encryptJSON, decryptJSON } from "../handleRoomSelections/encryption";
9
+ import { createRandomSeed } from "../handleRoomSelections/random";
10
+ import { z, ZodType } from "zod";
11
+ import { UserSelectionSchema } from "../schema/selected";
12
+ import { PluginManager } from "@roster-lock/plugin-runtime";
13
+ import { handleDownloads } from "../handleDownloads";
14
+ import { IFolderDB } from "../globals/FolderDB";
15
+ import { RosterLockDownloadUpdate } from "@roster-lock/types";
16
+
17
+ type MachinePublicKey = string;
18
+
19
+ type EncryptedValue = Awaited<ReturnType<typeof encryptJSON>>;
20
+
21
+ type RoomState = (
22
+ | { state: "user-selection" }
23
+ | { state: "all-selection-for-user-decryption" }
24
+ | { state: "all-decryption-for-user-final", encrypted: Record<MachinePublicKey, EncryptedValue["encryptedValue"]> }
25
+ | { state: "user-download", finalSelection: FinalSelection }
26
+ | { state: "all-download", finalSelection: FinalSelection, downloadResults: Awaited<ReturnType<typeof handleDownloads>> }
27
+ | { state: "ended" }
28
+ )
29
+
30
+ type RoomArgs = {
31
+ bridge: MessageBridge,
32
+ fileDB: IFolderDB,
33
+ pluginRuntime: PluginManager,
34
+ machines: Array<{ publicKey: MachinePublicKey, playerCount: number }>,
35
+ ownMachinePublicKey: MachinePublicKey,
36
+ // This machine's local players' selections, keyed by local player index
37
+ // (0-based). A machine with multiple controllers plugged in submits one
38
+ // entry per local player over its single relay connection.
39
+ ownSelections: Record<number, UserSelection>,
40
+ lockConfig: RosterLockV1Config,
41
+ gameControlledSelections: Record<string, Array<SelectedPiece> | Record<PlayerId, Array<SelectedPiece>>>,
42
+ }
43
+
44
+ export type ProgressListeners = Partial<{
45
+ onState: (state: string)=>void,
46
+ onDownloadProgress: (update: RosterLockDownloadUpdate)=>void,
47
+ }>
48
+
49
+ type UserInput = {
50
+ userSelection: UserSelection,
51
+ randomSeed: string,
52
+ }
53
+
54
+ // What one machine actually sends over the wire: all of its local players'
55
+ // selections plus the one random seed it contributes to the room.
56
+ type MachinePayload = {
57
+ selections: Record<number, UserSelection>,
58
+ randomSeed: string,
59
+ }
60
+
61
+ export function bindStepsToBridge(
62
+ {
63
+ bridge,
64
+ fileDB,
65
+ pluginRuntime,
66
+ machines,
67
+ ownMachinePublicKey,
68
+ ownSelections,
69
+ lockConfig,
70
+ gameControlledSelections,
71
+ }: RoomArgs,
72
+ progressListeners: ProgressListeners = {}
73
+ ): Promise<RosterLockV1SyncDLResult>{
74
+ const { resolve, reject, promise } = Promise.withResolvers<RosterLockV1SyncDLResult>();
75
+ const downloadAbort = new AbortController();
76
+ const heartbeat = trackHeartbeat(30_000);
77
+ const stateTracker = new StateTracker({ state: "user-selection" }, progressListeners);
78
+
79
+ const rngSeed = createRandomSeed();
80
+ const ownEncrypted = encryptJSON({ selections: ownSelections, randomSeed: rngSeed });
81
+ // Filled in by "all-decryption-for-user-final" below, read back by the
82
+ // handleFullSelection hook once the whole room promise settles.
83
+ let decryptedSelections: Record<PlayerId, UserInput> = {};
84
+
85
+ bridge.onRequest("ping", ()=>{
86
+ heartbeat.heartBeat();
87
+ return "pong";
88
+ });
89
+
90
+ // Return Encrypted Selections
91
+ bridge.onRequest("user-selection", async ()=>{
92
+ heartbeat.heartBeat();
93
+ const state = stateTracker.get();
94
+ if(state.state !== "user-selection") throw new Error("Invalid State");
95
+ const { encryptedValue } = await ownEncrypted;
96
+ stateTracker.set({ state: "all-selection-for-user-decryption" });
97
+ return encryptedValue;
98
+ });
99
+ // Handle All Selections
100
+ // Return Decryption Key
101
+ bridge.onRequest("all-selection-for-user-decryption", async (data)=>{
102
+ heartbeat.heartBeat();
103
+ const state = stateTracker.get();
104
+ if(state.state !== "all-selection-for-user-decryption") throw new Error("Invalid State");
105
+ const casted = EncryptedSelectionSchema.safeParse(data);
106
+ if(!casted.success) throw new Error("Invalid Selections");
107
+ if(Object.keys(casted.data).length !== machines.length) throw new Error("Invalid Machine Count");
108
+ for(const machine of machines){
109
+ if(!casted.data[machine.publicKey]) throw new Error("Missing Machine Selections");
110
+ }
111
+ const { key } = await ownEncrypted;
112
+ stateTracker.set({ state: "all-decryption-for-user-final", encrypted: casted.data });
113
+ return key;
114
+ })
115
+ // Handle All Decryption Keys
116
+ // Return Final Selections
117
+ bridge.onRequest("all-decryption-for-user-final", async (data)=>{
118
+ heartbeat.heartBeat();
119
+ const state = stateTracker.get();
120
+ if(state.state !== "all-decryption-for-user-final") throw new Error("Invalid State");
121
+ const allEncrypted = state.encrypted;
122
+ const casted = DecryptionKeySchema.safeParse(data);
123
+ if(!casted.success) throw new Error("Invalid Decryption Keys");
124
+ if(Object.keys(casted.data).length !== machines.length) throw new Error("Invalid Machine Count");
125
+ const keys = casted.data;
126
+ await Promise.all(machines.map(async (machine)=>{
127
+ const encrypted = allEncrypted[machine.publicKey];
128
+ if(!encrypted) throw new Error("Missing Encrypted Selections");
129
+ const key = keys[machine.publicKey];
130
+ if(!key) throw new Error("Missing Decryption Key");
131
+ const decrypted = await decryptJSON(key, encrypted);
132
+ const casted = MachinePayloadSchema.safeParse(decrypted);
133
+ if(!casted.success) throw new Error("Invalid Selections");
134
+ const { selections, randomSeed } = casted.data;
135
+ // A machine can't submit more or fewer player selections than it
136
+ // declared to the matchmaker when the room was created.
137
+ if(Object.keys(selections).length !== machine.playerCount){
138
+ throw new Error("Player Count Mismatch");
139
+ }
140
+ for(const [index, userSelection] of Object.entries(selections)){
141
+ decryptedSelections[`${machine.publicKey}:${index}`] = { userSelection, randomSeed };
142
+ }
143
+ }))
144
+ const finalSelection = await runSelection(
145
+ lockConfig, gameControlledSelections, decryptedSelections, (script)=>(pluginRuntime.runUntrustedScript(script))
146
+ );
147
+ stateTracker.set({ state: "user-download", finalSelection: finalSelection });
148
+
149
+ return finalSelection;
150
+ })
151
+
152
+ bridge.onRequest("user-download", async ()=>{
153
+ heartbeat.heartBeat();
154
+ const state = stateTracker.get();
155
+ if(state.state !== "user-download") throw new Error("Invalid State");
156
+ const downloadResults = await handleDownloads(
157
+ fileDB, lockConfig, state.finalSelection, {
158
+ onProgress: (event)=>{bridge.sendEvent("download-progress", event)},
159
+ abortSignal: downloadAbort.signal,
160
+ }
161
+ );
162
+ stateTracker.set({ state: "all-download", finalSelection: state.finalSelection, downloadResults });
163
+ return "ok"
164
+ })
165
+
166
+ bridge.onEvent("download-progress", (data: RosterLockDownloadUpdate)=>{
167
+ heartbeat.heartBeat();
168
+ progressListeners.onDownloadProgress?.(data);
169
+ })
170
+
171
+ bridge.onEvent("all-download", (data)=>{
172
+ heartbeat.heartBeat();
173
+ const state = stateTracker.get();
174
+ if(state.state !== "all-download") throw new Error("Invalid State");
175
+ resolve({
176
+ finalSelection: state.finalSelection,
177
+ downloadResults: state.downloadResults,
178
+ });
179
+ })
180
+
181
+ bridge.onEvent("error", (error)=>{
182
+ console.error("room error", error);
183
+ downloadAbort.abort();
184
+ reject(error);
185
+ })
186
+
187
+ // .finally()'s returned promise mirrors promise's rejection - it has to
188
+ // be observed here too, or a failed room leaves an unhandled rejection.
189
+ promise.finally(()=>{
190
+ heartbeat.stop();
191
+ stateTracker.set({ state: "ended" });
192
+ }).catch(()=>{});
193
+
194
+ // Only fires once the whole room succeeds (selection + downloads) - doesn't
195
+ // block "all-decryption-for-user-final"'s response, and a broken/misbehaving
196
+ // indexing plugin can't stop the room, so failures here are only logged.
197
+ promise.then(()=>{
198
+ const userSelections = Object.fromEntries(
199
+ Object.entries(decryptedSelections).map(([playerId, input])=>[playerId, input.userSelection])
200
+ );
201
+ const localPlayers = Object.keys(ownSelections).map((index)=>`${ownMachinePublicKey}:${index}`);
202
+ return pluginRuntime.pieceSort.handleFullSelection({ lockConfig, localUsers: localPlayers, userSelections });
203
+ }).catch((e)=>{
204
+ console.error("piece-selection-sort handleFullSelection failed", e);
205
+ });
206
+
207
+ return Promise.race([
208
+ promise,
209
+ heartbeat.promise.then(()=>{
210
+ bridge.sendEvent("error", "Heartbeat Timeout");
211
+ downloadAbort.abort();
212
+ throw new Error("Heartbeat Timeout")
213
+ })
214
+ ]);
215
+ }
216
+
217
+ const EncryptedSelectionSchema: ZodType<Record<MachinePublicKey, EncryptedValue["encryptedValue"]>> = z.record(z.string(), z.string());
218
+
219
+ const DecryptionKeySchema: ZodType<Record<MachinePublicKey, string>> = z.record(z.string(), z.string());
220
+
221
+ const MachinePayloadSchema: ZodType<MachinePayload> = z.object({
222
+ selections: z.record(z.string(), UserSelectionSchema),
223
+ randomSeed: z.string(),
224
+ });
225
+
226
+
227
+ function trackHeartbeat(maxTimeout: number){
228
+ const { resolve, promise } = Promise.withResolvers<void>();
229
+ let to = setTimeout(resolve, maxTimeout);
230
+
231
+ return {
232
+ promise,
233
+ heartBeat: ()=>{
234
+ clearTimeout(to);
235
+ to = setTimeout(resolve, maxTimeout);
236
+ },
237
+ stop: ()=>{
238
+ clearTimeout(to);
239
+ }
240
+ }
241
+ }
242
+
243
+ class StateTracker {
244
+ constructor(
245
+ private state: RoomState,
246
+ private listeners: ProgressListeners,
247
+ ){}
248
+ set(newState: RoomState){
249
+ this.state = newState;
250
+ this.listeners.onState?.(newState.state);
251
+ }
252
+ get(){
253
+ return this.state;
254
+ }
255
+ }
@@ -0,0 +1,30 @@
1
+
2
+
3
+ import { MessageBridge } from "@roster-lock/utils";
4
+ import { WebSocketHandlerCallback } from "../../../utils/websocket-router";
5
+ import { castRoomRequest, exchangeAndDownloadSelections } from "./exchange-and-download-selections";
6
+ import { V1Env } from "../globals/types";
7
+
8
+ export const wsHandler: WebSocketHandlerCallback = async function(
9
+ this: V1Env, { ws: gameWebSocket }, params, next
10
+ ){
11
+ const { fileDB, pluginRuntime } = this;
12
+ try {
13
+ const gameBridge = new MessageBridge((message)=>gameWebSocket.send(JSON.stringify(message)));
14
+ gameWebSocket.on("message", (message)=>{
15
+ gameBridge.handleMessage(JSON.parse(message.toString()));
16
+ });
17
+ gameBridge.onRequest("connect-to-relay", async (roomInfo)=>{
18
+ const roomRequest = castRoomRequest(roomInfo);
19
+ return await exchangeAndDownloadSelections(fileDB, pluginRuntime, roomRequest, {
20
+ onState: (state)=>{gameBridge.sendEvent("room-state", state)},
21
+ onDownloadProgress: (event)=>{gameBridge.sendEvent("download-progress", event)},
22
+ });
23
+ });
24
+ gameBridge.sendEvent("ready", {});
25
+ }catch(e){
26
+ gameWebSocket.terminate();
27
+ next(e)
28
+ }
29
+ }
30
+
@@ -0,0 +1,132 @@
1
+ import {
2
+ JSON_Unknown,
3
+ } from "@roster-lock/utils";
4
+ import {
5
+ RosterLockV1Config,
6
+ FinalSelection,
7
+ UserInput,
8
+ } from "@roster-lock/types";
9
+ import { MATCHLOCK_SELECTION_STATE, UserMessegeError } from "../constants";
10
+ import { IRoom } from "../globals/Room";
11
+ import { deepEqual } from "node:assert";
12
+ import { validateUnknown } from "../validateUnknown";
13
+
14
+ import { decryptJSON, encryptJSON, encryptedSchema } from "../handleRoomSelections/encryption";
15
+ import { userInputSchema, finalizeSelection, finalSelectionSchema } from "./user-selection";
16
+ import { PluginHandler } from "@roster-lock/plugin-runtime";
17
+
18
+ const STATE_ORDER = [
19
+ MATCHLOCK_SELECTION_STATE.hello,
20
+ MATCHLOCK_SELECTION_STATE.lockConfig,
21
+ MATCHLOCK_SELECTION_STATE.selectionEncrypt,
22
+ MATCHLOCK_SELECTION_STATE.selectionDecrypt,
23
+ MATCHLOCK_SELECTION_STATE.selectionFinal,
24
+ MATCHLOCK_SELECTION_STATE.goodbye,
25
+ ];
26
+
27
+ export class RoomState {
28
+ private state: MATCHLOCK_SELECTION_STATE = MATCHLOCK_SELECTION_STATE.hello;
29
+ private recievedMessages = new Map<MATCHLOCK_SELECTION_STATE, Set<string>>();
30
+
31
+ encryptedData = new Map<string, string>();
32
+ userSelections = new Map<string, UserInput>();
33
+ agreedSelection: FinalSelection | null = null;
34
+
35
+ constructor(
36
+ public room: IRoom,
37
+ public lockConfig: RosterLockV1Config,
38
+ public ownEncrypted: Awaited<ReturnType<typeof encryptJSON>>,
39
+ public pluginRuntime: PluginHandler
40
+ ){}
41
+
42
+ validateMessageState(
43
+ userId: string,
44
+ messageState: MATCHLOCK_SELECTION_STATE
45
+ ){
46
+ if(this.state !== messageState){
47
+ const currentIndex = STATE_ORDER.indexOf(this.state);
48
+ const messageIndex = STATE_ORDER.indexOf(messageState);
49
+ if(messageIndex === currentIndex + 1){
50
+ console.warn(`User Sent ${messageState} Early`, userId);
51
+ console.warn("We'll still process it as other messages may just be late");
52
+ } else {
53
+ throw new UserMessegeError(userId, `Expected ${this.state} message`);
54
+ }
55
+ }
56
+ if(!this.recievedMessages.has(messageState)){
57
+ this.recievedMessages.set(messageState, new Set());
58
+ }
59
+ if(this.recievedMessages.get(messageState)!.has(userId)){
60
+ throw new UserMessegeError(userId, "User Sent Message Twice");
61
+ }
62
+ this.recievedMessages.get(messageState)!.add(userId);
63
+ return this.recievedMessages.get(messageState)!.size;
64
+ }
65
+
66
+ updateStep(){
67
+ const currentIndex = STATE_ORDER.indexOf(this.state);
68
+ const nextIndex = currentIndex + 1;
69
+ if (nextIndex >= STATE_ORDER.length) {
70
+ throw new Error("No next phase after " + this.state);
71
+ }
72
+ this.state = STATE_ORDER[nextIndex];
73
+ }
74
+
75
+ stateHandlers: Record<string, {
76
+ message?(userId: string, data: JSON_Unknown):any,
77
+ finished():any
78
+ }> = {
79
+ [MATCHLOCK_SELECTION_STATE.hello]: {
80
+ finished: ()=>(
81
+ this.room.broadcast(MATCHLOCK_SELECTION_STATE.lockConfig, this.lockConfig)
82
+ ),
83
+ },
84
+ [MATCHLOCK_SELECTION_STATE.lockConfig]: {
85
+ message: (userId, data)=>{
86
+ deepEqual(data, this.lockConfig, "Restriction Mismatch")
87
+ },
88
+ finished: ()=>(
89
+ this.room.broadcast(MATCHLOCK_SELECTION_STATE.selectionEncrypt, this.ownEncrypted.encryptedValue)
90
+ )
91
+ },
92
+ [MATCHLOCK_SELECTION_STATE.selectionEncrypt]: {
93
+ message: (userId, data)=>{
94
+ const casted = validateUnknown(encryptedSchema, data);
95
+ this.encryptedData.set(userId, casted);
96
+ },
97
+ finished: ()=>(
98
+ this.room.broadcast(MATCHLOCK_SELECTION_STATE.selectionDecrypt, this.ownEncrypted.key)
99
+ )
100
+ },
101
+ [MATCHLOCK_SELECTION_STATE.selectionDecrypt]: {
102
+ message: async (userId, data)=>{
103
+ if(typeof data !== "string") throw new Error("Invalid Decryption Key");
104
+ const encrypted = this.encryptedData.get(userId);
105
+ if(!encrypted) throw new Error("Missing Encrypted Data");
106
+ const decrypted = await decryptJSON(data, encrypted);
107
+ const casted = await validateUnknown(userInputSchema, decrypted);
108
+ this.userSelections.set(userId, casted);
109
+ },
110
+ finished: async ()=>{
111
+ const ownFinalSelection = await finalizeSelection(
112
+ this.lockConfig, {}, Object.fromEntries(this.userSelections), this.pluginRuntime
113
+ );
114
+ this.agreedSelection = ownFinalSelection;
115
+ this.room.broadcast(MATCHLOCK_SELECTION_STATE.selectionFinal, ownFinalSelection)
116
+ }
117
+ },
118
+ [MATCHLOCK_SELECTION_STATE.selectionFinal]: {
119
+ message: (userId, data)=>{
120
+ const casted: FinalSelection = validateUnknown(finalSelectionSchema, data);
121
+ deepEqual(this.agreedSelection, casted, "Final Selection Mismatch");
122
+ },
123
+ finished: ()=>(
124
+ this.room.broadcast(MATCHLOCK_SELECTION_STATE.goodbye, "goodbye")
125
+ )
126
+ },
127
+ [MATCHLOCK_SELECTION_STATE.goodbye]: {
128
+ finished: ()=>{},
129
+ }
130
+ }
131
+ }
132
+
@@ -0,0 +1,93 @@
1
+ import { UserInput } from "@roster-lock/types";
2
+ import { ROSTERLOCK_V1_CASTER_JSONSCHEMA } from "@roster-lock/shared";
3
+ import { z, ZodType } from "zod";
4
+ import { getSQLite3FolderDB } from "../globals/FolderDB";
5
+ import { WebsocketRoom } from "../globals/Room";
6
+ import { MessageBridge } from "@roster-lock/utils";
7
+ import { WebSocket } from "ws";
8
+ import { tryToHandleRoomVersion1 } from "./handleRoomWithErrors";
9
+ import { UserSelectionSchema } from "../schema/selected";
10
+
11
+ /*
12
+ Flow
13
+ - user interacts with matchmaker
14
+ - matchmaker creates room in relay server
15
+ - matchmaker provides room id to user
16
+ - user connects to room
17
+ - How does the relay room know it's the user
18
+ - Signed GET parameter?
19
+ - User needs to know all the other users public keys
20
+ - User needs to know their own private key
21
+ */
22
+
23
+
24
+ type RoomRequest = {
25
+ folder: string,
26
+ relay: {
27
+ url: string,
28
+ roomId: string,
29
+ },
30
+ user: {
31
+ timestamp: number,
32
+ publicKey: string,
33
+ signature: string,
34
+ },
35
+ rosterConfig: any,
36
+ userSelection: UserInput["userSelection"],
37
+ }
38
+
39
+
40
+
41
+ const RequestCaster: ZodType<RoomRequest> = z.object({
42
+ folder: z.string(),
43
+ relay: z.object({
44
+ url: z.string(),
45
+ roomId: z.string(),
46
+ }),
47
+ user: z.object({
48
+ timestamp: z.number(),
49
+ publicKey: z.string(),
50
+ signature: z.string(),
51
+ }),
52
+ rosterConfig: z.any(),
53
+ userSelection: UserSelectionSchema,
54
+ }).strict();
55
+
56
+
57
+ export const bindRawStateToWebsocket = async (gameWebSocket: WebSocket)=>{
58
+ const messageBridge = new MessageBridge((message)=>gameWebSocket.send(JSON.stringify(message)));
59
+ gameWebSocket.on("message", (message)=>{
60
+ messageBridge.handleMessage(JSON.parse(message.toString()));
61
+ });
62
+ try {
63
+ const roomInfo = await messageBridge.sendRequest("roomInfo", {});
64
+
65
+ const parsed = RequestCaster.safeParse(roomInfo);
66
+ if(!parsed.success) throw new Error("Invalid request");
67
+ const roomRequest = parsed.data;
68
+ const config = ROSTERLOCK_V1_CASTER_JSONSCHEMA.cast(roomRequest.rosterConfig, true);
69
+ const db = getSQLite3FolderDB(roomRequest.folder);
70
+
71
+ const room = new WebsocketRoom(roomRequest.relay, roomRequest.user);
72
+ await room.readyPromise;
73
+
74
+ const results = await tryToHandleRoomVersion1(db, room, config, roomRequest.userSelection);
75
+
76
+ await messageBridge.sendRequest("roomComplete", results);
77
+ room.disconnect();
78
+ }catch(e){
79
+ console.error(e);
80
+ await messageBridge.sendRequest("roomFailed", (e as Error).message);
81
+ }finally{
82
+ gameWebSocket.close();
83
+ }
84
+ }
85
+
86
+ class WebsocketMessageBridge extends MessageBridge {
87
+ constructor(private websocket: WebSocket){
88
+ super((message)=>websocket.send(JSON.stringify(message)));
89
+ this.websocket.on("message", (message)=>{
90
+ this.handleMessage(JSON.parse(message.toString()));
91
+ });
92
+ }
93
+ }
@@ -0,0 +1,71 @@
1
+ import { MATCHLOCK_SELECTION_STATE } from "../constants";
2
+ import {
3
+ encryptJSON, decryptJSON,
4
+ } from "../handleRoomSelections/encryption";
5
+ import { createRandomSeed } from "../handleRoomSelections/random";
6
+ import { RosterLockV1Config, UserInput, FinalSelection } from "@roster-lock/types";
7
+ import { IRoom } from "../globals/Room/room";
8
+ import { RoomState } from "./RoomState";
9
+
10
+ export async function handleRoomSelections(
11
+ room: IRoom,
12
+ abortSignal: AbortSignal,
13
+ restriction: RosterLockV1Config,
14
+ selection: UserInput["userSelection"],
15
+ ){
16
+
17
+ const rngSeed = createRandomSeed();
18
+ const ownEncrypted = await encryptJSON({ selection, rngSeed })
19
+
20
+ if(abortSignal.aborted){
21
+ return Promise.reject(new Error("Aborted"));
22
+ }
23
+
24
+ const roomState = new RoomState(
25
+ room, restriction, ownEncrypted
26
+ );
27
+
28
+ const { promise, resolve, reject } = Promise.withResolvers<FinalSelection>();
29
+
30
+ // Send our "hello" after we start listening
31
+ Promise.resolve().then(async ()=>{
32
+ room.broadcast(MATCHLOCK_SELECTION_STATE.hello, "hello");
33
+ });
34
+ const stopListeningToRoom = room.listen(async (userId, event, data)=>{
35
+ try {
36
+ if(!(event in roomState.stateHandlers )){
37
+ return;
38
+ }
39
+ // ensure the message wasn't sent twice and event is in order
40
+ const count = roomState.validateMessageState(userId, event as MATCHLOCK_SELECTION_STATE);
41
+ const { message, finished } = roomState.stateHandlers[event];
42
+ // If there is a message handler, run it
43
+ await message?.(userId, data);
44
+ // If we haven't heard from everyone, exit
45
+ if(count < room.numberOfUsers) return;
46
+ // Update the room step
47
+ roomState.updateStep();
48
+ // Run the finished handler which includes broadcasting the next step
49
+ await finished();
50
+
51
+ // If we're in the goodbye state, we're done
52
+ if(event !== MATCHLOCK_SELECTION_STATE.goodbye) return;
53
+ if(!roomState.agreedSelection) throw new Error("Missing Agreed FInal Selection");
54
+ resolve(roomState.agreedSelection!);
55
+ }catch(e){
56
+ reject(e);
57
+ }
58
+ });
59
+
60
+ promise.finally(()=>{
61
+ stopListeningToRoom();
62
+ });
63
+
64
+ abortSignal.addEventListener("abort", ()=>(
65
+ reject(new Error("Aborted"))
66
+ ));
67
+
68
+ return promise;
69
+
70
+ }
71
+