gemwright 0.0.0-stage → 0.1.3
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 +21 -0
- package/README.md +70 -2
- package/dist/index.js +1686 -0
- package/package.json +38 -4
- package/patches/servuo-pub57-hosting.patch +218 -0
- package/patches/sphere-hosting.patch +269 -0
- package/templates/servuo/Dockerfile +14 -0
- package/templates/servuo/railway-start.sh +36 -0
- package/templates/servuo/start.sh +23 -0
- package/templates/sphere/Dockerfile +22 -0
- package/templates/sphere/railway-start.sh +39 -0
- package/templates/sphere/start.sh +17 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1686 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { realpathSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
|
|
9
|
+
// src/deploy.ts
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { mkdir as mkdir3, readFile as readFile6, rename, writeFile as writeFile3 } from "node:fs/promises";
|
|
12
|
+
import { join as join7, resolve as resolve2 } from "node:path";
|
|
13
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
14
|
+
|
|
15
|
+
// src/emulators/servuo/staging.ts
|
|
16
|
+
import { cp, mkdir, readFile as readFile2, rm as rm2, stat } from "node:fs/promises";
|
|
17
|
+
import { join as join3 } from "node:path";
|
|
18
|
+
|
|
19
|
+
// src/assets.ts
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
function assetPath(path) {
|
|
22
|
+
return fileURLToPath(new URL(`../${path}`, import.meta.url));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/process.ts
|
|
26
|
+
import { spawn } from "node:child_process";
|
|
27
|
+
function runCommand(args, cwd = process.cwd()) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const child = spawn(args[0], args.slice(1), { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
30
|
+
let stdout = "";
|
|
31
|
+
let stderr = "";
|
|
32
|
+
child.stdout.setEncoding("utf8").on("data", (chunk) => {
|
|
33
|
+
stdout += chunk;
|
|
34
|
+
});
|
|
35
|
+
child.stderr.setEncoding("utf8").on("data", (chunk) => {
|
|
36
|
+
stderr += chunk;
|
|
37
|
+
});
|
|
38
|
+
child.once("error", reject);
|
|
39
|
+
child.once("close", (code, signal) => resolve({
|
|
40
|
+
code: code ?? 1,
|
|
41
|
+
stdout: stdout.trim(),
|
|
42
|
+
stderr: stderr.trim() || (signal ? `Terminated by ${signal}` : "")
|
|
43
|
+
}));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
async function command(args, cwd) {
|
|
47
|
+
const { code, stdout, stderr } = await runCommand(args, cwd);
|
|
48
|
+
if (code !== 0)
|
|
49
|
+
throw new Error(`${args[0]} ${args[1] ?? ""} failed: ${stderr || stdout}`);
|
|
50
|
+
return stdout;
|
|
51
|
+
}
|
|
52
|
+
async function isAvailable(executable) {
|
|
53
|
+
try {
|
|
54
|
+
return (await runCommand([executable, "--version"])).code === 0;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error.code === "ENOENT")
|
|
57
|
+
return false;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/emulators/client-files.ts
|
|
63
|
+
import { createHash } from "node:crypto";
|
|
64
|
+
import { createReadStream, existsSync } from "node:fs";
|
|
65
|
+
import { readdir } from "node:fs/promises";
|
|
66
|
+
import { join, resolve } from "node:path";
|
|
67
|
+
async function packageClientFiles(directory, sourcePath, pattern, validate) {
|
|
68
|
+
const source = resolve(sourcePath);
|
|
69
|
+
const names = (await readdir(source, { withFileTypes: true })).filter((entry) => entry.isFile() && pattern.test(entry.name)).map((entry) => entry.name).sort();
|
|
70
|
+
validate(names.map((name) => name.toLowerCase()));
|
|
71
|
+
const hash = createHash("sha256");
|
|
72
|
+
let bytes = 0;
|
|
73
|
+
for (const name of names) {
|
|
74
|
+
hash.update(name + "\x00");
|
|
75
|
+
for await (const chunk of createReadStream(join(source, name))) {
|
|
76
|
+
hash.update(chunk);
|
|
77
|
+
bytes += chunk.length;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const digest = hash.digest("hex");
|
|
81
|
+
const archive = join(directory, ".gemwright", `client-${digest}.tar.gz`);
|
|
82
|
+
if (!existsSync(archive))
|
|
83
|
+
await command(["tar", "--no-xattrs", "-czf", archive, "-C", source, ...names], directory);
|
|
84
|
+
return { archive, digest, bytes };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/emulators/servuo/config.ts
|
|
88
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
89
|
+
import { mkdtemp, readdir as readdir2, readFile, rm, writeFile } from "node:fs/promises";
|
|
90
|
+
import { tmpdir } from "node:os";
|
|
91
|
+
import { join as join2 } from "node:path";
|
|
92
|
+
|
|
93
|
+
// src/emulators/accounts.ts
|
|
94
|
+
import { randomBytes } from "node:crypto";
|
|
95
|
+
function validateOwner(value) {
|
|
96
|
+
if (!/^[A-Za-z0-9_-]{1,16}$/.test(value))
|
|
97
|
+
return "Use 1-16 letters, numbers, underscores or hyphens.";
|
|
98
|
+
}
|
|
99
|
+
function validateOwnerPassword(value) {
|
|
100
|
+
if (value !== "" && !/^[\x20-\x7e]{8,16}$/.test(value))
|
|
101
|
+
return "Use 8-16 printable ASCII characters, or leave blank to generate a password.";
|
|
102
|
+
}
|
|
103
|
+
function generatedOwnerPassword() {
|
|
104
|
+
return randomBytes(12).toString("base64url");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/emulators/servuo/config.ts
|
|
108
|
+
async function fetchServuoConfig(source) {
|
|
109
|
+
if (!/^[a-f0-9]{40}$/.test(source.revision))
|
|
110
|
+
throw new Error("Invalid ServUO source revision.");
|
|
111
|
+
const temporary = await mkdtemp(join2(tmpdir(), "gemwright-config-"));
|
|
112
|
+
try {
|
|
113
|
+
const response = await fetch(`https://codeload.github.com/ServUO/ServUO/tar.gz/${source.revision}`, {
|
|
114
|
+
signal: AbortSignal.timeout(60000)
|
|
115
|
+
});
|
|
116
|
+
if (!response.ok)
|
|
117
|
+
throw new Error(`Could not fetch ServUO config: HTTP ${response.status}`);
|
|
118
|
+
const archive = join2(temporary, "source.tar.gz");
|
|
119
|
+
await writeFile(archive, new Uint8Array(await response.arrayBuffer()));
|
|
120
|
+
await command(["tar", "-xzf", archive, "-C", temporary, "--strip-components=1", `ServUO-${source.revision}/Config`], temporary);
|
|
121
|
+
const files = {};
|
|
122
|
+
for (const entry of await readdir2(join2(temporary, "Config"), { withFileTypes: true })) {
|
|
123
|
+
if (!entry.isFile())
|
|
124
|
+
throw new Error(`Unsupported entry in upstream Config: ${entry.name}`);
|
|
125
|
+
files[`Config/${entry.name}`] = await readFile(join2(temporary, "Config", entry.name), "utf8");
|
|
126
|
+
}
|
|
127
|
+
return files;
|
|
128
|
+
} finally {
|
|
129
|
+
await rm(temporary, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function configValue(text, key) {
|
|
133
|
+
return text.split(/\r?\n/).find((line) => line.startsWith(`${key}=`))?.slice(key.length + 1).trim();
|
|
134
|
+
}
|
|
135
|
+
function setConfigValue(text, key, value) {
|
|
136
|
+
const newline = text.includes(`\r
|
|
137
|
+
`) ? `\r
|
|
138
|
+
` : `
|
|
139
|
+
`;
|
|
140
|
+
let found = false;
|
|
141
|
+
const lines = text.split(/\r?\n/).map((line) => {
|
|
142
|
+
if (line.startsWith(`${key}=`) || line.startsWith(`@${key}=`) || line.startsWith(`#${key}=`)) {
|
|
143
|
+
if (found)
|
|
144
|
+
throw new Error(`Duplicate upstream config key: ${key}`);
|
|
145
|
+
found = true;
|
|
146
|
+
return `${key}=${value}`;
|
|
147
|
+
}
|
|
148
|
+
return line;
|
|
149
|
+
});
|
|
150
|
+
if (!found)
|
|
151
|
+
throw new Error(`Upstream ServUO config key is missing: ${key}. No files were written.`);
|
|
152
|
+
return lines.join(newline);
|
|
153
|
+
}
|
|
154
|
+
var expansionNames = {
|
|
155
|
+
base: "None",
|
|
156
|
+
t2a: "T2A",
|
|
157
|
+
uor: "UOR",
|
|
158
|
+
td: "UOTD",
|
|
159
|
+
lbr: "LBR",
|
|
160
|
+
aos: "AOS",
|
|
161
|
+
se: "SE",
|
|
162
|
+
ml: "ML",
|
|
163
|
+
sa: "SA",
|
|
164
|
+
hs: "HS",
|
|
165
|
+
tol: "TOL",
|
|
166
|
+
ej: "EJ"
|
|
167
|
+
};
|
|
168
|
+
function prepareServuoFiles(upstream, config, settings, password) {
|
|
169
|
+
for (const filename of ["Server", "Expansion", "Accounts", "DataPath"]) {
|
|
170
|
+
if (typeof upstream[`Config/${filename}.cfg`] !== "string")
|
|
171
|
+
throw new Error(`Missing upstream Config/${filename}.cfg`);
|
|
172
|
+
}
|
|
173
|
+
if (validateOwner(settings.owner))
|
|
174
|
+
throw new Error(validateOwner(settings.owner));
|
|
175
|
+
if (!password || validateOwnerPassword(password))
|
|
176
|
+
throw new Error("Invalid owner password.");
|
|
177
|
+
const files = { ...upstream };
|
|
178
|
+
if (config.adminEmail !== undefined)
|
|
179
|
+
files["Config/General.cfg"] = setConfigValue(files["Config/General.cfg"] ?? "", "SupportEmail", config.adminEmail);
|
|
180
|
+
if (config.url !== undefined)
|
|
181
|
+
files["Config/General.cfg"] = setConfigValue(files["Config/General.cfg"] ?? "", "SupportWebsite", config.url);
|
|
182
|
+
files["Config/Server.cfg"] = setConfigValue(files["Config/Server.cfg"], "Name", config.name);
|
|
183
|
+
files["Config/Expansion.cfg"] = setConfigValue(files["Config/Expansion.cfg"], "CurrentExpansion", expansionNames[config.expansion]);
|
|
184
|
+
files["Config/DataPath.cfg"] = setConfigValue(files["Config/DataPath.cfg"], "CustomPath", "data/client");
|
|
185
|
+
files["Config/Accounts.cfg"] = setConfigValue(files["Config/Accounts.cfg"], "AutoCreateAccounts", settings.autoCreateAccounts ? "True" : "False");
|
|
186
|
+
const protection = configValue(files["Config/Accounts.cfg"], "ProtectPasswords");
|
|
187
|
+
const hashes = {
|
|
188
|
+
Crypt: { algorithm: "md5", element: "cryptPassword", input: password },
|
|
189
|
+
NewCrypt: { algorithm: "sha1", element: "newCryptPassword", input: settings.owner + password },
|
|
190
|
+
NewSecureCrypt: { algorithm: "sha512", element: "newSecureCryptPassword", input: settings.owner + password }
|
|
191
|
+
};
|
|
192
|
+
const hash = protection ? hashes[protection] : undefined;
|
|
193
|
+
if (!hash)
|
|
194
|
+
throw new Error(`Unsupported upstream password protection: ${protection}. No files were written.`);
|
|
195
|
+
const digest = createHash2(hash.algorithm).update(hash.input, "ascii").digest("hex").toUpperCase().match(/../g).join("-");
|
|
196
|
+
files["Saves/Accounts/accounts.xml"] = `<?xml version="1.0" encoding="utf-8"?>
|
|
197
|
+
<accounts count="1">
|
|
198
|
+
<account>
|
|
199
|
+
<username>${settings.owner}</username>
|
|
200
|
+
<${hash.element}>${digest}</${hash.element}>
|
|
201
|
+
<accessLevel>Owner</accessLevel>
|
|
202
|
+
</account>
|
|
203
|
+
</accounts>
|
|
204
|
+
`;
|
|
205
|
+
files[".gitignore"] = `Saves/
|
|
206
|
+
Backups/
|
|
207
|
+
Logs/
|
|
208
|
+
data/client/
|
|
209
|
+
.gemwright/
|
|
210
|
+
`;
|
|
211
|
+
return files;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/emulators/compatibility.ts
|
|
215
|
+
function git(directory, args) {
|
|
216
|
+
return runCommand(["git", "-C", directory, ...args]);
|
|
217
|
+
}
|
|
218
|
+
async function applyCompatibility(directory, expectedRevision, patch, emulator) {
|
|
219
|
+
if (!/^[a-f0-9]{40}$/.test(expectedRevision))
|
|
220
|
+
throw new Error(`Invalid recorded ${emulator} revision. No patch was applied.`);
|
|
221
|
+
const revision = await git(directory, ["rev-parse", "HEAD"]);
|
|
222
|
+
if (revision.code !== 0 || revision.stdout !== expectedRevision) {
|
|
223
|
+
throw new Error(`${emulator} compatibility requires revision ${expectedRevision}. No patch was applied.`);
|
|
224
|
+
}
|
|
225
|
+
const reverse = await git(directory, ["apply", "--reverse", "--check", patch]);
|
|
226
|
+
if (reverse.code === 0)
|
|
227
|
+
return "already-applied";
|
|
228
|
+
const check = await git(directory, ["apply", "--check", patch]);
|
|
229
|
+
if (check.code !== 0) {
|
|
230
|
+
throw new Error(`${emulator} compatibility patch does not apply cleanly to recorded revision ${expectedRevision}. No patch was applied.
|
|
231
|
+
${check.stderr}`);
|
|
232
|
+
}
|
|
233
|
+
const result = await git(directory, ["apply", patch]);
|
|
234
|
+
if (result.code !== 0)
|
|
235
|
+
throw new Error(`Failed to apply ${emulator} compatibility patch: ${result.stderr}`);
|
|
236
|
+
return "applied";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// src/emulators/servuo/source.ts
|
|
240
|
+
var servuoSource = {
|
|
241
|
+
repository: "https://github.com/ServUO/ServUO.git",
|
|
242
|
+
ref: "pub57",
|
|
243
|
+
patch: assetPath("patches/servuo-pub57-hosting.patch")
|
|
244
|
+
};
|
|
245
|
+
async function resolveLatestServuoSource(fetcher = fetch) {
|
|
246
|
+
try {
|
|
247
|
+
const response = await fetcher(`https://api.github.com/repos/ServUO/ServUO/commits/${servuoSource.ref}`, {
|
|
248
|
+
headers: { Accept: "application/vnd.github+json", "User-Agent": "gemwright" },
|
|
249
|
+
signal: AbortSignal.timeout(15000)
|
|
250
|
+
});
|
|
251
|
+
if (!response.ok)
|
|
252
|
+
throw new Error(`GitHub returned HTTP ${response.status}`);
|
|
253
|
+
const result = await response.json();
|
|
254
|
+
if (!result || typeof result !== "object" || !("sha" in result) || typeof result.sha !== "string" || !/^[a-f0-9]{40}$/.test(result.sha)) {
|
|
255
|
+
throw new Error("GitHub returned an invalid commit revision");
|
|
256
|
+
}
|
|
257
|
+
return { repository: servuoSource.repository, ref: servuoSource.ref, revision: result.sha };
|
|
258
|
+
} catch (error) {
|
|
259
|
+
throw new Error(`Could not resolve the latest ServUO ${servuoSource.ref} revision. Check your connection and retry. ${error instanceof Error ? error.message : String(error)}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function applyServuoCompatibility(directory, expectedRevision) {
|
|
263
|
+
return applyCompatibility(directory, expectedRevision, servuoSource.patch, "ServUO");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// src/emulators/servuo/staging.ts
|
|
267
|
+
async function validateServuoShard(directory, config) {
|
|
268
|
+
if (!config.source || config.source.repository !== servuoSource.repository || config.source.ref !== servuoSource.ref) {
|
|
269
|
+
throw new Error("Missing or unsupported ServUO source. Initialize a new shard to record its source revision.");
|
|
270
|
+
}
|
|
271
|
+
const server = await readFile2(join3(directory, "Config/Server.cfg"), "utf8");
|
|
272
|
+
if (configValue(server, "Port") !== "2593" || configValue(server, "Listen") !== "0.0.0.0") {
|
|
273
|
+
throw new Error("Railway deployment currently requires Server.cfg Listen=0.0.0.0 and Port=2593. Config was not changed.");
|
|
274
|
+
}
|
|
275
|
+
const data = await readFile2(join3(directory, "Config/DataPath.cfg"), "utf8");
|
|
276
|
+
if (configValue(data, "CustomPath") !== "data/client")
|
|
277
|
+
throw new Error("Deployment currently supports DataPath.cfg CustomPath=data/client. Config was not changed.");
|
|
278
|
+
const restart = await readFile2(join3(directory, "Config/AutoRestart.cfg"), "utf8").catch(() => "Enabled=False");
|
|
279
|
+
if (configValue(restart, "Enabled")?.toLowerCase() === "true") {
|
|
280
|
+
throw new Error("ServUO self-restarts are not supported by the container startup wrapper yet. Config was not changed.");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async function stageServuo(directory, config) {
|
|
284
|
+
const context = join3(directory, ".gemwright/deploy");
|
|
285
|
+
await rm2(context, { recursive: true, force: true });
|
|
286
|
+
const server = join3(context, "server");
|
|
287
|
+
await mkdir(server, { recursive: true });
|
|
288
|
+
await command(["git", "init", "--quiet"], server);
|
|
289
|
+
await command(["git", "fetch", "--quiet", "--depth", "1", config.source.repository, config.source.revision], server);
|
|
290
|
+
await command(["git", "checkout", "--quiet", "--detach", "FETCH_HEAD"], server);
|
|
291
|
+
await applyServuoCompatibility(server, config.source.revision);
|
|
292
|
+
await rm2(join3(server, ".git"), { recursive: true, force: true });
|
|
293
|
+
await rm2(join3(server, "Config"), { recursive: true, force: true });
|
|
294
|
+
await cp(join3(directory, "Config"), join3(server, "Config"), { recursive: true });
|
|
295
|
+
for (const folder of ["Scripts", "Data"]) {
|
|
296
|
+
if ((await stat(join3(directory, folder)).catch(() => {
|
|
297
|
+
return;
|
|
298
|
+
}))?.isDirectory())
|
|
299
|
+
await cp(join3(directory, folder), join3(server, folder), { recursive: true });
|
|
300
|
+
}
|
|
301
|
+
await cp(join3(directory, "Saves"), join3(context, "seed/Saves"), { recursive: true });
|
|
302
|
+
for (const [source, target] of [
|
|
303
|
+
["Dockerfile", "Dockerfile"],
|
|
304
|
+
["railway-start.sh", "start.sh"],
|
|
305
|
+
["start.sh", "run-server.sh"]
|
|
306
|
+
]) {
|
|
307
|
+
await cp(assetPath(`templates/servuo/${source}`), join3(context, target));
|
|
308
|
+
}
|
|
309
|
+
return context;
|
|
310
|
+
}
|
|
311
|
+
var clientPattern = /^(?:map[0-5](?:LegacyMUL\.uop|\.mul)|statics[0-5]\.mul|staidx[0-5]\.mul|(?:mapdif|mapdifl|stadif|stadifl|stadifi)[0-5]\.mul|multi\.(?:idx|mul)|MultiCollection\.uop|tiledata\.mul|verdata\.mul)$/i;
|
|
312
|
+
function packageClientFiles2(directory, sourcePath) {
|
|
313
|
+
return packageClientFiles(directory, sourcePath, clientPattern, (lower) => {
|
|
314
|
+
if (!lower.includes("tiledata.mul") || !(lower.includes("map0.mul") || lower.includes("map0legacymul.uop")))
|
|
315
|
+
throw new Error("Client files must include tiledata.mul and map0.mul or map0LegacyMUL.uop.");
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// src/emulators/sphere/staging.ts
|
|
320
|
+
import { cp as cp2, mkdir as mkdir2, readFile as readFile4, rm as rm4, stat as stat2 } from "node:fs/promises";
|
|
321
|
+
import { join as join5 } from "node:path";
|
|
322
|
+
|
|
323
|
+
// src/emulators/sphere/config.ts
|
|
324
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
325
|
+
import { mkdtemp as mkdtemp2, readdir as readdir3, readFile as readFile3, rm as rm3, writeFile as writeFile2 } from "node:fs/promises";
|
|
326
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
327
|
+
import { join as join4 } from "node:path";
|
|
328
|
+
|
|
329
|
+
// src/emulators/sphere/rules.ts
|
|
330
|
+
var ruleFlags = {
|
|
331
|
+
combatFlags: [
|
|
332
|
+
[1, "Do not turn toward combat targets"],
|
|
333
|
+
[2, "Require facing the target"],
|
|
334
|
+
[4, "Instant first melee hit (prehit)"],
|
|
335
|
+
[8, "Elemental damage and resistances (AOS)"],
|
|
336
|
+
[16, "Destroy items when maximum durability reaches zero"],
|
|
337
|
+
[32, "Double-click yourself in combat to dismount"],
|
|
338
|
+
[64, "Allow attacks from ships"],
|
|
339
|
+
[128, "Pets stay loyal when attacked by their owner"],
|
|
340
|
+
[256, "Allow archery while moving"],
|
|
341
|
+
[512, "Cancel swings when the target leaves range"],
|
|
342
|
+
[1024, "Retaliate as soon as an attack is noticed"],
|
|
343
|
+
[2048, "Hide NPC attack announcements"],
|
|
344
|
+
[4096, "Stack armor coverage"],
|
|
345
|
+
[8192, "Disable legacy poison-on-hit behavior"]
|
|
346
|
+
],
|
|
347
|
+
magicFlags: [
|
|
348
|
+
[1, "Do not turn while casting or targeting"],
|
|
349
|
+
[2, "Precast spells before selecting a target"],
|
|
350
|
+
[4, "Magic ignores armor rating"],
|
|
351
|
+
[8, "Allow spells to harm their caster"],
|
|
352
|
+
[16, "Allow different stat spells to stack"],
|
|
353
|
+
[32, "Prevent movement while casting"],
|
|
354
|
+
[64, "Require summoned creatures to be able to walk at their destination"],
|
|
355
|
+
[128, "Prevent spell fields crossing walls"],
|
|
356
|
+
[256, "Disable spell animations"],
|
|
357
|
+
[512, "Use OSI spell damage and duration formulas"],
|
|
358
|
+
[1024, "Prevent casting while paralyzed with occupied hands"],
|
|
359
|
+
[2048, "Apply stat changes from polymorph forms"],
|
|
360
|
+
[4096, "Replace existing field spells on the same tile"]
|
|
361
|
+
],
|
|
362
|
+
racialFlags: [
|
|
363
|
+
[1, "Human: +60 carrying capacity"],
|
|
364
|
+
[2, "Human: +2 hit point regeneration"],
|
|
365
|
+
[4, "Human: gather additional resources"],
|
|
366
|
+
[8, "Human: minimum 20 skill for untrained abilities"],
|
|
367
|
+
[16, "Elf: permanent night sight"],
|
|
368
|
+
[32, "Elf: harder to track while hidden"],
|
|
369
|
+
[64, "Elf: +20 maximum mana"],
|
|
370
|
+
[128, "Gargoyle: flight (requires AOS ability book features)"],
|
|
371
|
+
[256, "Gargoyle: berserk damage bonuses when injured"],
|
|
372
|
+
[512, "Gargoyle: minimum 20 Throwing skill"],
|
|
373
|
+
[1024, "Gargoyle: minimum 30 Mysticism skill"]
|
|
374
|
+
],
|
|
375
|
+
revealFlags: [
|
|
376
|
+
[1, "Reveal when using Detecting Hidden"],
|
|
377
|
+
[2, "Reveal when looting your own corpse"],
|
|
378
|
+
[4, "Reveal when looting other corpses"],
|
|
379
|
+
[8, "Reveal when speaking"],
|
|
380
|
+
[16, "Reveal when casting spells"]
|
|
381
|
+
],
|
|
382
|
+
npcAI: [
|
|
383
|
+
[1, "Advanced pathfinding (increases CPU usage)"],
|
|
384
|
+
[2, "Search for food when hungry"],
|
|
385
|
+
[4, "Human NPCs equip weapons and night lights"],
|
|
386
|
+
[8, "Use maximum pathfinding intelligence (requires advanced pathfinding)"],
|
|
387
|
+
[16, "Intelligent food search"],
|
|
388
|
+
[64, "Cast helpful spells on allies during combat"],
|
|
389
|
+
[256, "Loot nearby items and corpses"],
|
|
390
|
+
[512, "Move items blocking the path when able to use hands"],
|
|
391
|
+
[1024, "Keep pursuing unreachable targets"],
|
|
392
|
+
[2048, "Choose combat targets by threat instead of distance"]
|
|
393
|
+
]
|
|
394
|
+
};
|
|
395
|
+
function atLeast(expansion, minimum) {
|
|
396
|
+
return expansions.findIndex((entry) => entry.value === expansion) >= expansions.findIndex((entry) => entry.value === minimum);
|
|
397
|
+
}
|
|
398
|
+
function sphereNumber(value) {
|
|
399
|
+
if (!/^(?:0[0-9a-fA-F]*|[1-9][0-9]*)(?:\|(?:0[0-9a-fA-F]*|[1-9][0-9]*))*$/.test(value))
|
|
400
|
+
throw new Error(`Unsupported Sphere numeric expression: ${value}`);
|
|
401
|
+
return value.split("|").reduce((result, part) => {
|
|
402
|
+
const number = Number.parseInt(part, part.startsWith("0") ? 16 : 10);
|
|
403
|
+
if (!Number.isSafeInteger(number) || number > 2147483647)
|
|
404
|
+
throw new Error(`Sphere numeric expression is out of range: ${value}`);
|
|
405
|
+
return result | number;
|
|
406
|
+
}, 0);
|
|
407
|
+
}
|
|
408
|
+
function flagValue(value) {
|
|
409
|
+
return value ? `0${value.toString(16)}` : "0";
|
|
410
|
+
}
|
|
411
|
+
function validateRules(rules) {
|
|
412
|
+
for (const key of Object.keys(ruleFlags)) {
|
|
413
|
+
if (!Number.isInteger(rules[key]) || rules[key] < 0 || rules[key] > 2147483647)
|
|
414
|
+
throw new Error(`Invalid Sphere ${key}.`);
|
|
415
|
+
}
|
|
416
|
+
for (const [key, maximum] of [
|
|
417
|
+
["damageEra", 2],
|
|
418
|
+
["hitChanceEra", 2],
|
|
419
|
+
["speedEra", 4],
|
|
420
|
+
["archeryMovementDelay", 2147483647],
|
|
421
|
+
["speedScaleFactor", 2147483647]
|
|
422
|
+
]) {
|
|
423
|
+
if (!Number.isInteger(rules[key]) || rules[key] < 0 || rules[key] > maximum || key === "speedScaleFactor" && rules[key] === 0)
|
|
424
|
+
throw new Error(`Invalid Sphere ${key}.`);
|
|
425
|
+
}
|
|
426
|
+
if (rules.npcAI & 8 && !(rules.npcAI & 1))
|
|
427
|
+
throw new Error("Maximum NPC pathfinding intelligence requires advanced pathfinding.");
|
|
428
|
+
}
|
|
429
|
+
function expansionFlags(expansion, optionFlags, extraFlags) {
|
|
430
|
+
const options = optionFlags & ~8214 | (atLeast(expansion, "hs") ? 0 : 2) | (atLeast(expansion, "aos") ? 16 : 4) | (atLeast(expansion, "ml") ? 8192 : 0);
|
|
431
|
+
const extra = extraFlags & ~31 | (atLeast(expansion, "ml") ? 1 : 0) | (atLeast(expansion, "sa") ? 6 : 0) | (atLeast(expansion, "tol") ? 24 : 0);
|
|
432
|
+
return { OptionFlags: flagValue(options), FeatureExtra: flagValue(extra) };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// src/config.ts
|
|
436
|
+
var expansions = [
|
|
437
|
+
{ value: "base", label: "Base" },
|
|
438
|
+
{ value: "t2a", label: "The Second Age" },
|
|
439
|
+
{ value: "uor", label: "Renaissance" },
|
|
440
|
+
{ value: "td", label: "Third Dawn" },
|
|
441
|
+
{ value: "lbr", label: "Lord Blackthorn's Revenge" },
|
|
442
|
+
{ value: "aos", label: "Age of Shadows" },
|
|
443
|
+
{ value: "se", label: "Samurai Empire" },
|
|
444
|
+
{ value: "ml", label: "Mondain's Legacy" },
|
|
445
|
+
{ value: "sa", label: "Stygian Abyss" },
|
|
446
|
+
{ value: "hs", label: "High Seas" },
|
|
447
|
+
{ value: "tol", label: "Time of Legends" },
|
|
448
|
+
{ value: "ej", label: "Endless Journey" }
|
|
449
|
+
];
|
|
450
|
+
var emulators = [
|
|
451
|
+
{ value: "servuo", label: "ServUO", disabled: false },
|
|
452
|
+
{ value: "sphere", label: "Sphere", disabled: false },
|
|
453
|
+
{ value: "sphere-x", label: "Sphere-X", disabled: true },
|
|
454
|
+
{ value: "runuo", label: "RunUO", disabled: true },
|
|
455
|
+
{ value: "pol", label: "POL", disabled: true }
|
|
456
|
+
];
|
|
457
|
+
var configFilename = "gemwright.json";
|
|
458
|
+
var defaultName = "Gemwright";
|
|
459
|
+
var defaultExpansion = "ej";
|
|
460
|
+
var defaultEmulator = "servuo";
|
|
461
|
+
var acceptedRegions = new Set([
|
|
462
|
+
"Africa",
|
|
463
|
+
"America",
|
|
464
|
+
"Antarctica",
|
|
465
|
+
"Arctic",
|
|
466
|
+
"Asia",
|
|
467
|
+
"Atlantic",
|
|
468
|
+
"Australia",
|
|
469
|
+
"Europe",
|
|
470
|
+
"Indian",
|
|
471
|
+
"Pacific"
|
|
472
|
+
]);
|
|
473
|
+
function shardId(name) {
|
|
474
|
+
return (name || defaultName).toLowerCase().replace(/'/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
475
|
+
}
|
|
476
|
+
function validateName(name) {
|
|
477
|
+
if (name !== "" && !/^[\x20-\x7e]{1,32}$/.test(name)) {
|
|
478
|
+
return "Use 1-32 printable ASCII characters.";
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
function isAcceptedTimezone(zone) {
|
|
482
|
+
if (!acceptedRegions.has(zone.split("/")[0]))
|
|
483
|
+
return false;
|
|
484
|
+
try {
|
|
485
|
+
new Intl.DateTimeFormat("en", { timeZone: zone });
|
|
486
|
+
return true;
|
|
487
|
+
} catch {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
function timezoneOptions() {
|
|
492
|
+
const zones = Intl.supportedValuesOf("timeZone").filter(isAcceptedTimezone);
|
|
493
|
+
const local = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
494
|
+
if (isAcceptedTimezone(local) && !zones.includes(local))
|
|
495
|
+
zones.push(local);
|
|
496
|
+
if (!zones.includes("Europe/London"))
|
|
497
|
+
zones.push("Europe/London");
|
|
498
|
+
return zones.sort();
|
|
499
|
+
}
|
|
500
|
+
function defaultTimezone() {
|
|
501
|
+
const local = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
502
|
+
return isAcceptedTimezone(local) ? local : "Europe/London";
|
|
503
|
+
}
|
|
504
|
+
function createConfig(input) {
|
|
505
|
+
const name = input.name || defaultName;
|
|
506
|
+
const error = validateName(name);
|
|
507
|
+
if (error)
|
|
508
|
+
throw new Error(error);
|
|
509
|
+
for (const [value, validate] of [
|
|
510
|
+
[input.adminEmail, validateAdminEmail],
|
|
511
|
+
[input.url, validateWebsite]
|
|
512
|
+
]) {
|
|
513
|
+
if (value !== undefined) {
|
|
514
|
+
const error = validate(value);
|
|
515
|
+
if (error)
|
|
516
|
+
throw new Error(error);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const timezone = input.timezone ?? defaultTimezone();
|
|
520
|
+
if (!isAcceptedTimezone(timezone)) {
|
|
521
|
+
throw new Error(`Unsupported timezone: ${timezone}. Choose an IANA zone in an accepted region.`);
|
|
522
|
+
}
|
|
523
|
+
const expansion = input.expansion ?? defaultExpansion;
|
|
524
|
+
if (!expansions.some((option) => option.value === expansion)) {
|
|
525
|
+
throw new Error(`Unknown expansion: ${expansion}. Choose ${expansions.map((option) => option.value).join(", ")}.`);
|
|
526
|
+
}
|
|
527
|
+
const emulator = input.emulator ?? defaultEmulator;
|
|
528
|
+
if (!emulators.some((option) => option.value === emulator && !option.disabled)) {
|
|
529
|
+
throw new Error(`Unsupported emulator: ${emulator}. Choose servuo or sphere.`);
|
|
530
|
+
}
|
|
531
|
+
if (input.source && (!/^[a-f0-9]{40}$/.test(input.source.revision) || !input.source.repository || !input.source.ref)) {
|
|
532
|
+
throw new Error("Invalid emulator source: repository, ref and a full commit revision are required.");
|
|
533
|
+
}
|
|
534
|
+
if (emulator === "sphere" && input.url?.includes("//"))
|
|
535
|
+
throw new Error("Sphere websites must omit http(s):// and contain no //; use www.example.com.");
|
|
536
|
+
if (input.sphere) {
|
|
537
|
+
const settings = input.sphere;
|
|
538
|
+
if (settings.rules !== undefined)
|
|
539
|
+
validateRules(settings.rules);
|
|
540
|
+
if (emulator !== "sphere" || !settings.scriptsSource || !/^[a-f0-9]{40}$/.test(settings.scriptsSource.revision) || !settings.scriptsSource.repository || !settings.scriptsSource.ref || typeof settings.autoCreateAccounts !== "boolean" || !/^[A-Za-z0-9_-]{1,16}$/.test(settings.owner) || !["expansion", "felucca"].includes(settings.maps) || !["modern", "legacy"].includes(settings.mapSize) || settings.clientEncryption !== undefined && !["encrypted", "both", "unencrypted"].includes(settings.clientEncryption) || settings.clientFiles !== undefined && typeof settings.clientFiles !== "string")
|
|
541
|
+
throw new Error("Invalid Sphere settings or scripts source.");
|
|
542
|
+
}
|
|
543
|
+
return {
|
|
544
|
+
...input.servuo ? { servuo: { ...input.servuo } } : {},
|
|
545
|
+
...input.sphere ? {
|
|
546
|
+
sphere: {
|
|
547
|
+
...input.sphere,
|
|
548
|
+
...input.sphere.rules ? { rules: { ...input.sphere.rules } } : {},
|
|
549
|
+
scriptsSource: { ...input.sphere.scriptsSource }
|
|
550
|
+
}
|
|
551
|
+
} : {},
|
|
552
|
+
...input.source ? { source: { ...input.source } } : {},
|
|
553
|
+
...input.adminEmail !== undefined ? { adminEmail: input.adminEmail } : {},
|
|
554
|
+
...input.url !== undefined ? { url: input.url } : {},
|
|
555
|
+
name,
|
|
556
|
+
id: shardId(name),
|
|
557
|
+
timezone,
|
|
558
|
+
expansion,
|
|
559
|
+
emulator
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
var defaultAdminEmail = "gemwright@example.com";
|
|
563
|
+
var defaultWebsite = "www.example.com";
|
|
564
|
+
function validateAdminEmail(value) {
|
|
565
|
+
if (!/^[A-Za-z0-9.!#$%&'*+?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?\.[A-Za-z]{2,}$/.test(value) || value.includes("//"))
|
|
566
|
+
return "Enter an email address, such as gemwright@example.com.";
|
|
567
|
+
}
|
|
568
|
+
function validateWebsite(value) {
|
|
569
|
+
if (!/^[\x21-\x7e]+$/.test(value) || /["<>\\]/.test(value))
|
|
570
|
+
return "Enter a website address without spaces, quotes or backslashes.";
|
|
571
|
+
try {
|
|
572
|
+
const url = new URL(/^https?:\/\//i.test(value) ? value : `https://${value}`);
|
|
573
|
+
if (!["http:", "https:"].includes(url.protocol) || !url.hostname.includes(".") || url.username || url.password)
|
|
574
|
+
throw new Error;
|
|
575
|
+
} catch {
|
|
576
|
+
return "Enter an HTTP(S) website or hostname, such as www.example.com.";
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// src/emulators/sphere/source.ts
|
|
581
|
+
var sphereSources = {
|
|
582
|
+
server: { repository: "https://github.com/Sphereserver/Source.git", ref: "master" },
|
|
583
|
+
scripts: { repository: "https://github.com/Sphereserver/Scripts.git", ref: "master" }
|
|
584
|
+
};
|
|
585
|
+
async function resolveLatestSphereSources(fetcher = fetch) {
|
|
586
|
+
async function resolve(kind) {
|
|
587
|
+
const source = sphereSources[kind];
|
|
588
|
+
const repository = kind === "server" ? "Source" : "Scripts";
|
|
589
|
+
try {
|
|
590
|
+
const response = await fetcher(`https://api.github.com/repos/Sphereserver/${repository}/commits/${source.ref}`, {
|
|
591
|
+
headers: { Accept: "application/vnd.github+json", "User-Agent": "gemwright" },
|
|
592
|
+
signal: AbortSignal.timeout(15000)
|
|
593
|
+
});
|
|
594
|
+
if (!response.ok)
|
|
595
|
+
throw new Error(`GitHub returned HTTP ${response.status}`);
|
|
596
|
+
const result = await response.json();
|
|
597
|
+
if (!result || typeof result !== "object" || !("sha" in result) || typeof result.sha !== "string" || !/^[a-f0-9]{40}$/.test(result.sha)) {
|
|
598
|
+
throw new Error("GitHub returned an invalid commit revision");
|
|
599
|
+
}
|
|
600
|
+
return { ...source, revision: result.sha };
|
|
601
|
+
} catch (error) {
|
|
602
|
+
throw new Error(`Could not resolve the latest Sphere ${repository} ${source.ref} revision. Check your connection and retry. ${error instanceof Error ? error.message : String(error)}`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
const [server, scripts] = await Promise.all([resolve("server"), resolve("scripts")]);
|
|
606
|
+
return { server, scripts };
|
|
607
|
+
}
|
|
608
|
+
var spherePatch = assetPath("patches/sphere-hosting.patch");
|
|
609
|
+
function applySphereCompatibility(directory, expectedRevision) {
|
|
610
|
+
return applyCompatibility(directory, expectedRevision, spherePatch, "Sphere");
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// src/emulators/sphere/config.ts
|
|
614
|
+
function validateSphereName(value) {
|
|
615
|
+
return validateName(value) ?? (/[<>/"\\[\]=]/.test(value) || value.trim() !== value ? 'Sphere names cannot contain < > / " \\ [ ] = or leading/trailing spaces.' : undefined);
|
|
616
|
+
}
|
|
617
|
+
function validateSphereOwner(value) {
|
|
618
|
+
return validateOwner(value) ?? (/^\d+$/.test(value) || ["EOF", "ACCOUNT", "ADD", "ADDMD5", "BLOCKED", "HELP", "JAILED", "UNUSED", "UPDATE"].includes(value.toUpperCase()) ? "Choose a non-numeric username that is not a reserved Sphere account command." : undefined);
|
|
619
|
+
}
|
|
620
|
+
async function fetchSphereFiles(sources) {
|
|
621
|
+
for (const kind of ["server", "scripts"]) {
|
|
622
|
+
const source = sources[kind];
|
|
623
|
+
if (source.repository !== sphereSources[kind].repository || source.ref !== sphereSources[kind].ref || !/^[a-f0-9]{40}$/.test(source.revision))
|
|
624
|
+
throw new Error(`Invalid Sphere ${kind} source.`);
|
|
625
|
+
}
|
|
626
|
+
const temporary = await mkdtemp2(join4(tmpdir2(), "gemwright-sphere-config-"));
|
|
627
|
+
try {
|
|
628
|
+
async function extract(kind) {
|
|
629
|
+
const repository = kind === "server" ? "Source" : "Scripts";
|
|
630
|
+
const revision = sources[kind].revision;
|
|
631
|
+
const response = await fetch(`https://codeload.github.com/Sphereserver/${repository}/tar.gz/${revision}`, {
|
|
632
|
+
signal: AbortSignal.timeout(60000)
|
|
633
|
+
});
|
|
634
|
+
if (!response.ok)
|
|
635
|
+
throw new Error(`Could not fetch Sphere ${repository}: HTTP ${response.status}`);
|
|
636
|
+
const archive = join4(temporary, `${kind}.tar.gz`);
|
|
637
|
+
await writeFile2(archive, new Uint8Array(await response.arrayBuffer()));
|
|
638
|
+
await command(["tar", "-xzf", archive, "-C", temporary], temporary);
|
|
639
|
+
return join4(temporary, `${repository}-${revision}`);
|
|
640
|
+
}
|
|
641
|
+
const results = await Promise.allSettled([extract("server"), extract("scripts")]);
|
|
642
|
+
const failure = results.find((result) => result.status === "rejected");
|
|
643
|
+
if (failure?.status === "rejected")
|
|
644
|
+
throw failure.reason;
|
|
645
|
+
const [server, scripts] = results.map((result) => result.value);
|
|
646
|
+
const files = {};
|
|
647
|
+
for (const filename of ["sphere.ini", "sphereCrypt.ini"]) {
|
|
648
|
+
files[filename] = await readFile3(join4(server, "src", filename));
|
|
649
|
+
}
|
|
650
|
+
async function readScripts(directory, prefix = "scripts") {
|
|
651
|
+
for (const entry of await readdir3(directory, { withFileTypes: true })) {
|
|
652
|
+
const filename = `${prefix}/${entry.name}`;
|
|
653
|
+
if (entry.isDirectory())
|
|
654
|
+
await readScripts(join4(directory, entry.name), filename);
|
|
655
|
+
else if (entry.isFile())
|
|
656
|
+
files[filename] = await readFile3(join4(directory, entry.name));
|
|
657
|
+
else
|
|
658
|
+
throw new Error(`Unsupported Sphere script entry: ${filename}`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
await readScripts(scripts);
|
|
662
|
+
return files;
|
|
663
|
+
} finally {
|
|
664
|
+
await rm3(temporary, { recursive: true, force: true });
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
function text(files, filename) {
|
|
668
|
+
const content = files[filename];
|
|
669
|
+
if (content === undefined)
|
|
670
|
+
throw new Error(`Missing upstream Sphere file: ${filename}`);
|
|
671
|
+
return typeof content === "string" ? content : Buffer.from(content).toString("latin1");
|
|
672
|
+
}
|
|
673
|
+
function sphereConfigValue(ini, key) {
|
|
674
|
+
return ini.split(/\r?\n/).find((line) => line.startsWith(`${key}=`))?.slice(key.length + 1).split("//")[0]?.trim();
|
|
675
|
+
}
|
|
676
|
+
function setValue(ini, key, value) {
|
|
677
|
+
const lines = ini.split(/\r?\n/);
|
|
678
|
+
let candidates = lines.flatMap((line, index) => line.startsWith(`${key}=`) ? [index] : []);
|
|
679
|
+
if (!candidates.length)
|
|
680
|
+
candidates = lines.flatMap((line, index) => line.startsWith(`//${key}=`) ? [index] : []);
|
|
681
|
+
if (candidates.length !== 1)
|
|
682
|
+
throw new Error(`Missing or duplicate upstream Sphere setting: ${key}. No files were written.`);
|
|
683
|
+
const index = candidates[0];
|
|
684
|
+
let comment = lines[index].match(/\s+\/\/.*$/)?.[0] ?? "";
|
|
685
|
+
if (/^Map[01]$/.test(key) && value.startsWith("6144,"))
|
|
686
|
+
comment = comment.replace("ML size", "Old size");
|
|
687
|
+
lines[index] = `${key}=${value}${comment}`;
|
|
688
|
+
return lines.join(ini.includes(`\r
|
|
689
|
+
`) ? `\r
|
|
690
|
+
` : `
|
|
691
|
+
`);
|
|
692
|
+
}
|
|
693
|
+
function sphereMapCount(expansion) {
|
|
694
|
+
if (atLeast(expansion, "sa"))
|
|
695
|
+
return 6;
|
|
696
|
+
if (atLeast(expansion, "se"))
|
|
697
|
+
return 5;
|
|
698
|
+
if (atLeast(expansion, "aos"))
|
|
699
|
+
return 4;
|
|
700
|
+
if (atLeast(expansion, "td"))
|
|
701
|
+
return 3;
|
|
702
|
+
if (atLeast(expansion, "uor"))
|
|
703
|
+
return 2;
|
|
704
|
+
return 1;
|
|
705
|
+
}
|
|
706
|
+
function prepareSphereFiles(upstream, config, settings, password) {
|
|
707
|
+
if (config.url?.includes("//"))
|
|
708
|
+
throw new Error("Sphere websites must omit http(s):// and contain no //.");
|
|
709
|
+
const nameError = validateSphereName(config.name);
|
|
710
|
+
if (nameError)
|
|
711
|
+
throw new Error(nameError);
|
|
712
|
+
if (validateSphereOwner(settings.owner))
|
|
713
|
+
throw new Error(validateSphereOwner(settings.owner));
|
|
714
|
+
if (!password || validateOwnerPassword(password))
|
|
715
|
+
throw new Error("Invalid owner password.");
|
|
716
|
+
if (!["expansion", "felucca"].includes(settings.maps) || !["modern", "legacy"].includes(settings.mapSize))
|
|
717
|
+
throw new Error("Invalid Sphere map settings.");
|
|
718
|
+
if (settings.mapSize === "legacy" && atLeast(config.expansion, "ml"))
|
|
719
|
+
throw new Error("Mondain's Legacy and later require modern map files.");
|
|
720
|
+
let ini = text(upstream, "sphere.ini");
|
|
721
|
+
text(upstream, "sphereCrypt.ini");
|
|
722
|
+
const updates = {
|
|
723
|
+
ServName: config.name,
|
|
724
|
+
TimeZone: String(sphereTimezone(config.timezone)),
|
|
725
|
+
...config.adminEmail !== undefined ? { AdminEmail: config.adminEmail } : {},
|
|
726
|
+
...config.url !== undefined ? { URL: config.url } : {},
|
|
727
|
+
ScpFiles: "scripts/",
|
|
728
|
+
WorldSave: "save/",
|
|
729
|
+
AcctFiles: "accounts/",
|
|
730
|
+
MulFiles: "data/client/",
|
|
731
|
+
Log: "logs/",
|
|
732
|
+
AutoAccountCreation: settings.autoCreateAccounts ? "1" : "0",
|
|
733
|
+
Md5Passwords: "1"
|
|
734
|
+
};
|
|
735
|
+
if (settings.clientEncryption !== undefined) {
|
|
736
|
+
if (!["encrypted", "both", "unencrypted"].includes(settings.clientEncryption))
|
|
737
|
+
throw new Error("Invalid Sphere client encryption mode.");
|
|
738
|
+
updates.UseCrypt = settings.clientEncryption === "unencrypted" ? "0" : "1";
|
|
739
|
+
updates.UseNoCrypt = settings.clientEncryption === "encrypted" ? "0" : "1";
|
|
740
|
+
}
|
|
741
|
+
const optionFlags = sphereConfigValue(ini, "OptionFlags");
|
|
742
|
+
const extraFlags = sphereConfigValue(ini, "FeatureExtra");
|
|
743
|
+
if (optionFlags === undefined || extraFlags === undefined)
|
|
744
|
+
throw new Error("Missing upstream Sphere option or extra feature flags.");
|
|
745
|
+
Object.assign(updates, expansionFlags(config.expansion, sphereNumber(optionFlags), sphereNumber(extraFlags)));
|
|
746
|
+
if (settings.rules) {
|
|
747
|
+
const rules = settings.rules;
|
|
748
|
+
validateRules(rules);
|
|
749
|
+
Object.assign(updates, {
|
|
750
|
+
CombatFlags: flagValue(rules.combatFlags),
|
|
751
|
+
CombatArcheryMovementDelay: String(rules.archeryMovementDelay),
|
|
752
|
+
CombatDamageEra: String(rules.damageEra),
|
|
753
|
+
CombatHitChanceEra: String(rules.hitChanceEra),
|
|
754
|
+
CombatSpeedEra: String(rules.speedEra),
|
|
755
|
+
SpeedScaleFactor: String(rules.speedScaleFactor),
|
|
756
|
+
MagicFlags: flagValue(rules.magicFlags),
|
|
757
|
+
RacialFlags: flagValue(rules.racialFlags),
|
|
758
|
+
RevealFlags: flagValue(rules.revealFlags),
|
|
759
|
+
NPCAI: flagValue(rules.npcAI)
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
for (const [key, expansion, value] of [
|
|
763
|
+
["FeatureT2A", "t2a", "01|02"],
|
|
764
|
+
["FeatureLBR", "lbr", "01|02"],
|
|
765
|
+
["FeatureAOS", "aos", "01|02|04|08"],
|
|
766
|
+
["FeatureSE", "se", "01|02"],
|
|
767
|
+
["FeatureML", "ml", "01"],
|
|
768
|
+
["FeatureSA", "sa", "01"],
|
|
769
|
+
["FeatureTOL", "tol", "01"]
|
|
770
|
+
])
|
|
771
|
+
updates[key] = atLeast(config.expansion, expansion) ? value : "0";
|
|
772
|
+
const count = settings.maps === "felucca" ? 1 : sphereMapCount(config.expansion);
|
|
773
|
+
for (let map = 0;map < 6; map++) {
|
|
774
|
+
const current = sphereConfigValue(ini, `Map${map}`);
|
|
775
|
+
if (!current || !/^\d+,\d+,-?\d+,\d+,\d+$/.test(current))
|
|
776
|
+
throw new Error(`Unsupported upstream Sphere Map${map}.`);
|
|
777
|
+
updates[`Map${map}`] = map === 5 ? current : map >= count ? "" : map < 2 ? `${settings.mapSize === "modern" ? 7168 : 6144},4096,-1,${map},${map}` : current;
|
|
778
|
+
}
|
|
779
|
+
for (const [key, value] of Object.entries(updates))
|
|
780
|
+
ini = setValue(ini, key, value);
|
|
781
|
+
const newline = ini.includes(`\r
|
|
782
|
+
`) ? `\r
|
|
783
|
+
` : `
|
|
784
|
+
`;
|
|
785
|
+
const lines = ini.split(/\r?\n/);
|
|
786
|
+
const servers = lines.flatMap((line, index) => line.trim().toUpperCase() === "[SERVERS]" ? [index] : []);
|
|
787
|
+
if (servers.length !== 1)
|
|
788
|
+
throw new Error("Missing or duplicate upstream Sphere [SERVERS] section.");
|
|
789
|
+
const index = servers[0] + 1;
|
|
790
|
+
if (!lines[index] || lines[index + 1] !== "127.0.0.1" || lines[index + 2] !== "2593")
|
|
791
|
+
throw new Error("Unsupported upstream Sphere local server entry.");
|
|
792
|
+
lines[index] = config.name;
|
|
793
|
+
ini = lines.join(newline);
|
|
794
|
+
let tables = text(upstream, "scripts/spheretables.scp");
|
|
795
|
+
const found = new Set;
|
|
796
|
+
tables = tables.split(/\r?\n/).map((line) => {
|
|
797
|
+
const match = /^(\/\/)?(scripts\/maps\/sphere_map(?:_points)?([0-5])(?:_([a-z]+))?\.scp)$/.exec(line);
|
|
798
|
+
if (!match)
|
|
799
|
+
return line;
|
|
800
|
+
const map = Number(match[3]);
|
|
801
|
+
found.add(map);
|
|
802
|
+
if (upstream[match[2]] === undefined)
|
|
803
|
+
throw new Error(`Missing upstream map script: ${match[2]}`);
|
|
804
|
+
const extension = match[4];
|
|
805
|
+
if (extension && !["se", "ml", "sa", "hs", "tol"].includes(extension))
|
|
806
|
+
throw new Error(`Unsupported upstream Sphere map extension: ${extension}`);
|
|
807
|
+
const enabled = map < count && (!extension || atLeast(config.expansion, extension)) && (settings.mapSize === "modern" || !extension || extension === "se");
|
|
808
|
+
return `${enabled ? "" : "//"}${match[2]}`;
|
|
809
|
+
}).join(tables.includes(`\r
|
|
810
|
+
`) ? `\r
|
|
811
|
+
` : `
|
|
812
|
+
`);
|
|
813
|
+
if (found.size !== 6)
|
|
814
|
+
throw new Error("Upstream Sphere map script layout changed. No files were written.");
|
|
815
|
+
const scriptUpdates = {};
|
|
816
|
+
if (settings.rules?.speedEra === 4) {
|
|
817
|
+
let overrides = 0;
|
|
818
|
+
for (const filename of Object.keys(upstream).filter((name) => name.startsWith("scripts/") && name.endsWith(".scp"))) {
|
|
819
|
+
const original = text(upstream, filename);
|
|
820
|
+
const updated = original.replace(/^([ \t]*)\/\/(TAG\.OVERRIDE\.SPEED=\d+[^\r\n]*)/gm, (_line, indentation, value) => {
|
|
821
|
+
overrides++;
|
|
822
|
+
return indentation + value;
|
|
823
|
+
});
|
|
824
|
+
if (updated !== original)
|
|
825
|
+
scriptUpdates[filename] = Buffer.from(updated, "latin1");
|
|
826
|
+
}
|
|
827
|
+
if (!overrides)
|
|
828
|
+
throw new Error("ML swing speed requires upstream commented weapon speed overrides. No files were written.");
|
|
829
|
+
}
|
|
830
|
+
const digest = createHash3("md5").update(password, "ascii").digest("hex");
|
|
831
|
+
return {
|
|
832
|
+
...upstream,
|
|
833
|
+
...scriptUpdates,
|
|
834
|
+
"sphere.ini": Buffer.from(ini, "latin1"),
|
|
835
|
+
"scripts/spheretables.scp": Buffer.from(tables, "latin1"),
|
|
836
|
+
"accounts/sphereaccu.scp": `[${settings.owner}]
|
|
837
|
+
PLEVEL=Owner
|
|
838
|
+
MD5PASSWORD=${digest}
|
|
839
|
+
|
|
840
|
+
[EOF]
|
|
841
|
+
`,
|
|
842
|
+
"accounts/sphereacct.scp": `[EOF]
|
|
843
|
+
`,
|
|
844
|
+
"save/.gitkeep": "",
|
|
845
|
+
".gitignore": `accounts/
|
|
846
|
+
save/
|
|
847
|
+
logs/
|
|
848
|
+
data/client/
|
|
849
|
+
.gemwright/
|
|
850
|
+
`
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
function sphereTimezone(zone, year = new Date().getUTCFullYear()) {
|
|
854
|
+
const formatter = new Intl.DateTimeFormat("en", { timeZone: zone, timeZoneName: "shortOffset" });
|
|
855
|
+
const offsets = Array.from({ length: 12 }, (_, month) => {
|
|
856
|
+
const label = formatter.formatToParts(new Date(Date.UTC(year, month, 15))).find((part) => part.type === "timeZoneName").value;
|
|
857
|
+
if (label === "GMT")
|
|
858
|
+
return 0;
|
|
859
|
+
const match = /^GMT([+-])(\d{1,2})(?::(\d{2}))?$/.exec(label);
|
|
860
|
+
if (!match)
|
|
861
|
+
throw new Error(`Cannot determine Sphere timezone for ${zone}.`);
|
|
862
|
+
return (match[1] === "+" ? 1 : -1) * (Number(match[2]) + Number(match[3] ?? 0) / 60);
|
|
863
|
+
});
|
|
864
|
+
const offset = -Math.min(...offsets);
|
|
865
|
+
if (!Number.isInteger(offset))
|
|
866
|
+
throw new Error(`Sphere cannot represent the fractional-hour timezone ${zone}. Choose a whole-hour zone.`);
|
|
867
|
+
return offset || 0;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// src/emulators/sphere/staging.ts
|
|
871
|
+
async function validateSphereShard(directory, config) {
|
|
872
|
+
const scripts = config.sphere?.scriptsSource;
|
|
873
|
+
if (config.source?.repository !== sphereSources.server.repository || config.source.ref !== sphereSources.server.ref || !scripts || scripts.repository !== sphereSources.scripts.repository || scripts.ref !== sphereSources.scripts.ref)
|
|
874
|
+
throw new Error("Missing or unsupported Sphere source pair. Initialize a shard to record both revisions.");
|
|
875
|
+
const ini = await readFile4(join5(directory, "sphere.ini"), "latin1");
|
|
876
|
+
for (const [key, value] of Object.entries({
|
|
877
|
+
ServPort: "2593",
|
|
878
|
+
ScpFiles: "scripts/",
|
|
879
|
+
WorldSave: "save/",
|
|
880
|
+
AcctFiles: "accounts/",
|
|
881
|
+
MulFiles: "data/client/",
|
|
882
|
+
Log: "logs/"
|
|
883
|
+
})) {
|
|
884
|
+
if (sphereConfigValue(ini, key) !== value)
|
|
885
|
+
throw new Error(`Sphere deployment currently requires ${key}=${value}. Config was not changed.`);
|
|
886
|
+
}
|
|
887
|
+
for (const folder of ["scripts", "accounts", "save"]) {
|
|
888
|
+
if (!(await stat2(join5(directory, folder))).isDirectory())
|
|
889
|
+
throw new Error(`Missing Sphere ${folder} directory.`);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
async function stageSphere(directory, config) {
|
|
893
|
+
await validateSphereShard(directory, config);
|
|
894
|
+
const context = join5(directory, ".gemwright/deploy");
|
|
895
|
+
await rm4(context, { recursive: true, force: true });
|
|
896
|
+
const server = join5(context, "server");
|
|
897
|
+
await mkdir2(server, { recursive: true });
|
|
898
|
+
await command(["git", "init", "--quiet"], server);
|
|
899
|
+
await command(["git", "fetch", "--quiet", "--depth", "1", config.source.repository, config.source.revision], server);
|
|
900
|
+
await command(["git", "checkout", "--quiet", "--detach", "FETCH_HEAD"], server);
|
|
901
|
+
await applySphereCompatibility(server, config.source.revision);
|
|
902
|
+
await rm4(join5(server, ".git"), { recursive: true, force: true });
|
|
903
|
+
await mkdir2(join5(context, "shard"));
|
|
904
|
+
for (const name of ["sphere.ini", "sphereCrypt.ini", "scripts"])
|
|
905
|
+
await cp2(join5(directory, name), join5(context, "shard", name), { recursive: true });
|
|
906
|
+
for (const name of ["accounts", "save"])
|
|
907
|
+
await cp2(join5(directory, name), join5(context, "seed", name), { recursive: true });
|
|
908
|
+
for (const [source, target] of [
|
|
909
|
+
["Dockerfile", "Dockerfile"],
|
|
910
|
+
["railway-start.sh", "start.sh"],
|
|
911
|
+
["start.sh", "run-server.sh"]
|
|
912
|
+
])
|
|
913
|
+
await cp2(assetPath(`templates/sphere/${source}`), join5(context, target));
|
|
914
|
+
return context;
|
|
915
|
+
}
|
|
916
|
+
var clientPattern2 = /^(?:map[0-5](?:LegacyMUL\.uop|\.mul)|statics[0-5]\.mul|staidx[0-5]\.mul|(?:mapdif|mapdifl|stadif|stadifl|stadifi)[0-5]\.mul|multi\.(?:idx|mul)|hues\.mul|tiledata\.mul|verdata\.mul)$/i;
|
|
917
|
+
async function packageClientFiles3(directory, sourcePath) {
|
|
918
|
+
const ini = await readFile4(join5(directory, "sphere.ini"), "latin1");
|
|
919
|
+
return packageClientFiles(directory, sourcePath, clientPattern2, (names) => {
|
|
920
|
+
const required = ["tiledata.mul", "hues.mul", "multi.idx", "multi.mul"];
|
|
921
|
+
for (let map = 0;map < 6; map++) {
|
|
922
|
+
if (!sphereConfigValue(ini, `Map${map}`))
|
|
923
|
+
continue;
|
|
924
|
+
required.push(`statics${map}.mul`, `staidx${map}.mul`);
|
|
925
|
+
if (!names.includes(`map${map}.mul`) && !names.includes(`map${map}legacymul.uop`))
|
|
926
|
+
throw new Error(`Sphere client files need map${map}.mul or map${map}LegacyMUL.uop for enabled Map${map}.`);
|
|
927
|
+
}
|
|
928
|
+
const missing = required.filter((name) => !names.includes(name));
|
|
929
|
+
if (missing.length)
|
|
930
|
+
throw new Error(`Sphere client files are missing: ${missing.join(", ")}. MultiCollection.uop is not a replacement for multi.idx/multi.mul in this Sphere version.`);
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// src/emulators/deployment.ts
|
|
935
|
+
var deployments = {
|
|
936
|
+
servuo: { label: "ServUO", validate: validateServuoShard, stage: stageServuo, packageFiles: packageClientFiles2 },
|
|
937
|
+
sphere: { label: "Sphere", validate: validateSphereShard, stage: stageSphere, packageFiles: packageClientFiles3 }
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
// src/shard.ts
|
|
941
|
+
import { readFile as readFile5 } from "node:fs/promises";
|
|
942
|
+
import { join as join6 } from "node:path";
|
|
943
|
+
async function loadShard(directory) {
|
|
944
|
+
let input;
|
|
945
|
+
try {
|
|
946
|
+
input = JSON.parse(await readFile5(join6(directory, configFilename), "utf8"));
|
|
947
|
+
} catch {
|
|
948
|
+
throw new Error(`Cannot read ${configFilename}. Run gemwright init in the shard directory first.`);
|
|
949
|
+
}
|
|
950
|
+
for (const key of ["name", "id", "timezone", "expansion", "emulator"]) {
|
|
951
|
+
if (typeof input[key] !== "string")
|
|
952
|
+
throw new Error(`Invalid ${configFilename}: missing ${key}.`);
|
|
953
|
+
}
|
|
954
|
+
const config = createConfig(input);
|
|
955
|
+
if (config.id !== input.id || !config.id)
|
|
956
|
+
throw new Error("The recorded shard ID must be a non-empty slug derived from its name.");
|
|
957
|
+
return config;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// src/deploy.ts
|
|
961
|
+
async function saveState(directory, state) {
|
|
962
|
+
const path = join7(directory, ".gemwright/railway.json");
|
|
963
|
+
await mkdir3(join7(directory, ".gemwright"), { recursive: true });
|
|
964
|
+
await writeFile3(`${path}.tmp`, JSON.stringify(state, null, 2) + `
|
|
965
|
+
`);
|
|
966
|
+
await rename(`${path}.tmp`, path);
|
|
967
|
+
}
|
|
968
|
+
async function ensureRailway(directory, slug, run, pause = sleep) {
|
|
969
|
+
let state;
|
|
970
|
+
try {
|
|
971
|
+
state = JSON.parse(await readFile6(join7(directory, ".gemwright/railway.json"), "utf8"));
|
|
972
|
+
} catch (error) {
|
|
973
|
+
if (error.code !== "ENOENT")
|
|
974
|
+
throw error;
|
|
975
|
+
state = { projectName: `${slug}-${randomUUID().slice(0, 8)}`, environment: "production" };
|
|
976
|
+
await saveState(directory, state);
|
|
977
|
+
}
|
|
978
|
+
if (!state.projectName || !state.environment)
|
|
979
|
+
throw new Error("Invalid .gemwright/railway.json state.");
|
|
980
|
+
if (!state.project) {
|
|
981
|
+
const projects = await run(["project", "list", "--json"]);
|
|
982
|
+
const matches = projects.filter((project) => project.name === state.projectName && !project.deletedAt);
|
|
983
|
+
if (matches.length > 1)
|
|
984
|
+
throw new Error("Multiple projects match the recorded provisioning name.");
|
|
985
|
+
if (matches.length)
|
|
986
|
+
state.project = matches[0].id;
|
|
987
|
+
else {
|
|
988
|
+
const account = await run(["whoami", "--json"]);
|
|
989
|
+
if (account.workspaces.length !== 1)
|
|
990
|
+
throw new Error("Deployment currently requires a single Railway workspace, or a project already recorded in .gemwright/railway.json.");
|
|
991
|
+
const project = await run([
|
|
992
|
+
"init",
|
|
993
|
+
"--name",
|
|
994
|
+
state.projectName,
|
|
995
|
+
"--workspace",
|
|
996
|
+
account.workspaces[0].id,
|
|
997
|
+
"--json"
|
|
998
|
+
]);
|
|
999
|
+
state.project = project.id;
|
|
1000
|
+
}
|
|
1001
|
+
await saveState(directory, state);
|
|
1002
|
+
}
|
|
1003
|
+
const projectFlags = ["--project", state.project, "--environment", state.environment];
|
|
1004
|
+
if (!state.service) {
|
|
1005
|
+
const services = await run(["service", "list", ...projectFlags, "--json"]);
|
|
1006
|
+
const match = services.find((service) => service.name === slug);
|
|
1007
|
+
if (match)
|
|
1008
|
+
state.service = match.id;
|
|
1009
|
+
else {
|
|
1010
|
+
await run(["link", ...projectFlags, "--json"]);
|
|
1011
|
+
const service = await run(["add", "--service", slug, "--json"]);
|
|
1012
|
+
state.service = service.id;
|
|
1013
|
+
}
|
|
1014
|
+
await saveState(directory, state);
|
|
1015
|
+
}
|
|
1016
|
+
const flags = [...projectFlags, "--service", state.service];
|
|
1017
|
+
const volumes = await run([
|
|
1018
|
+
"volume",
|
|
1019
|
+
...flags,
|
|
1020
|
+
"list",
|
|
1021
|
+
"--json"
|
|
1022
|
+
]);
|
|
1023
|
+
const attached = volumes.volumes.filter((volume) => volume.serviceName === slug);
|
|
1024
|
+
if (attached.length > 1)
|
|
1025
|
+
throw new Error("More than one volume is attached to the shard service.");
|
|
1026
|
+
let volume = attached[0];
|
|
1027
|
+
if (!volume) {
|
|
1028
|
+
const created = await run([
|
|
1029
|
+
"volume",
|
|
1030
|
+
...flags,
|
|
1031
|
+
"add",
|
|
1032
|
+
"--mount-path",
|
|
1033
|
+
"/app/data",
|
|
1034
|
+
"--json"
|
|
1035
|
+
]);
|
|
1036
|
+
volume = { ...created, mountPath: "/app/data", serviceName: slug };
|
|
1037
|
+
}
|
|
1038
|
+
if (state.volume && state.volume !== volume.id) {
|
|
1039
|
+
delete state.clientDigest;
|
|
1040
|
+
delete state.pendingClientDigest;
|
|
1041
|
+
}
|
|
1042
|
+
state.volume = volume.id;
|
|
1043
|
+
await saveState(directory, state);
|
|
1044
|
+
if (volume.name !== "data" || volume.mountPath !== "/app/data") {
|
|
1045
|
+
for (let attempt = 0;; attempt++) {
|
|
1046
|
+
try {
|
|
1047
|
+
await run([
|
|
1048
|
+
"volume",
|
|
1049
|
+
...flags,
|
|
1050
|
+
"update",
|
|
1051
|
+
"--volume",
|
|
1052
|
+
volume.id,
|
|
1053
|
+
"--name",
|
|
1054
|
+
"data",
|
|
1055
|
+
"--mount-path",
|
|
1056
|
+
"/app/data",
|
|
1057
|
+
"--json"
|
|
1058
|
+
]);
|
|
1059
|
+
break;
|
|
1060
|
+
} catch (error) {
|
|
1061
|
+
if (attempt === 9)
|
|
1062
|
+
throw error;
|
|
1063
|
+
await pause(3000);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
const proxies = await run(["tcp-proxy", "list", ...flags, "--json"]);
|
|
1068
|
+
if (proxies.proxies.length && !proxies.proxies.some((proxy) => proxy.applicationPort === 2593)) {
|
|
1069
|
+
throw new Error("The existing TCP proxy points to another application port. It was not replaced.");
|
|
1070
|
+
}
|
|
1071
|
+
if (!proxies.proxies.length)
|
|
1072
|
+
await run(["tcp-proxy", "create", ...flags, "--port", "2593", "--json"]);
|
|
1073
|
+
await run([
|
|
1074
|
+
"variable",
|
|
1075
|
+
"set",
|
|
1076
|
+
...flags,
|
|
1077
|
+
"RAILWAY_DOCKERFILE_PATH=Dockerfile",
|
|
1078
|
+
"RAILWAY_DEPLOYMENT_DRAINING_SECONDS=45",
|
|
1079
|
+
"--skip-deploys",
|
|
1080
|
+
"--json"
|
|
1081
|
+
]);
|
|
1082
|
+
return state;
|
|
1083
|
+
}
|
|
1084
|
+
async function deploy(options, directory = process.cwd()) {
|
|
1085
|
+
directory = resolve2(directory);
|
|
1086
|
+
if (!await isAvailable("railway"))
|
|
1087
|
+
throw new Error("Railway CLI is missing. Install it and log in before running gemwright deploy.");
|
|
1088
|
+
if (!await isAvailable("git"))
|
|
1089
|
+
throw new Error("Git is required to stage the recorded emulator source.");
|
|
1090
|
+
const run = async (args) => JSON.parse(await command(["railway", ...args], directory));
|
|
1091
|
+
try {
|
|
1092
|
+
await run(["whoami", "--json"]);
|
|
1093
|
+
} catch {
|
|
1094
|
+
throw new Error("Railway is not logged in or cannot be reached. Run railway login and check your connection.");
|
|
1095
|
+
}
|
|
1096
|
+
const config = await loadShard(directory);
|
|
1097
|
+
const emulator = deployments[config.emulator];
|
|
1098
|
+
await emulator.validate(directory, config);
|
|
1099
|
+
const recorded = await readFile6(join7(directory, ".gemwright/railway.json"), "utf8").then((text) => JSON.parse(text), (error) => {
|
|
1100
|
+
if (error.code !== "ENOENT")
|
|
1101
|
+
throw error;
|
|
1102
|
+
return;
|
|
1103
|
+
});
|
|
1104
|
+
if (recorded?.emulator && recorded.emulator !== config.emulator)
|
|
1105
|
+
throw new Error("The recorded Railway service belongs to another emulator. It was not changed.");
|
|
1106
|
+
const clientPath = options.files ?? config[config.emulator]?.clientFiles;
|
|
1107
|
+
console.log(`Staging ${emulator.label} ${config.source.revision.slice(0, 12)}...`);
|
|
1108
|
+
const context = await emulator.stage(directory, config);
|
|
1109
|
+
const client = clientPath ? await emulator.packageFiles(directory, clientPath, config) : undefined;
|
|
1110
|
+
const state = await ensureRailway(directory, config.id, run);
|
|
1111
|
+
state.emulator = config.emulator;
|
|
1112
|
+
await saveState(directory, state);
|
|
1113
|
+
const flags = ["--project", state.project, "--environment", state.environment, "--service", state.service];
|
|
1114
|
+
await run(["variable", "set", ...flags, `TZ=${config.timezone}`, "--skip-deploys", "--json"]);
|
|
1115
|
+
console.log("Uploading build context to Railway...");
|
|
1116
|
+
const upload = await command(["railway", "up", context, "--path-as-root", "--no-gitignore", ...flags, "--detach", "--json"], directory);
|
|
1117
|
+
let deploymentId;
|
|
1118
|
+
for (const line of upload.split(`
|
|
1119
|
+
`)) {
|
|
1120
|
+
try {
|
|
1121
|
+
const value = JSON.parse(line);
|
|
1122
|
+
deploymentId = value.deploymentId ?? value.id ?? deploymentId;
|
|
1123
|
+
} catch {}
|
|
1124
|
+
}
|
|
1125
|
+
let active = false;
|
|
1126
|
+
for (let attempt = 0;attempt < 180; attempt++) {
|
|
1127
|
+
const deployments = await run([
|
|
1128
|
+
"deployment",
|
|
1129
|
+
"list",
|
|
1130
|
+
...flags,
|
|
1131
|
+
"--limit",
|
|
1132
|
+
"1",
|
|
1133
|
+
"--json"
|
|
1134
|
+
]);
|
|
1135
|
+
const latest = deployments[0];
|
|
1136
|
+
if (latest && (!deploymentId || latest.id === deploymentId)) {
|
|
1137
|
+
if (latest.status === "SUCCESS") {
|
|
1138
|
+
active = true;
|
|
1139
|
+
break;
|
|
1140
|
+
}
|
|
1141
|
+
if (["FAILED", "CRASHED", "REMOVED"].includes(latest.status))
|
|
1142
|
+
throw new Error(`Railway deployment ${latest.id} ${latest.status.toLowerCase()}. Inspect its build and deployment logs.`);
|
|
1143
|
+
}
|
|
1144
|
+
if (attempt % 6 === 0)
|
|
1145
|
+
console.log(`Waiting for Railway build: ${latest?.status ?? "pending"}...`);
|
|
1146
|
+
await sleep(5000);
|
|
1147
|
+
}
|
|
1148
|
+
if (!active)
|
|
1149
|
+
throw new Error("Railway build is still pending. Resources are recorded; rerun deploy to resume.");
|
|
1150
|
+
if (client && state.clientDigest !== client.digest) {
|
|
1151
|
+
const volumeFlags = ["volume", ...flags, "files", "--volume", state.volume];
|
|
1152
|
+
const root = await run([...volumeFlags, "list", "/", "--json"]);
|
|
1153
|
+
const names = new Set(root.files.map((file) => file.name));
|
|
1154
|
+
const ready = names.has(".client-ready");
|
|
1155
|
+
if (state.clientDigest || ready && state.pendingClientDigest !== client.digest) {
|
|
1156
|
+
throw new Error("Different client files are already installed. They were not replaced automatically.");
|
|
1157
|
+
}
|
|
1158
|
+
if (state.pendingClientDigest && state.pendingClientDigest !== client.digest)
|
|
1159
|
+
throw new Error("An import of different client files is pending. Resume with the same files.");
|
|
1160
|
+
state.pendingClientDigest = client.digest;
|
|
1161
|
+
await saveState(directory, state);
|
|
1162
|
+
if (!ready && !names.has("client.tar.gz")) {
|
|
1163
|
+
console.log(`Uploading ${Math.round(client.bytes / 1024 / 1024)} MiB of client data to the volume...`);
|
|
1164
|
+
await run([...volumeFlags, "upload", client.archive, "/client.tar.gz.uploading", "--overwrite", "--json"]);
|
|
1165
|
+
await run([...volumeFlags, "rename", "/client.tar.gz.uploading", "/client.tar.gz", "--json"]);
|
|
1166
|
+
}
|
|
1167
|
+
state.clientDigest = client.digest;
|
|
1168
|
+
delete state.pendingClientDigest;
|
|
1169
|
+
await saveState(directory, state);
|
|
1170
|
+
}
|
|
1171
|
+
const proxies = await run(["tcp-proxy", "list", ...flags, "--json"]);
|
|
1172
|
+
console.log(`Railway deployment active. Shard endpoint: ${proxies.proxies[0]?.endpoint ?? "pending"}`);
|
|
1173
|
+
if (!client && !state.clientDigest)
|
|
1174
|
+
console.log("Waiting for client files. Rerun gemwright deploy --files <directory> to import them.");
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// src/init.ts
|
|
1178
|
+
import { resolve as resolve5 } from "node:path";
|
|
1179
|
+
import * as prompts5 from "@clack/prompts";
|
|
1180
|
+
|
|
1181
|
+
// src/emulators/servuo/init.ts
|
|
1182
|
+
import { stat as stat3 } from "node:fs/promises";
|
|
1183
|
+
import { resolve as resolve3 } from "node:path";
|
|
1184
|
+
import * as prompts2 from "@clack/prompts";
|
|
1185
|
+
|
|
1186
|
+
// src/prompts.ts
|
|
1187
|
+
import * as prompts from "@clack/prompts";
|
|
1188
|
+
|
|
1189
|
+
class Cancelled extends Error {
|
|
1190
|
+
}
|
|
1191
|
+
function answer(value) {
|
|
1192
|
+
if (prompts.isCancel(value)) {
|
|
1193
|
+
prompts.cancel("Initialization cancelled.");
|
|
1194
|
+
throw new Cancelled;
|
|
1195
|
+
}
|
|
1196
|
+
return value;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// src/emulators/servuo/init.ts
|
|
1200
|
+
async function initializeServuo(config, yes = false, loadConfig = fetchServuoConfig) {
|
|
1201
|
+
let generatedPassword;
|
|
1202
|
+
const upstream = await loadConfig(config.source);
|
|
1203
|
+
const registration = configValue(upstream["Config/Accounts.cfg"] ?? "", "AutoCreateAccounts");
|
|
1204
|
+
if (!registration || !/^(true|false)$/i.test(registration))
|
|
1205
|
+
throw new Error("Upstream automatic registration default is missing or invalid.");
|
|
1206
|
+
const clientFiles = yes ? "" : answer(await prompts2.text({
|
|
1207
|
+
message: "UO client files directory (optional; leave blank to add at deploy)",
|
|
1208
|
+
validate: async (value) => {
|
|
1209
|
+
if (!value)
|
|
1210
|
+
return;
|
|
1211
|
+
try {
|
|
1212
|
+
if (!(await stat3(resolve3(value))).isDirectory())
|
|
1213
|
+
return "Choose a directory.";
|
|
1214
|
+
} catch {
|
|
1215
|
+
return "That directory does not exist.";
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}));
|
|
1219
|
+
const autoCreateAccounts = yes ? registration.toLowerCase() === "true" : answer(await prompts2.confirm({
|
|
1220
|
+
message: "Allow players to create accounts on first login?",
|
|
1221
|
+
initialValue: registration.toLowerCase() === "true"
|
|
1222
|
+
}));
|
|
1223
|
+
const owner = yes ? "admin" : answer(await prompts2.text({
|
|
1224
|
+
message: "Owner username",
|
|
1225
|
+
placeholder: "admin",
|
|
1226
|
+
defaultValue: "admin",
|
|
1227
|
+
validate: (value) => validateOwner(value || "admin")
|
|
1228
|
+
}));
|
|
1229
|
+
let password2 = yes ? "" : answer(await prompts2.password({
|
|
1230
|
+
message: "Owner password (leave blank to generate)",
|
|
1231
|
+
validate: (value) => validateOwnerPassword(value ?? "")
|
|
1232
|
+
}));
|
|
1233
|
+
if (!password2)
|
|
1234
|
+
password2 = generatedPassword = generatedOwnerPassword();
|
|
1235
|
+
const settings = {
|
|
1236
|
+
...clientFiles ? { clientFiles: resolve3(clientFiles) } : {},
|
|
1237
|
+
autoCreateAccounts,
|
|
1238
|
+
owner
|
|
1239
|
+
};
|
|
1240
|
+
return { settings, files: prepareServuoFiles(upstream, config, settings, password2), generatedPassword };
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// src/emulators/sphere/init.ts
|
|
1244
|
+
import { stat as stat4 } from "node:fs/promises";
|
|
1245
|
+
import { resolve as resolve4 } from "node:path";
|
|
1246
|
+
import * as prompts4 from "@clack/prompts";
|
|
1247
|
+
|
|
1248
|
+
// src/emulators/sphere/rules-prompts.ts
|
|
1249
|
+
import * as prompts3 from "@clack/prompts";
|
|
1250
|
+
function defaultRules(ini) {
|
|
1251
|
+
function number(key, fallback) {
|
|
1252
|
+
const value = sphereConfigValue(ini, key);
|
|
1253
|
+
if (value !== undefined)
|
|
1254
|
+
return sphereNumber(value);
|
|
1255
|
+
if (fallback !== undefined && new RegExp(`^//${key}=0(?:\\s|$)`, "m").test(ini))
|
|
1256
|
+
return fallback;
|
|
1257
|
+
throw new Error(`Missing upstream Sphere setting: ${key}.`);
|
|
1258
|
+
}
|
|
1259
|
+
return {
|
|
1260
|
+
combatFlags: number("CombatFlags", 0),
|
|
1261
|
+
archeryMovementDelay: number("CombatArcheryMovementDelay"),
|
|
1262
|
+
damageEra: number("CombatDamageEra"),
|
|
1263
|
+
hitChanceEra: number("CombatHitChanceEra"),
|
|
1264
|
+
speedEra: number("CombatSpeedEra"),
|
|
1265
|
+
speedScaleFactor: number("SpeedScaleFactor"),
|
|
1266
|
+
magicFlags: number("MagicFlags", 0),
|
|
1267
|
+
racialFlags: number("RacialFlags"),
|
|
1268
|
+
revealFlags: number("RevealFlags"),
|
|
1269
|
+
npcAI: number("NPCAI", 0)
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
async function askSphereRules(ini, yes) {
|
|
1273
|
+
const rules = defaultRules(ini);
|
|
1274
|
+
if (yes) {
|
|
1275
|
+
validateRules(rules);
|
|
1276
|
+
return rules;
|
|
1277
|
+
}
|
|
1278
|
+
async function flags(key, message) {
|
|
1279
|
+
const options = ruleFlags[key].map(([value, label]) => ({ value: Number(value), label }));
|
|
1280
|
+
const known = options.reduce((mask, option) => mask | option.value, 0);
|
|
1281
|
+
while (true) {
|
|
1282
|
+
const selected = answer(await prompts3.multiselect({
|
|
1283
|
+
message,
|
|
1284
|
+
options,
|
|
1285
|
+
required: false,
|
|
1286
|
+
maxItems: 8,
|
|
1287
|
+
initialValues: options.filter((option) => rules[key] & option.value).map((option) => option.value)
|
|
1288
|
+
}));
|
|
1289
|
+
rules[key] = rules[key] & ~known | selected.reduce((mask, value) => mask | value, 0);
|
|
1290
|
+
if (key !== "npcAI" || !(rules.npcAI & 8) || rules.npcAI & 1)
|
|
1291
|
+
break;
|
|
1292
|
+
prompts3.log.warn("Maximum intelligence requires advanced pathfinding. Select both or neither.");
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
async function integer(message, value, minimum = 0) {
|
|
1296
|
+
return Number(answer(await prompts3.text({
|
|
1297
|
+
message,
|
|
1298
|
+
placeholder: String(value),
|
|
1299
|
+
defaultValue: String(value),
|
|
1300
|
+
validate: (input) => {
|
|
1301
|
+
const text = input || String(value);
|
|
1302
|
+
return !/^\d+$/.test(text) || !Number.isSafeInteger(Number(text)) || Number(text) < minimum || Number(text) > 2147483647 ? `Enter a whole number from ${minimum} to 2147483647.` : undefined;
|
|
1303
|
+
}
|
|
1304
|
+
})));
|
|
1305
|
+
}
|
|
1306
|
+
await flags("combatFlags", "Combat behavior");
|
|
1307
|
+
if (!(rules.combatFlags & 256))
|
|
1308
|
+
rules.archeryMovementDelay = await integer("Archery delay after movement (tenths of a second; pre-AOS 10, AOS 5, SE 2)", rules.archeryMovementDelay);
|
|
1309
|
+
const formulas = [
|
|
1310
|
+
{ value: 0, label: "Sphere custom" },
|
|
1311
|
+
{ value: 1, label: "Pre-AOS" },
|
|
1312
|
+
{ value: 2, label: "AOS" }
|
|
1313
|
+
];
|
|
1314
|
+
rules.damageEra = answer(await prompts3.select({ message: "Physical damage formula", initialValue: rules.damageEra, options: formulas }));
|
|
1315
|
+
rules.hitChanceEra = answer(await prompts3.select({
|
|
1316
|
+
message: "Hit chance formula",
|
|
1317
|
+
initialValue: rules.hitChanceEra,
|
|
1318
|
+
options: formulas.map((option) => ({
|
|
1319
|
+
...option,
|
|
1320
|
+
hint: option.value < 2 ? "No Hit Chance Increase support" : "Supports Hit Chance Increase"
|
|
1321
|
+
}))
|
|
1322
|
+
}));
|
|
1323
|
+
await flags("magicFlags", "Magic behavior");
|
|
1324
|
+
await flags("racialFlags", "Racial traits");
|
|
1325
|
+
await flags("revealFlags", "Reveal invisible characters when…");
|
|
1326
|
+
await flags("npcAI", "NPC intelligence");
|
|
1327
|
+
validateRules(rules);
|
|
1328
|
+
return rules;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// src/emulators/sphere/init.ts
|
|
1332
|
+
async function initializeSphere(config, sources, yes = false, loadFiles = fetchSphereFiles) {
|
|
1333
|
+
let generatedPassword;
|
|
1334
|
+
const upstream = await loadFiles(sources);
|
|
1335
|
+
const ini = upstream["sphere.ini"];
|
|
1336
|
+
if (ini === undefined)
|
|
1337
|
+
throw new Error("Missing upstream sphere.ini.");
|
|
1338
|
+
const iniText = typeof ini === "string" ? ini : Buffer.from(ini).toString("latin1");
|
|
1339
|
+
const useCrypt = sphereConfigValue(iniText, "UseCrypt");
|
|
1340
|
+
const useNoCrypt = sphereConfigValue(iniText, "UseNoCrypt");
|
|
1341
|
+
if (!["0", "1"].includes(useCrypt ?? "") || !["0", "1"].includes(useNoCrypt ?? "") || useCrypt === "0" && useNoCrypt === "0")
|
|
1342
|
+
throw new Error("Upstream Sphere encryption defaults are missing or invalid.");
|
|
1343
|
+
const defaultEncryption = useCrypt === "0" ? "unencrypted" : useNoCrypt === "0" ? "encrypted" : "both";
|
|
1344
|
+
const registration = sphereConfigValue(typeof ini === "string" ? ini : Buffer.from(ini).toString("latin1"), "AutoAccountCreation");
|
|
1345
|
+
if (registration !== "0" && registration !== "1")
|
|
1346
|
+
throw new Error("Upstream Sphere registration default is missing or invalid.");
|
|
1347
|
+
if (!yes)
|
|
1348
|
+
prompts4.note("Expansion sets client features. Combat and other rules can be customized below; Enter keeps upstream rule defaults.", "Sphere");
|
|
1349
|
+
const clientFiles = yes ? "" : answer(await prompts4.text({
|
|
1350
|
+
message: "UO client files directory (optional; leave blank to add at deploy)",
|
|
1351
|
+
validate: async (value) => {
|
|
1352
|
+
if (!value)
|
|
1353
|
+
return;
|
|
1354
|
+
try {
|
|
1355
|
+
if (!(await stat4(resolve4(value))).isDirectory())
|
|
1356
|
+
return "Choose a directory.";
|
|
1357
|
+
} catch {
|
|
1358
|
+
return "That directory does not exist.";
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
}));
|
|
1362
|
+
const clientEncryption = yes ? defaultEncryption : answer(await prompts4.select({
|
|
1363
|
+
message: "Client encryption",
|
|
1364
|
+
initialValue: defaultEncryption,
|
|
1365
|
+
options: [
|
|
1366
|
+
{ value: "encrypted", label: "Encrypted clients only" },
|
|
1367
|
+
{
|
|
1368
|
+
value: "both",
|
|
1369
|
+
label: "Encrypted and unencrypted clients",
|
|
1370
|
+
hint: "Allows ClassicUO with encryption off"
|
|
1371
|
+
},
|
|
1372
|
+
{ value: "unencrypted", label: "Unencrypted clients only" }
|
|
1373
|
+
]
|
|
1374
|
+
}));
|
|
1375
|
+
const maps = yes || sphereMapCount(config.expansion) === 1 ? "expansion" : answer(await prompts4.select({
|
|
1376
|
+
message: "World maps",
|
|
1377
|
+
initialValue: "expansion",
|
|
1378
|
+
options: [
|
|
1379
|
+
{ value: "expansion", label: "All maps through the selected expansion" },
|
|
1380
|
+
{ value: "felucca", label: "Felucca only" }
|
|
1381
|
+
]
|
|
1382
|
+
}));
|
|
1383
|
+
const mapSize = yes || expansions.findIndex((entry) => entry.value === config.expansion) >= expansions.findIndex((entry) => entry.value === "ml") ? "modern" : answer(await prompts4.select({
|
|
1384
|
+
message: "Which UO map files will you use?",
|
|
1385
|
+
initialValue: "modern",
|
|
1386
|
+
options: [
|
|
1387
|
+
{
|
|
1388
|
+
value: "modern",
|
|
1389
|
+
label: "Mondain's Legacy or newer",
|
|
1390
|
+
hint: "7168 × 4096 Felucca/Trammel terrain"
|
|
1391
|
+
},
|
|
1392
|
+
{
|
|
1393
|
+
value: "legacy",
|
|
1394
|
+
label: "Before Mondain's Legacy",
|
|
1395
|
+
hint: "6144 × 4096 Felucca/Trammel terrain"
|
|
1396
|
+
}
|
|
1397
|
+
]
|
|
1398
|
+
}));
|
|
1399
|
+
const rules = await askSphereRules(iniText, yes);
|
|
1400
|
+
const autoCreateAccounts = yes ? registration === "1" : answer(await prompts4.confirm({
|
|
1401
|
+
message: "Allow players to create accounts on first login?",
|
|
1402
|
+
initialValue: registration === "1"
|
|
1403
|
+
}));
|
|
1404
|
+
const owner = yes ? "admin" : answer(await prompts4.text({
|
|
1405
|
+
message: "Owner username",
|
|
1406
|
+
placeholder: "admin",
|
|
1407
|
+
defaultValue: "admin",
|
|
1408
|
+
validate: (value) => validateSphereOwner(value || "admin")
|
|
1409
|
+
}));
|
|
1410
|
+
let password = yes ? "" : answer(await prompts4.password({
|
|
1411
|
+
message: "Owner password (leave blank to generate)",
|
|
1412
|
+
validate: (value) => validateOwnerPassword(value ?? "")
|
|
1413
|
+
}));
|
|
1414
|
+
if (!password)
|
|
1415
|
+
password = generatedPassword = generatedOwnerPassword();
|
|
1416
|
+
const settings = {
|
|
1417
|
+
scriptsSource: sources.scripts,
|
|
1418
|
+
...clientFiles ? { clientFiles: resolve4(clientFiles) } : {},
|
|
1419
|
+
autoCreateAccounts,
|
|
1420
|
+
clientEncryption,
|
|
1421
|
+
owner,
|
|
1422
|
+
maps,
|
|
1423
|
+
mapSize,
|
|
1424
|
+
rules
|
|
1425
|
+
};
|
|
1426
|
+
return { settings, files: prepareSphereFiles(upstream, config, settings, password), generatedPassword };
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// src/instance.ts
|
|
1430
|
+
import { appendFile, lstat, mkdir as mkdir4, readFile as readFile7, stat as stat5, writeFile as writeFile4 } from "node:fs/promises";
|
|
1431
|
+
import { dirname, isAbsolute, join as join8 } from "node:path";
|
|
1432
|
+
async function existingAncestor(directory) {
|
|
1433
|
+
try {
|
|
1434
|
+
if (!(await stat5(directory)).isDirectory())
|
|
1435
|
+
throw new Error(`Not a directory: ${directory}`);
|
|
1436
|
+
return directory;
|
|
1437
|
+
} catch (error) {
|
|
1438
|
+
if (error.code !== "ENOENT")
|
|
1439
|
+
throw error;
|
|
1440
|
+
const parent = dirname(directory);
|
|
1441
|
+
if (parent === directory)
|
|
1442
|
+
throw error;
|
|
1443
|
+
return existingAncestor(parent);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
async function gitStatus(directory) {
|
|
1447
|
+
try {
|
|
1448
|
+
const result = await runCommand(["git", "-C", await existingAncestor(directory), "rev-parse", "--show-toplevel"]);
|
|
1449
|
+
return { available: true, insideRepository: result.code === 0 };
|
|
1450
|
+
} catch (error) {
|
|
1451
|
+
if (error.code === "ENOENT")
|
|
1452
|
+
return { available: false, insideRepository: false };
|
|
1453
|
+
throw error;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
async function ensureNewConfig(directory) {
|
|
1457
|
+
try {
|
|
1458
|
+
await stat5(join8(directory, configFilename));
|
|
1459
|
+
} catch (error) {
|
|
1460
|
+
if (error.code === "ENOENT")
|
|
1461
|
+
return;
|
|
1462
|
+
throw error;
|
|
1463
|
+
}
|
|
1464
|
+
throw new Error(`${configFilename} already exists in ${directory}. Refusing to overwrite it.`);
|
|
1465
|
+
}
|
|
1466
|
+
async function saveInstance(directory, config, initializeGit, files = {}) {
|
|
1467
|
+
const output = { ...files, [configFilename]: `${JSON.stringify(config, null, 2)}
|
|
1468
|
+
` };
|
|
1469
|
+
if (output[".gitignore"] !== undefined && typeof output[".gitignore"] !== "string")
|
|
1470
|
+
throw new Error(".gitignore must contain text.");
|
|
1471
|
+
for (const filename of Object.keys(output)) {
|
|
1472
|
+
if (isAbsolute(filename) || filename.split(/[\\/]/).some((part) => part === ".." || part === "")) {
|
|
1473
|
+
throw new Error(`Invalid instance file path: ${filename}`);
|
|
1474
|
+
}
|
|
1475
|
+
const parts = filename.split("/");
|
|
1476
|
+
for (let depth = 1;depth < parts.length; depth++) {
|
|
1477
|
+
try {
|
|
1478
|
+
const parent = await lstat(join8(directory, ...parts.slice(0, depth)));
|
|
1479
|
+
if (!parent.isDirectory())
|
|
1480
|
+
throw new Error(`Refusing to write through a non-directory: ${parts.slice(0, depth).join("/")}`);
|
|
1481
|
+
} catch (error) {
|
|
1482
|
+
if (error.code !== "ENOENT")
|
|
1483
|
+
throw error;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
try {
|
|
1487
|
+
const info = await lstat(join8(directory, filename));
|
|
1488
|
+
if (filename === ".gitignore" && info.isFile())
|
|
1489
|
+
continue;
|
|
1490
|
+
} catch (error) {
|
|
1491
|
+
if (error.code === "ENOENT")
|
|
1492
|
+
continue;
|
|
1493
|
+
throw error;
|
|
1494
|
+
}
|
|
1495
|
+
throw new Error(`Refusing to overwrite ${join8(directory, filename)}.`);
|
|
1496
|
+
}
|
|
1497
|
+
await mkdir4(directory, { recursive: true });
|
|
1498
|
+
for (const [filename, content] of Object.entries(output)) {
|
|
1499
|
+
await mkdir4(dirname(join8(directory, filename)), { recursive: true });
|
|
1500
|
+
if (filename === ".gitignore") {
|
|
1501
|
+
if (typeof content !== "string")
|
|
1502
|
+
throw new Error(".gitignore must contain text.");
|
|
1503
|
+
let existing = "";
|
|
1504
|
+
try {
|
|
1505
|
+
existing = await readFile7(join8(directory, filename), "utf8");
|
|
1506
|
+
} catch (error) {
|
|
1507
|
+
if (error.code !== "ENOENT")
|
|
1508
|
+
throw error;
|
|
1509
|
+
}
|
|
1510
|
+
const present = new Set(existing.split(/\r?\n/));
|
|
1511
|
+
const missing = content.split(`
|
|
1512
|
+
`).filter((line) => line && !present.has(line));
|
|
1513
|
+
if (missing.length)
|
|
1514
|
+
await appendFile(join8(directory, filename), `${existing && !existing.endsWith(`
|
|
1515
|
+
`) ? `
|
|
1516
|
+
` : ""}${missing.join(`
|
|
1517
|
+
`)}
|
|
1518
|
+
`);
|
|
1519
|
+
} else {
|
|
1520
|
+
await writeFile4(join8(directory, filename), content, {
|
|
1521
|
+
flag: "wx",
|
|
1522
|
+
mode: ["Saves/Accounts/accounts.xml", "accounts/sphereaccu.scp"].includes(filename) ? 384 : 420
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
if (initializeGit) {
|
|
1527
|
+
try {
|
|
1528
|
+
await command(["git", "init", directory], directory);
|
|
1529
|
+
} catch (error) {
|
|
1530
|
+
throw new Error(`Saved ${configFilename}, but Git initialization failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
// src/init.ts
|
|
1536
|
+
async function initialize(options, resolveServuoSource = resolveLatestServuoSource, loadServuoConfig = fetchServuoConfig, resolveSphereSources = resolveLatestSphereSources, loadSphereFiles = fetchSphereFiles) {
|
|
1537
|
+
const directory = resolve5(options.directory ?? ".");
|
|
1538
|
+
await existingAncestor(directory);
|
|
1539
|
+
await ensureNewConfig(directory);
|
|
1540
|
+
createConfig({
|
|
1541
|
+
name: options.name,
|
|
1542
|
+
timezone: options.timezone,
|
|
1543
|
+
expansion: options.expansion,
|
|
1544
|
+
emulator: options.emulator
|
|
1545
|
+
});
|
|
1546
|
+
const git = await gitStatus(directory);
|
|
1547
|
+
if (options.git && !git.available)
|
|
1548
|
+
throw new Error("Git is not installed. Install Git or omit --git.");
|
|
1549
|
+
if (!options.yes && !process.stdin.isTTY) {
|
|
1550
|
+
throw new Error("The questionnaire requires an interactive terminal. Use --yes to accept defaults.");
|
|
1551
|
+
}
|
|
1552
|
+
prompts5.intro("Create an Ultima Online shard");
|
|
1553
|
+
const name = options.name ?? (options.yes ? defaultName : answer(await prompts5.text({
|
|
1554
|
+
message: "Shard name",
|
|
1555
|
+
placeholder: defaultName,
|
|
1556
|
+
defaultValue: defaultName,
|
|
1557
|
+
validate: (value) => validateName(value ?? "")
|
|
1558
|
+
})));
|
|
1559
|
+
const emulator = options.emulator ?? (options.yes ? defaultEmulator : answer(await prompts5.select({
|
|
1560
|
+
message: "Which server emulator?",
|
|
1561
|
+
initialValue: defaultEmulator,
|
|
1562
|
+
options: [...emulators]
|
|
1563
|
+
})));
|
|
1564
|
+
if (emulator === "sphere") {
|
|
1565
|
+
const error = validateSphereName(name);
|
|
1566
|
+
if (error)
|
|
1567
|
+
throw new Error(error);
|
|
1568
|
+
}
|
|
1569
|
+
const timezone = options.timezone ?? (options.yes ? defaultTimezone() : answer(await prompts5.autocomplete({
|
|
1570
|
+
message: "Timezone",
|
|
1571
|
+
placeholder: "Type to search IANA zones",
|
|
1572
|
+
initialValue: defaultTimezone(),
|
|
1573
|
+
options: timezoneOptions().map((value) => ({ value })),
|
|
1574
|
+
maxItems: 8
|
|
1575
|
+
})));
|
|
1576
|
+
const expansion = options.expansion ?? (options.yes ? defaultExpansion : answer(await prompts5.select({
|
|
1577
|
+
message: "Expansion",
|
|
1578
|
+
initialValue: defaultExpansion,
|
|
1579
|
+
options: expansions.map((option) => ({
|
|
1580
|
+
...option,
|
|
1581
|
+
...emulator === "sphere" && option.value === "ej" ? { hint: "Uses Time of Legends features in Sphere" } : {}
|
|
1582
|
+
}))
|
|
1583
|
+
})));
|
|
1584
|
+
const adminEmail = options.yes ? defaultAdminEmail : answer(await prompts5.text({
|
|
1585
|
+
message: "Admin email (optional)",
|
|
1586
|
+
placeholder: defaultAdminEmail,
|
|
1587
|
+
defaultValue: defaultAdminEmail,
|
|
1588
|
+
validate: (value) => validateAdminEmail(value || defaultAdminEmail)
|
|
1589
|
+
}));
|
|
1590
|
+
const url = options.yes ? defaultWebsite : answer(await prompts5.text({
|
|
1591
|
+
message: emulator === "sphere" ? "Shard website (optional; omit http(s)://)" : "Shard website (optional)",
|
|
1592
|
+
placeholder: defaultWebsite,
|
|
1593
|
+
defaultValue: defaultWebsite,
|
|
1594
|
+
validate: (value) => emulator === "sphere" && value?.includes("//") ? "Sphere requires a hostname/path without http(s):// or //." : validateWebsite(value || defaultWebsite)
|
|
1595
|
+
}));
|
|
1596
|
+
const sphereSources = emulator === "sphere" ? await resolveSphereSources() : undefined;
|
|
1597
|
+
const source = emulator === "servuo" ? await resolveServuoSource() : sphereSources?.server;
|
|
1598
|
+
let files = {};
|
|
1599
|
+
let generatedPassword;
|
|
1600
|
+
let config = createConfig({ name, emulator, timezone, expansion, source, adminEmail, url });
|
|
1601
|
+
if (emulator === "servuo") {
|
|
1602
|
+
const result = await initializeServuo(config, options.yes, loadServuoConfig);
|
|
1603
|
+
config = createConfig({ ...config, servuo: result.settings });
|
|
1604
|
+
files = result.files;
|
|
1605
|
+
generatedPassword = result.generatedPassword;
|
|
1606
|
+
} else if (sphereSources) {
|
|
1607
|
+
const result = await initializeSphere(config, sphereSources, options.yes, loadSphereFiles);
|
|
1608
|
+
config = createConfig({ ...config, sphere: result.settings });
|
|
1609
|
+
files = result.files;
|
|
1610
|
+
generatedPassword = result.generatedPassword;
|
|
1611
|
+
}
|
|
1612
|
+
const initializeGit = git.available && !git.insideRepository && (options.git ?? (options.yes ? true : answer(await prompts5.confirm({
|
|
1613
|
+
message: "Initialize a Git repository?",
|
|
1614
|
+
initialValue: true
|
|
1615
|
+
}))));
|
|
1616
|
+
await saveInstance(directory, config, initializeGit, files);
|
|
1617
|
+
if (generatedPassword)
|
|
1618
|
+
prompts5.note(`Username: ${config.servuo?.owner ?? config.sphere?.owner}
|
|
1619
|
+
Password: ${generatedPassword}
|
|
1620
|
+
Save this password; it will not be shown again.`, "Owner account");
|
|
1621
|
+
prompts5.outro(`Created ${configFilename} in ${directory}`);
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// src/index.ts
|
|
1625
|
+
var help = `Usage: gemwright init [directory] [options]
|
|
1626
|
+
gemwright deploy [--files <path>]
|
|
1627
|
+
|
|
1628
|
+
Options:
|
|
1629
|
+
--name <name> Shard name (default: Gemwright)
|
|
1630
|
+
--timezone <timezone> IANA timezone (default: local zone or Europe/London)
|
|
1631
|
+
--expansion <code> Expansion (default: ej)
|
|
1632
|
+
--emulator <name> Server emulator: servuo or sphere (default: servuo)
|
|
1633
|
+
--git Initialize Git if outside a repository
|
|
1634
|
+
--yes Accept defaults for unanswered questions
|
|
1635
|
+
--files <path> UO client files to import during deployment
|
|
1636
|
+
--help Show this help`;
|
|
1637
|
+
async function main(args = process.argv.slice(2)) {
|
|
1638
|
+
const { values, positionals } = parseArgs({
|
|
1639
|
+
args,
|
|
1640
|
+
allowPositionals: true,
|
|
1641
|
+
strict: true,
|
|
1642
|
+
options: {
|
|
1643
|
+
files: { type: "string" },
|
|
1644
|
+
name: { type: "string" },
|
|
1645
|
+
timezone: { type: "string" },
|
|
1646
|
+
expansion: { type: "string" },
|
|
1647
|
+
emulator: { type: "string" },
|
|
1648
|
+
git: { type: "boolean" },
|
|
1649
|
+
yes: { type: "boolean" },
|
|
1650
|
+
help: { type: "boolean" }
|
|
1651
|
+
}
|
|
1652
|
+
});
|
|
1653
|
+
if (values.help || positionals.length === 0) {
|
|
1654
|
+
console.log(help);
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
const [command, directory, ...extra] = positionals;
|
|
1658
|
+
if (command === "deploy") {
|
|
1659
|
+
if (directory || extra.length || Object.keys(values).some((key) => key !== "files"))
|
|
1660
|
+
throw new Error("Usage: gemwright deploy [--files <path>]");
|
|
1661
|
+
await deploy({ files: values.files });
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
if (values.files)
|
|
1665
|
+
throw new Error("--files is only supported by deploy.");
|
|
1666
|
+
if (command !== "init")
|
|
1667
|
+
throw new Error(`Unknown command: ${command}. Run gemwright --help.`);
|
|
1668
|
+
if (extra.length)
|
|
1669
|
+
throw new Error("Expected at most one directory. Run gemwright --help.");
|
|
1670
|
+
await initialize({ directory, ...values });
|
|
1671
|
+
}
|
|
1672
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath2(import.meta.url)) {
|
|
1673
|
+
try {
|
|
1674
|
+
await main();
|
|
1675
|
+
} catch (error) {
|
|
1676
|
+
if (error instanceof Cancelled)
|
|
1677
|
+
process.exitCode = 130;
|
|
1678
|
+
else {
|
|
1679
|
+
console.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
1680
|
+
process.exitCode = 1;
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
export {
|
|
1685
|
+
main
|
|
1686
|
+
};
|