@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,116 @@
|
|
|
1
|
+
import { MatchLockPublishPiece } from "@match-lock/shared";
|
|
2
|
+
import { downloadPieceAssets } from "./index";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Example usage of the new asset-based download system
|
|
6
|
+
*/
|
|
7
|
+
export async function exampleDownload() {
|
|
8
|
+
// Example piece with individual assets
|
|
9
|
+
const examplePiece: MatchLockPublishPiece = {
|
|
10
|
+
name: "example-character",
|
|
11
|
+
author: "game-dev",
|
|
12
|
+
published: "2024-01-01T00:00:00Z",
|
|
13
|
+
sha256: "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234",
|
|
14
|
+
signature: "example-signature",
|
|
15
|
+
signatureVerificationUrl: "https://example.com/verify",
|
|
16
|
+
preview: {
|
|
17
|
+
image: "https://example.com/preview.jpg",
|
|
18
|
+
url: "https://example.com/preview"
|
|
19
|
+
},
|
|
20
|
+
sources: [
|
|
21
|
+
{
|
|
22
|
+
type: "http",
|
|
23
|
+
url: "https://example.com/assets/"
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
pieceDefinition: "character",
|
|
27
|
+
version: {
|
|
28
|
+
logic: "logic-sha256-hash-here",
|
|
29
|
+
media: "media-sha256-hash-here"
|
|
30
|
+
},
|
|
31
|
+
assets: {
|
|
32
|
+
"character.json": {
|
|
33
|
+
assetType: "json",
|
|
34
|
+
sha256: "file1-sha256-hash-here",
|
|
35
|
+
sizeBytes: 1024
|
|
36
|
+
},
|
|
37
|
+
"sprites/idle.png": {
|
|
38
|
+
assetType: "png",
|
|
39
|
+
sha256: "file2-sha256-hash-here",
|
|
40
|
+
sizeBytes: 2048
|
|
41
|
+
},
|
|
42
|
+
"sounds/jump.wav": {
|
|
43
|
+
assetType: "wav",
|
|
44
|
+
sha256: "file3-sha256-hash-here",
|
|
45
|
+
sizeBytes: 4096
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const targetDirectory = "./downloaded-pieces";
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const pieceFolder = await downloadPieceAssets(
|
|
54
|
+
examplePiece,
|
|
55
|
+
targetDirectory,
|
|
56
|
+
(progress) => {
|
|
57
|
+
console.log(`Download progress for ${progress.pieceId}:`);
|
|
58
|
+
console.log(` Completed: ${progress.completedAssets}/${progress.totalAssets}`);
|
|
59
|
+
if (progress.currentAsset) {
|
|
60
|
+
console.log(` Current: ${progress.currentAsset.filePath} (${Math.round(progress.currentAsset.progress * 100)}%)`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
console.log(`Successfully downloaded piece to: ${pieceFolder}`);
|
|
66
|
+
|
|
67
|
+
// The downloaded structure will be:
|
|
68
|
+
// ./downloaded-pieces/game-dev/example-character-logic-sh/
|
|
69
|
+
// ├── character.json
|
|
70
|
+
// ├── sprites/
|
|
71
|
+
// │ └── idle.png
|
|
72
|
+
// ├── sounds/
|
|
73
|
+
// │ └── jump.wav
|
|
74
|
+
// └── piece.matchlock.json (metadata)
|
|
75
|
+
|
|
76
|
+
return pieceFolder;
|
|
77
|
+
|
|
78
|
+
} catch (error) {
|
|
79
|
+
console.error("Download failed:", error);
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Example of downloading a single asset
|
|
86
|
+
*/
|
|
87
|
+
export async function exampleSingleAssetDownload() {
|
|
88
|
+
const { downloadSingleAsset } = await import("./downloadSingleAsset");
|
|
89
|
+
|
|
90
|
+
const examplePiece: MatchLockPublishPiece = {
|
|
91
|
+
// ... same as above
|
|
92
|
+
} as any;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const result = await downloadSingleAsset({
|
|
96
|
+
piece: examplePiece,
|
|
97
|
+
filePath: "character.json",
|
|
98
|
+
asset: {
|
|
99
|
+
assetType: "json",
|
|
100
|
+
sha256: "file1-sha256-hash-here",
|
|
101
|
+
sizeBytes: 1024
|
|
102
|
+
},
|
|
103
|
+
targetDirectory: "./single-asset-test",
|
|
104
|
+
onProgress: (progress) => {
|
|
105
|
+
console.log(`Downloading ${progress.filePath}: ${Math.round(progress.progress * 100)}%`);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
console.log(`Downloaded ${result.filePath} to ${result.localPath}`);
|
|
110
|
+
return result;
|
|
111
|
+
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.error("Single asset download failed:", error);
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { join as pathJoin } from "node:path";
|
|
2
|
+
import { mkdir as fsMkdir, writeFile as fsWriteFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
import { MatchLockPublishPiece } from "@match-lock/shared";
|
|
5
|
+
import { downloadSingleAsset } from "./downloadSingleAsset";
|
|
6
|
+
import { validateAssetFile } from "./validateAsset";
|
|
7
|
+
import {
|
|
8
|
+
ProgressListener,
|
|
9
|
+
PieceDownloadProgress,
|
|
10
|
+
AssetDownloadResult
|
|
11
|
+
} from "./types";
|
|
12
|
+
|
|
13
|
+
export async function downloadPieceAssets(
|
|
14
|
+
piece: MatchLockPublishPiece,
|
|
15
|
+
targetDirectory: string,
|
|
16
|
+
onProgress?: ProgressListener
|
|
17
|
+
): Promise<string> {
|
|
18
|
+
// Create piece-specific directory using author/name-logicSha pattern
|
|
19
|
+
const logicShaShort = piece.version.logic.substring(0, 8);
|
|
20
|
+
const pieceFolder = pathJoin(targetDirectory, piece.author, `${piece.name}-${logicShaShort}`);
|
|
21
|
+
|
|
22
|
+
// Ensure piece directory exists
|
|
23
|
+
await fsMkdir(pieceFolder, { recursive: true });
|
|
24
|
+
|
|
25
|
+
// Check if piece is already downloaded and up to date
|
|
26
|
+
if (await isPieceUpToDate(piece, pieceFolder)) {
|
|
27
|
+
return pieceFolder;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const assetEntries = Object.entries(piece.assets);
|
|
31
|
+
const totalAssets = assetEntries.length;
|
|
32
|
+
let completedAssets = 0;
|
|
33
|
+
const downloadResults: AssetDownloadResult[] = [];
|
|
34
|
+
|
|
35
|
+
// Download each asset
|
|
36
|
+
for (const [filePath, asset] of assetEntries) {
|
|
37
|
+
const targetFilePath = pathJoin(pieceFolder, filePath);
|
|
38
|
+
|
|
39
|
+
// Check if this specific asset is already downloaded and valid
|
|
40
|
+
if (await validateAssetFile(targetFilePath, asset)) {
|
|
41
|
+
completedAssets++;
|
|
42
|
+
downloadResults.push({
|
|
43
|
+
filePath,
|
|
44
|
+
localPath: targetFilePath,
|
|
45
|
+
asset
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Report progress
|
|
49
|
+
if (onProgress) {
|
|
50
|
+
onProgress({
|
|
51
|
+
pieceId: piece.name,
|
|
52
|
+
completedAssets,
|
|
53
|
+
totalAssets
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Download the asset
|
|
60
|
+
try {
|
|
61
|
+
const result = await downloadSingleAsset({
|
|
62
|
+
piece,
|
|
63
|
+
filePath,
|
|
64
|
+
asset,
|
|
65
|
+
targetDirectory: pieceFolder,
|
|
66
|
+
onProgress: (assetProgress) => {
|
|
67
|
+
if (onProgress) {
|
|
68
|
+
onProgress({
|
|
69
|
+
pieceId: piece.name,
|
|
70
|
+
completedAssets,
|
|
71
|
+
totalAssets,
|
|
72
|
+
currentAsset: assetProgress
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
downloadResults.push(result);
|
|
79
|
+
completedAssets++;
|
|
80
|
+
|
|
81
|
+
// Report completion of this asset
|
|
82
|
+
if (onProgress) {
|
|
83
|
+
onProgress({
|
|
84
|
+
pieceId: piece.name,
|
|
85
|
+
completedAssets,
|
|
86
|
+
totalAssets
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw new Error(`Failed to download piece ${piece.name}: ${error}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Create piece metadata file
|
|
96
|
+
await createPieceMetadata(piece, pieceFolder, downloadResults);
|
|
97
|
+
|
|
98
|
+
return pieceFolder;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function isPieceUpToDate(
|
|
102
|
+
piece: MatchLockPublishPiece,
|
|
103
|
+
pieceFolder: string
|
|
104
|
+
): Promise<boolean> {
|
|
105
|
+
try {
|
|
106
|
+
// Check if all assets exist and are valid
|
|
107
|
+
for (const [filePath, asset] of Object.entries(piece.assets)) {
|
|
108
|
+
const targetFilePath = pathJoin(pieceFolder, filePath);
|
|
109
|
+
if (!(await validateAssetFile(targetFilePath, asset))) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return true;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function createPieceMetadata(
|
|
120
|
+
piece: MatchLockPublishPiece,
|
|
121
|
+
pieceFolder: string,
|
|
122
|
+
downloadResults: AssetDownloadResult[]
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
const metadata = {
|
|
125
|
+
pieceId: piece.name,
|
|
126
|
+
author: piece.author,
|
|
127
|
+
version: piece.version,
|
|
128
|
+
downloadedAt: new Date().toISOString(),
|
|
129
|
+
assets: downloadResults.map(result => ({
|
|
130
|
+
filePath: result.filePath,
|
|
131
|
+
localPath: result.localPath,
|
|
132
|
+
sha256: result.asset.sha256,
|
|
133
|
+
sizeBytes: result.asset.sizeBytes,
|
|
134
|
+
assetType: result.asset.assetType
|
|
135
|
+
}))
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const metadataPath = pathJoin(pieceFolder, "piece.matchlock.json");
|
|
139
|
+
await fsWriteFile(metadataPath, JSON.stringify(metadata, null, 2), "utf8");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export * from "./types";
|
|
143
|
+
export { downloadSingleAsset } from "./downloadSingleAsset";
|
|
144
|
+
export { validateAssetFile } from "./validateAsset";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { MatchLockPublishPiece, MatchLockAsset } from "@match-lock/shared";
|
|
2
|
+
|
|
3
|
+
export type AssetDownloadProgress = {
|
|
4
|
+
pieceId: string;
|
|
5
|
+
filePath: string;
|
|
6
|
+
bytesDownloaded: number;
|
|
7
|
+
totalBytes: number;
|
|
8
|
+
progress: number; // 0-1
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type AssetDownloadResult = {
|
|
12
|
+
filePath: string;
|
|
13
|
+
localPath: string;
|
|
14
|
+
asset: MatchLockAsset;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type PieceDownloadProgress = {
|
|
18
|
+
pieceId: string;
|
|
19
|
+
completedAssets: number;
|
|
20
|
+
totalAssets: number;
|
|
21
|
+
currentAsset?: AssetDownloadProgress;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type ProgressListener = (progress: PieceDownloadProgress) => void;
|
|
25
|
+
|
|
26
|
+
export type AssetDownloadOptions = {
|
|
27
|
+
piece: MatchLockPublishPiece;
|
|
28
|
+
filePath: string;
|
|
29
|
+
asset: MatchLockAsset;
|
|
30
|
+
targetDirectory: string;
|
|
31
|
+
onProgress?: (progress: AssetDownloadProgress) => void;
|
|
32
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { stat as fsStat, readFile as fsReadFile } from "node:fs/promises";
|
|
3
|
+
import { MatchLockAsset } from "@match-lock/shared";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
export async function validateAssetFile(
|
|
7
|
+
filePath: string,
|
|
8
|
+
expectedAsset: MatchLockAsset
|
|
9
|
+
): Promise<boolean> {
|
|
10
|
+
try {
|
|
11
|
+
const stats = await fsStat(filePath);
|
|
12
|
+
|
|
13
|
+
// Check file size
|
|
14
|
+
if (stats.size !== expectedAsset.sizeBytes) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Check SHA256 hash
|
|
19
|
+
const fileBuffer = await fsReadFile(filePath);
|
|
20
|
+
const hash = createHash("sha256").update(fileBuffer).digest("hex");
|
|
21
|
+
|
|
22
|
+
return hash === expectedAsset.sha256;
|
|
23
|
+
} catch (error) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function calculateFileHash(filePath: string): Promise<string> {
|
|
29
|
+
const fileBuffer = await fsReadFile(filePath);
|
|
30
|
+
return createHash("sha256").update(fileBuffer).digest("hex");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function getFileSize(filePath: string): Promise<number> {
|
|
34
|
+
const stats = await fsStat(filePath);
|
|
35
|
+
return stats.size;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function validateAssetIntegrity(
|
|
39
|
+
actualSize: number,
|
|
40
|
+
actualHash: string,
|
|
41
|
+
expectedAsset: MatchLockAsset
|
|
42
|
+
): void {
|
|
43
|
+
if (actualSize !== expectedAsset.sizeBytes) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Asset size mismatch: expected ${expectedAsset.sizeBytes} bytes, got ${actualSize} bytes`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (actualHash !== expectedAsset.sha256) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Asset hash mismatch: expected ${expectedAsset.sha256}, got ${actualHash}`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { JSON_Unknown } from "@match-lock/shared";
|
|
2
|
+
|
|
3
|
+
export enum MATCHLOCK_SELECTION_STATE {
|
|
4
|
+
failure = "failure",
|
|
5
|
+
|
|
6
|
+
hello = "hello",
|
|
7
|
+
restriction = "restriction",
|
|
8
|
+
selectionEncrypt = "selection-encrypt",
|
|
9
|
+
selectionDecrypt = "selection-decrypt",
|
|
10
|
+
selectionFinal = "selection-final",
|
|
11
|
+
goodbye = "goodbye",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export enum MATCHLOCK_DOWNLOAD_STATE {
|
|
15
|
+
failure = "failure",
|
|
16
|
+
|
|
17
|
+
hello = "hello",
|
|
18
|
+
selectionFinal = "selection-final",
|
|
19
|
+
downloadStart = "download-start",
|
|
20
|
+
downloadProgress = "download-progress",
|
|
21
|
+
downloadValidation = "download-validation",
|
|
22
|
+
downloadFinished = "download-finished",
|
|
23
|
+
|
|
24
|
+
downloadFailure = "download-failure",
|
|
25
|
+
downloadAllComplete = "download-all-complete",
|
|
26
|
+
goodbye = "goodbye",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class ExternalUserError extends Error {
|
|
30
|
+
constructor(public userId: string, public rawError: JSON_Unknown){
|
|
31
|
+
super("External User Error");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class UserMessegeError extends Error {
|
|
36
|
+
constructor(public userId: string, public message: string){
|
|
37
|
+
super(message);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { JSON_Unknown, RosterLockV1Config, UserInput } from "@match-lock/shared";
|
|
2
|
+
import { MATCHLOCK_SELECTION_STATE, UserMessegeError } from "./constants";
|
|
3
|
+
import {
|
|
4
|
+
encryptJSON, decryptJSON,
|
|
5
|
+
CastEncryptedMessage,
|
|
6
|
+
} from "./steps/encrypton";
|
|
7
|
+
import {
|
|
8
|
+
validatePlayerSelection, createRandomSeed,
|
|
9
|
+
PlayerSelectionWithSeed
|
|
10
|
+
} from "./steps/player-selection";
|
|
11
|
+
import {
|
|
12
|
+
finalizeSelection,
|
|
13
|
+
FinalSelection,
|
|
14
|
+
CastFinalSelection
|
|
15
|
+
} from "./steps/final-selection";
|
|
16
|
+
import { Room } from "../../../types/room";
|
|
17
|
+
import { deepEqual } from "node:assert";
|
|
18
|
+
|
|
19
|
+
const STATE_ORDER = [
|
|
20
|
+
MATCHLOCK_SELECTION_STATE.hello,
|
|
21
|
+
MATCHLOCK_SELECTION_STATE.restriction,
|
|
22
|
+
MATCHLOCK_SELECTION_STATE.selectionEncrypt,
|
|
23
|
+
MATCHLOCK_SELECTION_STATE.selectionDecrypt,
|
|
24
|
+
MATCHLOCK_SELECTION_STATE.selectionFinal,
|
|
25
|
+
MATCHLOCK_SELECTION_STATE.goodbye,
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export async function handleRoomSelections(
|
|
29
|
+
room: Room,
|
|
30
|
+
abortSignal: AbortSignal,
|
|
31
|
+
restriction: RosterLockV1Config,
|
|
32
|
+
selection: UserInput["userSelection"],
|
|
33
|
+
){
|
|
34
|
+
|
|
35
|
+
const rngSeed = createRandomSeed();
|
|
36
|
+
const [ownEncrypted] = await Promise.all([
|
|
37
|
+
// Prepare own Selections
|
|
38
|
+
encryptJSON({ selection, rngSeed }),
|
|
39
|
+
// Validate our own selection
|
|
40
|
+
validatePlayerSelection(restriction, { selection, rngSeed })
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
if(abortSignal.aborted){
|
|
44
|
+
return Promise.reject(new Error("Aborted"));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const roomState = new RoomState(
|
|
48
|
+
room, restriction, ownEncrypted
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const { promise, resolve, reject } = Promise.withResolvers<FinalSelection>();
|
|
52
|
+
|
|
53
|
+
// Send our "hello" after we start listening
|
|
54
|
+
Promise.resolve().then(async ()=>{
|
|
55
|
+
room.broadcast(MATCHLOCK_SELECTION_STATE.hello, "hello");
|
|
56
|
+
});
|
|
57
|
+
const stopListeningToRoom = room.listen(async (userId, event, data)=>{
|
|
58
|
+
try {
|
|
59
|
+
if(!(event in roomState.stateHandlers )){
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// ensure the message wasn't sent twice and event is in order
|
|
63
|
+
const count = roomState.validateMessageState(userId, event as MATCHLOCK_SELECTION_STATE);
|
|
64
|
+
const { message, finished } = roomState.stateHandlers[event];
|
|
65
|
+
// If there is a message handler, run it
|
|
66
|
+
await message?.(userId, data);
|
|
67
|
+
// If we haven't heard from everyone, exit
|
|
68
|
+
if(count < room.numberOfUsers) return;
|
|
69
|
+
// Update the room step
|
|
70
|
+
roomState.updateStep();
|
|
71
|
+
// Run the finished handler which includes broadcasting the next step
|
|
72
|
+
await finished();
|
|
73
|
+
|
|
74
|
+
// If we're in the goodbye state, we're done
|
|
75
|
+
if(event !== MATCHLOCK_SELECTION_STATE.goodbye) return;
|
|
76
|
+
if(!roomState.agreedSelection) throw new Error("Missing Agreed FInal Selection");
|
|
77
|
+
resolve(roomState.agreedSelection!);
|
|
78
|
+
}catch(e){
|
|
79
|
+
reject(e);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
promise.finally(()=>{
|
|
84
|
+
stopListeningToRoom();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
abortSignal.addEventListener("abort", ()=>(
|
|
88
|
+
reject(new Error("Aborted"))
|
|
89
|
+
));
|
|
90
|
+
|
|
91
|
+
return promise;
|
|
92
|
+
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
class RoomState {
|
|
96
|
+
private state: MATCHLOCK_SELECTION_STATE = MATCHLOCK_SELECTION_STATE.hello;
|
|
97
|
+
private recievedMessages = new Map<MATCHLOCK_SELECTION_STATE, Set<string>>();
|
|
98
|
+
|
|
99
|
+
encryptedData = new Map<string, { iv: string, ciphertext: string }>();
|
|
100
|
+
userSelections = new Map<string, PlayerSelectionWithSeed>();
|
|
101
|
+
agreedSelection: FinalSelection | null = null;
|
|
102
|
+
|
|
103
|
+
constructor(
|
|
104
|
+
public room: Room,
|
|
105
|
+
public restriction: RosterLockV1Config,
|
|
106
|
+
public ownEncrypted: Awaited<ReturnType<typeof encryptJSON>>,
|
|
107
|
+
){}
|
|
108
|
+
|
|
109
|
+
validateMessageState(
|
|
110
|
+
userId: string,
|
|
111
|
+
messageState: MATCHLOCK_SELECTION_STATE
|
|
112
|
+
){
|
|
113
|
+
if(this.state !== messageState){
|
|
114
|
+
const currentIndex = STATE_ORDER.indexOf(this.state);
|
|
115
|
+
const messageIndex = STATE_ORDER.indexOf(messageState);
|
|
116
|
+
if(messageIndex === currentIndex + 1){
|
|
117
|
+
console.warn(`User Sent ${messageState} Early`, userId);
|
|
118
|
+
console.warn("We'll still process it as other messages may just be late");
|
|
119
|
+
} else {
|
|
120
|
+
throw new UserMessegeError(userId, `Expected ${this.state} message`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if(!this.recievedMessages.has(messageState)){
|
|
124
|
+
this.recievedMessages.set(messageState, new Set());
|
|
125
|
+
}
|
|
126
|
+
if(this.recievedMessages.get(messageState)!.has(userId)){
|
|
127
|
+
throw new UserMessegeError(userId, "User Sent Message Twice");
|
|
128
|
+
}
|
|
129
|
+
this.recievedMessages.get(messageState)!.add(userId);
|
|
130
|
+
return this.recievedMessages.get(messageState)!.size;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
updateStep(){
|
|
134
|
+
const currentIndex = STATE_ORDER.indexOf(this.state);
|
|
135
|
+
const nextIndex = currentIndex + 1;
|
|
136
|
+
if (nextIndex >= STATE_ORDER.length) {
|
|
137
|
+
throw new Error("No next phase after " + this.state);
|
|
138
|
+
}
|
|
139
|
+
this.state = STATE_ORDER[nextIndex];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
stateHandlers: Record<string, {
|
|
143
|
+
message?(userId: string, data: JSON_Unknown):any,
|
|
144
|
+
finished():any
|
|
145
|
+
}> = {
|
|
146
|
+
[MATCHLOCK_SELECTION_STATE.hello]: {
|
|
147
|
+
finished: ()=>(
|
|
148
|
+
this.room.broadcast(MATCHLOCK_SELECTION_STATE.restriction, this.restriction)
|
|
149
|
+
),
|
|
150
|
+
},
|
|
151
|
+
[MATCHLOCK_SELECTION_STATE.restriction]: {
|
|
152
|
+
message: (userId, data)=>{
|
|
153
|
+
deepEqual(data, this.restriction, "Restriction Mismatch")
|
|
154
|
+
},
|
|
155
|
+
finished: ()=>(
|
|
156
|
+
this.room.broadcast(MATCHLOCK_SELECTION_STATE.selectionEncrypt, this.ownEncrypted.encrypted)
|
|
157
|
+
)
|
|
158
|
+
},
|
|
159
|
+
[MATCHLOCK_SELECTION_STATE.selectionEncrypt]: {
|
|
160
|
+
message: (userId, data)=>{
|
|
161
|
+
const casted = CastEncryptedMessage.check(data);
|
|
162
|
+
this.encryptedData.set(userId, casted);
|
|
163
|
+
},
|
|
164
|
+
finished: ()=>(
|
|
165
|
+
this.room.broadcast(MATCHLOCK_SELECTION_STATE.selectionDecrypt, this.ownEncrypted.key)
|
|
166
|
+
)
|
|
167
|
+
},
|
|
168
|
+
[MATCHLOCK_SELECTION_STATE.selectionDecrypt]: {
|
|
169
|
+
message: async (userId, data)=>{
|
|
170
|
+
if(typeof data !== "string") throw new Error("Invalid Decryption Key");
|
|
171
|
+
const encrypted = this.encryptedData.get(userId);
|
|
172
|
+
if(!encrypted) throw new Error("Missing Encrypted Data");
|
|
173
|
+
const decrypted = await decryptJSON(data, encrypted);
|
|
174
|
+
const casted = await validatePlayerSelection(this.restriction, decrypted);
|
|
175
|
+
this.userSelections.set(userId, casted);
|
|
176
|
+
},
|
|
177
|
+
finished: async ()=>{
|
|
178
|
+
const ownFinalSelection = await finalizeSelection(
|
|
179
|
+
this.restriction, Object.fromEntries(this.userSelections)
|
|
180
|
+
);
|
|
181
|
+
this.room.broadcast(MATCHLOCK_SELECTION_STATE.selectionFinal, ownFinalSelection)
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
[MATCHLOCK_SELECTION_STATE.selectionFinal]: {
|
|
185
|
+
message: (userId, data)=>{
|
|
186
|
+
const casted: FinalSelection = CastFinalSelection.check(data);
|
|
187
|
+
if(this.agreedSelection === null){
|
|
188
|
+
this.agreedSelection = casted;
|
|
189
|
+
} else {
|
|
190
|
+
deepEqual(this.agreedSelection, casted, "Final Selection Mismatch");
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
finished: ()=>(
|
|
194
|
+
this.room.broadcast(MATCHLOCK_SELECTION_STATE.goodbye, "goodbye")
|
|
195
|
+
)
|
|
196
|
+
},
|
|
197
|
+
[MATCHLOCK_SELECTION_STATE.goodbye]: {
|
|
198
|
+
finished: ()=>{},
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { RosterLockV1Config, UserInput } from "@match-lock/shared";
|
|
2
|
+
|
|
3
|
+
import { Room } from "../../../types/room";
|
|
4
|
+
import { waitForExternalError, waitForStoppedRoomHeartbeat } from "./roomErrors";
|
|
5
|
+
import { handleRoomSelections } from "./handleRoomSelections";
|
|
6
|
+
import { ExternalUserError, MATCHLOCK_SELECTION_STATE } from "./constants";
|
|
7
|
+
|
|
8
|
+
export async function handleSelection(
|
|
9
|
+
room: Room,
|
|
10
|
+
lockConfig: RosterLockV1Config,
|
|
11
|
+
selection: UserInput["userSelection"]
|
|
12
|
+
){
|
|
13
|
+
const abortController = new AbortController();
|
|
14
|
+
try {
|
|
15
|
+
await Promise.race([
|
|
16
|
+
handleRoomSelections(room, abortController.signal, lockConfig, selection),
|
|
17
|
+
waitForExternalError(room, abortController.signal),
|
|
18
|
+
waitForStoppedRoomHeartbeat(room, 10_000, abortController.signal),
|
|
19
|
+
])
|
|
20
|
+
}catch(e){
|
|
21
|
+
console.log("Match To Start Failed", e);
|
|
22
|
+
if(!(e instanceof ExternalUserError) && e instanceof Error){
|
|
23
|
+
room.broadcast(MATCHLOCK_SELECTION_STATE.failure, e.message);
|
|
24
|
+
}
|
|
25
|
+
room.disconnect();
|
|
26
|
+
throw e;
|
|
27
|
+
}finally{
|
|
28
|
+
abortController.abort();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
|
|
2
|
+
import { Room } from "../../../types/room";
|
|
3
|
+
import { ExternalUserError, MATCHLOCK_SELECTION_STATE } from "./constants";
|
|
4
|
+
|
|
5
|
+
import { waitForEvent, waitForEventTimeout } from "@match-lock/shared";
|
|
6
|
+
export async function waitForExternalError(room: Room, abortSignal: AbortSignal){
|
|
7
|
+
const [userId, event, message] = await waitForEvent(
|
|
8
|
+
room.listen.filter((userId, event, message)=>{
|
|
9
|
+
return event === MATCHLOCK_SELECTION_STATE.failure;
|
|
10
|
+
}),
|
|
11
|
+
abortSignal
|
|
12
|
+
)
|
|
13
|
+
throw new ExternalUserError(userId, message);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function waitForStoppedRoomHeartbeat(room: Room, timeout: number, abortSignal: AbortSignal){
|
|
17
|
+
await waitForEventTimeout(
|
|
18
|
+
room.listen.filter((userId, event, message)=>{
|
|
19
|
+
return event !== MATCHLOCK_SELECTION_STATE.failure;
|
|
20
|
+
}),
|
|
21
|
+
timeout,
|
|
22
|
+
abortSignal
|
|
23
|
+
);
|
|
24
|
+
throw new Error("Heartbeat Timeout");
|
|
25
|
+
}
|