@amxts/core 0.1.0 → 0.2.1
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/README.md +21 -25
- package/README.ru.md +22 -24
- package/as/amxts.d.ts +63 -3
- package/as/constants.ts +684 -116
- package/as/entities.ts +1905 -1364
- package/as/events.ts +1696 -907
- package/as/facade.ts +1240 -220
- package/as/fetch.ts +1519 -0
- package/as/flags.ts +159 -145
- package/as/hlds.ts +1071 -0
- package/as/hooks.ts +5226 -5379
- package/as/kit.ts +15 -5
- package/as/lib/check.ts +1 -1
- package/as/natives.ts +2658 -2499
- package/as/remote.ts +39 -15
- package/as/tsconfig.json +7 -2
- package/as/vector.ts +12 -9
- package/includes/order.txt +22 -29
- package/includes/sources.json +0 -8
- package/node_modules/assemblyscript/std/assembly/index.d.ts +20 -11
- package/package.json +29 -7
- package/runtime/deps/assemblyscript/dist/asc.js +86 -16
- package/runtime/deps/assemblyscript/dist/assemblyscript.js +29 -29
- package/runtime/licenses/MPL-2.0.txt +373 -0
- package/runtime/licenses/README.md +5 -1
- package/runtime/licenses/curl-COPYING +22 -0
- package/runtime/licenses/libssh2-COPYING +43 -0
- package/runtime/licenses/mbedTLS-LICENSE +553 -0
- package/runtime/natives.txt +108 -105
- package/runtime/patches/assemblyscript-0.28.20-amxts.patch +752 -296
- package/runtime/patches/wamr-2.4.5-amxts.patch +148 -0
- package/scripts/apply-docs.ts +2 -2
- package/scripts/auto-imports.ts +2 -2
- package/scripts/build-identity.ts +115 -0
- package/scripts/build-wasm.ts +101 -50
- package/scripts/check.ts +17 -3
- package/scripts/client-messages.ts +246 -0
- package/scripts/compile-cache.ts +4 -2
- package/scripts/compile-pool.ts +128 -0
- package/scripts/compile-worker.ts +23 -0
- package/scripts/compile.ts +156 -59
- package/scripts/docker-server.ts +13 -3
- package/scripts/docs/as/amxts.ts +86 -0
- package/scripts/docs/as/facade.ts +688 -188
- package/scripts/docs/as/fetch.ts +805 -0
- package/scripts/docs/entities.ts +43 -144
- package/scripts/docs/events.ts +17 -28
- package/scripts/docs/game.ts +68 -22
- package/scripts/docs/messages.ts +330 -16
- package/scripts/editor-api.ts +101 -0
- package/scripts/hlds-events.ts +370 -0
- package/scripts/includes.ts +12 -10
- package/scripts/player-fields.ts +16 -15
- package/scripts/prebuilt.ts +65 -23
- package/scripts/prepare.ts +42 -28
- package/scripts/project.ts +120 -20
- package/scripts/reapi-events.ts +98 -0
- package/scripts/shared-modules.ts +85 -23
- package/scripts/source-map.ts +242 -0
- package/scripts/system.ts +1 -1
- package/scripts/typed-commands.ts +263 -0
- package/scripts/typed-configs.ts +23 -12
- package/scripts/ui.ts +3 -1
- package/scripts/upgrade-menus.ts +274 -0
- package/scripts/upgrade-names.ts +384 -0
- package/scripts/upgrade.ts +857 -0
- package/src/cli-api.mjs +37 -4
- package/src/system.mjs +74 -3
- package/src/testing/compile.ts +8 -5
- package/src/testing/index.ts +2 -1
- package/src/testing/natives.ts +199 -5
- package/src/testing/network.ts +531 -0
- package/src/testing/server.ts +572 -48
- package/src/testing/setup.ts +1 -1
- package/src/testing/tables.ts +25 -0
- package/as/modules/http.ts +0 -184
- package/scripts/docs/as/modules/http.ts +0 -66
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
import type { Kind } from './upgrade-names';
|
|
2
|
+
// `amxts upgrade`: a project's code brought to the API of the core it has
|
|
3
|
+
// installed.
|
|
4
|
+
//
|
|
5
|
+
// npx amxts upgrade rewrites the files, and lists every change
|
|
6
|
+
// npx amxts upgrade --dry-run lists them and writes nothing
|
|
7
|
+
//
|
|
8
|
+
// An import of the core's API by `~/` - `~/natives`, `~/fs`, `~/facade` -
|
|
9
|
+
// becomes one by the package's name (`@amxts/core/natives`, `@amxts/core`),
|
|
10
|
+
// and an import of a module package by its place in the build's tree
|
|
11
|
+
// (`~/modules/menu-core`) one by its name: `~/` is the project's own files.
|
|
12
|
+
// The specifiers are found with the TypeScript parser - imports, exports,
|
|
13
|
+
// `import()` and `declare module` - so a string or a comment that looks like
|
|
14
|
+
// one is left alone, and only the text between the quotes changes. A
|
|
15
|
+
// command's handler takes one object: `(player) =>` becomes `({ player })
|
|
16
|
+
// =>`, and so does `(player, args) =>` that never reads `args`; one that
|
|
17
|
+
// reads the words after the name is listed, to be written by hand with the
|
|
18
|
+
// words in the usage. An import of `@amxts/core/http` goes:
|
|
19
|
+
// fetch is a global, and what reads its response the old way is listed.
|
|
20
|
+
// `Player.all(options)` is `server.players` and a `filter`; a field, a method
|
|
21
|
+
// or a game event named in the engine's words has the player's
|
|
22
|
+
// (scripts/upgrade-names.ts: `player.account` is `player.money`, the event
|
|
23
|
+
// `restartRound` is `newRound`), and one left out of the API is listed, to be
|
|
24
|
+
// read with the natives. A flag's name is lowerCamelCase where the code
|
|
25
|
+
// says it is one: `player.buttons.includes("Jump")` is `includes("jump")`.
|
|
26
|
+
// A game message is heard through its own method, by
|
|
27
|
+
// its name in the player's words: `server.addEventListener("message:DeathMsg",
|
|
28
|
+
// ...)` is `server.addMessageListener("death", ...)`. A menu-core menu's
|
|
29
|
+
// functions take one object, the menu's context, and an item is one object
|
|
30
|
+
// with its title (scripts/upgrade-menus.ts). What is rewritten no longer
|
|
31
|
+
// matches, so a second run changes nothing.
|
|
32
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
import ts from 'typescript';
|
|
36
|
+
import { MESSAGE_NAMES } from './client-messages';
|
|
37
|
+
import { CORE_ENTRIES, CORE_PLUGINS, loadProject } from './project';
|
|
38
|
+
import { apiFiles, serverFolder } from './system';
|
|
39
|
+
import { c, log } from './ui';
|
|
40
|
+
import { upgradeMenus } from './upgrade-menus';
|
|
41
|
+
import { COMMON, EVENTS, FLAG_NAMES, GAME_EVENT_FIELDS, HIDDEN, HIDDEN_EVENTS, JOIN_OPTIONS, RENAMED, SERVER_EVENT_CLASSES, SERVER_EVENT_FIELDS, SERVER_EVENTS, SERVER_EVENTS_BY_HAND, SERVER_FIELD_CLASSES, SERVER_GAME_EVENTS } from './upgrade-names';
|
|
42
|
+
|
|
43
|
+
/** The old spelling of a specifier and the new one. */
|
|
44
|
+
export type Renames = Map<string, string>;
|
|
45
|
+
|
|
46
|
+
/** One rewritten specifier: where, and from what to what. */
|
|
47
|
+
export interface Change {
|
|
48
|
+
file: string;
|
|
49
|
+
line: number;
|
|
50
|
+
from: string;
|
|
51
|
+
to: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The facade's own files, whose exports it gives as its own. */
|
|
55
|
+
const FACADE_PARTS = ['entities', 'events', 'flags', 'hooks', 'vector', 'effects', 'fetch'];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What `~/<place>` meant and is now written: the core's entries, the files
|
|
59
|
+
* the facade exports, the modules the project lists. A place the project's
|
|
60
|
+
* plugins folder has a file at is its own, and stays.
|
|
61
|
+
*/
|
|
62
|
+
export function renamesFor(modules: { name: string; short: string }[], own: (place: string) => boolean = () => false): Renames {
|
|
63
|
+
const renames: Renames = new Map();
|
|
64
|
+
const add = (place: string, to: string) => {
|
|
65
|
+
if (!own(`${place}.ts`)) renames.set(`~/${place}`, to);
|
|
66
|
+
};
|
|
67
|
+
for (const [name, file] of Object.entries(CORE_ENTRIES)) add(file.replace(/\.ts$/, ''), name);
|
|
68
|
+
for (const part of FACADE_PARTS) add(part, '@amxts/core');
|
|
69
|
+
for (const pkg of modules) add(`modules/${pkg.short}`, pkg.name);
|
|
70
|
+
return renames;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A specifier's new spelling, or null: a package's file by its path inside the package too. */
|
|
74
|
+
export function renamed(spec: string, renames: Renames): string | null {
|
|
75
|
+
const exact = renames.get(spec);
|
|
76
|
+
if (exact) return exact;
|
|
77
|
+
const pkg = spec.match(/^(~\/modules\/[^/]+)\/(.+)$/);
|
|
78
|
+
const to = pkg && renames.get(pkg[1]);
|
|
79
|
+
return to && to !== '@amxts/core' && !to.startsWith('@amxts/core/') ? `${to}/${pkg[2]}` : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The module specifiers of a file: imports, exports, `import()` and `declare module`. */
|
|
83
|
+
export function specifiers(source: ts.SourceFile): ts.StringLiteral[] {
|
|
84
|
+
const found: ts.StringLiteral[] = [];
|
|
85
|
+
const visit = (node: ts.Node) => {
|
|
86
|
+
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) found.push(node.moduleSpecifier);
|
|
87
|
+
else if (ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) found.push(node.name);
|
|
88
|
+
else if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword && node.arguments[0] && ts.isStringLiteral(node.arguments[0])) found.push(node.arguments[0]);
|
|
89
|
+
else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) && ts.isStringLiteral(node.argument.literal)) found.push(node.argument.literal);
|
|
90
|
+
ts.forEachChild(node, visit);
|
|
91
|
+
};
|
|
92
|
+
visit(source);
|
|
93
|
+
return found;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A file's text with its specifiers renamed, and what changed. */
|
|
97
|
+
export function upgradeText(file: string, text: string, renames: Renames): { text: string; changes: Change[] } {
|
|
98
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
99
|
+
const changes = specifiers(source)
|
|
100
|
+
.map((literal) => {
|
|
101
|
+
const to = renamed(literal.text, renames);
|
|
102
|
+
return to ? { literal, to } : null;
|
|
103
|
+
})
|
|
104
|
+
.filter(each => each !== null);
|
|
105
|
+
let out = text;
|
|
106
|
+
// From the end, so every earlier position stays where it was.
|
|
107
|
+
for (const { literal, to } of [...changes].reverse()) out = `${out.slice(0, literal.getStart(source) + 1)}${to}${out.slice(literal.getEnd() - 1)}`;
|
|
108
|
+
return {
|
|
109
|
+
text: out,
|
|
110
|
+
changes: changes.map(({ literal, to }) => ({ file, line: source.getLineAndCharacterOfPosition(literal.getStart(source)).line + 1, from: literal.text, to })),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A command handler upgrade cannot rewrite itself: where, and what to do. */
|
|
115
|
+
export interface Left {
|
|
116
|
+
file: string;
|
|
117
|
+
line: number;
|
|
118
|
+
why: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Where fetch was imported from before it was a global. */
|
|
122
|
+
const HTTP = new Set(['@amxts/core/http', '~/modules/http']);
|
|
123
|
+
|
|
124
|
+
const HTTP_BY_HAND = 'fetch is a global now, as in the browser: the body is `await response.text()` (or `response.json<T>()`), headers an object `{ name: value }`, and useFetch<T>(url) reads JSON in one call';
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A file's imports of fetch from `@amxts/core/http` taken out, each with its
|
|
128
|
+
* line: fetch, Response and RequestInit are globals. What reads a response's
|
|
129
|
+
* `text` as a field is the author's to change, so every file that had the
|
|
130
|
+
* import is listed.
|
|
131
|
+
*/
|
|
132
|
+
export function dropHttpImports(file: string, text: string): { text: string; changes: Change[]; left: Left[] } {
|
|
133
|
+
if (!text.includes('http')) return { text, changes: [], left: [] };
|
|
134
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
135
|
+
const found = source.statements.filter((statement): statement is ts.ImportDeclaration =>
|
|
136
|
+
ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier) && HTTP.has(statement.moduleSpecifier.text));
|
|
137
|
+
let out = text;
|
|
138
|
+
for (const statement of [...found].reverse()) {
|
|
139
|
+
const end = statement.getEnd();
|
|
140
|
+
const next = out.startsWith('\r\n', end) ? end + 2 : out.startsWith('\n', end) ? end + 1 : end;
|
|
141
|
+
out = out.slice(0, statement.getStart(source)) + out.slice(next);
|
|
142
|
+
}
|
|
143
|
+
const lineOf = (statement: ts.Node) => source.getLineAndCharacterOfPosition(statement.getStart(source)).line + 1;
|
|
144
|
+
return {
|
|
145
|
+
text: out,
|
|
146
|
+
changes: found.map(statement => ({ file, line: lineOf(statement), from: (statement.moduleSpecifier as ts.StringLiteral).text, to: 'fetch, a global' })),
|
|
147
|
+
left: found.slice(0, 1).map(statement => ({ file, line: lineOf(statement), why: HTTP_BY_HAND })),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const BY_HAND = 'it reads the words after the name: write them in the usage, "/give <amount>", and take them by name, ({ player, amount })';
|
|
152
|
+
|
|
153
|
+
/** Whether a function reads a parameter after its first: `(player, args) =>` that never reads `args` takes the player alone. */
|
|
154
|
+
function readsWords(fn: ts.ArrowFunction | ts.FunctionExpression): boolean {
|
|
155
|
+
const names = new Set(fn.parameters.slice(1).map(param => ts.isIdentifier(param.name) ? param.name.text : ''));
|
|
156
|
+
if (names.has('')) return true;
|
|
157
|
+
const reads = (node: ts.Node): boolean =>
|
|
158
|
+
(ts.isIdentifier(node) && names.has(node.text) && !(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node)) || ts.forEachChild(node, reads) === true;
|
|
159
|
+
return reads(fn.body);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The parameter list's text for a handler that took the player as `name`: `{ player }`, or `{ player: name }`. */
|
|
163
|
+
function playerBinding(name: string): string {
|
|
164
|
+
return name === 'player' ? '{ player }' : `{ player: ${name} }`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A file's command handlers brought to one argument: `(player) =>` becomes
|
|
169
|
+
* `({ player }) =>`, a function passed by its name and taking the player is
|
|
170
|
+
* called from `({ player }) => name(player)`. A handler that reads the words
|
|
171
|
+
* after the name - a second parameter it uses, or a server command's one - is
|
|
172
|
+
* left, with what to write; one that takes nothing, or already one object, is
|
|
173
|
+
* right.
|
|
174
|
+
*/
|
|
175
|
+
export function upgradeHandlers(file: string, text: string): { text: string; changes: Change[]; left: Left[] } {
|
|
176
|
+
if (!text.includes('addCommand') && !text.includes('addServerCommand')) return { text, changes: [], left: [] };
|
|
177
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
178
|
+
const edits: Edit[] = [];
|
|
179
|
+
const left: Left[] = [];
|
|
180
|
+
const lineOf = (node: ts.Node) => source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
|
|
181
|
+
const declared = new Map(source.statements.filter(ts.isFunctionDeclaration).filter(fn => fn.name).map(fn => [fn.name!.text, fn]));
|
|
182
|
+
|
|
183
|
+
const visit = (node: ts.Node) => {
|
|
184
|
+
ts.forEachChild(node, visit);
|
|
185
|
+
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return;
|
|
186
|
+
const method = node.expression.name.text;
|
|
187
|
+
if ((method !== 'addCommand' && method !== 'addServerCommand') || !ts.isIdentifier(node.expression.expression) || node.expression.expression.text !== 'server') return;
|
|
188
|
+
const handler = node.arguments[1];
|
|
189
|
+
if (!handler) return;
|
|
190
|
+
const player = method === 'addCommand';
|
|
191
|
+
|
|
192
|
+
if (ts.isArrowFunction(handler) || ts.isFunctionExpression(handler)) {
|
|
193
|
+
const params = handler.parameters;
|
|
194
|
+
if (params.length === 0 || !ts.isIdentifier(params[0].name)) return;
|
|
195
|
+
if (!player || readsWords(handler)) {
|
|
196
|
+
left.push({ file, line: lineOf(handler), why: BY_HAND });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const param = params[0];
|
|
200
|
+
const end = params[params.length - 1].getEnd();
|
|
201
|
+
const parenthesized = text[param.getStart(source) - 1] === '(' || text.slice(handler.getStart(source), param.getStart(source)).includes('(');
|
|
202
|
+
const binding = playerBinding((param.name as ts.Identifier).text);
|
|
203
|
+
edits.push({ start: param.getStart(source), end, with: parenthesized ? binding : `(${binding})`, from: text.slice(param.getStart(source), end) });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (ts.isIdentifier(handler) || ts.isPropertyAccessExpression(handler)) {
|
|
208
|
+
const fn = ts.isIdentifier(handler) ? declared.get(handler.text) : undefined;
|
|
209
|
+
if (!fn) {
|
|
210
|
+
left.push({ file, line: lineOf(handler), why: `${handler.getText(source)} is declared elsewhere: if it takes the player, pass ({ player }) => ${handler.getText(source)}(player)` });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (fn.parameters.length === 0) return;
|
|
214
|
+
if (!player || fn.parameters.length > 1 || !ts.isIdentifier(fn.parameters[0].name)) {
|
|
215
|
+
left.push({ file, line: lineOf(handler), why: BY_HAND });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
edits.push({ start: handler.getStart(source), end: handler.getEnd(), with: `({ player }) => ${handler.getText(source)}(player)`, from: handler.getText(source) });
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
visit(source);
|
|
222
|
+
return { ...applyEdits(file, text, source, edits), left };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A piece of a file's text to replace: where, with what, and what it was. */
|
|
226
|
+
export interface Edit {
|
|
227
|
+
start: number;
|
|
228
|
+
end: number;
|
|
229
|
+
with: string;
|
|
230
|
+
from: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The text with its edits made - from the end, so every earlier position stays where it was - and each as a change. */
|
|
234
|
+
export function applyEdits(file: string, text: string, source: ts.SourceFile, edits: Edit[]): { text: string; changes: Change[] } {
|
|
235
|
+
let out = text;
|
|
236
|
+
for (const edit of [...edits].sort((a, b) => b.start - a.start)) out = out.slice(0, edit.start) + edit.with + out.slice(edit.end);
|
|
237
|
+
return {
|
|
238
|
+
text: out,
|
|
239
|
+
changes: edits.map(edit => ({ file, line: source.getLineAndCharacterOfPosition(edit.start).line + 1, from: edit.from, to: edit.with })),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const eventClass = (name: string) => `${name[0].toUpperCase()}${name.slice(1)}Event`;
|
|
244
|
+
const EVENT_CLASSES = new Map(Object.entries(EVENTS).map(([from, to]) => [eventClass(from), eventClass(to)]));
|
|
245
|
+
const HIDDEN_CLASSES = new Map(Object.entries(HIDDEN_EVENTS).map(([name, native]) => [eventClass(name), native]));
|
|
246
|
+
|
|
247
|
+
/** `Player.all`'s options, each as the test of a player it was, for the value `true`; `false` narrowed nothing. */
|
|
248
|
+
const PLAYER_TESTS: Record<string, (player: string) => string> = {
|
|
249
|
+
alive: player => `${player}.isAlive`,
|
|
250
|
+
dead: player => `!${player}.isAlive`,
|
|
251
|
+
bots: player => `${player}.isBot`,
|
|
252
|
+
humans: player => `!${player}.isBot`,
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The name of the player a `filter` written for `Player.all` takes: `player`,
|
|
257
|
+
* else - where the code has a `player` already, a command's handler - `other`,
|
|
258
|
+
* `p`, `p2`..., the first that hides nothing.
|
|
259
|
+
*/
|
|
260
|
+
function playerName(taken: Set<string>): string {
|
|
261
|
+
const name = ['player', 'other', 'p'].find(each => !taken.has(each));
|
|
262
|
+
if (name) return name;
|
|
263
|
+
let number = 2;
|
|
264
|
+
while (taken.has(`p${number}`)) number++;
|
|
265
|
+
return `p${number}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The array methods whose callback takes the array's element first. */
|
|
269
|
+
const ELEMENT_CALLBACKS = new Set(['filter', 'find', 'findLast', 'forEach', 'map', 'some', 'every', 'flatMap']);
|
|
270
|
+
/** The array methods that give an element, and the ones that give an array of the same elements. */
|
|
271
|
+
const ELEMENT_OF = new Set(['find', 'findLast', 'at', 'pop', 'shift']);
|
|
272
|
+
const SAME_ELEMENTS = new Set(['filter', 'slice', 'concat', 'sort', 'reverse', 'toSorted', 'toReversed']);
|
|
273
|
+
|
|
274
|
+
/** The fields that are a weapon - the ones a player's `items` hold - and the entity classes a `new` or an annotation names. */
|
|
275
|
+
const WEAPON_FIELDS = new Set(['activeItem', 'lastItem', 'activeItemSent', 'clientActiveItem']);
|
|
276
|
+
const CLASSES = new Set<Kind>(['Player', 'Weapon', 'Entity']);
|
|
277
|
+
/** What a value an annotation names is: a `Client` is a player who is not in the game yet. */
|
|
278
|
+
const TYPE_KINDS: Record<string, Kind> = { Player: 'Player', Client: 'Player', Weapon: 'Weapon', Entity: 'Entity' };
|
|
279
|
+
/** An annotation of one of them, or a list of them: `Player | null`, `Weapon[]`. */
|
|
280
|
+
const ONE_TYPE = /^(Player|Client|Weapon|Entity)(?:\s*\|\s*(?:null|undefined))*$/;
|
|
281
|
+
const LIST_TYPE = /^(?:(Player|Client|Weapon|Entity)\[\]|Array<(Player|Client|Weapon|Entity)>)$/;
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* A file's names as TypeScript binds them: one file alone, no library -
|
|
285
|
+
* enough to tell which `player` a name is, not to type it. `declaration` is
|
|
286
|
+
* where a name is declared, `namesAt` every name the code sees at a place.
|
|
287
|
+
*/
|
|
288
|
+
function bindingsOf(source: ts.SourceFile) {
|
|
289
|
+
const options: ts.CompilerOptions = { noLib: true, noResolve: true, types: [] };
|
|
290
|
+
const host = ts.createCompilerHost(options);
|
|
291
|
+
host.getSourceFile = name => name === source.fileName ? source : undefined;
|
|
292
|
+
const checker = ts.createProgram([source.fileName], options, host).getTypeChecker();
|
|
293
|
+
return {
|
|
294
|
+
declaration: (name: ts.Identifier): ts.Declaration | undefined =>
|
|
295
|
+
(ts.isShorthandPropertyAssignment(name.parent) ? checker.getShorthandAssignmentValueSymbol(name.parent) : checker.getSymbolAtLocation(name))?.declarations?.[0],
|
|
296
|
+
namesAt: (node: ts.Node) => new Set(checker.getSymbolsInScope(node, ts.SymbolFlags.Value | ts.SymbolFlags.Alias).map(symbol => symbol.name)),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The old names of fields and methods, renamed or out of the API. */
|
|
301
|
+
const OLD_MEMBERS = new Set([...Object.values(RENAMED), ...Object.values(HIDDEN)].flatMap(Object.keys));
|
|
302
|
+
|
|
303
|
+
/** An old name a file may hold: a field, a method, an event or its class. */
|
|
304
|
+
const OLD_NAMES = new RegExp(`\\b(?:Player\\.all|${[
|
|
305
|
+
...OLD_MEMBERS,
|
|
306
|
+
...Object.keys(EVENTS),
|
|
307
|
+
...Object.keys(HIDDEN_EVENTS),
|
|
308
|
+
].map(name => name.replace(/^./, first => `[${first}${first.toUpperCase()}]`)).join('|')})\\b`);
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* A file brought to the API's names. `Player.all()` is `server.players`, and
|
|
312
|
+
* its options a `filter` of what each one tested. A field or a method named
|
|
313
|
+
* after the engine's member (`player.account`, `game.numCtWins`,
|
|
314
|
+
* `weapon.inReload`) is the player's word (`money`, `ctWins`, `isReloading`),
|
|
315
|
+
* and a game event's name in `addEventListener` and its class are the new
|
|
316
|
+
* ones. Without a type checker a value is a player, a weapon, an entity or the
|
|
317
|
+
* game where the code says so: `event.player`, `new Weapon(id)`, an
|
|
318
|
+
* annotation, `player.activeItem`, an element of `server.players` or of a
|
|
319
|
+
* player's `items`, `{ player }` taken from an event or a command, a
|
|
320
|
+
* command handler's first parameter, `game`.
|
|
321
|
+
* The rest is listed: an old name on a value the code does not say, options
|
|
322
|
+
* not written out as `true` or a team's name, and a field or an event left out
|
|
323
|
+
* of the API, which the natives reach.
|
|
324
|
+
*/
|
|
325
|
+
export function upgradeNames(file: string, text: string): { text: string; changes: Change[]; left: Left[] } {
|
|
326
|
+
if (!OLD_NAMES.test(text)) return { text, changes: [], left: [] };
|
|
327
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
328
|
+
const { declaration, namesAt } = bindingsOf(source);
|
|
329
|
+
const edits: Edit[] = [];
|
|
330
|
+
const left: Left[] = [];
|
|
331
|
+
const lineOf = (node: ts.Node) => source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
|
|
332
|
+
const replace = (node: ts.Node, to: string) => edits.push({ start: node.getStart(source), end: node.getEnd(), with: to, from: node.getText(source) });
|
|
333
|
+
const leave = (node: ts.Node, why: string) => left.push({ file, line: lineOf(node), why });
|
|
334
|
+
|
|
335
|
+
const bare = (node: ts.Expression): ts.Expression => ts.isParenthesizedExpression(node) || ts.isNonNullExpression(node) ? bare(node.expression) : node;
|
|
336
|
+
const isPlayerAll = (node: ts.Node): boolean =>
|
|
337
|
+
ts.isPropertyAccessExpression(node) && node.name.text === 'all' && ts.isIdentifier(node.expression) && node.expression.text === 'Player';
|
|
338
|
+
const method = (node: ts.Expression) => ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) ? node.expression : undefined;
|
|
339
|
+
|
|
340
|
+
/** What the elements of a list are: `server.players`, a player's `items`, `Entity.findAll(...)`. */
|
|
341
|
+
const elementsOf = (expr: ts.Expression): Kind | undefined => {
|
|
342
|
+
const node = bare(expr);
|
|
343
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
344
|
+
if (node.name.text === 'players' && ts.isIdentifier(node.expression) && node.expression.text === 'server') return 'Player';
|
|
345
|
+
return node.name.text === 'items' && kindOf(node.expression) === 'Player' ? 'Weapon' : undefined;
|
|
346
|
+
}
|
|
347
|
+
const call = method(node);
|
|
348
|
+
if (call) {
|
|
349
|
+
if (isPlayerAll(call)) return 'Player';
|
|
350
|
+
if (call.name.text === 'findAll' && ts.isIdentifier(call.expression) && call.expression.text === 'Entity') return 'Entity';
|
|
351
|
+
return SAME_ELEMENTS.has(call.name.text) ? elementsOf(call.expression) : undefined;
|
|
352
|
+
}
|
|
353
|
+
const declared = ts.isIdentifier(node) ? declaration(node) : undefined;
|
|
354
|
+
if (!declared || !ts.isVariableDeclaration(declared)) return undefined;
|
|
355
|
+
if (declared.type) return TYPE_KINDS[declared.type.getText(source).match(LIST_TYPE)?.slice(1).find(Boolean) ?? ''];
|
|
356
|
+
return declared.initializer && elementsOf(declared.initializer);
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
/** What a value is, where the code says so. */
|
|
360
|
+
const kindOf = (expr: ts.Expression): Kind | undefined => {
|
|
361
|
+
const node = bare(expr);
|
|
362
|
+
if (ts.isNewExpression(node)) return ts.isIdentifier(node.expression) && CLASSES.has(node.expression.text as Kind) ? node.expression.text as Kind : undefined;
|
|
363
|
+
if (ts.isPropertyAccessExpression(node)) return node.name.text === 'player' ? 'Player' : WEAPON_FIELDS.has(node.name.text) ? 'Weapon' : undefined;
|
|
364
|
+
if (ts.isElementAccessExpression(node)) return elementsOf(node.expression);
|
|
365
|
+
const call = method(node);
|
|
366
|
+
if (call) return ELEMENT_OF.has(call.name.text) ? elementsOf(call.expression) : undefined;
|
|
367
|
+
if (!ts.isIdentifier(node)) return undefined;
|
|
368
|
+
const declared = declaration(node);
|
|
369
|
+
// `game` is the facade's, auto-imported or imported by name.
|
|
370
|
+
if (node.text === 'game' && (!declared || ts.isImportSpecifier(declared))) return 'Game';
|
|
371
|
+
return declared && declaredKind(declared);
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const declaredKind = (declared: ts.Declaration): Kind | undefined => {
|
|
375
|
+
if (ts.isBindingElement(declared)) return (declared.propertyName ?? declared.name).getText(source) === 'player' ? 'Player' : undefined;
|
|
376
|
+
if (!ts.isVariableDeclaration(declared) && !ts.isParameter(declared)) return undefined;
|
|
377
|
+
if (declared.type) return TYPE_KINDS[declared.type.getText(source).match(ONE_TYPE)?.[1] ?? ''];
|
|
378
|
+
if (ts.isVariableDeclaration(declared)) {
|
|
379
|
+
if (declared.initializer) return kindOf(declared.initializer);
|
|
380
|
+
const loop = declared.parent.parent;
|
|
381
|
+
return ts.isForOfStatement(loop) ? elementsOf(loop.expression) : undefined;
|
|
382
|
+
}
|
|
383
|
+
// The first parameter of a callback over a list, `.filter(each => ...)`,
|
|
384
|
+
// or of a command's handler that reads the words, `(player, args) =>`.
|
|
385
|
+
const fn = declared.parent;
|
|
386
|
+
const call = fn.parent;
|
|
387
|
+
if (fn.parameters[0] !== declared || !ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) return undefined;
|
|
388
|
+
if (call.expression.name.text === 'addCommand' && call.arguments[1] === fn && call.expression.expression.getText(source) === 'server') return 'Player';
|
|
389
|
+
return ELEMENT_CALLBACKS.has(call.expression.name.text) ? elementsOf(call.expression.expression) : undefined;
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
/** `Player.all(options)` as `server.players`, or null when its options are not written out. */
|
|
393
|
+
const playersOf = (call: ts.CallExpression): string | null => {
|
|
394
|
+
const options = call.arguments[0];
|
|
395
|
+
if (!options) return 'server.players';
|
|
396
|
+
if (call.arguments.length > 1 || !ts.isObjectLiteralExpression(options)) return null;
|
|
397
|
+
const player = playerName(namesAt(call));
|
|
398
|
+
const tests: string[] = [];
|
|
399
|
+
for (const option of options.properties) {
|
|
400
|
+
if (!ts.isPropertyAssignment(option) || !ts.isIdentifier(option.name)) return null;
|
|
401
|
+
const name = option.name.text;
|
|
402
|
+
const value = option.initializer;
|
|
403
|
+
if (name === 'team' && ts.isStringLiteral(value)) tests.push(`${player}.team === ${value.getText(source)}`);
|
|
404
|
+
else if (PLAYER_TESTS[name] && value.kind === ts.SyntaxKind.TrueKeyword) tests.push(PLAYER_TESTS[name](player));
|
|
405
|
+
else if (!PLAYER_TESTS[name] || value.kind !== ts.SyntaxKind.FalseKeyword) return null;
|
|
406
|
+
}
|
|
407
|
+
return tests.length ? `server.players.filter(${player} => ${tests.join(' && ')})` : 'server.players';
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
/** A field or a method by an old name: the new one where the value is known, else a line for the author. */
|
|
411
|
+
const upgradeMember = (node: ts.PropertyAccessExpression) => {
|
|
412
|
+
const name = node.name.text;
|
|
413
|
+
if (!OLD_MEMBERS.has(name) || node.expression.kind === ts.SyntaxKind.ThisKeyword) return;
|
|
414
|
+
const kind = kindOf(node.expression);
|
|
415
|
+
const renamed = kind && RENAMED[kind][name];
|
|
416
|
+
const hidden = kind && HIDDEN[kind][name];
|
|
417
|
+
if (renamed) replace(node.name, renamed);
|
|
418
|
+
else if (hidden) leave(node, `${name} is not in the API: read it with the natives of @amxts/core/natives, e.g. get_member(id, ${hidden})`);
|
|
419
|
+
if (kind || COMMON.has(name)) return;
|
|
420
|
+
const where = (Object.keys(RENAMED) as Kind[]).filter(each => RENAMED[each][name]);
|
|
421
|
+
if (where.length) leave(node, `${name} is ${where.map(each => `\`${RENAMED[each][name]}\` on ${each === 'Game' ? 'the game' : `a ${each}`}`).join(', ')}: if this is one, write that`);
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
/** A game event's name in `addEventListener`: the new one, or a line for one out of the API. */
|
|
425
|
+
const upgradeListener = (call: ts.CallExpression) => {
|
|
426
|
+
const name = call.arguments[0];
|
|
427
|
+
if (!name || !ts.isStringLiteralLike(name)) return;
|
|
428
|
+
const to = EVENTS[name.text];
|
|
429
|
+
if (to) edits.push({ start: name.getStart(source) + 1, end: name.getEnd() - 1, with: to, from: name.text });
|
|
430
|
+
else if (Object.hasOwn(HIDDEN_EVENTS, name.text)) leave(name, `"${name.text}" is not in the API: hook it with @amxts/core/natives, ${HIDDEN_EVENTS[name.text]}`);
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
/** A test's player joining the fake server with an option by its old name: `server.join("Alice", { authid })` is `{ steamId: authid }`. */
|
|
434
|
+
const upgradeJoin = (call: ts.CallExpression) => {
|
|
435
|
+
const options = call.arguments[1];
|
|
436
|
+
if (!options || !ts.isObjectLiteralExpression(options)) return;
|
|
437
|
+
for (const option of options.properties) {
|
|
438
|
+
const name = option.name && ts.isIdentifier(option.name) ? option.name.text : '';
|
|
439
|
+
if (!Object.hasOwn(JOIN_OPTIONS, name)) continue;
|
|
440
|
+
const to = JOIN_OPTIONS[name];
|
|
441
|
+
replace(option.name!, ts.isShorthandPropertyAssignment(option) ? `${to}: ${name}` : to);
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const visit = (node: ts.Node) => {
|
|
446
|
+
if (ts.isCallExpression(node) && isPlayerAll(node.expression)) {
|
|
447
|
+
const to = playersOf(node);
|
|
448
|
+
if (to) replace(node, to);
|
|
449
|
+
else leave(node, 'Player.all is server.players: write its options as a filter, server.players.filter(player => player.isAlive && player.team === "CT")');
|
|
450
|
+
node.arguments.forEach(visit);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
upgradeNode(node);
|
|
454
|
+
ts.forEachChild(node, visit);
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const upgradeNode = (node: ts.Node) => {
|
|
458
|
+
if (isPlayerAll(node)) {
|
|
459
|
+
leave(node, 'Player.all is server.players, a list read each time');
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (ts.isIdentifier(node)) {
|
|
464
|
+
const to = EVENT_CLASSES.get(node.text);
|
|
465
|
+
if (to) replace(node, to);
|
|
466
|
+
else if (HIDDEN_CLASSES.has(node.text)) leave(node, `${node.text} is not in the API: hook its event with @amxts/core/natives, ${HIDDEN_CLASSES.get(node.text)}`);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
471
|
+
upgradeMember(node);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return;
|
|
476
|
+
if (/^(?:add|remove)EventListener$/.test(node.expression.name.text)) upgradeListener(node);
|
|
477
|
+
else if (node.expression.name.text === 'join') upgradeJoin(node);
|
|
478
|
+
};
|
|
479
|
+
visit(source);
|
|
480
|
+
return { ...applyEdits(file, text, source, edits), left };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const mapOf = (record: Record<string, string>) => new Map(Object.entries(record));
|
|
484
|
+
const SERVER_RENAMED = mapOf(SERVER_EVENTS);
|
|
485
|
+
const SERVER_TO_GAME = mapOf(SERVER_GAME_EVENTS);
|
|
486
|
+
const SERVER_BY_HAND = mapOf(SERVER_EVENTS_BY_HAND);
|
|
487
|
+
const CLASSES_RENAMED = mapOf(SERVER_EVENT_CLASSES);
|
|
488
|
+
/** Each event's fields by their old names, by its source and its name: `game:buyAmmo`. */
|
|
489
|
+
const EVENT_FIELDS = new Map<string, Map<string, string>>([
|
|
490
|
+
...Object.entries(GAME_EVENT_FIELDS).map(([event, fields]) => [`game:${event}`, mapOf(fields)] as const),
|
|
491
|
+
...Object.entries(SERVER_EVENT_FIELDS).map(([event, fields]) => [`server:${event}`, mapOf(fields)] as const),
|
|
492
|
+
]);
|
|
493
|
+
/** The same by the event's class, which an annotation names. */
|
|
494
|
+
const CLASS_FIELDS = new Map([
|
|
495
|
+
...Object.keys(GAME_EVENT_FIELDS).map(event => [eventClass(event), EVENT_FIELDS.get(`game:${event}`)!] as const),
|
|
496
|
+
...Object.entries(SERVER_FIELD_CLASSES).map(([name, event]) => [name, EVENT_FIELDS.get(`server:${event}`)!] as const),
|
|
497
|
+
]);
|
|
498
|
+
/** An old name a file may hold: a server event's, its class, or an event's field. */
|
|
499
|
+
const OLD_EVENT_WORDS = new RegExp(`\\b(?:${[
|
|
500
|
+
...SERVER_RENAMED.keys(),
|
|
501
|
+
...SERVER_TO_GAME.keys(),
|
|
502
|
+
...SERVER_BY_HAND.keys(),
|
|
503
|
+
...CLASSES_RENAMED.keys(),
|
|
504
|
+
...[...EVENT_FIELDS.values()].flatMap(fields => [...fields.keys()]),
|
|
505
|
+
].join('|')})\\b`);
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* A file's events by their names in the author's words. A server event's
|
|
509
|
+
* name in `server.addEventListener` is the new one (`"putinserver"` is
|
|
510
|
+
* `"putInServer"`), one that is a game event is heard through `game`
|
|
511
|
+
* (`server.addEventListener("PreThink", ...)` is
|
|
512
|
+
* `game.addEventListener("preThink", ...)`), and cstrike's buying is listed.
|
|
513
|
+
* An event's field named after reapi's or Pawn's parameter is the new one
|
|
514
|
+
* (`event.weapon_entity` is `event.weapon`) where the code says which event
|
|
515
|
+
* it is: a listener given to `addEventListener` by its name - written in
|
|
516
|
+
* place, or a function of the file - or a parameter annotated with the
|
|
517
|
+
* event's class. A field destructured keeps its local name:
|
|
518
|
+
* `({ weapon_entity })` is `({ weapon: weapon_entity })`.
|
|
519
|
+
*/
|
|
520
|
+
export function upgradeEvents(file: string, text: string): { text: string; changes: Change[]; left: Left[] } {
|
|
521
|
+
if (!OLD_EVENT_WORDS.test(text)) return { text, changes: [], left: [] };
|
|
522
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
523
|
+
const { declaration } = bindingsOf(source);
|
|
524
|
+
const edits: Edit[] = [];
|
|
525
|
+
const left: Left[] = [];
|
|
526
|
+
const replace = (node: ts.Node, to: string) => edits.push({ start: node.getStart(source), end: node.getEnd(), with: to, from: node.getText(source) });
|
|
527
|
+
const inQuotes = (node: ts.StringLiteralLike, to: string) => edits.push({ start: node.getStart(source) + 1, end: node.getEnd() - 1, with: to, from: node.text });
|
|
528
|
+
const leave = (node: ts.Node, why: string) => left.push({ file, line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, why });
|
|
529
|
+
/** The fields renamed for each listener: the function, or the parameter annotated with its class. */
|
|
530
|
+
const listeners = new Map<ts.Node, Map<string, string>>();
|
|
531
|
+
|
|
532
|
+
/** The function a listener argument is: written in place, or a function of the file by its name. */
|
|
533
|
+
const functionOf = (node: ts.Expression): ts.SignatureDeclaration | undefined => {
|
|
534
|
+
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return node;
|
|
535
|
+
const declared = ts.isIdentifier(node) ? declaration(node) : undefined;
|
|
536
|
+
if (declared && ts.isFunctionDeclaration(declared)) return declared;
|
|
537
|
+
const initializer = declared && ts.isVariableDeclaration(declared) ? declared.initializer : undefined;
|
|
538
|
+
return initializer && (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) ? initializer : undefined;
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
const upgradeListener = (call: ts.CallExpression, receiver: string) => {
|
|
542
|
+
const [name, listener] = call.arguments;
|
|
543
|
+
if (!name || !ts.isStringLiteralLike(name)) return;
|
|
544
|
+
let event = name.text;
|
|
545
|
+
if (receiver === 'server') {
|
|
546
|
+
const game = SERVER_TO_GAME.get(event);
|
|
547
|
+
const renamed = SERVER_RENAMED.get(event);
|
|
548
|
+
if (game) {
|
|
549
|
+
replace((call.expression as ts.PropertyAccessExpression).expression, 'game');
|
|
550
|
+
inQuotes(name, game);
|
|
551
|
+
event = game;
|
|
552
|
+
receiver = 'game';
|
|
553
|
+
if (game === 'touch') leave(name, 'a touch hands toucher and touched as entities: the number of one is its .id');
|
|
554
|
+
} else if (renamed) {
|
|
555
|
+
event = renamed;
|
|
556
|
+
inQuotes(name, event);
|
|
557
|
+
} else if (SERVER_BY_HAND.has(event)) {
|
|
558
|
+
leave(name, `"${event}" is not a server event: ${SERVER_BY_HAND.get(event)}`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
const fields = EVENT_FIELDS.get(`${receiver}:${event}`);
|
|
562
|
+
const fn = listener && functionOf(listener);
|
|
563
|
+
if (fields && fn) listeners.set(fn, fields);
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
const findListeners = (node: ts.Node) => {
|
|
567
|
+
ts.forEachChild(node, findListeners);
|
|
568
|
+
if (ts.isIdentifier(node) && CLASSES_RENAMED.has(node.text)) replace(node, CLASSES_RENAMED.get(node.text)!);
|
|
569
|
+
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression) || !/^(?:add|remove)EventListener$/.test(node.expression.name.text)) return;
|
|
570
|
+
const receiver = node.expression.expression;
|
|
571
|
+
if (ts.isIdentifier(receiver) && (receiver.text === 'server' || receiver.text === 'game')) upgradeListener(node, receiver.text);
|
|
572
|
+
};
|
|
573
|
+
findListeners(source);
|
|
574
|
+
|
|
575
|
+
/** The fields renamed for what a parameter holds: its annotation's event class, or the listener it is the first of. */
|
|
576
|
+
const fieldsOf = (parameter: ts.ParameterDeclaration): Map<string, string> | undefined => {
|
|
577
|
+
if (parameter.type) return CLASS_FIELDS.get(parameter.type.getText(source));
|
|
578
|
+
return parameter.parent.parameters[0] === parameter ? listeners.get(parameter.parent) : undefined;
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
const renameFields = (node: ts.Node) => {
|
|
582
|
+
ts.forEachChild(node, renameFields);
|
|
583
|
+
if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
|
|
584
|
+
const declared = declaration(node.expression);
|
|
585
|
+
const to = declared && ts.isParameter(declared) ? fieldsOf(declared)?.get(node.name.text) : undefined;
|
|
586
|
+
if (to) replace(node.name, to);
|
|
587
|
+
} else if (ts.isParameter(node) && ts.isObjectBindingPattern(node.name)) {
|
|
588
|
+
const fields = fieldsOf(node);
|
|
589
|
+
for (const element of node.name.elements) {
|
|
590
|
+
const key = element.propertyName ?? element.name;
|
|
591
|
+
const to = ts.isIdentifier(key) ? fields?.get(key.text) : undefined;
|
|
592
|
+
const local = element.name.getText(source);
|
|
593
|
+
// `{ tracehandle: trace }` is `{ trace }`, not `{ trace: trace }`.
|
|
594
|
+
if (to && element.propertyName && to === local) edits.push({ start: key.getStart(source), end: element.name.getEnd(), with: to, from: element.getText(source) });
|
|
595
|
+
else if (to) replace(key, element.propertyName ? to : `${to}: ${local}`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
renameFields(source);
|
|
600
|
+
return { ...applyEdits(file, text, source, edits), left };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** The methods a message was listened to with, and the ones it is now. */
|
|
604
|
+
const MESSAGE_METHODS: Record<string, string> = { addEventListener: 'addMessageListener', removeEventListener: 'removeMessageListener' };
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* A file's game messages brought to their own methods:
|
|
608
|
+
* `addEventListener("message:DeathMsg", ...)` is
|
|
609
|
+
* `addMessageListener("death", ...)`, and `removeEventListener` likewise. A
|
|
610
|
+
* name the game does not have is listed.
|
|
611
|
+
*/
|
|
612
|
+
export function upgradeMessages(file: string, text: string): { text: string; changes: Change[]; left: Left[] } {
|
|
613
|
+
if (!text.includes('message:')) return { text, changes: [], left: [] };
|
|
614
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
615
|
+
const edits: Edit[] = [];
|
|
616
|
+
const left: Left[] = [];
|
|
617
|
+
|
|
618
|
+
const visit = (node: ts.Node) => {
|
|
619
|
+
ts.forEachChild(node, visit);
|
|
620
|
+
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return;
|
|
621
|
+
const method = MESSAGE_METHODS[node.expression.name.text];
|
|
622
|
+
const name = node.arguments[0];
|
|
623
|
+
if (!method || !name || !ts.isStringLiteralLike(name) || !name.text.startsWith('message:')) return;
|
|
624
|
+
const to = MESSAGE_NAMES[name.text.slice('message:'.length)];
|
|
625
|
+
if (!to) {
|
|
626
|
+
left.push({ file, line: source.getLineAndCharacterOfPosition(name.getStart(source)).line + 1, why: `"${name.text}" is not a message the game has: server.addMessageListener takes one of the names the editor lists` });
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
edits.push({ start: node.expression.name.getStart(source), end: node.expression.name.getEnd(), with: method, from: node.expression.name.text });
|
|
630
|
+
edits.push({ start: name.getStart(source) + 1, end: name.getEnd() - 1, with: to, from: name.text });
|
|
631
|
+
};
|
|
632
|
+
visit(source);
|
|
633
|
+
return { ...applyEdits(file, text, source, edits), left };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** The flag families: a value of one, or a list of them, is a flag's name. */
|
|
637
|
+
const FLAG_FAMILIES = ['HideHud', 'Button', 'Effect', 'EntityFlag', 'Damage', 'Access', 'ScoreStatus', 'WeaponState', 'PhysicsFlag', 'FlagName', 'DeathMessageFlag', 'KillRarity'];
|
|
638
|
+
/** The fields that hold flags: `player.hideHud`, `entity.flags`, `event.damageType`, a death message's `event.rarity`. */
|
|
639
|
+
const FLAG_FIELDS = new Set(['access', 'buttons', 'oldButtons', 'buttonLast', 'buttonPressed', 'buttonReleased', 'hideHud', 'hideHudSent', 'damageType', 'effects', 'flags', 'physicsFlags', 'weaponState', 'rarity']);
|
|
640
|
+
/** The options that take flags: `bot.move({ buttons })`, `server.addCommand(..., { access })`. */
|
|
641
|
+
const FLAG_OPTIONS = new Set(['access', 'buttons']);
|
|
642
|
+
/** The calls that take flags, by the argument's place: `screen.hideHud(["Money"])`, `cmd(name, handler, "KICK")`. */
|
|
643
|
+
const FLAG_ARGUMENTS: Record<string, number> = { hideHud: 0, heal: 1, cmd: 2, cmdWide: 2 };
|
|
644
|
+
/** The array methods that take an element or a list of them: `buttons.includes("Jump")`. */
|
|
645
|
+
const ELEMENT_ARGUMENTS = new Set(['includes', 'indexOf', 'lastIndexOf', 'push', 'unshift', 'concat']);
|
|
646
|
+
const FLAG_TYPE = new RegExp(`\\b(?:${FLAG_FAMILIES.join('|')})\\b`);
|
|
647
|
+
const FLAG_WORDS = new RegExp(`\\b(?:${[...new Set([...FLAG_FAMILIES, ...FLAG_FIELDS, ...Object.keys(FLAG_ARGUMENTS)])].join('|')})\\b`);
|
|
648
|
+
const COMPARISONS = new Set([
|
|
649
|
+
ts.SyntaxKind.EqualsEqualsToken,
|
|
650
|
+
ts.SyntaxKind.EqualsEqualsEqualsToken,
|
|
651
|
+
ts.SyntaxKind.ExclamationEqualsToken,
|
|
652
|
+
ts.SyntaxKind.ExclamationEqualsEqualsToken,
|
|
653
|
+
]);
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* A flag's name as the API writes it, lowerCamelCase like every union value:
|
|
657
|
+
* `"Jump"` is `"jump"`, a command's `"LEVEL_A"` is `"levelA"`; null for a
|
|
658
|
+
* name already written so.
|
|
659
|
+
*/
|
|
660
|
+
function flagName(old: string, constant: boolean): string | null {
|
|
661
|
+
if (constant) return /^[A-Z][A-Z0-9_]*$/.test(old) ? old.toLowerCase().replace(/_([a-z0-9])/g, (_, next: string) => next.toUpperCase()) : null;
|
|
662
|
+
return FLAG_NAMES[old] ?? (/^[A-Z][a-z0-9]\w*$/.test(old) ? old[0].toLowerCase() + old.slice(1) : null);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* A file's flag names brought to lowerCamelCase: `player.buttons.includes("Jump")`
|
|
667
|
+
* is `includes("jump")`. A string is a flag's name where the code says so
|
|
668
|
+
* without a type checker: assigned to a flag field (`player.hideHud =
|
|
669
|
+
* ["Money"]`) or an option (`{ buttons: ["Jump"] }`, `{ access: "Kick" }`),
|
|
670
|
+
* given to `includes`, `push` or `concat` of one, to `screen.hideHud`,
|
|
671
|
+
* `heal` or `cmd`, compared with an element of one (`flag != "Bomb"` in its
|
|
672
|
+
* `filter`, a `for of` over it, a `switch`), or held by a name annotated with
|
|
673
|
+
* a family (`const parts: HideHud[] = ["Money"]`, a parameter `button:
|
|
674
|
+
* Button`, and what the file's own function takes there). A name given where a
|
|
675
|
+
* flag goes is followed to the literal it was declared with; one the file
|
|
676
|
+
* does not say - a parameter, an import, a function's result - is listed.
|
|
677
|
+
*/
|
|
678
|
+
export function upgradeFlags(file: string, text: string): { text: string; changes: Change[]; left: Left[] } {
|
|
679
|
+
if (!FLAG_WORDS.test(text)) return { text, changes: [], left: [] };
|
|
680
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
681
|
+
const { declaration } = bindingsOf(source);
|
|
682
|
+
const edits: Edit[] = [];
|
|
683
|
+
const left: Left[] = [];
|
|
684
|
+
const followed = new Set<ts.Node>();
|
|
685
|
+
const lineOf = (node: ts.Node) => source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
|
|
686
|
+
|
|
687
|
+
const callee = (node: ts.CallExpression) => ts.isPropertyAccessExpression(node.expression) ? node.expression.name.text : ts.isIdentifier(node.expression) ? node.expression.text : '';
|
|
688
|
+
|
|
689
|
+
/** Whether a list holds flags: a flag field, `accessOf`'s rights, a name annotated or declared as one, the same list filtered. */
|
|
690
|
+
const isFlags = (node: ts.Node): boolean => {
|
|
691
|
+
if (ts.isPropertyAccessExpression(node)) return FLAG_FIELDS.has(node.name.text);
|
|
692
|
+
if (ts.isCallExpression(node)) return callee(node) === 'accessOf' || (ts.isPropertyAccessExpression(node.expression) && SAME_ELEMENTS.has(node.expression.name.text) && isFlags(node.expression.expression));
|
|
693
|
+
const declared = ts.isIdentifier(node) ? declaration(node) : undefined;
|
|
694
|
+
return holdsFlags(declared) || (!!declared && ts.isVariableDeclaration(declared) && !!declared.initializer && isFlags(declared.initializer));
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
/** Whether a name holds a flag or a list of them: annotated with a family, or an element of a flag field in its callback or its `for of`. */
|
|
698
|
+
const holdsFlags = (declared: ts.Node | undefined): boolean => {
|
|
699
|
+
if (!declared || !(ts.isParameter(declared) || ts.isVariableDeclaration(declared))) return false;
|
|
700
|
+
if (declared.type) return FLAG_TYPE.test(declared.type.getText(source));
|
|
701
|
+
const owner = declared.parent;
|
|
702
|
+
if (ts.isParameter(declared)) {
|
|
703
|
+
const call = owner.parent;
|
|
704
|
+
return ts.isFunctionLike(owner) && owner.parameters[0] === declared && ts.isCallExpression(call) && call.arguments[0] === owner
|
|
705
|
+
&& ts.isPropertyAccessExpression(call.expression) && ELEMENT_CALLBACKS.has(call.expression.name.text) && isFlags(call.expression.expression);
|
|
706
|
+
}
|
|
707
|
+
return ts.isVariableDeclarationList(owner) && ts.isForOfStatement(owner.parent) && isFlags(owner.parent.expression);
|
|
708
|
+
};
|
|
709
|
+
const isFlag = (node: ts.Expression) =>
|
|
710
|
+
(ts.isIdentifier(node) && holdsFlags(declaration(node))) || (ts.isElementAccessExpression(node) && isFlags(node.expression));
|
|
711
|
+
|
|
712
|
+
/** A value where flags go: its strings rewritten, a name followed to its literal, what the file does not say listed. */
|
|
713
|
+
const value = (node: ts.Expression, constant = false): void => {
|
|
714
|
+
if (followed.has(node)) return;
|
|
715
|
+
followed.add(node);
|
|
716
|
+
if (ts.isStringLiteralLike(node)) {
|
|
717
|
+
const to = flagName(node.text, constant);
|
|
718
|
+
if (to) edits.push({ start: node.getStart(source) + 1, end: node.getEnd() - 1, with: to, from: node.text });
|
|
719
|
+
} else if (ts.isArrayLiteralExpression(node)) {
|
|
720
|
+
for (const element of node.elements) value(element, constant);
|
|
721
|
+
} else if (ts.isSpreadElement(node) || ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isNonNullExpression(node)) {
|
|
722
|
+
value(node.expression, constant);
|
|
723
|
+
} else if (ts.isConditionalExpression(node)) {
|
|
724
|
+
value(node.whenTrue, constant);
|
|
725
|
+
value(node.whenFalse, constant);
|
|
726
|
+
} else if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && SAME_ELEMENTS.has(node.expression.name.text)) {
|
|
727
|
+
value(node.expression.expression, constant);
|
|
728
|
+
if (node.expression.name.text === 'concat') {
|
|
729
|
+
for (const list of node.arguments) value(list, constant);
|
|
730
|
+
}
|
|
731
|
+
} else if (ts.isIdentifier(node)) {
|
|
732
|
+
const declared = declaration(node);
|
|
733
|
+
if (holdsFlags(declared)) return;
|
|
734
|
+
if (declared && ts.isVariableDeclaration(declared) && declared.initializer) value(declared.initializer, constant);
|
|
735
|
+
else left.push({ file, line: lineOf(node), why: `flag names are lowerCamelCase ("jump", not "Jump"): write the ones \`${node.text}\` holds so` });
|
|
736
|
+
} else if (!isFlags(node) && !isFlag(node)) {
|
|
737
|
+
left.push({ file, line: lineOf(node), why: `flag names are lowerCamelCase ("jump", not "Jump"): write the ones \`${node.getText(source)}\` gives so` });
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
const visit = (node: ts.Node) => {
|
|
742
|
+
ts.forEachChild(node, visit);
|
|
743
|
+
if (ts.isBinaryExpression(node)) {
|
|
744
|
+
const operator = node.operatorToken.kind;
|
|
745
|
+
if (operator === ts.SyntaxKind.EqualsToken && (isFlags(node.left) || isFlag(node.left))) value(node.right);
|
|
746
|
+
if (!COMPARISONS.has(operator)) return;
|
|
747
|
+
if (isFlag(node.left) && ts.isStringLiteralLike(node.right)) value(node.right);
|
|
748
|
+
if (isFlag(node.right) && ts.isStringLiteralLike(node.left)) value(node.left);
|
|
749
|
+
} else if ((ts.isVariableDeclaration(node) || ts.isParameter(node)) && node.initializer && node.type && holdsFlags(node)) {
|
|
750
|
+
value(node.initializer);
|
|
751
|
+
} else if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name) && FLAG_OPTIONS.has(node.name.text)) {
|
|
752
|
+
value(node.initializer);
|
|
753
|
+
} else if (ts.isShorthandPropertyAssignment(node) && FLAG_OPTIONS.has(node.name.text)) {
|
|
754
|
+
value(node.name);
|
|
755
|
+
} else if (ts.isCaseClause(node) && isFlag(node.parent.parent.expression)) {
|
|
756
|
+
value(node.expression);
|
|
757
|
+
} else if (ts.isCallExpression(node)) {
|
|
758
|
+
const name = callee(node);
|
|
759
|
+
const at = FLAG_ARGUMENTS[name];
|
|
760
|
+
if (at !== undefined && node.arguments[at]) value(node.arguments[at], name.startsWith('cmd'));
|
|
761
|
+
if (ELEMENT_ARGUMENTS.has(name) && ts.isPropertyAccessExpression(node.expression) && isFlags(node.expression.expression) && node.arguments[0]) value(node.arguments[0]);
|
|
762
|
+
const own = ts.isIdentifier(node.expression) ? declaration(node.expression) : undefined;
|
|
763
|
+
const fn = own && ts.isVariableDeclaration(own) ? own.initializer : own;
|
|
764
|
+
if (fn && ts.isFunctionLike(fn)) {
|
|
765
|
+
node.arguments.forEach((argument, i) => {
|
|
766
|
+
if (fn.parameters[i]?.type && holdsFlags(fn.parameters[i])) value(argument);
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
visit(source);
|
|
772
|
+
return { ...applyEdits(file, text, source, edits), left };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Folders that are not the project's code: what is installed, built or generated. */
|
|
776
|
+
const SKIP = new Set(['node_modules', 'dist', '.amxts', '.git']);
|
|
777
|
+
|
|
778
|
+
/** A server's addons/amxts: the module's files, written over on every start. */
|
|
779
|
+
const SERVER_FOLDER = /[\\/]addons[\\/]amxts$/i;
|
|
780
|
+
|
|
781
|
+
/** The project's TypeScript files: its plugins, its tests and fixtures, a module's sources. */
|
|
782
|
+
function codeFiles(dir: string, skip: Set<string>): string[] {
|
|
783
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
784
|
+
const path = join(dir, entry.name);
|
|
785
|
+
if (entry.isDirectory()) return SKIP.has(entry.name) || skip.has(path) || entry.name.startsWith('.') || SERVER_FOLDER.test(path) ? [] : codeFiles(path, skip);
|
|
786
|
+
return /\.(?:ts|mts|cts)$/.test(entry.name) ? [path] : [];
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** The server's addons/amxts that AMXTS_SERVER names, or '' when it names none. */
|
|
791
|
+
function serverDir(): string {
|
|
792
|
+
try {
|
|
793
|
+
const dir = serverFolder(process.env.AMXTS_SERVER ?? '');
|
|
794
|
+
return dir && resolve(dir);
|
|
795
|
+
} catch {
|
|
796
|
+
return '';
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* The project's code to upgrade. The module's own files - the API a server
|
|
802
|
+
* writes beside its plugins, at the core's version it runs - are not, in the
|
|
803
|
+
* server's folder or a copy of one; a file of that name in the plugins
|
|
804
|
+
* folder is the author's.
|
|
805
|
+
*/
|
|
806
|
+
function projectFiles(dir: string, pluginsDir: string, outDir: string): string[] {
|
|
807
|
+
// The API at the top of as/, and the editor's globals: what the module
|
|
808
|
+
// writes beside the plugins (scripts/server-files.ts).
|
|
809
|
+
const theirs = new Set([...apiFiles(), 'imports.d.ts']);
|
|
810
|
+
return codeFiles(dir, new Set([outDir, serverDir()])).filter(file => !theirs.has(basename(file)) || dirname(file) === pluginsDir);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** Rewrites the project in `dir`: every change, in the order of the files, and what is left to do by hand. `write: false` only lists them. */
|
|
814
|
+
export function upgradeProject(dir: string, { write = true } = {}): { changes: Change[]; left: Left[] } {
|
|
815
|
+
const project = loadProject(dir);
|
|
816
|
+
const ownFolder = resolve(project.pluginsDir) !== resolve(CORE_PLUGINS);
|
|
817
|
+
const renames = renamesFor(project.modules, place => ownFolder && existsSync(join(project.pluginsDir, place)));
|
|
818
|
+
const changes: Change[] = [];
|
|
819
|
+
const left: Left[] = [];
|
|
820
|
+
for (const file of projectFiles(project.dir, resolve(project.pluginsDir), project.outDir)) {
|
|
821
|
+
const text = readFileSync(file, 'utf8');
|
|
822
|
+
const name = relative(project.dir, file).replace(/\\/g, '/');
|
|
823
|
+
const http = dropHttpImports(name, text);
|
|
824
|
+
const imports = http.text.includes('~/') ? upgradeText(name, http.text, renames) : { text: http.text, changes: [] };
|
|
825
|
+
const handlers = upgradeHandlers(name, imports.text);
|
|
826
|
+
// A menu's functions first: `({ player }) =>` says what `player` is, so
|
|
827
|
+
// the names in their bodies are rewritten rather than listed.
|
|
828
|
+
const menus = upgradeMenus(name, handlers.text);
|
|
829
|
+
const names = upgradeNames(name, menus.text);
|
|
830
|
+
const events = upgradeEvents(name, names.text);
|
|
831
|
+
const messages = upgradeMessages(name, events.text);
|
|
832
|
+
const flags = upgradeFlags(name, messages.text);
|
|
833
|
+
left.push(...http.left, ...handlers.left, ...menus.left, ...names.left, ...events.left, ...messages.left, ...flags.left);
|
|
834
|
+
if (flags.text === text) continue;
|
|
835
|
+
if (write) writeFileSync(file, flags.text);
|
|
836
|
+
changes.push(...http.changes, ...imports.changes, ...handlers.changes, ...menus.changes, ...names.changes, ...events.changes, ...messages.changes, ...flags.changes);
|
|
837
|
+
}
|
|
838
|
+
return { changes, left };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// Run as a task (scripts/run.ts names it in argv) or by itself.
|
|
842
|
+
if (import.meta.main || resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) {
|
|
843
|
+
// `--dry-run` writes nothing; `--report <file>` hands the lists to the
|
|
844
|
+
// amxts command as JSON, which prints what is left to do in its summary.
|
|
845
|
+
const args = process.argv.slice(2);
|
|
846
|
+
const report = args.includes('--report') ? args[args.indexOf('--report') + 1] : undefined;
|
|
847
|
+
const { changes, left } = upgradeProject(process.cwd(), { write: !args.includes('--dry-run') });
|
|
848
|
+
for (const change of changes) console.log(` ${c.dim(`${change.file}:${change.line}`)} ${change.from} ${c.dim('→')} ${change.to}`);
|
|
849
|
+
if (report) {
|
|
850
|
+
writeFileSync(report, JSON.stringify({ changes, left }));
|
|
851
|
+
} else {
|
|
852
|
+
const files = new Set(changes.map(change => change.file)).size;
|
|
853
|
+
if (changes.length) log.success(`upgraded ${changes.length} place(s) in ${files} file(s)`);
|
|
854
|
+
else if (!left.length) log.success('nothing to upgrade: the code already uses this core\'s API');
|
|
855
|
+
for (const each of left) log.warn(`${each.file}:${each.line} ${each.why}`);
|
|
856
|
+
}
|
|
857
|
+
}
|