@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
package/src/index.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
export * from "./handle-room/version-1";
|
|
4
|
+
|
|
5
|
+
import { join as pathJoin, resolve as pathResolve } from "node:path";
|
|
6
|
+
import { mkdir } from "node:fs/promises";
|
|
7
|
+
import {
|
|
8
|
+
CONFIG_FILE_NAME, DEFAULT_MATCH_CONIFG, getConfig, setConfig, configExists, resolveConfigFolders, findConfigFile,
|
|
9
|
+
} from "./config";
|
|
10
|
+
import { generateAuthCode, authMiddleware, validateAuthCode } from "./authentication";
|
|
11
|
+
import { corsMiddleware } from "./cors";
|
|
12
|
+
import { createEditorV1Router } from "./editor-support";
|
|
13
|
+
import { MatchAgentServer } from "./server";
|
|
14
|
+
import { program } from "commander";
|
|
15
|
+
import { createV1Routers } from "./handle-room/version-1";
|
|
16
|
+
import { getSQLite3FolderDB } from "./handle-room/version-1/globals/FolderDB";
|
|
17
|
+
import {
|
|
18
|
+
PluginManager, createPluginCommands, syncPluginsToOfficialManifest, DEFAULT_REPO_URL, manifestUrlFromRepo,
|
|
19
|
+
} from "@roster-lock/plugin-runtime";
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
program
|
|
23
|
+
.name("rosterlock-match-agent")
|
|
24
|
+
.description("Runs the match-agent server");
|
|
25
|
+
|
|
26
|
+
// Resolves the same way `listen` does (sibling config next to the executable,
|
|
27
|
+
// falling back to home), so `plugin install` without -d/--plugin-dir lands
|
|
28
|
+
// in the folder a mounted USB's config actually points at, instead of
|
|
29
|
+
// silently defaulting to plugin-runtime's own ~/roster-lock/plugins.
|
|
30
|
+
async function defaultPluginDirFromConfig(): Promise<string> {
|
|
31
|
+
const configFilePath = await findConfigFile();
|
|
32
|
+
const existingConfig = (await configExists(configFilePath)) ? await getConfig(configFilePath) : null;
|
|
33
|
+
return resolveConfigFolders(configFilePath, existingConfig ?? {}).pluginFolder;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Plugin management (install/update/uninstall/list/priority), reused as-is
|
|
37
|
+
// from plugin-runtime under its own `plugin` group - mainly here for testing
|
|
38
|
+
// convenience, not meant to be the primary way plugins get managed.
|
|
39
|
+
const pluginCommand = program
|
|
40
|
+
.command("plugin")
|
|
41
|
+
.description("Manage plugins (install/update/uninstall/list/priority)");
|
|
42
|
+
for(const command of createPluginCommands({ getDefaultPluginDir: defaultPluginDirFromConfig })){
|
|
43
|
+
pluginCommand.addCommand(command);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
program
|
|
47
|
+
.command("listen")
|
|
48
|
+
.description("Runs the match-agent server (leave this running while you play - it can serve multiple games/matches at once)")
|
|
49
|
+
.option("--port <port>", "port to listen on", "58732")
|
|
50
|
+
.option("--auth-code <string>", "authentication code required for access (overrides the config file)")
|
|
51
|
+
.option("--piece-folder <path>", "folder to store downloaded pieces in (overrides the config file)")
|
|
52
|
+
.option("--plugin-folder <path>", "folder to load plugins from (overrides the config file)")
|
|
53
|
+
.option(
|
|
54
|
+
"--config-file <path>",
|
|
55
|
+
"config file to read/persist the auth code and folders from " +
|
|
56
|
+
"(defaults to a config next to this executable, falling back to the home directory)"
|
|
57
|
+
)
|
|
58
|
+
.action(async (options: {
|
|
59
|
+
port: string, authCode?: string, pieceFolder?: string, pluginFolder?: string, configFile?: string,
|
|
60
|
+
}) => {
|
|
61
|
+
const port = Number(options.port);
|
|
62
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
63
|
+
throw new Error(`Invalid port: ${options.port}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const configFilePath = await findConfigFile(options.configFile);
|
|
67
|
+
const existingConfig = (await configExists(configFilePath)) ? await getConfig(configFilePath) : null;
|
|
68
|
+
|
|
69
|
+
const authCode = options.authCode || existingConfig?.authCode || generateAuthCode();
|
|
70
|
+
const resolvedFolders = resolveConfigFolders(configFilePath, existingConfig ?? {});
|
|
71
|
+
const pieceFolder = options.pieceFolder ? pathResolve(options.pieceFolder) : resolvedFolders.pieceFolder;
|
|
72
|
+
const pluginFolder = options.pluginFolder ? pathResolve(options.pluginFolder) : resolvedFolders.pluginFolder;
|
|
73
|
+
|
|
74
|
+
// Persist so the auth code stays stable across runs (and a first run
|
|
75
|
+
// against a fresh config writes one out) - keep the config's own
|
|
76
|
+
// relative pieceFolder/pluginFolder untouched so a CLI override on this
|
|
77
|
+
// run doesn't get baked in as this config's permanent setting.
|
|
78
|
+
await setConfig(configFilePath, {
|
|
79
|
+
authCode,
|
|
80
|
+
pieceFolder: existingConfig?.pieceFolder,
|
|
81
|
+
pluginFolder: existingConfig?.pluginFolder,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await mkdir(pieceFolder, { recursive: true });
|
|
85
|
+
await mkdir(pluginFolder, { recursive: true });
|
|
86
|
+
|
|
87
|
+
startServer(port, authCode, pluginFolder, pieceFolder);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
program
|
|
91
|
+
.command("mount-usb <path>")
|
|
92
|
+
.description(
|
|
93
|
+
"Scaffold a portable config at <path>: creates pieces/plugins folders and a " +
|
|
94
|
+
CONFIG_FILE_NAME + " next to where the match-agent executable will run from " +
|
|
95
|
+
"(e.g. a mounted USB drive), addressing the folders by relative path."
|
|
96
|
+
)
|
|
97
|
+
.option("--piece-folder <name>", "name of the pieces folder to create, relative to <path>", "pieces")
|
|
98
|
+
.option("--plugin-folder <name>", "name of the plugins folder to create, relative to <path>", "plugins")
|
|
99
|
+
.option("--auth-code <string>", "auth code to store in the config (a random one is generated if omitted)")
|
|
100
|
+
.option("--force", "overwrite an existing config at <path>, generating a new auth code unless --auth-code is given", false)
|
|
101
|
+
.option(
|
|
102
|
+
"--manifest-url <url>",
|
|
103
|
+
"if given, sync plugins to match the official manifest at this URL right after mounting"
|
|
104
|
+
)
|
|
105
|
+
.action(async (targetPath: string, options: {
|
|
106
|
+
pieceFolder: string, pluginFolder: string, authCode?: string, force: boolean, manifestUrl?: string,
|
|
107
|
+
}) => {
|
|
108
|
+
const mountFolder = pathResolve(targetPath);
|
|
109
|
+
await mkdir(mountFolder, { recursive: true });
|
|
110
|
+
|
|
111
|
+
const configFilePath = pathJoin(mountFolder, CONFIG_FILE_NAME);
|
|
112
|
+
const existingConfig = (await configExists(configFilePath)) ? await getConfig(configFilePath) : null;
|
|
113
|
+
if(existingConfig && !options.force){
|
|
114
|
+
throw new Error(`${configFilePath} already exists - pass --force to overwrite it`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const pluginFolder = pathJoin(mountFolder, options.pluginFolder);
|
|
118
|
+
await mkdir(pathJoin(mountFolder, options.pieceFolder), { recursive: true });
|
|
119
|
+
await mkdir(pluginFolder, { recursive: true });
|
|
120
|
+
|
|
121
|
+
const authCode = options.authCode || existingConfig?.authCode || generateAuthCode();
|
|
122
|
+
await setConfig(configFilePath, {
|
|
123
|
+
authCode,
|
|
124
|
+
pieceFolder: options.pieceFolder,
|
|
125
|
+
pluginFolder: options.pluginFolder,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
console.log(`Wrote ${configFilePath}`);
|
|
129
|
+
|
|
130
|
+
if(options.manifestUrl){
|
|
131
|
+
const { installed, priorityChanges } = await syncPluginsToOfficialManifest(pluginFolder, options.manifestUrl);
|
|
132
|
+
for(const p of installed) console.log(`Installed ${p.package}@${p.version}`);
|
|
133
|
+
for(const p of priorityChanges) console.log(`Set priority of ${p.package} to ${p.priority}`);
|
|
134
|
+
console.log(`Plugin sync complete - ${installed.length} plugin(s) installed/updated, ${priorityChanges.length} priority change(s) applied.`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
program
|
|
139
|
+
.command("install <authCode>")
|
|
140
|
+
.description(
|
|
141
|
+
"Install match-agent for regular (non-USB) use: creates pieces/plugins folders and a " +
|
|
142
|
+
CONFIG_FILE_NAME + " at the default config location, stores the given auth code, and syncs " +
|
|
143
|
+
"plugins to the official manifest. Intended as the one-time setup step before `listen`."
|
|
144
|
+
)
|
|
145
|
+
.option(
|
|
146
|
+
"--config-file <path>",
|
|
147
|
+
"config file to write (defaults to the standard home-directory location)"
|
|
148
|
+
)
|
|
149
|
+
.option("--piece-folder <name>", "name of the pieces folder to create, relative to the config file", "pieces")
|
|
150
|
+
.option("--plugin-folder <name>", "name of the plugins folder to create, relative to the config file", "plugins")
|
|
151
|
+
.option("--force", "overwrite an existing config at the target location", false)
|
|
152
|
+
.option("--manifest-url <url>", "URL of the official plugin manifest to sync plugins against", manifestUrlFromRepo(DEFAULT_REPO_URL))
|
|
153
|
+
.action(async (authCode: string, options: {
|
|
154
|
+
configFile?: string, pieceFolder: string, pluginFolder: string, force: boolean, manifestUrl: string,
|
|
155
|
+
}) => {
|
|
156
|
+
const configFilePath = options.configFile ? pathResolve(options.configFile) : DEFAULT_MATCH_CONIFG;
|
|
157
|
+
const existingConfig = (await configExists(configFilePath)) ? await getConfig(configFilePath) : null;
|
|
158
|
+
if(existingConfig && !options.force){
|
|
159
|
+
throw new Error(`${configFilePath} already exists - pass --force to overwrite it`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const resolvedFolders = resolveConfigFolders(configFilePath, {
|
|
163
|
+
pieceFolder: options.pieceFolder, pluginFolder: options.pluginFolder,
|
|
164
|
+
});
|
|
165
|
+
await mkdir(resolvedFolders.pieceFolder, { recursive: true });
|
|
166
|
+
await mkdir(resolvedFolders.pluginFolder, { recursive: true });
|
|
167
|
+
|
|
168
|
+
await setConfig(configFilePath, {
|
|
169
|
+
authCode,
|
|
170
|
+
pieceFolder: options.pieceFolder,
|
|
171
|
+
pluginFolder: options.pluginFolder,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
console.log(`Wrote ${configFilePath}`);
|
|
175
|
+
|
|
176
|
+
const { installed, priorityChanges } = await syncPluginsToOfficialManifest(
|
|
177
|
+
resolvedFolders.pluginFolder, options.manifestUrl
|
|
178
|
+
);
|
|
179
|
+
for(const p of installed) console.log(`Installed ${p.package}@${p.version}`);
|
|
180
|
+
for(const p of priorityChanges) console.log(`Set priority of ${p.package} to ${p.priority}`);
|
|
181
|
+
console.log(`Plugin sync complete - ${installed.length} plugin(s) installed/updated, ${priorityChanges.length} priority change(s) applied.`);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
program
|
|
185
|
+
.command("set-auth-code <authCode>")
|
|
186
|
+
.description("Update the auth code stored in a config file (defaults to the same discovery rules as running the server)")
|
|
187
|
+
.option(
|
|
188
|
+
"--config-file <path>",
|
|
189
|
+
"config file to update (defaults to a config next to this executable, falling back to the home directory)"
|
|
190
|
+
)
|
|
191
|
+
.action(async (authCode: string, options: { configFile?: string }) => {
|
|
192
|
+
const configFilePath = await findConfigFile(options.configFile);
|
|
193
|
+
const existingConfig = (await configExists(configFilePath)) ? await getConfig(configFilePath) : null;
|
|
194
|
+
await setConfig(configFilePath, {
|
|
195
|
+
authCode,
|
|
196
|
+
pieceFolder: existingConfig?.pieceFolder,
|
|
197
|
+
pluginFolder: existingConfig?.pluginFolder,
|
|
198
|
+
});
|
|
199
|
+
console.log(`Updated auth code in ${configFilePath}`);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (require.main === module) {
|
|
203
|
+
program.parse();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function startServer(port: number, authCode: string, pluginFolder: string, piecesFolder: string){
|
|
207
|
+
const pluginRuntime = await PluginManager.create(pluginFolder);
|
|
208
|
+
const fileDB = getSQLite3FolderDB(piecesFolder, pluginRuntime);
|
|
209
|
+
|
|
210
|
+
const server = new MatchAgentServer();
|
|
211
|
+
// "{/*path}" = path-to-regexp v8 catch-all; a bare "/" only matches the
|
|
212
|
+
// root exactly, which would skip CORS on every real route.
|
|
213
|
+
server.httpRouter.use("{/*path}", corsMiddleware);
|
|
214
|
+
server.httpRouter.get("/", ({ res })=>{
|
|
215
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
216
|
+
res.end(JSON.stringify({ "hello": "world" }));
|
|
217
|
+
});
|
|
218
|
+
server.httpRouter.post("/validate-authcode", validateAuthCode(authCode))
|
|
219
|
+
|
|
220
|
+
const { httpRouter: v1HttpRouter, wsRouter: v1WsRouter } = createV1Routers(fileDB, pluginRuntime);
|
|
221
|
+
server.httpRouter.use("/v1", authMiddleware(authCode), v1HttpRouter);
|
|
222
|
+
server.wsRouter.use("/v1", authMiddleware(authCode), v1WsRouter);
|
|
223
|
+
|
|
224
|
+
// Config-editor support lives on its own version axis (/editor/v1) so the
|
|
225
|
+
// room protocol (/v1) can move to /v2 without dragging the editor API along.
|
|
226
|
+
server.httpRouter.use("/editor/v1", authMiddleware(authCode), createEditorV1Router(pluginRuntime, fileDB));
|
|
227
|
+
|
|
228
|
+
server.listen(port, () => {
|
|
229
|
+
console.log(`match-agent listening on port ${port}`);
|
|
230
|
+
});
|
|
231
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
|
|
2
|
+
import { IncomingMessage, Server } from "http";
|
|
3
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
4
|
+
import { Duplex } from "stream";
|
|
5
|
+
import { WebSocketRouter, WSRequest } from "./utils/websocket-router";
|
|
6
|
+
import { HTTPRouter, HTTPError } from "./utils/http-router";
|
|
7
|
+
|
|
8
|
+
export class MatchAgentServer {
|
|
9
|
+
private httpServer: Server;
|
|
10
|
+
private wss: WebSocketServer;
|
|
11
|
+
public wsRouter = new WebSocketRouter();
|
|
12
|
+
public httpRouter = new HTTPRouter();
|
|
13
|
+
constructor(debug = true){
|
|
14
|
+
this.httpServer = new Server();
|
|
15
|
+
this.wss = new WebSocketServer({ noServer: true });
|
|
16
|
+
this.httpServer.on('upgrade', async (request, socket, head)=>{
|
|
17
|
+
const context = await (async ()=>{
|
|
18
|
+
try {
|
|
19
|
+
if(!request.url) throw new Error("No URL");
|
|
20
|
+
const url = new URL(request.url, "ws://localhost");
|
|
21
|
+
const ws = await handleUpgrade(this.wss, request, socket, head);
|
|
22
|
+
return { ws, req: request };
|
|
23
|
+
}catch(e){
|
|
24
|
+
console.log("Failed to upgrade connection", e);
|
|
25
|
+
socket.destroy();
|
|
26
|
+
throw e;
|
|
27
|
+
}
|
|
28
|
+
})()
|
|
29
|
+
this.wsRouter.handleRequest(context, (err)=>{
|
|
30
|
+
debug && console.log("WS Error:", err);
|
|
31
|
+
context.ws.terminate();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
this.httpServer.on('request', (req, res)=>{
|
|
35
|
+
this.httpRouter.handleRequest({ req, res }, (err: unknown)=>{
|
|
36
|
+
debug && console.log("HTTP Error:", err);
|
|
37
|
+
if(res.writableEnded){
|
|
38
|
+
debug && console.warn("Router threw error and response ended")
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if(res.headersSent){
|
|
42
|
+
debug && console.warn("Router threw error and headers already sent")
|
|
43
|
+
res.destroy();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const error: HTTPError = (()=>{
|
|
47
|
+
if(err instanceof HTTPError) return err;
|
|
48
|
+
if(!err){
|
|
49
|
+
return new HTTPError(404, "Not Found");
|
|
50
|
+
} else if(typeof err === "string"){
|
|
51
|
+
return new HTTPError(500, err);
|
|
52
|
+
} else if(err instanceof Error){
|
|
53
|
+
return new HTTPError(500, err.message);
|
|
54
|
+
} else if(typeof err !== "object" || err === null || Array.isArray(err)) {
|
|
55
|
+
return new HTTPError(500, "Unknown Error");
|
|
56
|
+
}
|
|
57
|
+
return new HTTPError(
|
|
58
|
+
"statusCode" in err && typeof err.statusCode === "number" ? err.statusCode : 500,
|
|
59
|
+
"message" in err && typeof err.message === "string" ? err.message : "Unknown Error"
|
|
60
|
+
);
|
|
61
|
+
})();
|
|
62
|
+
|
|
63
|
+
res.writeHead(error.statusCode, { "Content-Type": "application/json" });
|
|
64
|
+
res.end(JSON.stringify({
|
|
65
|
+
error: error.message,
|
|
66
|
+
context: error.context,
|
|
67
|
+
}));
|
|
68
|
+
});
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
listen(...args: Parameters<Server["listen"]>){
|
|
72
|
+
return this.httpServer.listen(...args);
|
|
73
|
+
}
|
|
74
|
+
close(...args: Parameters<Server["close"]>){
|
|
75
|
+
return this.httpServer.close(...args);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function handleUpgrade(wss: WebSocketServer, request: IncomingMessage, socket: Duplex, head: Buffer){
|
|
80
|
+
return new Promise<WebSocket>((resolve, reject)=>{
|
|
81
|
+
wss.handleUpgrade(request, socket, head, function done(ws) {
|
|
82
|
+
resolve(ws);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
package/src/utils/fs.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readdir, stat as getStat } from "fs/promises";
|
|
2
|
+
import { join as pathJoin, sep as pathSep, extname } from "path";
|
|
3
|
+
import { lookup as mimeLookup } from "mime-types";
|
|
4
|
+
|
|
5
|
+
// getAssetsOfFiles matches file paths against each asset's glob (e.g.
|
|
6
|
+
// "media/particles.json"), which is relative to the piece folder root -
|
|
7
|
+
// so these must be yielded relative, not absolute.
|
|
8
|
+
export async function* getFilesFromFolder(rootPath: string, relative: string = "./"): AsyncIterable<string> {
|
|
9
|
+
for(const file of await readdir(pathJoin(rootPath, relative))){
|
|
10
|
+
const stat = await getStat(pathJoin(rootPath, relative, file));
|
|
11
|
+
if(stat.isDirectory()){
|
|
12
|
+
yield* getFilesFromFolder(rootPath, pathJoin(relative, file))
|
|
13
|
+
} else {
|
|
14
|
+
yield pathJoin(relative, file).split(pathSep).join("/");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Only extensions in this set get a real mimetype - mime-types resolves
|
|
20
|
+
// its full table (mime-db) against ANY extension, including ones that
|
|
21
|
+
// collide with engine-specific formats (.ts -> video/mp2t, .air -> Adobe
|
|
22
|
+
// AIR installer), so gate lookups to formats we actually intend to serve
|
|
23
|
+
// as their web mimetype. Everything else (.cns, .def, .sff, unknown) is
|
|
24
|
+
// application/octet-stream - the engine reads it as raw bytes regardless.
|
|
25
|
+
const WEB_SAFE_EXTENSIONS = new Set([
|
|
26
|
+
// images
|
|
27
|
+
"png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "svg",
|
|
28
|
+
// audio
|
|
29
|
+
"mp3", "wav", "ogg", "oga", "m4a", "flac", "aac",
|
|
30
|
+
// video
|
|
31
|
+
"mp4", "webm", "ogv",
|
|
32
|
+
// fonts
|
|
33
|
+
"woff", "woff2", "ttf", "otf",
|
|
34
|
+
// text/data
|
|
35
|
+
"json", "txt", "csv", "xml", "html", "css",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
export function mimeTypeFor(filePath: string): string {
|
|
39
|
+
const ext = extname(filePath).slice(1).toLowerCase();
|
|
40
|
+
if(!WEB_SAFE_EXTENSIONS.has(ext)) return "application/octet-stream";
|
|
41
|
+
return mimeLookup(filePath) || "application/octet-stream";
|
|
42
|
+
}
|
|
43
|
+
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
|
|
2
|
+
import { IncomingMessage, ServerResponse } from "http";
|
|
3
|
+
import { GenericRouter, RouterConfig, GenericRouterCallbackArg, GenericHandlerCallback } from "@roster-lock/utils";
|
|
4
|
+
|
|
5
|
+
export async function jsonBody(req: IncomingMessage){
|
|
6
|
+
const data: string = await new Promise((resolve, reject)=>{
|
|
7
|
+
let data = "";
|
|
8
|
+
req.on("data", (chunk)=>{data += chunk});
|
|
9
|
+
req.on("end", ()=>{resolve(data)});
|
|
10
|
+
req.on("error", reject);
|
|
11
|
+
})
|
|
12
|
+
return JSON.parse(data);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type HTTPRequest = { req: IncomingMessage, res: ServerResponse };
|
|
16
|
+
|
|
17
|
+
export type HTTPRequestHandler = GenericHandlerCallback<HTTPRequest>;
|
|
18
|
+
|
|
19
|
+
export class HTTPRouter extends GenericRouter<HTTPRequest> {
|
|
20
|
+
constructor(config: RouterConfig = {}){
|
|
21
|
+
super(
|
|
22
|
+
(request)=>new URL(request.req.url || "/", "ws://localhost:80"),
|
|
23
|
+
(request)=>((request.req.method || "GET").toUpperCase()),
|
|
24
|
+
config
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
get(path: string, ...callbacks: Array<GenericRouterCallbackArg<HTTPRequest>>){
|
|
28
|
+
return this.addHandler("GET", path, false, ...callbacks);
|
|
29
|
+
}
|
|
30
|
+
query(path: string, ...callbacks: Array<GenericRouterCallbackArg<HTTPRequest>>){
|
|
31
|
+
return this.addHandler("QUERY", path, false, ...callbacks);
|
|
32
|
+
}
|
|
33
|
+
post(path: string, ...callbacks: Array<GenericRouterCallbackArg<HTTPRequest>>){
|
|
34
|
+
return this.addHandler("POST", path, false, ...callbacks);
|
|
35
|
+
}
|
|
36
|
+
put(path: string, ...callbacks: Array<GenericRouterCallbackArg<HTTPRequest>>){
|
|
37
|
+
return this.addHandler("PUT", path, false, ...callbacks);
|
|
38
|
+
}
|
|
39
|
+
patch(path: string, ...callbacks: Array<GenericRouterCallbackArg<HTTPRequest>>){
|
|
40
|
+
return this.addHandler("PATCH", path, false, ...callbacks);
|
|
41
|
+
}
|
|
42
|
+
delete(path: string, ...callbacks: Array<GenericRouterCallbackArg<HTTPRequest>>){
|
|
43
|
+
return this.addHandler("DELETE", path, false, ...callbacks);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class HTTPError extends Error {
|
|
48
|
+
constructor(
|
|
49
|
+
public statusCode: number,
|
|
50
|
+
message: string,
|
|
51
|
+
public context?: any,
|
|
52
|
+
){
|
|
53
|
+
super(message);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { HTTPError } from "./http-router";
|
|
2
|
+
|
|
3
|
+
export function handlePagination(search: URLSearchParams){
|
|
4
|
+
const page = handleNumber(search, "page", 0);
|
|
5
|
+
if(page < 0) throw new HTTPError(400, "should be greater than or equal to 0", { key: "page" })
|
|
6
|
+
const limit = handleNumber(search, "limit", 200);
|
|
7
|
+
if(limit <= 0) throw new HTTPError(400, "should be greater than or equal to 0", { key: "limit" })
|
|
8
|
+
return { page, limit }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function handleNumber(search: URLSearchParams, key: string, defaultValue: number){
|
|
12
|
+
const value = search.get(key);
|
|
13
|
+
if(value === null) return defaultValue;
|
|
14
|
+
const numValue = Number.parseInt(value);
|
|
15
|
+
if(Number.isNaN(numValue)) throw new HTTPError(400, "invalid number", { key });
|
|
16
|
+
return numValue
|
|
17
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
|
|
2
|
+
import { IncomingMessage } from "http";
|
|
3
|
+
import { WebSocket } from "ws";
|
|
4
|
+
import { GenericRouter, RouterConfig, GenericRouterCallbackArg, GenericHandlerCallback } from "@roster-lock/utils";
|
|
5
|
+
|
|
6
|
+
const WEBSOCKET_METHOD = "WebSocket";
|
|
7
|
+
|
|
8
|
+
export type WSRequest = { req: IncomingMessage, ws: WebSocket };
|
|
9
|
+
|
|
10
|
+
export type WebSocketHandlerCallback = GenericHandlerCallback<WSRequest>;
|
|
11
|
+
export class WebSocketRouter extends GenericRouter<WSRequest> {
|
|
12
|
+
constructor(config: RouterConfig = {}){
|
|
13
|
+
super(
|
|
14
|
+
({ req })=>new URL(req.url || "/", "ws://localhost:80"),
|
|
15
|
+
(request)=>WEBSOCKET_METHOD,
|
|
16
|
+
config
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
mount(path: string, ...callbacks: Array<GenericRouterCallbackArg<WSRequest>>){
|
|
20
|
+
return this.addHandler(WEBSOCKET_METHOD, path, false, ...callbacks);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { startTestServer, TestServer, errorBody } from "./helpers/server";
|
|
3
|
+
import {
|
|
4
|
+
makeEngine, makePiece, makeTempFolder, seedCompletePiece, cleanupFolder, TEST_PIECE_TYPE,
|
|
5
|
+
} from "./helpers/piece";
|
|
6
|
+
import { RosterLockPiece } from "@roster-lock/types";
|
|
7
|
+
|
|
8
|
+
type SearchResult = {
|
|
9
|
+
total: number,
|
|
10
|
+
items: Array<{
|
|
11
|
+
piece: (
|
|
12
|
+
& Pick<RosterLockPiece, "version" | "humanInfo" | "pathVariables">
|
|
13
|
+
& {
|
|
14
|
+
engineName: string,
|
|
15
|
+
pieceType: string,
|
|
16
|
+
downloadSources: Array<{
|
|
17
|
+
source: string,
|
|
18
|
+
lastTest: number | null,
|
|
19
|
+
success: boolean | null,
|
|
20
|
+
error?: string,
|
|
21
|
+
}>,
|
|
22
|
+
}
|
|
23
|
+
),
|
|
24
|
+
completedAt: number | null,
|
|
25
|
+
}>,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe("POST /editor/v1/pieces/search", () => {
|
|
29
|
+
let server: TestServer;
|
|
30
|
+
let folder: string;
|
|
31
|
+
|
|
32
|
+
beforeEach(async () => {
|
|
33
|
+
folder = await makeTempFolder();
|
|
34
|
+
server = await startTestServer(folder);
|
|
35
|
+
});
|
|
36
|
+
afterEach(async () => {
|
|
37
|
+
await server.close();
|
|
38
|
+
await cleanupFolder(folder);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const engine = makeEngine();
|
|
42
|
+
|
|
43
|
+
function search(body: unknown) {
|
|
44
|
+
return fetch(`${server.httpUrl}/editor/v1/pieces/search`, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: {
|
|
47
|
+
"Content-Type": "application/json",
|
|
48
|
+
Authorization: `Bearer ${server.authCode}`,
|
|
49
|
+
},
|
|
50
|
+
body: JSON.stringify(body),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
it("400s on a malformed body", async () => {
|
|
55
|
+
const res = await search({});
|
|
56
|
+
expect(res.status).toBe(400);
|
|
57
|
+
expect((await errorBody(res)).error).toBe("Bad body");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns completed pieces with their stored info and tested sources", async () => {
|
|
61
|
+
const complete = makePiece({ id: "downloaded-piece" });
|
|
62
|
+
const pending = makePiece({
|
|
63
|
+
id: "pending-piece",
|
|
64
|
+
version: { logic: "2.0.0", media: "1.0.0", docs: "1.0.0" },
|
|
65
|
+
});
|
|
66
|
+
await seedCompletePiece({
|
|
67
|
+
folder, engine, pieceType: TEST_PIECE_TYPE, piece: complete, folderName: "a", files: {},
|
|
68
|
+
});
|
|
69
|
+
await seedCompletePiece({
|
|
70
|
+
folder, engine, pieceType: TEST_PIECE_TYPE, piece: pending, folderName: "b", files: {}, complete: false,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const res = await search({ engineName: engine.name });
|
|
74
|
+
expect(res.status).toBe(200);
|
|
75
|
+
const body = await res.json() as SearchResult;
|
|
76
|
+
expect(body.total).toBe(1);
|
|
77
|
+
expect(body.items).toHaveLength(1);
|
|
78
|
+
// Everything the agent stores round-trips - version hashes, human info,
|
|
79
|
+
// path variables, and per-source test state.
|
|
80
|
+
expect(body.items[0].piece).toEqual({
|
|
81
|
+
engineName: engine.name,
|
|
82
|
+
pieceType: TEST_PIECE_TYPE,
|
|
83
|
+
version: complete.version,
|
|
84
|
+
humanInfo: complete.humanInfo,
|
|
85
|
+
pathVariables: complete.pathVariables,
|
|
86
|
+
downloadSources: [{
|
|
87
|
+
// The seeded download counts as this source's passed test.
|
|
88
|
+
source: complete.downloadSources[0],
|
|
89
|
+
lastTest: expect.any(Number),
|
|
90
|
+
success: true,
|
|
91
|
+
}],
|
|
92
|
+
});
|
|
93
|
+
expect(body.items[0].completedAt).toBeTypeOf("number");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("filters by search text over human info and sources", async () => {
|
|
97
|
+
const ryu = makePiece({
|
|
98
|
+
id: "ryu",
|
|
99
|
+
version: { logic: "ryu-1", media: "1.0.0", docs: "1.0.0" },
|
|
100
|
+
humanInfo: { name: "Ryu", author: "capcom", url: "http://example.com" },
|
|
101
|
+
});
|
|
102
|
+
const ken = makePiece({
|
|
103
|
+
id: "ken",
|
|
104
|
+
version: { logic: "ken-1", media: "1.0.0", docs: "1.0.0" },
|
|
105
|
+
humanInfo: { name: "Ken", author: "capcom", url: "http://example.com" },
|
|
106
|
+
});
|
|
107
|
+
await seedCompletePiece({ folder, engine, pieceType: TEST_PIECE_TYPE, piece: ryu, folderName: "a", files: {} });
|
|
108
|
+
await seedCompletePiece({ folder, engine, pieceType: TEST_PIECE_TYPE, piece: ken, folderName: "b", files: {} });
|
|
109
|
+
|
|
110
|
+
const res = await search({ engineName: engine.name, search: "Ryu" });
|
|
111
|
+
const body = await res.json() as SearchResult;
|
|
112
|
+
expect(body.total).toBe(1);
|
|
113
|
+
expect(body.items[0].piece.version.logic).toBe("ryu-1");
|
|
114
|
+
|
|
115
|
+
const both = await search({ engineName: engine.name, search: "capcom" });
|
|
116
|
+
expect((await both.json() as SearchResult).total).toBe(2);
|
|
117
|
+
|
|
118
|
+
const bySource = await search({ engineName: engine.name, search: "example.com/download" });
|
|
119
|
+
expect((await bySource.json() as SearchResult).total).toBe(2);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("paginates and filters by pieceType", async () => {
|
|
123
|
+
const pieces = ["a", "b", "c"].map((id, i) => makePiece({
|
|
124
|
+
id, version: { logic: `${id}-1`, media: "1.0.0", docs: "1.0.0" },
|
|
125
|
+
}));
|
|
126
|
+
for (const [i, piece] of pieces.entries()) {
|
|
127
|
+
await seedCompletePiece({
|
|
128
|
+
folder, engine, pieceType: TEST_PIECE_TYPE, piece, folderName: `folder-${i}`, files: {},
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const page0 = await search({
|
|
133
|
+
engineName: engine.name, pieceType: TEST_PIECE_TYPE, page: 0, limit: 2,
|
|
134
|
+
});
|
|
135
|
+
const page0Body = await page0.json() as SearchResult;
|
|
136
|
+
expect(page0Body.total).toBe(3);
|
|
137
|
+
expect(page0Body.items).toHaveLength(2);
|
|
138
|
+
|
|
139
|
+
const page1 = await search({
|
|
140
|
+
engineName: engine.name, pieceType: TEST_PIECE_TYPE, page: 1, limit: 2,
|
|
141
|
+
});
|
|
142
|
+
expect((await page1.json() as SearchResult).items).toHaveLength(1);
|
|
143
|
+
|
|
144
|
+
const otherType = await search({ engineName: engine.name, pieceType: "not-a-type" });
|
|
145
|
+
expect((await otherType.json() as SearchResult).total).toBe(0);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { startTestServer, TestServer, errorBody } from "./helpers/server";
|
|
5
|
+
import { makeTempFolder, cleanupFolder } from "./helpers/piece";
|
|
6
|
+
import { createFixturePluginDir } from "./helpers/plugin-dir";
|
|
7
|
+
import { makeValidLockConfig, makeValidHeroSelection } from "./helpers/validLockConfig";
|
|
8
|
+
|
|
9
|
+
describe("POST /v1/game-complete", () => {
|
|
10
|
+
const cleanups: Array<() => Promise<void> | void> = [];
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
while (cleanups.length) await cleanups.pop()!();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function post(server: TestServer, body: unknown) {
|
|
16
|
+
return fetch(`${server.httpUrl}/v1/game-complete`, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${server.authCode}` },
|
|
19
|
+
body: JSON.stringify(body),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function makeServer(packageNames: Array<string> = []) {
|
|
24
|
+
const fixture = await createFixturePluginDir(packageNames);
|
|
25
|
+
cleanups.push(fixture.cleanup);
|
|
26
|
+
const folder = await makeTempFolder();
|
|
27
|
+
cleanups.push(() => cleanupFolder(folder));
|
|
28
|
+
const server = await startTestServer(folder, undefined, fixture.pluginDir);
|
|
29
|
+
cleanups.push(() => server.close());
|
|
30
|
+
return { server, pluginDir: fixture.pluginDir };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
it("400s on a malformed body", async () => {
|
|
34
|
+
const { server } = await makeServer();
|
|
35
|
+
const res = await post(server, {});
|
|
36
|
+
expect(res.status).toBe(400);
|
|
37
|
+
expect((await errorBody(res)).error).toBe("Bad Form");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("400s when lockConfig fails schema validation", async () => {
|
|
41
|
+
const { server } = await makeServer();
|
|
42
|
+
const res = await post(server, {
|
|
43
|
+
lockConfig: { not: "a valid config" }, localUsers: [], userSelections: {}, winners: [],
|
|
44
|
+
});
|
|
45
|
+
expect(res.status).toBe(400);
|
|
46
|
+
expect((await errorBody(res)).error).toBe("Invalid lockConfig");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("acks with no installed plugins to fan out to", async () => {
|
|
50
|
+
const { server } = await makeServer();
|
|
51
|
+
const res = await post(server, {
|
|
52
|
+
lockConfig: makeValidLockConfig(), localUsers: [], userSelections: {}, winners: [],
|
|
53
|
+
});
|
|
54
|
+
expect(res.status).toBe(200);
|
|
55
|
+
expect(await res.json()).toEqual({ ok: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("fans out to every installed piece-selection-sort plugin, giving each its own dataDir", async () => {
|
|
59
|
+
const { server, pluginDir } = await makeServer(["@roster-lock/piece-selection-sort-highest-winrate-locally"]);
|
|
60
|
+
|
|
61
|
+
const res = await post(server, {
|
|
62
|
+
lockConfig: makeValidLockConfig(),
|
|
63
|
+
localUsers: ["pk-a"],
|
|
64
|
+
userSelections: { "pk-a": makeValidHeroSelection() },
|
|
65
|
+
winners: ["pk-a"],
|
|
66
|
+
});
|
|
67
|
+
expect(res.status).toBe(200);
|
|
68
|
+
expect(await res.json()).toEqual({ ok: true });
|
|
69
|
+
|
|
70
|
+
expect(existsSync(join(pluginDir, "data", "@roster-lock/piece-selection-sort-highest-winrate-locally"))).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
});
|