@cmpose/core 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +3 -2
- package/src/game/buildScenes.js +40 -0
- package/src/game/contract.js +40 -0
- package/src/game/describe.js +25 -0
- package/src/game/flags.js +314 -0
- package/src/game/generate.js +86 -0
- package/src/game/guid.js +71 -0
- package/src/game/guidIntegrity.js +81 -0
- package/src/game/identity.js +78 -0
- package/src/game/ids.js +88 -0
- package/src/game/index.js +36 -0
- package/src/game/markers.js +32 -0
- package/src/game/modules.js +138 -0
- package/src/game/packages.js +31 -0
- package/src/game/projectSettings.js +53 -0
- package/src/game/readme.js +20 -0
- package/src/game/record.js +47 -0
- package/src/game/residue.js +40 -0
- package/src/game/unityYaml.js +193 -0
- package/src/game/validate.js +160 -0
- package/src/game/verifier.js +36 -0
- package/src/index.js +1 -0
- package/src/pins.js +3 -1
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
# @cmpose/core
|
|
2
2
|
|
|
3
3
|
The cmpose generator library: it turns the pinned Compose Multiplatform template into a new project (validation, package and name rewrites, optional features and modules). It is the same code cmpose.dev runs. See the README at the root of the cmpose repository for usage and development.
|
|
4
|
+
|
|
5
|
+
It also generates Unity mobile games from the pinned game template: `game.validateGame`, `game.generateGame` and `game.describeGame`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cmpose/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "cmpose project generator core (Compose Multiplatform)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"node": ">=20"
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
|
-
"test": "node --test test/*.test.js"
|
|
32
|
+
"test": "node --test test/*.test.js",
|
|
33
|
+
"prepublishOnly": "node scripts/check-pins.js"
|
|
33
34
|
},
|
|
34
35
|
"dependencies": {
|
|
35
36
|
"fs-extra": "^11.3.2"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
|
|
4
|
+
// ProjectSettings/EditorBuildSettings.asset lists the scenes a build contains, three lines each:
|
|
5
|
+
// - enabled: 1
|
|
6
|
+
// path: Assets/…/Name.unity
|
|
7
|
+
// guid: <32 hex>
|
|
8
|
+
// A flag that deletes a scene file must drop its entry too, or the build skips it and its guid dangles.
|
|
9
|
+
const FILE = 'ProjectSettings/EditorBuildSettings.asset';
|
|
10
|
+
const ENTRY = /^ {2}- enabled: [01]\r?\n {4}path: (.*?)\r?\n {4}guid: [0-9a-f]{32}\r?\n/gm;
|
|
11
|
+
|
|
12
|
+
async function readSettings(projectDir) {
|
|
13
|
+
const file = path.join(projectDir, FILE);
|
|
14
|
+
return (await fs.pathExists(file)) ? { file, text: await fs.readFile(file, 'utf8') } : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function pruneBuildScenes(projectDir) {
|
|
18
|
+
const settings = await readSettings(projectDir);
|
|
19
|
+
if (!settings) return [];
|
|
20
|
+
const dropped = [];
|
|
21
|
+
const next = settings.text.replace(ENTRY, (entry, scene) => {
|
|
22
|
+
if (fs.existsSync(path.join(projectDir, scene))) return entry;
|
|
23
|
+
dropped.push(scene);
|
|
24
|
+
return '';
|
|
25
|
+
});
|
|
26
|
+
if (dropped.length) await fs.writeFile(settings.file, next);
|
|
27
|
+
return dropped;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function findMissingBuildScenes(projectDir) {
|
|
31
|
+
const settings = await readSettings(projectDir);
|
|
32
|
+
if (!settings) return [];
|
|
33
|
+
const problems = [];
|
|
34
|
+
for (const [, scene] of settings.text.matchAll(ENTRY)) {
|
|
35
|
+
if (!(await fs.pathExists(path.join(projectDir, scene)))) problems.push(`build scene missing: ${scene}`);
|
|
36
|
+
}
|
|
37
|
+
return problems;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { pruneBuildScenes, findMissingBuildScenes };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { hashContract } = require('../contract');
|
|
3
|
+
const limits = require('../limits');
|
|
4
|
+
|
|
5
|
+
// The Unity game generator's contract: what the game_* tools accept. Published facts feed contractVersion.
|
|
6
|
+
const GAME_SCHEMA = Object.freeze({
|
|
7
|
+
engine: 'unity',
|
|
8
|
+
unityVersion: '6000.3.25f1',
|
|
9
|
+
templateTypes: ['showcase', 'blank'],
|
|
10
|
+
renders: ['2d', '3d'],
|
|
11
|
+
flags: ['saveSystem', 'uiTheme', 'multiLang', 'network', 'networkInspector', 'purchases', 'ads', 'leaderboards'],
|
|
12
|
+
flagRequires: { networkInspector: ['network'] },
|
|
13
|
+
idGroups: {
|
|
14
|
+
admob: { flag: 'ads', fields: ['androidAppId', 'iosAppId', 'androidBanner', 'androidInterstitial', 'androidRewarded', 'iosBanner', 'iosInterstitial', 'iosRewarded'] },
|
|
15
|
+
revenueCat: { flag: 'purchases', fields: ['appleApiKey', 'googleApiKey'] },
|
|
16
|
+
gameServices: { flag: 'leaderboards', fields: ['androidAppId', 'leaderboardAndroid', 'firstCompletionAndroid', 'tenCompletionsAndroid', 'leaderboardIos', 'firstCompletionIos', 'tenCompletionsIos'] }
|
|
17
|
+
},
|
|
18
|
+
limits: { minSdkMin: 25, minSdkMax: 36, iosVersionMin: '15.0', maxModules: 3, nameMax: 30, appNameMax: 30, packageNameMax: 50, moduleNameMax: 20 },
|
|
19
|
+
reservedNames: ['UnityBase', 'Kturker'],
|
|
20
|
+
// Module names a copy of Sample cannot take: the template's own folders; names whose copy breaks C# binding
|
|
21
|
+
// (game: GameInstaller would call itself; action, i: VContainer's ActionInstaller/IInstaller; bonus, leaderboard:
|
|
22
|
+
// Sample's own BonusView/LeaderboardView); names whose namespace would hide a type the Sample code uses
|
|
23
|
+
// (Button, Lifetime, Object, ...); and the template's string-key prefixes (a duplicate key is dropped)
|
|
24
|
+
reservedModuleNames: ['sample', 'shell', 'showcase', 'build',
|
|
25
|
+
'game', 'action', 'i', 'bonus', 'leaderboard',
|
|
26
|
+
'button', 'lifetime', 'object', 'preserve', 'resources', 'serializable', 'transform',
|
|
27
|
+
'about', 'ads', 'boot', 'common', 'guide', 'inspector', 'leaderboards', 'menu', 'network', 'pause',
|
|
28
|
+
'purchases', 'runner2d', 'settings', 'theme', 'track3d', 'update'],
|
|
29
|
+
reservedPackagePrefix: 'com.kturker'
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function gameContractFacts() {
|
|
33
|
+
return { ...GAME_SCHEMA, limits: { ...GAME_SCHEMA.limits, iosVersionMax: limits.iosVersionMax } };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function gameContractVersion() {
|
|
37
|
+
return hashContract(gameContractFacts());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { GAME_SCHEMA, gameContractFacts, gameContractVersion };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const pins = require('../pins');
|
|
3
|
+
const { GAME_SCHEMA, gameContractFacts, gameContractVersion } = require('./contract');
|
|
4
|
+
|
|
5
|
+
/** What the Unity game generator offers: engine, pinned template, defaults and the contract facts */
|
|
6
|
+
function describeGame() {
|
|
7
|
+
return {
|
|
8
|
+
engine: GAME_SCHEMA.engine,
|
|
9
|
+
unityVersion: GAME_SCHEMA.unityVersion,
|
|
10
|
+
contractVersion: gameContractVersion(),
|
|
11
|
+
template: pins.game,
|
|
12
|
+
defaults: {
|
|
13
|
+
templateType: 'showcase',
|
|
14
|
+
render: '2d',
|
|
15
|
+
flags: { showcase: GAME_SCHEMA.flags.slice(), blank: [] },
|
|
16
|
+
features: [],
|
|
17
|
+
minSdk: GAME_SCHEMA.limits.minSdkMin,
|
|
18
|
+
iosVersion: GAME_SCHEMA.limits.iosVersionMin,
|
|
19
|
+
ci: false
|
|
20
|
+
},
|
|
21
|
+
contract: gameContractFacts()
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { describeGame };
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const { removeLineMarkedBlock, stripLineMarkers } = require('./markers');
|
|
4
|
+
const { removePackagesInProject } = require('./packages');
|
|
5
|
+
const { getDefines, setDefines } = require('./projectSettings');
|
|
6
|
+
const { readMetaGuid } = require('./guid');
|
|
7
|
+
const { findResidue } = require('./residue');
|
|
8
|
+
const { pruneBuildScenes } = require('./buildScenes');
|
|
9
|
+
|
|
10
|
+
// cmpose.dev/game: a feature flag is folders + marked C# blocks ('// cmpose:<flag>' … '// /cmpose:<flag>') +
|
|
11
|
+
// asmdef references + packages + a scripting define, all listed in the template's .cmpose/flags.json. A flag may
|
|
12
|
+
// require others ("requires"); turning one off turns off the flags that require it. A render
|
|
13
|
+
// other than the upstream one is an overlay under .cmpose/layers/ whose files replace the project's (same guids).
|
|
14
|
+
// Turning flags off and picking the render only edits files; Unity never runs here.
|
|
15
|
+
// Internal flags (Faz 2f: sample, showcase and its games) are never turned off by name: a project kind does it
|
|
16
|
+
// ("kinds": blank turns the showcase off, showcase turns the sample off and keeps the render's game). Scenes whose
|
|
17
|
+
// file a flag removes are dropped from Build Settings.
|
|
18
|
+
const MANIFEST = '.cmpose/flags.json';
|
|
19
|
+
const LAYERS = '.cmpose/layers';
|
|
20
|
+
const DEFINE_TARGETS = ['Android', 'iPhone'];
|
|
21
|
+
const SYMBOL = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
22
|
+
const FLAG_NAME = /^[A-Za-z][A-Za-z0-9]*$/;
|
|
23
|
+
const RENDER_NAME = /^[a-z0-9]+$/;
|
|
24
|
+
const KIND_NAME = /^[a-z]+$/;
|
|
25
|
+
// Files only the upstream repo or Unity's local state carry; never scanned for residue
|
|
26
|
+
const RESIDUE_IGNORE = ['README.md', 'MAINTAINING.md', 'tools/', 'Builds/', 'Assets/Editor/Maintainer/', '.cmpose/'];
|
|
27
|
+
|
|
28
|
+
const isObject = (v) => v && typeof v === 'object' && !Array.isArray(v);
|
|
29
|
+
|
|
30
|
+
function safeRel(p, what) {
|
|
31
|
+
if (typeof p !== 'string' || !p || path.isAbsolute(p) || p.split(/[\\/]/).includes('..')) {
|
|
32
|
+
throw new Error(`${MANIFEST}: ${what}: '${p}' must be a relative path inside the project`);
|
|
33
|
+
}
|
|
34
|
+
return p;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function nameList(value, what) {
|
|
38
|
+
if (value === undefined) return [];
|
|
39
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string' || !v)) {
|
|
40
|
+
throw new Error(`${MANIFEST}: ${what} must be a list of names`);
|
|
41
|
+
}
|
|
42
|
+
return [...value];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readInternal(value, name) {
|
|
46
|
+
if (value === undefined) return false;
|
|
47
|
+
if (typeof value !== 'boolean') throw new Error(`${MANIFEST}: flag ${name} "internal" must be true or false`);
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A manifest from before Faz 2f has no "kinds": it only knows blank, which turns nothing off
|
|
52
|
+
function parseKinds(value, flags, renders) {
|
|
53
|
+
if (value === undefined) return { blank: { off: [], games: {} } };
|
|
54
|
+
if (!isObject(value) || !isObject(value.blank)) throw new Error(`${MANIFEST}: "kinds" needs a "blank" kind`);
|
|
55
|
+
const kinds = {};
|
|
56
|
+
for (const [name, kind] of Object.entries(value)) {
|
|
57
|
+
if (!KIND_NAME.test(name)) throw new Error(`${MANIFEST}: '${name}' is not a valid kind name`);
|
|
58
|
+
if (!isObject(kind)) throw new Error(`${MANIFEST}: kind ${name} must be an object`);
|
|
59
|
+
const off = nameList(kind.off, `kind ${name} off`);
|
|
60
|
+
for (const flag of off) {
|
|
61
|
+
if (!flags[flag]) throw new Error(`${MANIFEST}: kind ${name} turns off unknown flag ${flag}`);
|
|
62
|
+
if (!flags[flag].internal) throw new Error(`${MANIFEST}: kind ${name} turns off ${flag}, which is not internal`);
|
|
63
|
+
}
|
|
64
|
+
const games = {};
|
|
65
|
+
if (kind.games !== undefined) {
|
|
66
|
+
if (!isObject(kind.games)) throw new Error(`${MANIFEST}: kind ${name} games must map renders to flags`);
|
|
67
|
+
for (const [render, game] of Object.entries(kind.games)) {
|
|
68
|
+
if (!Object.prototype.hasOwnProperty.call(renders, render)) {
|
|
69
|
+
throw new Error(`${MANIFEST}: kind ${name} has a game for unknown render ${render}`);
|
|
70
|
+
}
|
|
71
|
+
if (!flags[game] || !flags[game].internal) {
|
|
72
|
+
throw new Error(`${MANIFEST}: kind ${name} game ${game} must be an internal flag`);
|
|
73
|
+
}
|
|
74
|
+
games[render] = game;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
kinds[name] = { off, games };
|
|
78
|
+
}
|
|
79
|
+
return kinds;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseFlagManifest(text) {
|
|
83
|
+
let raw;
|
|
84
|
+
try {
|
|
85
|
+
raw = JSON.parse(text);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
throw new Error(`${MANIFEST}: ${error.message}`);
|
|
88
|
+
}
|
|
89
|
+
if (!isObject(raw) || !isObject(raw.flags) || !isObject(raw.renders)) {
|
|
90
|
+
throw new Error(`${MANIFEST}: needs "flags" and "renders" objects`);
|
|
91
|
+
}
|
|
92
|
+
const flags = {};
|
|
93
|
+
for (const [name, flag] of Object.entries(raw.flags)) {
|
|
94
|
+
if (!FLAG_NAME.test(name)) throw new Error(`${MANIFEST}: '${name}' is not a valid flag name`);
|
|
95
|
+
if (!isObject(flag) || !SYMBOL.test(flag.define || '')) throw new Error(`${MANIFEST}: flag ${name} needs a valid "define"`);
|
|
96
|
+
flags[name] = {
|
|
97
|
+
define: flag.define,
|
|
98
|
+
paths: nameList(flag.paths, `flag ${name} paths`).map((p) => safeRel(p, `flag ${name}`)),
|
|
99
|
+
asmdefs: nameList(flag.asmdefs, `flag ${name} asmdefs`),
|
|
100
|
+
packages: nameList(flag.packages, `flag ${name} packages`),
|
|
101
|
+
requires: nameList(flag.requires, `flag ${name} requires`),
|
|
102
|
+
internal: readInternal(flag.internal, name)
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
for (const [name, flag] of Object.entries(flags)) {
|
|
106
|
+
for (const required of flag.requires) {
|
|
107
|
+
if (!flags[required]) throw new Error(`${MANIFEST}: flag ${name} requires unknown flag ${required}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const renders = {};
|
|
111
|
+
for (const [name, dir] of Object.entries(raw.renders)) {
|
|
112
|
+
if (!RENDER_NAME.test(name)) throw new Error(`${MANIFEST}: '${name}' is not a valid render name`);
|
|
113
|
+
renders[name] = dir === null ? null : safeRel(dir, `render ${name}`);
|
|
114
|
+
}
|
|
115
|
+
if (Object.values(renders).filter((d) => d === null).length !== 1) {
|
|
116
|
+
throw new Error(`${MANIFEST}: exactly one render must be the upstream one (null)`);
|
|
117
|
+
}
|
|
118
|
+
return { flags, renders, kinds: parseKinds(raw.kinds, flags, renders) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function readFlagManifest(projectDir) {
|
|
122
|
+
const file = path.join(projectDir, MANIFEST);
|
|
123
|
+
if (!(await fs.pathExists(file))) throw new Error(`${MANIFEST} not found in ${projectDir}`);
|
|
124
|
+
return parseFlagManifest(await fs.readFile(file, 'utf8'));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Every file under dir (relative, '/'-separated, sorted); with extensions, only those
|
|
128
|
+
async function listFiles(dir, extensions) {
|
|
129
|
+
const out = [];
|
|
130
|
+
async function walk(abs, rel) {
|
|
131
|
+
const entries = (await fs.readdir(abs, { withFileTypes: true })).sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
const r = rel ? `${rel}/${entry.name}` : entry.name;
|
|
134
|
+
if (entry.isDirectory()) await walk(path.join(abs, entry.name), r);
|
|
135
|
+
else if (!extensions || extensions.has(path.extname(entry.name))) out.push(r);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (await fs.pathExists(dir)) await walk(dir, '');
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function checkRenderLayers(projectDir, manifest) {
|
|
143
|
+
const problems = [];
|
|
144
|
+
for (const [name, rel] of Object.entries(manifest.renders)) {
|
|
145
|
+
if (rel === null) continue;
|
|
146
|
+
const layer = path.join(projectDir, rel);
|
|
147
|
+
const files = await listFiles(layer);
|
|
148
|
+
if (files.length === 0) {
|
|
149
|
+
problems.push(`render ${name}: ${rel} is missing or empty`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
for (const file of files) {
|
|
153
|
+
const target = path.join(projectDir, file);
|
|
154
|
+
const targetExists = await fs.pathExists(target);
|
|
155
|
+
if (!file.endsWith('.meta')) {
|
|
156
|
+
if (!files.includes(`${file}.meta`)) problems.push(`render ${name}: ${file} has no .meta`);
|
|
157
|
+
if (!targetExists) problems.push(`render ${name}: ${file} replaces nothing`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (!targetExists) {
|
|
161
|
+
problems.push(`render ${name}: ${file} replaces nothing`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
let own;
|
|
165
|
+
let theirs;
|
|
166
|
+
try {
|
|
167
|
+
own = readMetaGuid(await fs.readFile(path.join(layer, file), 'utf8'));
|
|
168
|
+
theirs = readMetaGuid(await fs.readFile(target, 'utf8'));
|
|
169
|
+
} catch (error) {
|
|
170
|
+
problems.push(`render ${name}: ${file}: ${error.message}`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (own !== theirs) problems.push(`render ${name}: ${file} guid ${own} differs from the project's ${theirs}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return problems.sort();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// A flag that requires a turned-off flag goes off with it (networkInspector without network), chains included
|
|
180
|
+
function withRequirers(manifest, off) {
|
|
181
|
+
const out = [...new Set(off)];
|
|
182
|
+
let grown = true;
|
|
183
|
+
while (grown) {
|
|
184
|
+
grown = false;
|
|
185
|
+
for (const [name, flag] of Object.entries(manifest.flags)) {
|
|
186
|
+
if (!out.includes(name) && flag.requires.some((r) => out.includes(r))) {
|
|
187
|
+
out.push(name);
|
|
188
|
+
grown = true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function resolveOptions(manifest, { kind = 'blank', off = [], render } = {}) {
|
|
196
|
+
const chosenKind = manifest.kinds[kind];
|
|
197
|
+
if (!chosenKind) throw new Error(`unknown kind: ${kind}`);
|
|
198
|
+
for (const flag of off) {
|
|
199
|
+
if (!manifest.flags[flag]) throw new Error(`unknown flag: ${flag}`);
|
|
200
|
+
if (manifest.flags[flag].internal) throw new Error(`flag ${flag} is internal: pick a kind instead`);
|
|
201
|
+
}
|
|
202
|
+
const upstream = Object.keys(manifest.renders).find((r) => manifest.renders[r] === null);
|
|
203
|
+
const chosen = render === undefined ? upstream : render;
|
|
204
|
+
if (!Object.prototype.hasOwnProperty.call(manifest.renders, chosen)) throw new Error(`unknown render: ${chosen}`);
|
|
205
|
+
const games = Object.values(chosenKind.games);
|
|
206
|
+
if (games.length && !chosenKind.games[chosen]) throw new Error(`${kind} has no game for render ${chosen}`);
|
|
207
|
+
const otherGames = games.filter((g) => g !== chosenKind.games[chosen]);
|
|
208
|
+
return { off: withRequirers(manifest, [...off, ...chosenKind.off, ...otherGames]), render: chosen };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function applyFlags(projectDir, options = {}) {
|
|
212
|
+
const manifest = await readFlagManifest(projectDir);
|
|
213
|
+
const { off, render } = resolveOptions(manifest, options);
|
|
214
|
+
const offSet = new Set(off);
|
|
215
|
+
const on = Object.keys(manifest.flags).filter((f) => !offSet.has(f));
|
|
216
|
+
|
|
217
|
+
const layerProblems = await checkRenderLayers(projectDir, manifest);
|
|
218
|
+
if (layerProblems.length) throw new Error(`render layers: ${layerProblems.join('; ')}`);
|
|
219
|
+
if (manifest.renders[render] !== null) {
|
|
220
|
+
await fs.copy(path.join(projectDir, manifest.renders[render]), projectDir, { overwrite: true });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Every path is checked before anything is removed, so a path inside another turned-off flag's folder (multiLang's
|
|
224
|
+
// tables inside network's folder) is simply gone by its turn; a path missing from the template still fails
|
|
225
|
+
const removals = off.flatMap((flag) => manifest.flags[flag].paths.map((rel) => ({ flag, rel })));
|
|
226
|
+
for (const { flag, rel } of removals) {
|
|
227
|
+
if (!(await fs.pathExists(path.join(projectDir, rel)))) throw new Error(`${flag}: path not found: ${rel}`);
|
|
228
|
+
}
|
|
229
|
+
for (const { rel } of removals) {
|
|
230
|
+
const abs = path.join(projectDir, rel);
|
|
231
|
+
await fs.remove(abs);
|
|
232
|
+
await fs.remove(`${abs}.meta`);
|
|
233
|
+
}
|
|
234
|
+
await pruneBuildScenes(projectDir);
|
|
235
|
+
|
|
236
|
+
for (const rel of await listFiles(path.join(projectDir, 'Assets'), new Set(['.cs']))) {
|
|
237
|
+
const abs = path.join(projectDir, 'Assets', rel);
|
|
238
|
+
const before = await fs.readFile(abs, 'utf8');
|
|
239
|
+
let text = before;
|
|
240
|
+
try {
|
|
241
|
+
for (const flag of off) text = removeLineMarkedBlock(text, flag);
|
|
242
|
+
for (const flag of on) text = stripLineMarkers(text, flag);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
throw new Error(`Assets/${rel}: ${error.message}`);
|
|
245
|
+
}
|
|
246
|
+
if (text !== before) await fs.writeFile(abs, text);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const dropped = new Set(off.flatMap((f) => manifest.flags[f].asmdefs));
|
|
250
|
+
for (const rel of await listFiles(path.join(projectDir, 'Assets'), new Set(['.asmdef']))) {
|
|
251
|
+
const abs = path.join(projectDir, 'Assets', rel);
|
|
252
|
+
const asmdef = JSON.parse(await fs.readFile(abs, 'utf8'));
|
|
253
|
+
if (!Array.isArray(asmdef.references)) continue;
|
|
254
|
+
const kept = asmdef.references.filter((r) => !dropped.has(r));
|
|
255
|
+
if (kept.length === asmdef.references.length) continue;
|
|
256
|
+
asmdef.references = kept;
|
|
257
|
+
await fs.writeFile(abs, `${JSON.stringify(asmdef, null, 4)}\n`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (off.length) {
|
|
261
|
+
const settingsPath = path.join(projectDir, 'ProjectSettings', 'ProjectSettings.asset');
|
|
262
|
+
let settings = await fs.readFile(settingsPath, 'utf8');
|
|
263
|
+
const drop = new Set(off.map((f) => manifest.flags[f].define));
|
|
264
|
+
for (const target of DEFINE_TARGETS) {
|
|
265
|
+
const current = getDefines(settings, target);
|
|
266
|
+
for (const flag of off) {
|
|
267
|
+
const define = manifest.flags[flag].define;
|
|
268
|
+
if (!current.includes(define)) throw new Error(`${flag}: define ${define} missing for ${target}`);
|
|
269
|
+
}
|
|
270
|
+
settings = setDefines(settings, target, current.filter((d) => !drop.has(d)));
|
|
271
|
+
}
|
|
272
|
+
await fs.writeFile(settingsPath, settings);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const keep = new Set(on.flatMap((f) => manifest.flags[f].packages));
|
|
276
|
+
const removedPackages = [...new Set(off.flatMap((f) => manifest.flags[f].packages))].filter((p) => !keep.has(p));
|
|
277
|
+
if (removedPackages.length) await removePackagesInProject(projectDir, removedPackages);
|
|
278
|
+
|
|
279
|
+
await fs.remove(path.join(projectDir, MANIFEST));
|
|
280
|
+
await fs.remove(path.join(projectDir, LAYERS));
|
|
281
|
+
return { off, render, removedPackages };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// What a turned-off flag must not leave behind: its define, its assembly names (which also cover 'using'
|
|
285
|
+
// lines, namespaces being the assembly names) and, for every flag, the markers themselves
|
|
286
|
+
async function findFlagResidue(projectDir, manifest, off) {
|
|
287
|
+
const rules = off.map((flag) => ({
|
|
288
|
+
name: `flag ${flag}:`,
|
|
289
|
+
tokens: [manifest.flags[flag].define, ...manifest.flags[flag].asmdefs]
|
|
290
|
+
}));
|
|
291
|
+
rules.push({ name: 'marker', tokens: Object.keys(manifest.flags).map((f) => `cmpose:${f}`), extensions: ['.cs'] });
|
|
292
|
+
return findResidue(projectDir, rules, { ignore: RESIDUE_IGNORE });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function flagVariants(manifest) {
|
|
296
|
+
const flags = Object.keys(manifest.flags).filter((f) => !manifest.flags[f].internal);
|
|
297
|
+
const overlays = Object.keys(manifest.renders).filter((r) => manifest.renders[r] !== null);
|
|
298
|
+
const variants = flags.map((f) => ({ name: `no-${f}`, off: [f], render: undefined, kind: 'blank' }));
|
|
299
|
+
for (const r of overlays) variants.push({ name: `render-${r}`, off: [], render: r, kind: 'blank' });
|
|
300
|
+
if (flags.length) {
|
|
301
|
+
const r = overlays[0];
|
|
302
|
+
variants.push({ name: r ? `all-off-${r}` : 'all-off', off: flags, render: r, kind: 'blank' });
|
|
303
|
+
}
|
|
304
|
+
const showcase = manifest.kinds.showcase;
|
|
305
|
+
for (const render of Object.keys(showcase ? showcase.games : {})) {
|
|
306
|
+
variants.push({ name: `showcase-${render}`, off: [], render, kind: 'showcase' });
|
|
307
|
+
variants.push({ name: `showcase-all-off-${render}`, off: flags, render, kind: 'showcase' });
|
|
308
|
+
}
|
|
309
|
+
return variants;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
module.exports = {
|
|
313
|
+
MANIFEST, LAYERS, parseFlagManifest, readFlagManifest, checkRenderLayers, applyFlags, findFlagResidue, flagVariants
|
|
314
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { log } = require('../log');
|
|
5
|
+
const pins = require('../pins');
|
|
6
|
+
const { applyCiOption } = require('../ciOption');
|
|
7
|
+
const { COMMIT_FILE_NAME, REF_FILE_NAME } = require('../templateVersions');
|
|
8
|
+
const { GAME_SCHEMA } = require('./contract');
|
|
9
|
+
const { cloneModules } = require('./modules');
|
|
10
|
+
const { applyFlags } = require('./flags');
|
|
11
|
+
const { applyGameIdentity } = require('./identity');
|
|
12
|
+
const { writeGameIds } = require('./ids');
|
|
13
|
+
const { applyGameReadme } = require('./readme');
|
|
14
|
+
const { buildGameRecord, writeGameRecord } = require('./record');
|
|
15
|
+
const { verifyGameProject } = require('./verifier');
|
|
16
|
+
|
|
17
|
+
// The template cache's own records and Unity's local state never reach the project; nor does the template's
|
|
18
|
+
// LICENSE (the generated project is the user's, to license as they choose)
|
|
19
|
+
const LEFTOVERS = ['.git', COMMIT_FILE_NAME, REF_FILE_NAME, 'LICENSE', 'Library', 'Temp', 'Logs', 'UserSettings'];
|
|
20
|
+
const PACKAGE_GUIDS = path.join('.cmpose', 'package-guids.txt');
|
|
21
|
+
|
|
22
|
+
function gameWarnings(value) {
|
|
23
|
+
const on = (flag) => value.flags.includes(flag);
|
|
24
|
+
const warnings = [];
|
|
25
|
+
if (on('ads') && !value.ids.admob) warnings.push('Ads use AdMob test IDs: replace them with your own before release.');
|
|
26
|
+
if (on('purchases') && !value.ids.revenueCat) warnings.push('Purchases use the RevenueCat Test Store key: replace it before release.');
|
|
27
|
+
if (on('leaderboards') && !value.ids.gameServices) {
|
|
28
|
+
warnings.push('Leaderboard and achievement IDs are placeholders: game services stay off until you set them.');
|
|
29
|
+
}
|
|
30
|
+
warnings.push(`Open the project with Unity ${GAME_SCHEMA.unityVersion} (Android and iOS Build Support); the first import takes a few minutes.`);
|
|
31
|
+
return warnings;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Turns a fresh Unity template copy (projectDir) into the requested game, in place.
|
|
35
|
+
// value: validateGame().value. generatorVersion: who ran the core, for the record (e.g. mcp@0.2.0). template: { repo, ref, commit } for the record; without it the pinned repo and the
|
|
36
|
+
// cache's markers are recorded. Throws when a step fails or the result fails verification.
|
|
37
|
+
async function generateGame(value, { projectDir, template = null, coreVersion, generatorVersion = null, now = new Date() }) {
|
|
38
|
+
const readMarker = (name) => fs.readFile(path.join(projectDir, name), 'utf8').then((v) => v.trim() || null, () => null);
|
|
39
|
+
const source = template || {
|
|
40
|
+
repo: pins.game.repo,
|
|
41
|
+
ref: (await readMarker(REF_FILE_NAME)) || pins.game.ref,
|
|
42
|
+
commit: await readMarker(COMMIT_FILE_NAME)
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// 1. leftovers
|
|
46
|
+
for (const leftover of LEFTOVERS) await fs.remove(path.join(projectDir, leftover));
|
|
47
|
+
log.info('✅ Cleanup completed');
|
|
48
|
+
|
|
49
|
+
// 2. blank modules, cloned before the flags so a turned-off flag removes their parts too
|
|
50
|
+
if (value.templateType === 'blank' && value.features.length) {
|
|
51
|
+
await cloneModules(projectDir, value.features, { seed: value.packageName });
|
|
52
|
+
log.info(`✅ Added modules: ${value.features.join(', ')}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 3. flags and render (showcase keeps every flag)
|
|
56
|
+
const off = GAME_SCHEMA.flags.filter((f) => !value.flags.includes(f));
|
|
57
|
+
await applyFlags(projectDir, { kind: value.templateType, render: value.render, off });
|
|
58
|
+
log.info(`✅ Applied ${value.templateType} (${value.render}), flags off: ${off.length ? off.join(', ') : 'none'}`);
|
|
59
|
+
|
|
60
|
+
// 4–6. identity, real IDs, CI and README
|
|
61
|
+
await applyGameIdentity(projectDir, value);
|
|
62
|
+
await writeGameIds(projectDir, value.ids);
|
|
63
|
+
await applyCiOption(projectDir, value.ci);
|
|
64
|
+
await applyGameReadme(projectDir, value);
|
|
65
|
+
log.info(`✅ Renamed to ${value.companyName}.${value.projectName} (${value.packageName})`);
|
|
66
|
+
|
|
67
|
+
// 7. a broken project is never returned
|
|
68
|
+
const problems = await verifyGameProject(projectDir);
|
|
69
|
+
if (problems.length) throw new Error('generated project failed verification:\n- ' + problems.join('\n- '));
|
|
70
|
+
|
|
71
|
+
// 8. the template's package guid allowlist goes; the record stays
|
|
72
|
+
await fs.remove(path.join(projectDir, PACKAGE_GUIDS));
|
|
73
|
+
const record = buildGameRecord({ template: source, coreVersion, generatorVersion, value, now });
|
|
74
|
+
await writeGameRecord(projectDir, record);
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
templateType: value.templateType,
|
|
78
|
+
render: value.render,
|
|
79
|
+
flags: value.flags.slice(),
|
|
80
|
+
features: value.features.slice(),
|
|
81
|
+
ids: record.ids,
|
|
82
|
+
warnings: gameWarnings(value)
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { generateGame, gameWarnings };
|
package/src/game/guid.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const { escapeRe } = require('./unityYaml');
|
|
5
|
+
|
|
6
|
+
// Unity asset identity: every asset's .meta carries 'guid: <32 lowercase hex>'; scenes, prefabs and assets
|
|
7
|
+
// point at it ('guid: <hex>'), asmdefs may too ('GUID:<hex>'). A cloned folder gets new guids, derived from
|
|
8
|
+
// a seed so the same request always produces the same project.
|
|
9
|
+
const GUID_RE = /^[0-9a-f]{32}$/;
|
|
10
|
+
const TEXT_EXTENSIONS = new Set(['.meta', '.unity', '.prefab', '.asset', '.mat', '.asmdef', '.asmref', '.cs',
|
|
11
|
+
'.controller', '.anim', '.json', '.txt']);
|
|
12
|
+
|
|
13
|
+
function newGuid(seed, oldGuid, name) {
|
|
14
|
+
return crypto.createHash('md5').update(`${seed}:${oldGuid}:${name}`).digest('hex');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readMetaGuid(metaText) {
|
|
18
|
+
const m = /^guid: ([0-9a-f]{32})\r?$/m.exec(metaText);
|
|
19
|
+
if (!m) throw new Error('the .meta file has no valid guid line');
|
|
20
|
+
return m[1];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function remapGuids(text, map) {
|
|
24
|
+
return text.replace(/(guid: |GUID:)([0-9a-f]{32})(?![0-9a-f])/g,
|
|
25
|
+
(match, prefix, guid) => (map.has(guid) ? prefix + map.get(guid) : match));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// One pass, longest 'from' first, so a replacement is never replaced again
|
|
29
|
+
function renamer(pairs) {
|
|
30
|
+
if (!pairs.length) return (s) => s;
|
|
31
|
+
const sorted = [...pairs].sort((a, b) => b.from.length - a.from.length);
|
|
32
|
+
const lookup = new Map(sorted.map((p) => [p.from, p.to]));
|
|
33
|
+
const re = new RegExp(sorted.map((p) => escapeRe(p.from)).join('|'), 'g');
|
|
34
|
+
return (s) => s.replace(re, (m) => lookup.get(m));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function walk(dir) {
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const entry of (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
40
|
+
const full = path.join(dir, entry.name);
|
|
41
|
+
if (entry.isDirectory()) out.push(...await walk(full));
|
|
42
|
+
else out.push(full);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function cloneWithNewGuids(srcDir, dstDir, { seed, rename = [] }) {
|
|
48
|
+
if (await fs.pathExists(dstDir)) throw new Error(`destination exists: ${dstDir}`);
|
|
49
|
+
const apply = renamer(rename);
|
|
50
|
+
// The folder's own .meta sits next to it
|
|
51
|
+
const sources = await walk(srcDir);
|
|
52
|
+
const jobs = sources.map((file) => ({ from: file, to: path.join(dstDir, apply(path.relative(srcDir, file))) }));
|
|
53
|
+
if (await fs.pathExists(`${srcDir}.meta`)) jobs.push({ from: `${srcDir}.meta`, to: `${dstDir}.meta` });
|
|
54
|
+
|
|
55
|
+
const map = new Map();
|
|
56
|
+
for (const job of jobs.filter((j) => j.from.endsWith('.meta'))) {
|
|
57
|
+
const old = readMetaGuid(await fs.readFile(job.from, 'utf8'));
|
|
58
|
+
const name = path.relative(path.dirname(dstDir), job.to).split(path.sep).join('/');
|
|
59
|
+
map.set(old, newGuid(seed, old, name));
|
|
60
|
+
}
|
|
61
|
+
for (const job of jobs) {
|
|
62
|
+
if (TEXT_EXTENSIONS.has(path.extname(job.from))) {
|
|
63
|
+
await fs.outputFile(job.to, remapGuids(apply(await fs.readFile(job.from, 'utf8')), map));
|
|
64
|
+
} else {
|
|
65
|
+
await fs.copy(job.from, job.to);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { map };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { GUID_RE, newGuid, readMetaGuid, remapGuids, cloneWithNewGuids };
|