@bitmagic/cli 0.1.83-dev.3 → 0.1.83-dev.4
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 +34 -3
- package/assets/metahuman/src/work/metahuman/MetaHumanMaterials.ts +4 -0
- package/assets/metahuman/src/work/metahuman/StudioEnvironment.ts +4 -0
- package/assets/optimizing-assets/SKILL.md +3 -2
- package/assets/optimizing-assets/references/tools.md +1 -1
- package/dist/cli.d.ts +9 -0
- package/dist/commands/upgrade.d.ts +18 -0
- package/dist/commands/upgrade.js +94 -1
- package/dist/commands/upgrade.js.map +1 -1
- package/dist/config/environments.d.ts +1 -1
- package/dist/editor/animation-preview-page.js +20 -0
- package/dist/editor/animation-preview-page.js.map +1 -1
- package/dist/editor/asset-viewer.js +4 -0
- package/dist/editor/asset-viewer.js.map +1 -1
- package/dist/editor/design.d.ts +2 -2
- package/dist/editor/design.js +2 -2
- package/dist/editor/design.js.map +1 -1
- package/dist/editor/hud-replay-page.js +5 -0
- package/dist/editor/hud-replay-page.js.map +1 -1
- package/dist/editor/recording-sink.js +1 -1
- package/dist/editor/runtime-token.d.ts +61 -0
- package/dist/editor/runtime-token.js +74 -0
- package/dist/editor/runtime-token.js.map +1 -0
- package/dist/editor/save.d.ts +2 -2
- package/dist/editor/server.js +39 -12
- package/dist/editor/server.js.map +1 -1
- package/dist/editor/shell-page.d.ts +12 -8
- package/dist/editor/shell-page.js +69 -10
- package/dist/editor/shell-page.js.map +1 -1
- package/dist/forge/browser-host.d.ts +1 -1
- package/dist/forge/browser-host.js +13 -10
- package/dist/forge/browser-host.js.map +1 -1
- package/dist/generate/model.js +4 -1
- package/dist/generate/model.js.map +1 -1
- package/dist/generate/prop.js +4 -1
- package/dist/generate/prop.js.map +1 -1
- package/dist/http/signed-upload.d.ts +6 -4
- package/dist/http/signed-upload.js +37 -14
- package/dist/http/signed-upload.js.map +1 -1
- package/dist/mobile/rewrite-shim.js +1 -1
- package/dist/publish/game-exchange.js +9 -3
- package/dist/publish/game-exchange.js.map +1 -1
- package/dist/scaffold/blender-level-skill-files.js +2 -2
- package/dist/scaffold/game-migration.d.ts +88 -0
- package/dist/scaffold/game-migration.js +492 -0
- package/dist/scaffold/game-migration.js.map +1 -0
- package/dist/scaffold/import-codemod.d.ts +51 -0
- package/dist/scaffold/import-codemod.js +117 -0
- package/dist/scaffold/import-codemod.js.map +1 -0
- package/dist/scaffold/legacy-game-baselines.d.ts +14 -0
- package/dist/scaffold/legacy-game-baselines.js +218 -0
- package/dist/scaffold/legacy-game-baselines.js.map +1 -0
- package/dist/scaffold/project-files.d.ts +1 -1
- package/dist/scaffold/project-files.js +11 -45
- package/dist/scaffold/project-files.js.map +1 -1
- package/dist/scaffold/project.js +8 -17
- package/dist/scaffold/project.js.map +1 -1
- package/dist/smoke/run.js +8 -1
- package/dist/smoke/run.js.map +1 -1
- package/dist/trailer/record-session.d.ts +1 -1
- package/dist/trailer/record-session.js +5 -8
- package/dist/trailer/record-session.js.map +1 -1
- package/dist/trailer/vendored-recorder.d.ts +8 -0
- package/dist/trailer/vendored-recorder.js +26 -0
- package/dist/trailer/vendored-recorder.js.map +1 -0
- package/dist/verify/browser.d.ts +6 -0
- package/dist/verify/browser.js +3 -0
- package/dist/verify/browser.js.map +1 -1
- package/dist/verify/classify.d.ts +4 -0
- package/dist/verify/classify.js.map +1 -1
- package/dist/verify/mobile-parity.d.ts +1 -1
- package/dist/verify/mobile-parity.js +1 -1
- package/dist/verify/model-downloads.d.ts +47 -0
- package/dist/verify/model-downloads.js +127 -0
- package/dist/verify/model-downloads.js.map +1 -0
- package/package.json +8 -5
|
@@ -21,14 +21,20 @@
|
|
|
21
21
|
import * as fs from 'node:fs';
|
|
22
22
|
import * as path from 'node:path';
|
|
23
23
|
import { parseGameExchangeDeclaration, } from '@bitmagic/asset-core/game-exchange';
|
|
24
|
-
/**
|
|
25
|
-
|
|
24
|
+
/**
|
|
25
|
+
* The files the engine's exchange module is installed from, current home first; none exists in an
|
|
26
|
+
* engine that predates it. The old path stays: an engine vendored before the module moved has only it.
|
|
27
|
+
*/
|
|
28
|
+
const ENGINE_EXCHANGE_MODULES = [
|
|
29
|
+
['engine', 'engine', 'platform', 'exchange', 'installGameExchange.ts'],
|
|
30
|
+
['engine', 'engine', 'exchange', 'installGameExchange.ts'],
|
|
31
|
+
];
|
|
26
32
|
/**
|
|
27
33
|
* Whether the engine vendored into this project can show exchange tiles at all. A project's engine
|
|
28
34
|
* only moves on `bitmagic upgrade`, so a current CLI regularly meets an engine without the module.
|
|
29
35
|
*/
|
|
30
36
|
export function vendoredEngineSupportsExchange(root) {
|
|
31
|
-
return fs.existsSync(path.join(root, ...
|
|
37
|
+
return ENGINE_EXCHANGE_MODULES.some((parts) => fs.existsSync(path.join(root, ...parts)));
|
|
32
38
|
}
|
|
33
39
|
function readWorkFile(filePath) {
|
|
34
40
|
let parsed;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"game-exchange.js","sourceRoot":"","sources":["../../src/publish/game-exchange.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,KAAK,EAAE,MAAM,SAAS,CAAC;AAC9B,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAClC,OAAO,EACL,4BAA4B,GAI7B,MAAM,oCAAoC,CAAC;AAE5C
|
|
1
|
+
{"version":3,"file":"game-exchange.js","sourceRoot":"","sources":["../../src/publish/game-exchange.ts"],"names":[],"mappings":"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"}
|
|
@@ -250,7 +250,7 @@ under the level. Then, in \`src/work/Game.ts\`, after the player controller exis
|
|
|
250
250
|
|
|
251
251
|
\`\`\`ts
|
|
252
252
|
import { MeshLevel, DEFAULT_MESH_LEVEL_OPTIONS } from 'engine/meshlevel/MeshLevel.js';
|
|
253
|
-
import { DEFAULT_FALL_RESCUE_OPTIONS } from 'engine/FallRescue.js';
|
|
253
|
+
import { DEFAULT_FALL_RESCUE_OPTIONS } from 'engine/player/life/FallRescue.js';
|
|
254
254
|
import levelJson from './<name>.mesh-level.json';
|
|
255
255
|
|
|
256
256
|
const level = new MeshLevel(this.engine, { glb: { assetId: 'level-<name>' }, level: { data: levelJson } }, DEFAULT_MESH_LEVEL_OPTIONS);
|
|
@@ -643,7 +643,7 @@ Run it after EVERY rebuild; a door moved in the script and not synced is a leaf
|
|
|
643
643
|
|
|
644
644
|
\`\`\`ts
|
|
645
645
|
import { MeshLevel, DEFAULT_MESH_LEVEL_OPTIONS } from 'engine/meshlevel/MeshLevel.js';
|
|
646
|
-
import { DEFAULT_FALL_RESCUE_OPTIONS } from 'engine/FallRescue.js';
|
|
646
|
+
import { DEFAULT_FALL_RESCUE_OPTIONS } from 'engine/player/life/FallRescue.js';
|
|
647
647
|
import levelJson from './<name>.mesh-level.json';
|
|
648
648
|
|
|
649
649
|
// After setupPlayerController(), before any NPC spawns.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type * as TS from 'typescript';
|
|
2
|
+
import { type LegacyBody } from './legacy-game-baselines.js';
|
|
3
|
+
/**
|
|
4
|
+
* `bitmagic upgrade --migrate-game`: turn a Game.ts written on the old baseline — a whole
|
|
5
|
+
* `class VoxelGame implements GameModule` that built its own Spawner, VehicleManager,
|
|
6
|
+
* VehicleSpawner and PlayerLoader and handed them to the engine with `setX()` — into the small
|
|
7
|
+
* `class VoxelGame extends VoxelGameBase` every game is now, keeping only what the creator changed.
|
|
8
|
+
*
|
|
9
|
+
* It only rewrites what it can prove:
|
|
10
|
+
*
|
|
11
|
+
* - a member identical (token for token, comments and layout ignored) to the same member in any
|
|
12
|
+
* released baseline is the default, and is dropped;
|
|
13
|
+
* - `constructor`, `load`, `update` and `dispose` may additionally carry the creator's own
|
|
14
|
+
* statements, but only inserted at a point a VoxelGameBase hook runs at — after the world
|
|
15
|
+
* (`onWorldLoaded`), after the camera (`onCameraReady`), after the player controller
|
|
16
|
+
* (`onPlayerReady`), at the end of a frame (`updateCamera`), beside the vehicles' disposal
|
|
17
|
+
* (`disposeGameplay`), or at the very start or end of the method — and each default statement
|
|
18
|
+
* must still be there, in order;
|
|
19
|
+
* - a modified `setupCamera` or `setupVoxelLighting` stays as an override (both are hooks now);
|
|
20
|
+
* - anything the creator added (fields, methods, top-level code) is kept as it is, with
|
|
21
|
+
* `this.spawner` / `this.vehicleSpawner` read through `getSpawner()` / `getVehicleSpawner()`.
|
|
22
|
+
*
|
|
23
|
+
* Anything else — a default statement removed or edited, a statement added where no hook runs,
|
|
24
|
+
* a changed member VoxelGameBase also declares — leaves the file unchanged and says why. The
|
|
25
|
+
* result is then type-checked with the project's own compiler and put back if it does not
|
|
26
|
+
* compile, so a migration that runs is one that compiles.
|
|
27
|
+
*/
|
|
28
|
+
export declare const GAME_FILE = "src/work/Game.ts";
|
|
29
|
+
export declare const BASE_FILE = "src/work/VoxelGameBase.ts";
|
|
30
|
+
/** The new baseline, as the engine just vendored it. */
|
|
31
|
+
export declare const VENDORED_BASELINE_DIR = "engine/baseline";
|
|
32
|
+
/** The methods whose bodies may carry insertions, and the gaps they may sit in. */
|
|
33
|
+
export type SegmentedMethod = 'constructor' | 'load' | 'update' | 'dispose';
|
|
34
|
+
export declare const SEGMENTED_METHODS: readonly SegmentedMethod[];
|
|
35
|
+
/** A statement or member compared by its tokens alone, so comments and layout never matter. */
|
|
36
|
+
export declare function fingerprint(ts: typeof TS, node: TS.Node, sf: TS.SourceFile): string;
|
|
37
|
+
/** What one Game.ts version contributes to the known defaults (the generator's half). */
|
|
38
|
+
export interface LegacyGameShape {
|
|
39
|
+
members: Record<string, string>;
|
|
40
|
+
bodies: Partial<Record<SegmentedMethod, LegacyBody>>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The fingerprints of a pre-VoxelGameBase Game.ts — every member, plus the statement list and
|
|
44
|
+
* hook gaps of each segmented method. Null when the file has no such class. Run over every
|
|
45
|
+
* released baseline by `scripts/gen-legacy-game-baselines.mjs` to produce the known defaults.
|
|
46
|
+
*/
|
|
47
|
+
export declare function describeLegacyGame(ts: typeof TS, text: string): LegacyGameShape | null;
|
|
48
|
+
export type GameMigrationOutcome = {
|
|
49
|
+
status: 'migrated';
|
|
50
|
+
kept: string[];
|
|
51
|
+
hooks: string[];
|
|
52
|
+
wroteBase: boolean;
|
|
53
|
+
} | {
|
|
54
|
+
status: 'already-migrated';
|
|
55
|
+
} | {
|
|
56
|
+
status: 'skipped';
|
|
57
|
+
reason: string;
|
|
58
|
+
};
|
|
59
|
+
export interface PlanResult {
|
|
60
|
+
outcome: GameMigrationOutcome;
|
|
61
|
+
/** The new Game.ts, when the outcome is `migrated`. */
|
|
62
|
+
gameText?: string;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Plan the rewrite of one Game.ts against the new baseline: pure, reads nothing but its inputs.
|
|
66
|
+
*
|
|
67
|
+
* `newBaselineGame` is the vendored `engine/baseline/Game.ts` (the file a game with nothing of
|
|
68
|
+
* its own becomes, verbatim), `newBaseText` the vendored `VoxelGameBase.ts`.
|
|
69
|
+
*/
|
|
70
|
+
export declare function planGameMigration(ts: typeof TS, oldText: string, newBaselineGame: string, newBaseText: string): PlanResult;
|
|
71
|
+
export interface TypeCheckResult {
|
|
72
|
+
ok: boolean;
|
|
73
|
+
/** The compiler's output, for the report when it failed. */
|
|
74
|
+
output: string;
|
|
75
|
+
}
|
|
76
|
+
export interface MigrateGameOptions {
|
|
77
|
+
root: string;
|
|
78
|
+
ts: typeof TS;
|
|
79
|
+
/** Runs the project's type-check; the migration is kept only when it passes before and after. */
|
|
80
|
+
typeCheck: () => TypeCheckResult;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Migrate `src/work/Game.ts` onto VoxelGameBase in place: plan it, write the new Game.ts and the
|
|
84
|
+
* vendored `VoxelGameBase.ts` beside it, type-check, and put both files back as they were when the
|
|
85
|
+
* project no longer compiles. A project that did not compile before is not touched at all, since
|
|
86
|
+
* a failure afterwards could not be told apart from one it already had.
|
|
87
|
+
*/
|
|
88
|
+
export declare function migrateGameFile(options: MigrateGameOptions): GameMigrationOutcome;
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
import { LEGACY_GAME_BODIES, LEGACY_GAME_MEMBERS } from './legacy-game-baselines.js';
|
|
5
|
+
/**
|
|
6
|
+
* `bitmagic upgrade --migrate-game`: turn a Game.ts written on the old baseline — a whole
|
|
7
|
+
* `class VoxelGame implements GameModule` that built its own Spawner, VehicleManager,
|
|
8
|
+
* VehicleSpawner and PlayerLoader and handed them to the engine with `setX()` — into the small
|
|
9
|
+
* `class VoxelGame extends VoxelGameBase` every game is now, keeping only what the creator changed.
|
|
10
|
+
*
|
|
11
|
+
* It only rewrites what it can prove:
|
|
12
|
+
*
|
|
13
|
+
* - a member identical (token for token, comments and layout ignored) to the same member in any
|
|
14
|
+
* released baseline is the default, and is dropped;
|
|
15
|
+
* - `constructor`, `load`, `update` and `dispose` may additionally carry the creator's own
|
|
16
|
+
* statements, but only inserted at a point a VoxelGameBase hook runs at — after the world
|
|
17
|
+
* (`onWorldLoaded`), after the camera (`onCameraReady`), after the player controller
|
|
18
|
+
* (`onPlayerReady`), at the end of a frame (`updateCamera`), beside the vehicles' disposal
|
|
19
|
+
* (`disposeGameplay`), or at the very start or end of the method — and each default statement
|
|
20
|
+
* must still be there, in order;
|
|
21
|
+
* - a modified `setupCamera` or `setupVoxelLighting` stays as an override (both are hooks now);
|
|
22
|
+
* - anything the creator added (fields, methods, top-level code) is kept as it is, with
|
|
23
|
+
* `this.spawner` / `this.vehicleSpawner` read through `getSpawner()` / `getVehicleSpawner()`.
|
|
24
|
+
*
|
|
25
|
+
* Anything else — a default statement removed or edited, a statement added where no hook runs,
|
|
26
|
+
* a changed member VoxelGameBase also declares — leaves the file unchanged and says why. The
|
|
27
|
+
* result is then type-checked with the project's own compiler and put back if it does not
|
|
28
|
+
* compile, so a migration that runs is one that compiles.
|
|
29
|
+
*/
|
|
30
|
+
export const GAME_FILE = 'src/work/Game.ts';
|
|
31
|
+
export const BASE_FILE = 'src/work/VoxelGameBase.ts';
|
|
32
|
+
/** The new baseline, as the engine just vendored it. */
|
|
33
|
+
export const VENDORED_BASELINE_DIR = 'engine/baseline';
|
|
34
|
+
export const SEGMENTED_METHODS = ['constructor', 'load', 'update', 'dispose'];
|
|
35
|
+
/** Changed methods that are hooks now, kept as `protected` overrides. */
|
|
36
|
+
const HOOK_METHODS = new Set(['setupCamera', 'setupVoxelLighting']);
|
|
37
|
+
/** The text a node's tokens make, single-spaced: comments and layout never count. */
|
|
38
|
+
function tokenText(ts, node, sf) {
|
|
39
|
+
const tokens = [];
|
|
40
|
+
const walk = (n) => {
|
|
41
|
+
const children = n.getChildren(sf);
|
|
42
|
+
if (children.length === 0) {
|
|
43
|
+
// An empty syntax list (no arguments, no modifiers) is a leaf with no text.
|
|
44
|
+
const text = n.kind === ts.SyntaxKind.EndOfFileToken ? '' : n.getText(sf);
|
|
45
|
+
if (text.length > 0)
|
|
46
|
+
tokens.push(text);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
for (const child of children)
|
|
50
|
+
walk(child);
|
|
51
|
+
};
|
|
52
|
+
walk(node);
|
|
53
|
+
return tokens.join(' ');
|
|
54
|
+
}
|
|
55
|
+
/** A statement or member compared by its tokens alone, so comments and layout never matter. */
|
|
56
|
+
export function fingerprint(ts, node, sf) {
|
|
57
|
+
return createHash('sha1').update(tokenText(ts, node, sf)).digest('hex').slice(0, 16);
|
|
58
|
+
}
|
|
59
|
+
function memberName(ts, member, sf) {
|
|
60
|
+
if (ts.isConstructorDeclaration(member))
|
|
61
|
+
return 'constructor';
|
|
62
|
+
if (!member.name)
|
|
63
|
+
return null;
|
|
64
|
+
const name = member.name.getText(sf);
|
|
65
|
+
if (ts.isGetAccessorDeclaration(member))
|
|
66
|
+
return `get ${name}`;
|
|
67
|
+
if (ts.isSetAccessorDeclaration(member))
|
|
68
|
+
return `set ${name}`;
|
|
69
|
+
return name;
|
|
70
|
+
}
|
|
71
|
+
function findGameClass(ts, sf) {
|
|
72
|
+
for (const statement of sf.statements) {
|
|
73
|
+
if (ts.isClassDeclaration(statement) && statement.name?.text === 'VoxelGame')
|
|
74
|
+
return statement;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
/** The statements a segmented body is compared on: a `load` wrapped in try/catch is its try block. */
|
|
79
|
+
function bodyStatements(ts, method) {
|
|
80
|
+
const body = (ts.isConstructorDeclaration(method) || ts.isMethodDeclaration(method)) ? method.body : undefined;
|
|
81
|
+
if (!body)
|
|
82
|
+
return null;
|
|
83
|
+
const only = body.statements.length === 1 ? body.statements[0] : undefined;
|
|
84
|
+
if (only && ts.isTryStatement(only) && only.catchClause && !only.finallyBlock) {
|
|
85
|
+
return { statements: only.tryBlock.statements, catchClause: only.catchClause };
|
|
86
|
+
}
|
|
87
|
+
return { statements: body.statements, catchClause: null };
|
|
88
|
+
}
|
|
89
|
+
/** The named gaps of a default body: where a hook (or the method's own start/end) runs. */
|
|
90
|
+
function anchorsFor(method, texts) {
|
|
91
|
+
const at = (pred) => texts.findIndex(pred);
|
|
92
|
+
const anchors = { start: 0, end: texts.length };
|
|
93
|
+
if (method === 'constructor')
|
|
94
|
+
delete anchors.start;
|
|
95
|
+
if (method === 'load') {
|
|
96
|
+
delete anchors.start;
|
|
97
|
+
const player = at((t) => t.startsWith('this . playerLoader . prefetchPlayerAnimations') || t.startsWith('this . player = await this . playerLoader . loadPlayer'));
|
|
98
|
+
if (player >= 0)
|
|
99
|
+
anchors.onWorldLoaded = player;
|
|
100
|
+
const camera = at((t) => t === 'this . setupCamera ( ) ;');
|
|
101
|
+
if (camera >= 0)
|
|
102
|
+
anchors.onCameraReady = camera + 1;
|
|
103
|
+
const controller = at((t) => t === 'await this . setupPlayerController ( ) ;');
|
|
104
|
+
if (controller >= 0)
|
|
105
|
+
anchors.onPlayerReady = controller + 1;
|
|
106
|
+
}
|
|
107
|
+
if (method === 'dispose') {
|
|
108
|
+
const vehicles = at((t) => t === 'this . vehicleManager . dispose ( ) ;');
|
|
109
|
+
if (vehicles >= 0)
|
|
110
|
+
anchors.disposeGameplay = vehicles + 1;
|
|
111
|
+
}
|
|
112
|
+
return anchors;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The fingerprints of a pre-VoxelGameBase Game.ts — every member, plus the statement list and
|
|
116
|
+
* hook gaps of each segmented method. Null when the file has no such class. Run over every
|
|
117
|
+
* released baseline by `scripts/gen-legacy-game-baselines.mjs` to produce the known defaults.
|
|
118
|
+
*/
|
|
119
|
+
export function describeLegacyGame(ts, text) {
|
|
120
|
+
const sf = ts.createSourceFile('Game.ts', text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
121
|
+
const cls = findGameClass(ts, sf);
|
|
122
|
+
if (!cls || extendsClause(ts, cls))
|
|
123
|
+
return null;
|
|
124
|
+
const shape = { members: {}, bodies: {} };
|
|
125
|
+
for (const member of cls.members) {
|
|
126
|
+
const name = memberName(ts, member, sf);
|
|
127
|
+
if (name === null)
|
|
128
|
+
continue;
|
|
129
|
+
shape.members[name] = fingerprint(ts, member, sf);
|
|
130
|
+
if (SEGMENTED_METHODS.includes(name)) {
|
|
131
|
+
const body = bodyStatements(ts, member);
|
|
132
|
+
if (!body)
|
|
133
|
+
continue;
|
|
134
|
+
const texts = body.statements.map((s) => tokenText(ts, s, sf));
|
|
135
|
+
shape.bodies[name] = {
|
|
136
|
+
statements: body.statements.map((s) => fingerprint(ts, s, sf)),
|
|
137
|
+
...(body.catchClause ? { catchClause: fingerprint(ts, body.catchClause, sf) } : {}),
|
|
138
|
+
anchors: anchorsFor(name, texts),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return shape;
|
|
143
|
+
}
|
|
144
|
+
function extendsClause(ts, cls) {
|
|
145
|
+
for (const clause of cls.heritageClauses ?? []) {
|
|
146
|
+
if (clause.token === ts.SyntaxKind.ExtendsKeyword)
|
|
147
|
+
return clause.types[0] ?? null;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
function matchBody(ts, sf, method, statements, catchClause, version) {
|
|
152
|
+
const catchFp = catchClause ? fingerprint(ts, catchClause, sf) : undefined;
|
|
153
|
+
if (catchFp !== version.catchClause) {
|
|
154
|
+
return { ok: false, matched: 0, reason: `${method}() handles its errors differently from the baseline` };
|
|
155
|
+
}
|
|
156
|
+
const gaps = new Map();
|
|
157
|
+
let j = 0;
|
|
158
|
+
for (const statement of statements) {
|
|
159
|
+
if (j < version.statements.length && fingerprint(ts, statement, sf) === version.statements[j]) {
|
|
160
|
+
j++;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const list = gaps.get(j) ?? [];
|
|
164
|
+
list.push(statement);
|
|
165
|
+
gaps.set(j, list);
|
|
166
|
+
}
|
|
167
|
+
if (j < version.statements.length) {
|
|
168
|
+
return { ok: false, matched: j, reason: `${method}() no longer runs every statement the baseline's does (one was edited or removed)` };
|
|
169
|
+
}
|
|
170
|
+
const anchorAt = new Map(Object.entries(version.anchors).map(([name, gap]) => [gap, name]));
|
|
171
|
+
const byAnchor = new Map();
|
|
172
|
+
for (const [gap, added] of gaps) {
|
|
173
|
+
const anchor = anchorAt.get(gap);
|
|
174
|
+
if (anchor === undefined) {
|
|
175
|
+
const first = added[0];
|
|
176
|
+
const preview = first ? tokenText(ts, first, sf).slice(0, 60) : '';
|
|
177
|
+
return { ok: false, matched: j, reason: `${method}() has code added where no VoxelGameBase hook runs (\`${preview}\`)` };
|
|
178
|
+
}
|
|
179
|
+
byAnchor.set(anchor, added);
|
|
180
|
+
}
|
|
181
|
+
return { ok: true, insertions: { byAnchor } };
|
|
182
|
+
}
|
|
183
|
+
// ── Output ─────────────────────────────────────────────────────────────────────────────
|
|
184
|
+
const MEMBER_INDENT = ' ';
|
|
185
|
+
const BODY_INDENT = ' ';
|
|
186
|
+
/** A text block with its blank edges trimmed and its common indentation replaced by `indent`. */
|
|
187
|
+
function reindentText(text, indent) {
|
|
188
|
+
const lines = text.replace(/^\s*\n/, '').replace(/\s+$/, '').split('\n');
|
|
189
|
+
const widths = lines.filter((l) => l.trim().length > 0).map((l) => /^[ \t]*/.exec(l)?.[0].length ?? 0);
|
|
190
|
+
const strip = widths.length > 0 ? Math.min(...widths) : 0;
|
|
191
|
+
return lines.map((l) => (l.trim().length === 0 ? '' : indent + l.slice(strip))).join('\n');
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* A node's text with its leading comments, re-indented, with `this.spawner` /
|
|
195
|
+
* `this.vehicleSpawner` read through the getters VoxelGameBase keeps (the fields are gone: the
|
|
196
|
+
* engine owns both now) and a `private` modifier made `protected` when asked. Null when the node
|
|
197
|
+
* assigns one of those fields, which no rewrite can keep working.
|
|
198
|
+
*/
|
|
199
|
+
function nodeText(ts, sf, node, indent, makeProtected = false) {
|
|
200
|
+
const offset = node.getFullStart();
|
|
201
|
+
const edits = [];
|
|
202
|
+
let assigned = false;
|
|
203
|
+
const visit = (n) => {
|
|
204
|
+
if (ts.isPropertyAccessExpression(n) && n.expression.kind === ts.SyntaxKind.ThisKeyword
|
|
205
|
+
&& (n.name.text === 'spawner' || n.name.text === 'vehicleSpawner')) {
|
|
206
|
+
const parent = n.parent;
|
|
207
|
+
if (ts.isBinaryExpression(parent) && parent.left === n && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken)
|
|
208
|
+
assigned = true;
|
|
209
|
+
const getter = n.name.text === 'spawner' ? 'getSpawner' : 'getVehicleSpawner';
|
|
210
|
+
edits.push({ start: n.getStart(sf) - offset, end: n.end - offset, text: `this.${getter}()` });
|
|
211
|
+
}
|
|
212
|
+
ts.forEachChild(n, visit);
|
|
213
|
+
};
|
|
214
|
+
visit(node);
|
|
215
|
+
if (assigned)
|
|
216
|
+
return null;
|
|
217
|
+
if (makeProtected && ts.canHaveModifiers(node)) {
|
|
218
|
+
const modifier = ts.getModifiers(node)?.find((m) => m.kind === ts.SyntaxKind.PrivateKeyword);
|
|
219
|
+
if (modifier)
|
|
220
|
+
edits.push({ start: modifier.getStart(sf) - offset, end: modifier.end - offset, text: 'protected' });
|
|
221
|
+
}
|
|
222
|
+
let text = sf.text.slice(offset, node.end);
|
|
223
|
+
for (const e of edits.sort((x, y) => y.start - x.start))
|
|
224
|
+
text = text.slice(0, e.start) + e.text + text.slice(e.end);
|
|
225
|
+
return reindentText(text, indent);
|
|
226
|
+
}
|
|
227
|
+
function statementsText(ts, sf, statements, indent) {
|
|
228
|
+
const parts = [];
|
|
229
|
+
for (const statement of statements) {
|
|
230
|
+
const text = nodeText(ts, sf, statement, indent);
|
|
231
|
+
if (text === null)
|
|
232
|
+
return null;
|
|
233
|
+
parts.push(text);
|
|
234
|
+
}
|
|
235
|
+
return parts.join('\n');
|
|
236
|
+
}
|
|
237
|
+
/** The hook override a set of insertions becomes: its name and its signature, and whether the creator's code runs before `super`. */
|
|
238
|
+
function hookFor(method, anchor, param) {
|
|
239
|
+
switch (`${method}:${anchor}`) {
|
|
240
|
+
case 'load:onWorldLoaded':
|
|
241
|
+
return { name: 'onWorldLoaded', signature: 'protected onWorldLoaded(): void', superCall: 'super.onWorldLoaded();', before: false };
|
|
242
|
+
case 'load:onCameraReady':
|
|
243
|
+
return { name: 'onCameraReady', signature: 'protected onCameraReady(): void', superCall: 'super.onCameraReady();', before: false };
|
|
244
|
+
case 'load:onPlayerReady':
|
|
245
|
+
return { name: 'onPlayerReady', signature: 'protected onPlayerReady(): void', superCall: 'super.onPlayerReady();', before: false };
|
|
246
|
+
case 'load:end':
|
|
247
|
+
return { name: 'load', signature: `async load(${param}: string): Promise<void>`, superCall: `await super.load(${param});`, before: false };
|
|
248
|
+
case 'update:start':
|
|
249
|
+
return { name: 'update', signature: `update(${param}: number): void`, superCall: `super.update(${param});`, before: true };
|
|
250
|
+
case 'update:end':
|
|
251
|
+
return { name: 'updateCamera', signature: `protected updateCamera(${param}: number): void`, superCall: `super.updateCamera(${param});`, before: false };
|
|
252
|
+
case 'dispose:start':
|
|
253
|
+
return { name: 'dispose', signature: 'dispose(): void', superCall: 'super.dispose();', before: true };
|
|
254
|
+
case 'dispose:disposeGameplay':
|
|
255
|
+
return { name: 'disposeGameplay', signature: 'protected disposeGameplay(): void', superCall: 'super.disposeGameplay();', before: false };
|
|
256
|
+
case 'dispose:end':
|
|
257
|
+
return { name: 'dispose', signature: 'dispose(): void', superCall: 'super.dispose();', before: false };
|
|
258
|
+
default:
|
|
259
|
+
throw new Error(`no hook for ${method}:${anchor}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** The names a class declares, read from the vendored VoxelGameBase. */
|
|
263
|
+
function baseMemberNames(ts, baseText) {
|
|
264
|
+
const sf = ts.createSourceFile('VoxelGameBase.ts', baseText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
265
|
+
const names = new Set();
|
|
266
|
+
for (const statement of sf.statements) {
|
|
267
|
+
if (!ts.isClassDeclaration(statement) || statement.name?.text !== 'VoxelGameBase')
|
|
268
|
+
continue;
|
|
269
|
+
for (const member of statement.members) {
|
|
270
|
+
const name = memberName(ts, member, sf);
|
|
271
|
+
if (name !== null)
|
|
272
|
+
names.add(name.replace(/^(get|set) /, ''));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return names;
|
|
276
|
+
}
|
|
277
|
+
/** Every identifier in a text, for pruning imports the new file no longer uses. */
|
|
278
|
+
function identifiersIn(ts, text) {
|
|
279
|
+
const sf = ts.createSourceFile('x.ts', text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
280
|
+
const out = new Set();
|
|
281
|
+
const visit = (n) => {
|
|
282
|
+
if (ts.isIdentifier(n))
|
|
283
|
+
out.add(n.text);
|
|
284
|
+
ts.forEachChild(n, visit);
|
|
285
|
+
};
|
|
286
|
+
visit(sf);
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
/** An import declaration with only the bindings `used` names, or null when none are left. */
|
|
290
|
+
function prunedImport(ts, sf, decl, used) {
|
|
291
|
+
const clause = decl.importClause;
|
|
292
|
+
if (!clause)
|
|
293
|
+
return decl.getText(sf); // a side-effect import is always kept
|
|
294
|
+
const keepDefault = clause.name !== undefined && used.has(clause.name.text);
|
|
295
|
+
const bindings = clause.namedBindings;
|
|
296
|
+
let named = null;
|
|
297
|
+
if (bindings && ts.isNamespaceImport(bindings)) {
|
|
298
|
+
named = used.has(bindings.name.text) ? bindings.getText(sf) : null;
|
|
299
|
+
}
|
|
300
|
+
else if (bindings && ts.isNamedImports(bindings)) {
|
|
301
|
+
const kept = bindings.elements.filter((e) => used.has(e.name.text)).map((e) => e.getText(sf));
|
|
302
|
+
named = kept.length > 0 ? `{ ${kept.join(', ')} }` : null;
|
|
303
|
+
}
|
|
304
|
+
if (!keepDefault && named === null)
|
|
305
|
+
return null;
|
|
306
|
+
if (keepDefault && named === null && bindings === undefined)
|
|
307
|
+
return decl.getText(sf);
|
|
308
|
+
const parts = [keepDefault && clause.name ? clause.name.text : null, named].filter((p) => p !== null);
|
|
309
|
+
const typeOnly = clause.isTypeOnly ? 'type ' : '';
|
|
310
|
+
return `import ${typeOnly}${parts.join(', ')} from ${decl.moduleSpecifier.getText(sf)};`;
|
|
311
|
+
}
|
|
312
|
+
const ASSIGNS_ENGINE_FIELD = 'assigns this.spawner or this.vehicleSpawner, which the engine owns now';
|
|
313
|
+
/**
|
|
314
|
+
* Plan the rewrite of one Game.ts against the new baseline: pure, reads nothing but its inputs.
|
|
315
|
+
*
|
|
316
|
+
* `newBaselineGame` is the vendored `engine/baseline/Game.ts` (the file a game with nothing of
|
|
317
|
+
* its own becomes, verbatim), `newBaseText` the vendored `VoxelGameBase.ts`.
|
|
318
|
+
*/
|
|
319
|
+
export function planGameMigration(ts, oldText, newBaselineGame, newBaseText) {
|
|
320
|
+
const skip = (reason) => ({ outcome: { status: 'skipped', reason } });
|
|
321
|
+
const sf = ts.createSourceFile('Game.ts', oldText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
322
|
+
const cls = findGameClass(ts, sf);
|
|
323
|
+
if (!cls)
|
|
324
|
+
return skip('Game.ts has no `VoxelGame` class');
|
|
325
|
+
const heritage = extendsClause(ts, cls);
|
|
326
|
+
if (heritage) {
|
|
327
|
+
return heritage.expression.getText(sf) === 'VoxelGameBase'
|
|
328
|
+
? { outcome: { status: 'already-migrated' } }
|
|
329
|
+
: skip(`VoxelGame extends ${heritage.expression.getText(sf)}, not the old baseline's class`);
|
|
330
|
+
}
|
|
331
|
+
const baseNames = baseMemberNames(ts, newBaseText);
|
|
332
|
+
if (baseNames.size === 0)
|
|
333
|
+
return skip('the vendored engine/baseline/VoxelGameBase.ts declares no VoxelGameBase class');
|
|
334
|
+
const fields = [];
|
|
335
|
+
const methods = [];
|
|
336
|
+
const kept = [];
|
|
337
|
+
const overrides = new Map();
|
|
338
|
+
let constructorTail = null;
|
|
339
|
+
for (const member of cls.members) {
|
|
340
|
+
const name = memberName(ts, member, sf);
|
|
341
|
+
if (name === null)
|
|
342
|
+
return skip('Game.ts has a class member this migration cannot name');
|
|
343
|
+
if (LEGACY_GAME_MEMBERS[name]?.includes(fingerprint(ts, member, sf)))
|
|
344
|
+
continue; // the default: VoxelGameBase has it
|
|
345
|
+
if (SEGMENTED_METHODS.includes(name)) {
|
|
346
|
+
const method = name;
|
|
347
|
+
const body = bodyStatements(ts, member);
|
|
348
|
+
if (!body)
|
|
349
|
+
return skip(`${method}() has no body to compare`);
|
|
350
|
+
let best = null;
|
|
351
|
+
for (const version of LEGACY_GAME_BODIES[method]) {
|
|
352
|
+
const match = matchBody(ts, sf, method, body.statements, body.catchClause, version);
|
|
353
|
+
if (match.ok) {
|
|
354
|
+
best = match;
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
if (!best || (!best.ok && match.matched > best.matched))
|
|
358
|
+
best = match;
|
|
359
|
+
}
|
|
360
|
+
if (!best)
|
|
361
|
+
return skip(`${method}() matches no released baseline`);
|
|
362
|
+
if (!best.ok)
|
|
363
|
+
return skip(best.reason);
|
|
364
|
+
const declared = ts.isMethodDeclaration(member) ? member.parameters[0]?.name.getText(sf) : undefined;
|
|
365
|
+
const param = declared ?? (method === 'load' ? 'gameId' : 'deltaTime');
|
|
366
|
+
for (const [anchor, added] of best.insertions.byAnchor) {
|
|
367
|
+
const text = statementsText(ts, sf, added, BODY_INDENT);
|
|
368
|
+
if (text === null)
|
|
369
|
+
return skip(`${method}() ${ASSIGNS_ENGINE_FIELD}`);
|
|
370
|
+
if (method === 'constructor') {
|
|
371
|
+
constructorTail = text;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const hook = hookFor(method, anchor, param);
|
|
375
|
+
if (anchor !== 'end' && method === 'load' && added.some((s) => /\bawait\b/.test(s.getText(sf)))) {
|
|
376
|
+
return skip(`load() awaits inside code that would move into the synchronous ${hook.name}() hook`);
|
|
377
|
+
}
|
|
378
|
+
if (overrides.has(hook.name))
|
|
379
|
+
return skip(`two parts of the old Game.ts would both become ${hook.name}()`);
|
|
380
|
+
const superLine = `${BODY_INDENT}${hook.superCall}`;
|
|
381
|
+
const inner = hook.before ? `${text}\n${superLine}` : `${superLine}\n${text}`;
|
|
382
|
+
overrides.set(hook.name, `${MEMBER_INDENT}${hook.signature} {\n${inner}\n${MEMBER_INDENT}}`);
|
|
383
|
+
}
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const isHook = HOOK_METHODS.has(name) && ts.isMethodDeclaration(member);
|
|
387
|
+
if (!isHook && baseNames.has(name)) {
|
|
388
|
+
return skip(`${name} is changed from the baseline and VoxelGameBase declares it too — move the change into a hook by hand`);
|
|
389
|
+
}
|
|
390
|
+
if (name === 'spawner' || name === 'vehicleSpawner') {
|
|
391
|
+
return skip(`the ${name} field is changed from the baseline; the engine owns it now`);
|
|
392
|
+
}
|
|
393
|
+
// A changed setupCamera/setupVoxelLighting is the same call in VoxelGameBase, now protected.
|
|
394
|
+
const text = nodeText(ts, sf, member, MEMBER_INDENT, isHook);
|
|
395
|
+
if (text === null)
|
|
396
|
+
return skip(`${name} ${ASSIGNS_ENGINE_FIELD}`);
|
|
397
|
+
if (ts.isPropertyDeclaration(member)) {
|
|
398
|
+
fields.push(text);
|
|
399
|
+
kept.push(`field ${name}`);
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
methods.push(text);
|
|
403
|
+
kept.push(isHook ? `${name}() (kept as an override)` : `${name}()`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// Everything outside the class (helpers, constants) stays, and so do the imports it and the kept members use.
|
|
407
|
+
const outside = [];
|
|
408
|
+
const imports = [];
|
|
409
|
+
for (const statement of sf.statements) {
|
|
410
|
+
if (statement === cls)
|
|
411
|
+
continue;
|
|
412
|
+
if (ts.isImportDeclaration(statement)) {
|
|
413
|
+
imports.push(statement);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const text = nodeText(ts, sf, statement, '');
|
|
417
|
+
if (text === null)
|
|
418
|
+
return skip(`code outside the class ${ASSIGNS_ENGINE_FIELD}`);
|
|
419
|
+
outside.push(text);
|
|
420
|
+
}
|
|
421
|
+
const hooks = [...overrides.keys()];
|
|
422
|
+
if (fields.length === 0 && methods.length === 0 && overrides.size === 0 && constructorTail === null && outside.length === 0) {
|
|
423
|
+
return { outcome: { status: 'migrated', kept: [], hooks: [], wroteBase: false }, gameText: newBaselineGame };
|
|
424
|
+
}
|
|
425
|
+
const ctorTail = constructorTail === null ? '' : `\n${constructorTail}`;
|
|
426
|
+
const constructorText = `${MEMBER_INDENT}constructor(engine: EngineLike, worldProfileData: WorldProfileData, gameData?: GameData) {\n`
|
|
427
|
+
+ `${BODY_INDENT}super(engine, worldProfileData, gameData);${ctorTail}\n${MEMBER_INDENT}}`;
|
|
428
|
+
const classBody = [fields.join('\n'), constructorText, ...overrides.values(), ...methods].filter((p) => p.length > 0).join('\n\n');
|
|
429
|
+
// The new baseline's file comment and class comment, so a migrated file reads like a new one.
|
|
430
|
+
const newSf = ts.createSourceFile('Game.ts', newBaselineGame, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
431
|
+
const newClass = findGameClass(ts, newSf);
|
|
432
|
+
const firstStatement = newSf.statements[0];
|
|
433
|
+
const header = firstStatement ? newBaselineGame.slice(0, firstStatement.getStart(newSf)) : '';
|
|
434
|
+
const classDoc = newClass ? newBaselineGame.slice(newClass.pos, newClass.getStart(newSf)).trim() : '';
|
|
435
|
+
const classText = `${classDoc ? `${classDoc}\n` : ''}export class VoxelGame extends VoxelGameBase {\n${classBody}\n}`;
|
|
436
|
+
const body = [...outside, classText].join('\n\n');
|
|
437
|
+
const used = identifiersIn(ts, body);
|
|
438
|
+
const importLines = imports.map((d) => prunedImport(ts, sf, d, used)).filter((l) => l !== null);
|
|
439
|
+
const joined = importLines.join('\n');
|
|
440
|
+
if (!['WorldProfileData', 'EngineLike', 'GameData'].every((n) => new RegExp(`\\b${n}\\b`).test(joined))) {
|
|
441
|
+
importLines.unshift("import type { WorldProfileData, EngineLike, GameData } from 'types/game.js';");
|
|
442
|
+
}
|
|
443
|
+
importLines.push("import { VoxelGameBase } from './VoxelGameBase.js';");
|
|
444
|
+
const gameText = `${header}${[...new Set(importLines)].join('\n')}\n\n${body}\n`;
|
|
445
|
+
return { outcome: { status: 'migrated', kept, hooks, wroteBase: false }, gameText };
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Migrate `src/work/Game.ts` onto VoxelGameBase in place: plan it, write the new Game.ts and the
|
|
449
|
+
* vendored `VoxelGameBase.ts` beside it, type-check, and put both files back as they were when the
|
|
450
|
+
* project no longer compiles. A project that did not compile before is not touched at all, since
|
|
451
|
+
* a failure afterwards could not be told apart from one it already had.
|
|
452
|
+
*/
|
|
453
|
+
export function migrateGameFile(options) {
|
|
454
|
+
const { root, ts } = options;
|
|
455
|
+
const gamePath = path.join(root, GAME_FILE);
|
|
456
|
+
const basePath = path.join(root, BASE_FILE);
|
|
457
|
+
const vendoredGame = path.join(root, VENDORED_BASELINE_DIR, 'Game.ts');
|
|
458
|
+
const vendoredBase = path.join(root, VENDORED_BASELINE_DIR, 'VoxelGameBase.ts');
|
|
459
|
+
if (!fs.existsSync(gamePath))
|
|
460
|
+
return { status: 'skipped', reason: `${GAME_FILE} does not exist` };
|
|
461
|
+
if (!fs.existsSync(vendoredBase) || !fs.existsSync(vendoredGame)) {
|
|
462
|
+
return { status: 'skipped', reason: 'the vendored engine has no baseline/VoxelGameBase.ts — upgrade to an engine that has one first' };
|
|
463
|
+
}
|
|
464
|
+
const oldText = fs.readFileSync(gamePath, 'utf-8');
|
|
465
|
+
const baseText = fs.readFileSync(vendoredBase, 'utf-8');
|
|
466
|
+
const plan = planGameMigration(ts, oldText, fs.readFileSync(vendoredGame, 'utf-8'), baseText);
|
|
467
|
+
if (plan.outcome.status !== 'migrated' || plan.gameText === undefined)
|
|
468
|
+
return plan.outcome;
|
|
469
|
+
const existingBase = fs.existsSync(basePath) ? fs.readFileSync(basePath, 'utf-8') : null;
|
|
470
|
+
if (existingBase !== null && existingBase !== baseText) {
|
|
471
|
+
return { status: 'skipped', reason: `${BASE_FILE} already exists and differs from the engine's — merge it by hand first` };
|
|
472
|
+
}
|
|
473
|
+
const before = options.typeCheck();
|
|
474
|
+
if (!before.ok) {
|
|
475
|
+
return { status: 'skipped', reason: `the project does not type-check before the migration; fix that first (run \`bitmagic check\`):\n${firstLines(before.output)}` };
|
|
476
|
+
}
|
|
477
|
+
fs.writeFileSync(gamePath, plan.gameText);
|
|
478
|
+
if (existingBase === null)
|
|
479
|
+
fs.writeFileSync(basePath, baseText);
|
|
480
|
+
const after = options.typeCheck();
|
|
481
|
+
if (!after.ok) {
|
|
482
|
+
fs.writeFileSync(gamePath, oldText);
|
|
483
|
+
if (existingBase === null)
|
|
484
|
+
fs.rmSync(basePath);
|
|
485
|
+
return { status: 'skipped', reason: `the migrated Game.ts does not type-check, so it was put back:\n${firstLines(after.output)}` };
|
|
486
|
+
}
|
|
487
|
+
return { ...plan.outcome, wroteBase: existingBase === null };
|
|
488
|
+
}
|
|
489
|
+
function firstLines(text, count = 8) {
|
|
490
|
+
return text.split('\n').filter((l) => l.trim().length > 0).slice(0, count).map((l) => ` ${l}`).join('\n');
|
|
491
|
+
}
|
|
492
|
+
//# sourceMappingURL=game-migration.js.map
|