@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.
- package/LICENSE.md +11 -0
- package/dist/authentication.js +53 -0
- package/dist/authentication.js.map +1 -0
- package/dist/config/constants.js +12 -0
- package/dist/config/constants.js.map +1 -0
- package/dist/config/findFile.js +32 -0
- package/dist/config/findFile.js.map +1 -0
- package/dist/config/index.js +29 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/manage.js +41 -0
- package/dist/config/manage.js.map +1 -0
- package/dist/cors.js +20 -0
- package/dist/cors.js.map +1 -0
- package/dist/editor-support/index.js +247 -0
- package/dist/editor-support/index.js.map +1 -0
- package/dist/handle-room/version-1/file-routes.js +53 -0
- package/dist/handle-room/version-1/file-routes.js.map +1 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/MultiAbort.js +76 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/MultiAbort.js.map +1 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/ensurePieceExists.js +218 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/ensurePieceExists.js.map +1 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/getVersions.js +18 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/getVersions.js.map +1 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/index.js +16 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/index.js.map +1 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/schema.js +370 -0
- package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/schema.js.map +1 -0
- package/dist/handle-room/version-1/globals/FolderDB/index.js +18 -0
- package/dist/handle-room/version-1/globals/FolderDB/index.js.map +1 -0
- package/dist/handle-room/version-1/globals/FolderDB/types.js +3 -0
- package/dist/handle-room/version-1/globals/FolderDB/types.js.map +1 -0
- package/dist/handle-room/version-1/globals/Room/WebsocketRoom.js +95 -0
- package/dist/handle-room/version-1/globals/Room/WebsocketRoom.js.map +1 -0
- package/dist/handle-room/version-1/globals/Room/index.js +18 -0
- package/dist/handle-room/version-1/globals/Room/index.js.map +1 -0
- package/dist/handle-room/version-1/globals/Room/room.js +3 -0
- package/dist/handle-room/version-1/globals/Room/room.js.map +1 -0
- package/dist/handle-room/version-1/globals/index.js +20 -0
- package/dist/handle-room/version-1/globals/index.js.map +1 -0
- package/dist/handle-room/version-1/globals/types.js +3 -0
- package/dist/handle-room/version-1/globals/types.js.map +1 -0
- package/dist/handle-room/version-1/handleDownloads/DownloadTracker.js +83 -0
- package/dist/handle-room/version-1/handleDownloads/DownloadTracker.js.map +1 -0
- package/dist/handle-room/version-1/handleDownloads/index.js +35 -0
- package/dist/handle-room/version-1/handleDownloads/index.js.map +1 -0
- package/dist/handle-room/version-1/handleDownloads/types.js +3 -0
- package/dist/handle-room/version-1/handleDownloads/types.js.map +1 -0
- package/dist/handle-room/version-1/handleRoomSelections/encryption.js +21 -0
- package/dist/handle-room/version-1/handleRoomSelections/encryption.js.map +1 -0
- package/dist/handle-room/version-1/handleRoomSelections/random.js +12 -0
- package/dist/handle-room/version-1/handleRoomSelections/random.js.map +1 -0
- package/dist/handle-room/version-1/index.js +28 -0
- package/dist/handle-room/version-1/index.js.map +1 -0
- package/dist/handle-room/version-1/piece-sort.js +71 -0
- package/dist/handle-room/version-1/piece-sort.js.map +1 -0
- package/dist/handle-room/version-1/room-handler-bridge/exchange-and-download-selections.js +89 -0
- package/dist/handle-room/version-1/room-handler-bridge/exchange-and-download-selections.js.map +1 -0
- package/dist/handle-room/version-1/room-handler-bridge/http-handler.js +19 -0
- package/dist/handle-room/version-1/room-handler-bridge/http-handler.js.map +1 -0
- package/dist/handle-room/version-1/room-handler-bridge/steps.js +170 -0
- package/dist/handle-room/version-1/room-handler-bridge/steps.js.map +1 -0
- package/dist/handle-room/version-1/room-handler-bridge/ws-handler.js +28 -0
- package/dist/handle-room/version-1/room-handler-bridge/ws-handler.js.map +1 -0
- package/dist/handle-room/version-1/schema/count.js +16 -0
- package/dist/handle-room/version-1/schema/count.js.map +1 -0
- package/dist/handle-room/version-1/schema/lock.js +46 -0
- package/dist/handle-room/version-1/schema/lock.js.map +1 -0
- package/dist/handle-room/version-1/schema/selected.js +13 -0
- package/dist/handle-room/version-1/schema/selected.js.map +1 -0
- package/dist/handle-room/version-1/select.js +94 -0
- package/dist/handle-room/version-1/select.js.map +1 -0
- package/dist/index.js +180 -0
- package/dist/index.js.map +1 -0
- package/dist/server.js +88 -0
- package/dist/server.js.map +1 -0
- package/dist/utils/fs.js +32 -0
- package/dist/utils/fs.js.map +1 -0
- package/dist/utils/http-router.js +49 -0
- package/dist/utils/http-router.js.map +1 -0
- package/dist/utils/pagination.js +23 -0
- package/dist/utils/pagination.js.map +1 -0
- package/dist/utils/websocket-router.js +15 -0
- package/dist/utils/websocket-router.js.map +1 -0
- package/package.json +66 -0
- package/src/authentication.ts +48 -0
- package/src/config/constants.ts +7 -0
- package/src/config/findFile.ts +34 -0
- package/src/config/index.ts +17 -0
- package/src/config/manage.ts +48 -0
- package/src/cors.ts +23 -0
- package/src/editor-support/index.ts +289 -0
- package/src/handle-room/version-0/piece-download/availableGamePiece/index.ts +109 -0
- package/src/handle-room/version-0/piece-download/availableGamePiece/isUpToDate.ts +78 -0
- package/src/handle-room/version-0/piece-download/downloadPieceAssets/downloadSingleAsset.ts +129 -0
- package/src/handle-room/version-0/piece-download/downloadPieceAssets/example.ts +116 -0
- package/src/handle-room/version-0/piece-download/downloadPieceAssets/index.ts +144 -0
- package/src/handle-room/version-0/piece-download/downloadPieceAssets/types.ts +32 -0
- package/src/handle-room/version-0/piece-download/downloadPieceAssets/validateAssetFile.ts +54 -0
- package/src/handle-room/version-0/piece-download/index.ts +5 -0
- package/src/handle-room/version-0/piece-selection/constants.ts +39 -0
- package/src/handle-room/version-0/piece-selection/handleRoomSelections.ts +202 -0
- package/src/handle-room/version-0/piece-selection/index.ts +31 -0
- package/src/handle-room/version-0/piece-selection/roomErrors.ts +25 -0
- package/src/handle-room/version-0/piece-selection/steps/encrypton.ts +70 -0
- package/src/handle-room/version-0/piece-selection/steps/final-selection.ts +72 -0
- package/src/handle-room/version-0/piece-selection/steps/player-selection.ts +100 -0
- package/src/handle-room/version-1/constants.ts +44 -0
- package/src/handle-room/version-1/file-routes.ts +68 -0
- package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/MultiAbort.ts +83 -0
- package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/ensurePieceExists.ts +317 -0
- package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/getVersions.ts +31 -0
- package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/index.ts +13 -0
- package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/schema.ts +547 -0
- package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/types.ts +6 -0
- package/src/handle-room/version-1/globals/FolderDB/index.ts +3 -0
- package/src/handle-room/version-1/globals/FolderDB/types.ts +72 -0
- package/src/handle-room/version-1/globals/Room/WebsocketRoom.ts +118 -0
- package/src/handle-room/version-1/globals/Room/index.ts +3 -0
- package/src/handle-room/version-1/globals/Room/room.ts +8 -0
- package/src/handle-room/version-1/globals/index.ts +4 -0
- package/src/handle-room/version-1/globals/types.ts +7 -0
- package/src/handle-room/version-1/handleDownloads/DownloadTracker.ts +98 -0
- package/src/handle-room/version-1/handleDownloads/index.ts +41 -0
- package/src/handle-room/version-1/handleDownloads/types.ts +9 -0
- package/src/handle-room/version-1/handleRoomSelections/encryption.ts +29 -0
- package/src/handle-room/version-1/handleRoomSelections/random.ts +9 -0
- package/src/handle-room/version-1/index.ts +33 -0
- package/src/handle-room/version-1/piece-sort.ts +96 -0
- package/src/handle-room/version-1/room-handler-bridge/exchange-and-download-selections.ts +125 -0
- package/src/handle-room/version-1/room-handler-bridge/http-handler.ts +16 -0
- package/src/handle-room/version-1/room-handler-bridge/server.pseudo.ts +85 -0
- package/src/handle-room/version-1/room-handler-bridge/steps.ts +255 -0
- package/src/handle-room/version-1/room-handler-bridge/ws-handler.ts +30 -0
- package/src/handle-room/version-1/room-handler-state/RoomState.ts +132 -0
- package/src/handle-room/version-1/room-handler-state/bind-to-websocket.ts +93 -0
- package/src/handle-room/version-1/room-handler-state/handleRoomSelections.ts +71 -0
- package/src/handle-room/version-1/room-handler-state/handleRoomWithErrors.ts +55 -0
- package/src/handle-room/version-1/room-handler-state/roomErrors.ts +35 -0
- package/src/handle-room/version-1/room-handler-state/user-selection.ts +98 -0
- package/src/handle-room/version-1/schema/count.ts +13 -0
- package/src/handle-room/version-1/schema/lock.ts +47 -0
- package/src/handle-room/version-1/schema/selected.ts +14 -0
- package/src/handle-room/version-1/select.ts +130 -0
- package/src/handle-room/version-1/validateUnknown.ts +11 -0
- package/src/index.old.ts +71 -0
- package/src/index.ts +231 -0
- package/src/server.ts +85 -0
- package/src/utils/fs.ts +43 -0
- package/src/utils/http-router.ts +55 -0
- package/src/utils/pagination.ts +17 -0
- package/src/utils/websocket-router.ts +23 -0
- package/tests/integration/editor-pieces-search.test.ts +147 -0
- package/tests/integration/game-complete.test.ts +72 -0
- package/tests/integration/handle-full-selection.test.ts +143 -0
- package/tests/integration/helpers/bridge.ts +22 -0
- package/tests/integration/helpers/fakeFolderDB.ts +37 -0
- package/tests/integration/helpers/fakePluginRuntime.ts +46 -0
- package/tests/integration/helpers/lockConfig.ts +71 -0
- package/tests/integration/helpers/make-tar-gz.ts +41 -0
- package/tests/integration/helpers/piece.ts +98 -0
- package/tests/integration/helpers/plugin-dir.ts +26 -0
- package/tests/integration/helpers/room.ts +42 -0
- package/tests/integration/helpers/server.ts +51 -0
- package/tests/integration/helpers/validLockConfig.ts +80 -0
- package/tests/integration/piece-download-and-list.test.ts +194 -0
- package/tests/integration/piece-ensure-ws.test.ts +256 -0
- package/tests/integration/piece-files.test.ts +139 -0
- package/tests/integration/piece-sort-list.test.ts +113 -0
- package/tests/integration/protocol.test.ts +129 -0
- package/tests/integration/root-and-auth.test.ts +100 -0
- package/tests/integration/sync-dl.test.ts +62 -0
- package/tests/tsconfig.json +14 -0
- package/tsconfig.json +22 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createSimpleEmitter, JSON_Unknown, handleFetch, delay } from "@roster-lock/utils";
|
|
2
|
+
import { IRoom } from "./room";
|
|
3
|
+
import { WebSocket } from "ws";
|
|
4
|
+
import { once } from "events";
|
|
5
|
+
|
|
6
|
+
type RelayConfig = {
|
|
7
|
+
url: string,
|
|
8
|
+
roomId: string,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type UserConfig = {
|
|
12
|
+
timestamp: number,
|
|
13
|
+
publicKey: string,
|
|
14
|
+
signature: string,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
type User = {
|
|
19
|
+
userId: string,
|
|
20
|
+
publicKey: string,
|
|
21
|
+
displayName: string,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type WebSocketRoomState = (
|
|
25
|
+
| { state: "connecting" }
|
|
26
|
+
| { state: "connected", websocket: WebSocket, users: Array<User> }
|
|
27
|
+
| { state: "destroyed" }
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
export class WebsocketRoom implements IRoom {
|
|
31
|
+
private state: WebSocketRoomState = { state: "connecting" };
|
|
32
|
+
private relayURL: URL;
|
|
33
|
+
private connectPromise: Promise<void>;
|
|
34
|
+
constructor(
|
|
35
|
+
private relayConfig: RelayConfig,
|
|
36
|
+
userConfig: UserConfig,
|
|
37
|
+
){
|
|
38
|
+
const url = new URL(relayConfig.url);
|
|
39
|
+
url.searchParams.set("room", userConfig.signature);
|
|
40
|
+
url.searchParams.set("t", userConfig.timestamp.toString());
|
|
41
|
+
url.searchParams.set("pk", userConfig.publicKey);
|
|
42
|
+
url.searchParams.set("sig", userConfig.signature);
|
|
43
|
+
this.relayURL = url;
|
|
44
|
+
this.connectPromise = this.setup();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get readyPromise(){
|
|
48
|
+
return this.connectPromise;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private async setup(){
|
|
52
|
+
const { relayURL: url } = this;
|
|
53
|
+
const wsURL = new URL(url);
|
|
54
|
+
wsURL.protocol = url.protocol === "https:" ? "wss:" : "ws";
|
|
55
|
+
wsURL.pathname = "/" + this.relayConfig.roomId;
|
|
56
|
+
const websocket = new WebSocket(wsURL.href);
|
|
57
|
+
|
|
58
|
+
const abortController = new AbortController();
|
|
59
|
+
const httpURL = new URL(url);
|
|
60
|
+
httpURL.pathname = "/" + this.relayConfig.roomId + "/users";
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const [users] = await Promise.race([
|
|
64
|
+
Promise.all([
|
|
65
|
+
handleFetch<Array<User>>(fetch(httpURL.href, { signal: abortController.signal })),
|
|
66
|
+
once(websocket, 'open'),
|
|
67
|
+
]),
|
|
68
|
+
Promise.resolve().then(async ()=>{
|
|
69
|
+
await delay(10_000);
|
|
70
|
+
throw new Error("Timeout");
|
|
71
|
+
})
|
|
72
|
+
]);
|
|
73
|
+
this.state = { state: "connected", websocket, users };
|
|
74
|
+
}catch(e){
|
|
75
|
+
abortController.abort();
|
|
76
|
+
websocket.close();
|
|
77
|
+
this.state = { state: "destroyed" };
|
|
78
|
+
throw e;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get numberOfUsers(){
|
|
83
|
+
if(this.state.state !== "connected") throw new Error("Not Connected");
|
|
84
|
+
return this.state.users.length;
|
|
85
|
+
}
|
|
86
|
+
listen = createSimpleEmitter()
|
|
87
|
+
async broadcast(event: string, data: JSON_Unknown){
|
|
88
|
+
if(this.state.state !== "connected") throw new Error("Not Connected");
|
|
89
|
+
await this.sendMessage({
|
|
90
|
+
type: event,
|
|
91
|
+
payload: data,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
disconnect = ()=>{
|
|
95
|
+
if(this.state.state === "destroyed") return;
|
|
96
|
+
if(this.state.state === "connecting"){
|
|
97
|
+
this.state = { state: "destroyed" };
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const { websocket } = this.state;
|
|
101
|
+
websocket.close();
|
|
102
|
+
this.state = { state: "destroyed" };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private sendMessage(message: any){
|
|
106
|
+
return new Promise<void>((resolve, reject)=>{
|
|
107
|
+
if(this.state.state !== "connected") return reject(new Error("Not Connected"));
|
|
108
|
+
this.state.websocket.send(JSON.stringify(message), (error) => {
|
|
109
|
+
if (error) {
|
|
110
|
+
reject(error);
|
|
111
|
+
} else {
|
|
112
|
+
resolve();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { JSON_Unknown, ISimpleEventEmitter } from "@roster-lock/utils";
|
|
2
|
+
|
|
3
|
+
export interface IRoom {
|
|
4
|
+
numberOfUsers: number
|
|
5
|
+
listen: ISimpleEventEmitter<[playerId: string, event: string, data: JSON_Unknown]>
|
|
6
|
+
broadcast: (event: string, data: JSON_Unknown)=>Promise<void>
|
|
7
|
+
disconnect: ()=>void
|
|
8
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { RosterLockV1Config, SelectedPiece, PieceType, PieceId } from "@roster-lock/types";
|
|
2
|
+
import { IFolderDB } from "../globals/FolderDB";
|
|
3
|
+
import { ProgressHandlers } from "./types";
|
|
4
|
+
|
|
5
|
+
import { DownloadResultsMap, DownloadResult } from "./types";
|
|
6
|
+
import { ROSTERLOCK_DOWNLOAD_STATE } from "@roster-lock/types";
|
|
7
|
+
|
|
8
|
+
export class PieceDownloadTracker {
|
|
9
|
+
private pieceTypes = new Map<PieceId, Map<PieceType, Promise<DownloadResult>>>();
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
public database: IFolderDB,
|
|
13
|
+
public lockConfig: RosterLockV1Config,
|
|
14
|
+
public progressHandlers: ProgressHandlers,
|
|
15
|
+
){}
|
|
16
|
+
|
|
17
|
+
public tryToDownloadAndNested(pieceType: PieceType, piece: SelectedPiece){
|
|
18
|
+
if(!this.isDownloadQueued(pieceType, piece.id)){
|
|
19
|
+
this.addNewDownload(
|
|
20
|
+
pieceType, piece.id,
|
|
21
|
+
tryToDownloadAnySource(this.database, this.lockConfig, pieceType, piece, this.progressHandlers)
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
// We don't skip required as different pieces may have different required pieces
|
|
25
|
+
for(const [requiredPieceType, requiredPieces] of Object.entries(piece.required)){
|
|
26
|
+
for(const requiredPiece of requiredPieces.mandatory){
|
|
27
|
+
this.tryToDownloadAndNested(requiredPieceType, requiredPiece);
|
|
28
|
+
}
|
|
29
|
+
for(const requiredPiece of requiredPieces.selectable){
|
|
30
|
+
this.tryToDownloadAndNested(requiredPieceType, requiredPiece);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private isDownloadQueued(pieceType: PieceType, pieceId: PieceId){
|
|
36
|
+
const pieces = this.pieceTypes.get(pieceType);
|
|
37
|
+
if(!pieces) return false;
|
|
38
|
+
return pieces.has(pieceId);
|
|
39
|
+
}
|
|
40
|
+
addNewDownload(pieceType: PieceType, pieceId: PieceId, promise: Promise<DownloadResult>){
|
|
41
|
+
if(!this.pieceTypes.has(pieceType)){
|
|
42
|
+
this.pieceTypes.set(pieceType, new Map());
|
|
43
|
+
}
|
|
44
|
+
this.pieceTypes.get(pieceType)!.set(pieceId, promise);
|
|
45
|
+
}
|
|
46
|
+
getDownloadPromises(){
|
|
47
|
+
return Array.from(this.pieceTypes.values()).flatMap(r=>Array.from(r.values()));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static resultsToMap(results: DownloadResult[]): DownloadResultsMap{
|
|
51
|
+
const typeMap: DownloadResultsMap = {};
|
|
52
|
+
for(const result of results){
|
|
53
|
+
const pieceMap: DownloadResultsMap[PieceType] = (()=>{
|
|
54
|
+
const map = typeMap[result.pieceType];
|
|
55
|
+
if(map) return map;
|
|
56
|
+
const newMap = {};
|
|
57
|
+
typeMap[result.pieceType] = newMap;
|
|
58
|
+
return newMap;
|
|
59
|
+
})();
|
|
60
|
+
pieceMap[result.pieceId] = result;
|
|
61
|
+
}
|
|
62
|
+
return typeMap;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function tryToDownloadAnySource(
|
|
68
|
+
db: IFolderDB,
|
|
69
|
+
lockconfig: RosterLockV1Config,
|
|
70
|
+
pieceType: string,
|
|
71
|
+
selectedPiece: SelectedPiece,
|
|
72
|
+
progressHandlers: ProgressHandlers
|
|
73
|
+
): Promise<DownloadResult>{
|
|
74
|
+
const pieces = lockconfig.rosters[pieceType];
|
|
75
|
+
if(!pieces){
|
|
76
|
+
throw new Error(`Missing Roster for piece type ${pieceType}`);
|
|
77
|
+
}
|
|
78
|
+
const piece = pieces.find(p=>p.id === selectedPiece.id);
|
|
79
|
+
if(!piece){
|
|
80
|
+
throw new Error(`Missing Piece ${selectedPiece.id} for piece type ${pieceType}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const pieceVersions = { logic: piece.version.logic, media: piece.version.media };
|
|
84
|
+
progressHandlers.onProgress({
|
|
85
|
+
type: ROSTERLOCK_DOWNLOAD_STATE.downloadStart,
|
|
86
|
+
pieceType,
|
|
87
|
+
pieceVersions,
|
|
88
|
+
});
|
|
89
|
+
const folder = await db.ensurePieceExists(
|
|
90
|
+
lockconfig.engine, pieceType, piece, progressHandlers
|
|
91
|
+
);
|
|
92
|
+
progressHandlers.onProgress({
|
|
93
|
+
type: ROSTERLOCK_DOWNLOAD_STATE.downloadFinished,
|
|
94
|
+
pieceType,
|
|
95
|
+
pieceVersions,
|
|
96
|
+
});
|
|
97
|
+
return { pieceType, pieceId: selectedPiece.id, pieceVersions, folder };
|
|
98
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { FinalSelection, RosterLockV1Config } from "@roster-lock/types";
|
|
2
|
+
|
|
3
|
+
import { PieceDownloadTracker } from "./DownloadTracker";
|
|
4
|
+
import { ROSTERLOCK_DOWNLOAD_STATE } from "@roster-lock/types";
|
|
5
|
+
import { IFolderDB } from "../globals/FolderDB";
|
|
6
|
+
import { ProgressHandlers, DownloadResultsMap } from "./types";
|
|
7
|
+
|
|
8
|
+
export async function handleDownloads(
|
|
9
|
+
db: IFolderDB,
|
|
10
|
+
lockConfig: RosterLockV1Config,
|
|
11
|
+
finalSelection: FinalSelection,
|
|
12
|
+
progressHandlers: ProgressHandlers,
|
|
13
|
+
): Promise<DownloadResultsMap> {
|
|
14
|
+
try {
|
|
15
|
+
const downloadTracker = new PieceDownloadTracker(db, lockConfig, progressHandlers);
|
|
16
|
+
for(const [pieceType, selection] of Object.entries(finalSelection)){
|
|
17
|
+
if(selection.type === "shared"){
|
|
18
|
+
for(const piece of selection.value){
|
|
19
|
+
downloadTracker.tryToDownloadAndNested(pieceType, piece);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if(selection.type === "personal"){
|
|
23
|
+
for(const pieces of Object.values(selection.value)){
|
|
24
|
+
for(const piece of pieces){
|
|
25
|
+
downloadTracker.tryToDownloadAndNested(pieceType, piece);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const results = await Promise.all(downloadTracker.getDownloadPromises());
|
|
31
|
+
progressHandlers.onProgress({ type: ROSTERLOCK_DOWNLOAD_STATE.downloadAllComplete });
|
|
32
|
+
return PieceDownloadTracker.resultsToMap(results);
|
|
33
|
+
}catch(e){
|
|
34
|
+
progressHandlers.onProgress({
|
|
35
|
+
type: ROSTERLOCK_DOWNLOAD_STATE.downloadFullFailure,
|
|
36
|
+
error: (e as Error).message,
|
|
37
|
+
});
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
|
|
2
|
+
import { RosterLockDownloadUpdate } from "@roster-lock/types";
|
|
3
|
+
|
|
4
|
+
export type ProgressHandlers = {
|
|
5
|
+
onProgress: (update: RosterLockDownloadUpdate) => void;
|
|
6
|
+
abortSignal: AbortSignal;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export { DownloadResult, DownloadResultsMap } from "@roster-lock/types";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
|
|
2
|
+
import { JSON_Unknown, ENCRYPT_SYMMETRIC, SymmetricEncryptionKey } from "@roster-lock/utils";
|
|
3
|
+
|
|
4
|
+
import { JSONSchemaType } from "ajv";
|
|
5
|
+
|
|
6
|
+
export const encryptedSchema: JSONSchemaType<string> = {
|
|
7
|
+
type: "string",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
export async function encryptJSON(
|
|
12
|
+
originalValue: JSON_Unknown
|
|
13
|
+
): Promise<{ key: SymmetricEncryptionKey, encryptedValue: string }> {
|
|
14
|
+
const key = await ENCRYPT_SYMMETRIC.generateKey();
|
|
15
|
+
const encryptedValue = await ENCRYPT_SYMMETRIC.encryptValue(key, originalValue);
|
|
16
|
+
return {
|
|
17
|
+
key: key,
|
|
18
|
+
encryptedValue: encryptedValue
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
export async function decryptJSON(
|
|
24
|
+
keyString: string, encryptedValue: string
|
|
25
|
+
): Promise<JSON_Unknown> {
|
|
26
|
+
return await ENCRYPT_SYMMETRIC.decryptValue(
|
|
27
|
+
keyString as SymmetricEncryptionKey, encryptedValue
|
|
28
|
+
) as JSON_Unknown;
|
|
29
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
|
|
2
|
+
export function createRandomSeed(){
|
|
3
|
+
const array = new Uint8Array(16); // 16 bytes = 128 bits = 32 hex characters
|
|
4
|
+
crypto.getRandomValues(array);
|
|
5
|
+
const randHex = Array.from(array)
|
|
6
|
+
.map(b => b.toString(16).padStart(2, "0"))
|
|
7
|
+
.join(""); // 32-character hex string
|
|
8
|
+
return randHex;
|
|
9
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { HTTPRouter } from "../../utils/http-router";
|
|
2
|
+
import { WebSocketRouter } from "../../utils/websocket-router";
|
|
3
|
+
|
|
4
|
+
import { wsHandler } from "./room-handler-bridge/ws-handler";
|
|
5
|
+
import { httpHandler } from "./room-handler-bridge/http-handler";
|
|
6
|
+
|
|
7
|
+
import { getFilesOfAsset, getPieceFileContents } from "./file-routes";
|
|
8
|
+
import { ensurePieceDownloaded, ensurePieceDownloadedWs, listDownloadedPiecesDirect } from "./select";
|
|
9
|
+
import { listAvailableSortPlugins, sortListPlugin, gameComplete } from "./piece-sort";
|
|
10
|
+
import { IFolderDB, V1Env } from "./globals";
|
|
11
|
+
import { PluginManager } from "@roster-lock/plugin-runtime";
|
|
12
|
+
|
|
13
|
+
export const createV1Routers = (fileDB: IFolderDB, pluginRuntime: PluginManager)=>{
|
|
14
|
+
const env: V1Env = { fileDB, pluginRuntime };
|
|
15
|
+
const httpRouter = new HTTPRouter();
|
|
16
|
+
const wsRouter = new WebSocketRouter()
|
|
17
|
+
|
|
18
|
+
httpRouter.post("/sync-dl", httpHandler.bind(env));
|
|
19
|
+
httpRouter.put("/piece", ensurePieceDownloaded.bind(env))
|
|
20
|
+
httpRouter.post("/piece/asset-files", getFilesOfAsset.bind(env));
|
|
21
|
+
httpRouter.post("/piece/file-contents", getPieceFileContents.bind(env));
|
|
22
|
+
httpRouter.query("/piece/list-downloaded/direct", listDownloadedPiecesDirect.bind(env));
|
|
23
|
+
httpRouter.get("/piece/sort-list/available", listAvailableSortPlugins.bind(env));
|
|
24
|
+
httpRouter.post("/piece/sort-list/plugin/:pluginName", sortListPlugin.bind(env));
|
|
25
|
+
httpRouter.post("/game-complete", gameComplete.bind(env));
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
wsRouter.mount("/sync-dl", wsHandler.bind(env));
|
|
29
|
+
wsRouter.mount("/piece/ensure", ensurePieceDownloadedWs.bind(env));
|
|
30
|
+
|
|
31
|
+
return { httpRouter, wsRouter };
|
|
32
|
+
}
|
|
33
|
+
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
|
|
2
|
+
import { RosterLockV1Config, UserSelection } from "@roster-lock/types";
|
|
3
|
+
import { ROSTERLOCK_V1_CASTER_JSONSCHEMA } from "@roster-lock/shared";
|
|
4
|
+
import { jsonBody, HTTPRequestHandler, HTTPError } from "../../utils/http-router";
|
|
5
|
+
import { UserSelectionSchema } from "./schema/selected";
|
|
6
|
+
import { V1Env } from "./globals/types";
|
|
7
|
+
import z, { ZodType } from "zod";
|
|
8
|
+
|
|
9
|
+
function castLockConfig(uncasted: unknown): RosterLockV1Config {
|
|
10
|
+
const casted = ROSTERLOCK_V1_CASTER_JSONSCHEMA.safeCast(uncasted, true);
|
|
11
|
+
if(!casted.valid) throw new HTTPError(400, "Invalid lockConfig", casted.error);
|
|
12
|
+
return casted.value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The plugin only knows how to rank pieces it has stats for - it may return a
|
|
16
|
+
// partial ranking (or none at all). This reorders the caller's own candidate
|
|
17
|
+
// list to match that ranking, appending anything the plugin didn't rank (in
|
|
18
|
+
// its original order) at the end, so the response always covers every id the
|
|
19
|
+
// caller asked about.
|
|
20
|
+
function reorderByRanking(candidateIds: Array<string>, ranked: Array<string>): Array<string> {
|
|
21
|
+
const candidateSet = new Set(candidateIds);
|
|
22
|
+
const rankedFiltered = ranked.filter((id)=>candidateSet.has(id));
|
|
23
|
+
const rankedSet = new Set(rankedFiltered);
|
|
24
|
+
const leftover = candidateIds.filter((id)=>!rankedSet.has(id));
|
|
25
|
+
return [...rankedFiltered, ...leftover];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const listAvailableSortPlugins: HTTPRequestHandler = async function(
|
|
29
|
+
this: V1Env, { res }
|
|
30
|
+
){
|
|
31
|
+
const available = await this.pluginRuntime.pieceSort.listAvailable();
|
|
32
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
33
|
+
res.end(JSON.stringify(available));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type SortListBody = {
|
|
37
|
+
lockConfig: unknown,
|
|
38
|
+
pieceType: string,
|
|
39
|
+
logicIds: Array<string>,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sortListBodySchema: ZodType<SortListBody> = z.object({
|
|
43
|
+
lockConfig: z.unknown(),
|
|
44
|
+
pieceType: z.string(),
|
|
45
|
+
logicIds: z.array(z.string()),
|
|
46
|
+
}).strict();
|
|
47
|
+
|
|
48
|
+
export const sortListPlugin: HTTPRequestHandler = async function(
|
|
49
|
+
this: V1Env, { req, res }, routeInfo
|
|
50
|
+
){
|
|
51
|
+
const pluginName = routeInfo.params.pluginName;
|
|
52
|
+
if(!pluginName) throw new HTTPError(400, "Missing pluginName");
|
|
53
|
+
|
|
54
|
+
const body = await jsonBody(req);
|
|
55
|
+
const parseResult = sortListBodySchema.safeParse(body);
|
|
56
|
+
if(!parseResult.success) throw new HTTPError(400, "Bad Form", parseResult.error);
|
|
57
|
+
const { pieceType, logicIds } = parseResult.data;
|
|
58
|
+
const lockConfig = castLockConfig(parseResult.data.lockConfig);
|
|
59
|
+
|
|
60
|
+
const ranked = await this.pluginRuntime.pieceSort.sortPieces(pluginName, { lockConfig, pieceType });
|
|
61
|
+
const result = reorderByRanking(logicIds, ranked);
|
|
62
|
+
|
|
63
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
64
|
+
res.end(JSON.stringify(result));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type GameCompleteBody = {
|
|
68
|
+
lockConfig: unknown,
|
|
69
|
+
localUsers: Array<string>,
|
|
70
|
+
userSelections: Record<string, UserSelection>,
|
|
71
|
+
winners: Array<string>,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const gameCompleteBodySchema: ZodType<GameCompleteBody> = z.object({
|
|
75
|
+
lockConfig: z.unknown(),
|
|
76
|
+
localUsers: z.array(z.string()),
|
|
77
|
+
userSelections: z.record(z.string(), UserSelectionSchema),
|
|
78
|
+
winners: z.array(z.string()),
|
|
79
|
+
}).strict();
|
|
80
|
+
|
|
81
|
+
export const gameComplete: HTTPRequestHandler = async function(
|
|
82
|
+
this: V1Env, { req, res }
|
|
83
|
+
){
|
|
84
|
+
const body = await jsonBody(req);
|
|
85
|
+
const parseResult = gameCompleteBodySchema.safeParse(body);
|
|
86
|
+
if(!parseResult.success) throw new HTTPError(400, "Bad Form", parseResult.error);
|
|
87
|
+
const { localUsers, userSelections, winners } = parseResult.data;
|
|
88
|
+
const lockConfig = castLockConfig(parseResult.data.lockConfig);
|
|
89
|
+
|
|
90
|
+
await this.pluginRuntime.pieceSort.handleGameComplete({
|
|
91
|
+
lockConfig, localUsers, userSelections, winners,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
95
|
+
res.end(JSON.stringify({ ok: true }));
|
|
96
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
|
|
2
|
+
import { WebSocket } from "ws";
|
|
3
|
+
import { MessageBridge, handleFetch } from "@roster-lock/utils";
|
|
4
|
+
import { bindStepsToBridge, ProgressListeners } from "./steps";
|
|
5
|
+
import { RosterLockV1Config, RosterLockV1SyncDLRequestClientToAgent } from "@roster-lock/types";
|
|
6
|
+
import { ROSTERLOCK_V1_CASTER_JSONSCHEMA } from "@roster-lock/shared";
|
|
7
|
+
import { UserSelectionSchema } from "../schema/selected";
|
|
8
|
+
import { IFolderDB } from "../globals/FolderDB";
|
|
9
|
+
import { once } from "events";
|
|
10
|
+
import z, { ZodType } from "zod";
|
|
11
|
+
import { PluginManager } from "@roster-lock/plugin-runtime";
|
|
12
|
+
|
|
13
|
+
const RequestCaster: ZodType<(
|
|
14
|
+
& RosterLockV1SyncDLRequestClientToAgent
|
|
15
|
+
& { rosterConfig: any }
|
|
16
|
+
)> = z.object({
|
|
17
|
+
relay: z.object({
|
|
18
|
+
url: z.string(),
|
|
19
|
+
roomId: z.string(),
|
|
20
|
+
}),
|
|
21
|
+
machine: z.object({
|
|
22
|
+
timestamp: z.number(),
|
|
23
|
+
publicKey: z.string(),
|
|
24
|
+
signature: z.string(),
|
|
25
|
+
}),
|
|
26
|
+
rosterConfig: z.any(),
|
|
27
|
+
// Record<number, UserSelection> at the type level, but JSON object keys
|
|
28
|
+
// are always strings on the wire - validate them as such.
|
|
29
|
+
playerSelections: z.record(z.string(), UserSelectionSchema),
|
|
30
|
+
}).strict();
|
|
31
|
+
|
|
32
|
+
export function castRoomRequest(uncasted: unknown): RosterLockV1SyncDLRequestClientToAgent {
|
|
33
|
+
const parsed = RequestCaster.safeParse(uncasted);
|
|
34
|
+
if(!parsed.success) throw new Error("Invalid request");
|
|
35
|
+
const roomRequest = parsed.data;
|
|
36
|
+
const config = ROSTERLOCK_V1_CASTER_JSONSCHEMA.cast(roomRequest.rosterConfig, true);
|
|
37
|
+
const casted = {
|
|
38
|
+
...roomRequest,
|
|
39
|
+
rosterConfig: config,
|
|
40
|
+
};
|
|
41
|
+
return casted;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type Machine = {
|
|
45
|
+
userId: string,
|
|
46
|
+
publicKey: string,
|
|
47
|
+
displayName: string,
|
|
48
|
+
playerCount: number,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export async function exchangeAndDownloadSelections(
|
|
52
|
+
fileDB: IFolderDB,
|
|
53
|
+
pluginRuntime: PluginManager,
|
|
54
|
+
roomRequest: RosterLockV1SyncDLRequestClientToAgent,
|
|
55
|
+
progressListeners: ProgressListeners = {}
|
|
56
|
+
){
|
|
57
|
+
const roomURL = prepareRelayURL(roomRequest);
|
|
58
|
+
|
|
59
|
+
const httpURL = new URL(roomURL);
|
|
60
|
+
httpURL.pathname = "/api/v1/room/" + roomRequest.relay.roomId + "/machines";
|
|
61
|
+
const machines = await handleFetch<Array<Machine>>(fetch(httpURL.href));
|
|
62
|
+
|
|
63
|
+
const wsURL = new URL(roomURL);
|
|
64
|
+
wsURL.protocol = roomURL.protocol === "https:" ? "wss:" : "ws:";
|
|
65
|
+
wsURL.pathname = "/api/v1/room/" + roomRequest.relay.roomId;
|
|
66
|
+
// perMessageDeflate disabled: workerd's hibernatable WebSocket (relay-server,
|
|
67
|
+
// local wrangler dev) mishandles fragmented/compressed frames for larger
|
|
68
|
+
// messages, corrupting frame parsing on the "ws" client (surfaces as
|
|
69
|
+
// "Invalid WebSocket frame: invalid payload length").
|
|
70
|
+
const roomWebSocket = new WebSocket(wsURL.href, { perMessageDeflate: false });
|
|
71
|
+
const roomBridge = new MessageBridge((message)=>roomWebSocket.send(JSON.stringify(message)));
|
|
72
|
+
roomWebSocket.on("message", (message)=>{
|
|
73
|
+
roomBridge.handleMessage(JSON.parse(message.toString()));
|
|
74
|
+
});
|
|
75
|
+
roomWebSocket.on("error", (err)=>{
|
|
76
|
+
// An unhandled "error" event on an EventEmitter throws and crashes the
|
|
77
|
+
// whole process - and one match-agent process serves many concurrent
|
|
78
|
+
// games, so a single room's socket hiccup must not take the rest down.
|
|
79
|
+
console.error("room websocket error", err);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const roomPromise = bindStepsToBridge({
|
|
83
|
+
bridge: roomBridge,
|
|
84
|
+
fileDB,
|
|
85
|
+
pluginRuntime,
|
|
86
|
+
machines: machines.map(machine=>({ publicKey: machine.publicKey, playerCount: machine.playerCount })),
|
|
87
|
+
ownMachinePublicKey: roomRequest.machine.publicKey,
|
|
88
|
+
ownSelections: roomRequest.playerSelections,
|
|
89
|
+
lockConfig: roomRequest.rosterConfig,
|
|
90
|
+
gameControlledSelections: {},
|
|
91
|
+
}, progressListeners);
|
|
92
|
+
|
|
93
|
+
// The relay closes the room websocket with a close reason as soon as it fails
|
|
94
|
+
// a room (e.g. a download failure on the other user's leg), but never sends a
|
|
95
|
+
// bridge-level "error" event - so without this, bindStepsToBridge's onEvent("error")
|
|
96
|
+
// handler never fires and the room hangs until its own 30s heartbeat times out,
|
|
97
|
+
// surfacing a misleading "Heartbeat Timeout" instead of the real failure reason.
|
|
98
|
+
const closedEarly = new Promise<never>((_, reject)=>{
|
|
99
|
+
roomWebSocket.on("close", (code, reason)=>{
|
|
100
|
+
const reasonText = reason.toString();
|
|
101
|
+
reject(new Error(reasonText || `Room websocket closed unexpectedly (code ${code})`));
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
// roomPromise usually wins the race (success or a bridge-level "error" event),
|
|
105
|
+
// in which case this rejection is never observed - swallow it here so that
|
|
106
|
+
// doesn't crash the process as an unhandled rejection.
|
|
107
|
+
closedEarly.catch(()=>{});
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
await once(roomWebSocket, 'open')
|
|
111
|
+
return await Promise.race([roomPromise, closedEarly]);
|
|
112
|
+
} finally {
|
|
113
|
+
roomWebSocket.close();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function prepareRelayURL({ relay, machine }: RosterLockV1SyncDLRequestClientToAgent){
|
|
118
|
+
const url = new URL(relay.url);
|
|
119
|
+
url.searchParams.set("room", relay.roomId);
|
|
120
|
+
url.searchParams.set("t", machine.timestamp.toString());
|
|
121
|
+
url.searchParams.set("pk", machine.publicKey);
|
|
122
|
+
url.searchParams.set("sig", machine.signature);
|
|
123
|
+
return url;
|
|
124
|
+
}
|
|
125
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
|
|
2
|
+
import { jsonBody, HTTPRequestHandler } from "../../../utils/http-router";
|
|
3
|
+
import { castRoomRequest, exchangeAndDownloadSelections } from "./exchange-and-download-selections";
|
|
4
|
+
import { V1Env } from "../globals/types";
|
|
5
|
+
|
|
6
|
+
export const httpHandler: HTTPRequestHandler = async function(this: V1Env, { req, res }, params, next){
|
|
7
|
+
try {
|
|
8
|
+
const body = await jsonBody(req);
|
|
9
|
+
const roomRequest = castRoomRequest(body);
|
|
10
|
+
const results = await exchangeAndDownloadSelections(this.fileDB, this.pluginRuntime, roomRequest);
|
|
11
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
12
|
+
res.end(JSON.stringify(results));
|
|
13
|
+
}catch(e){
|
|
14
|
+
next(e);
|
|
15
|
+
}
|
|
16
|
+
}
|