@cmpose/core 0.1.1 → 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 +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
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const { readMetaGuid } = require('./guid');
|
|
4
|
+
|
|
5
|
+
// Unity's own resources (default font, built-in materials) are referenced with these guids
|
|
6
|
+
const BUILTIN_GUIDS = ['0000000000000000e000000000000000', '0000000000000000f000000000000000'];
|
|
7
|
+
// Serialized YAML that may point at other assets
|
|
8
|
+
const YAML_EXTENSIONS = new Set(['.unity', '.prefab', '.asset', '.mat', '.controller', '.anim', '.asmdef', '.asmref', '.meta']);
|
|
9
|
+
|
|
10
|
+
// Unity skips hidden files/folders and anything ending in '~'
|
|
11
|
+
function ignoredByUnity(name) {
|
|
12
|
+
return name.startsWith('.') || name.endsWith('~');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Folders Unity imports as a single plugin asset: one .meta for the folder, nothing inside is imported
|
|
16
|
+
const FOLDER_ASSET_EXTENSIONS = new Set(['.androidlib', '.bundle', '.framework', '.xcframework', '.plugin']);
|
|
17
|
+
|
|
18
|
+
async function walk(abs, rel, out) {
|
|
19
|
+
const entries = (await fs.readdir(abs, { withFileTypes: true })).sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
if (ignoredByUnity(entry.name)) continue;
|
|
22
|
+
const r = `${rel}/${entry.name}`;
|
|
23
|
+
out.push({ rel: r, dir: entry.isDirectory() });
|
|
24
|
+
if (entry.isDirectory() && !FOLDER_ASSET_EXTENSIONS.has(path.extname(entry.name))) await walk(path.join(abs, entry.name), r, out);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function checkGuidIntegrity(dir, { allowlist = [], roots = ['Assets'] } = {}) {
|
|
29
|
+
const entries = [];
|
|
30
|
+
for (const root of roots) {
|
|
31
|
+
if (await fs.pathExists(path.join(dir, root))) await walk(path.join(dir, root), root, entries);
|
|
32
|
+
}
|
|
33
|
+
const present = new Set(entries.map((e) => e.rel));
|
|
34
|
+
const problems = [];
|
|
35
|
+
const owners = new Map();
|
|
36
|
+
|
|
37
|
+
for (const e of entries.filter((x) => !x.dir)) {
|
|
38
|
+
if (!e.rel.endsWith('.meta')) continue;
|
|
39
|
+
if (!present.has(e.rel.slice(0, -'.meta'.length))) problems.push(`orphan-meta ${e.rel}`);
|
|
40
|
+
let guid;
|
|
41
|
+
try {
|
|
42
|
+
guid = readMetaGuid(await fs.readFile(path.join(dir, e.rel), 'utf8'));
|
|
43
|
+
} catch (error) {
|
|
44
|
+
problems.push(`invalid-meta ${e.rel}`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
owners.set(guid, [...(owners.get(guid) || []), e.rel]);
|
|
48
|
+
}
|
|
49
|
+
for (const e of entries) {
|
|
50
|
+
if (!e.rel.endsWith('.meta') && !present.has(`${e.rel}.meta`)) problems.push(`missing-meta ${e.rel}`);
|
|
51
|
+
}
|
|
52
|
+
for (const [guid, paths] of owners) {
|
|
53
|
+
if (paths.length > 1) problems.push(`duplicate-guid ${guid}: ${[...paths].sort().join(', ')}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const known = new Set([...owners.keys(), ...BUILTIN_GUIDS, ...allowlist]);
|
|
57
|
+
for (const e of entries.filter((x) => !x.dir && YAML_EXTENSIONS.has(path.extname(x.rel)))) {
|
|
58
|
+
let text = await fs.readFile(path.join(dir, e.rel), 'utf8');
|
|
59
|
+
// A .meta's own 'guid:' line defines the asset; only the rest are references
|
|
60
|
+
if (e.rel.endsWith('.meta')) text = text.replace(/^guid: [0-9a-f]{32}\r?$/m, '');
|
|
61
|
+
for (const m of text.matchAll(/(?:guid: |GUID:)([0-9a-f]{32})(?![0-9a-f])/g)) {
|
|
62
|
+
if (!known.has(m[1])) problems.push(`dangling-guid ${m[1]} in ${e.rel}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return [...new Set(problems)].sort();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The template's .cmpose/package-guids.txt: guids of assets inside packages, which template assets may reference
|
|
69
|
+
async function readGuidAllowlist(file) {
|
|
70
|
+
if (!(await fs.pathExists(file))) return [];
|
|
71
|
+
const guids = [];
|
|
72
|
+
(await fs.readFile(file, 'utf8')).split('\n').forEach((raw, i) => {
|
|
73
|
+
const line = raw.trim();
|
|
74
|
+
if (!line || line.startsWith('#')) return;
|
|
75
|
+
if (!/^[0-9a-f]{32}$/.test(line)) throw new Error(`invalid guid on line ${i + 1} of ${file}`);
|
|
76
|
+
guids.push(line);
|
|
77
|
+
});
|
|
78
|
+
return guids;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { BUILTIN_GUIDS, checkGuidIntegrity, readGuidAllowlist };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const { escapeRe, getMapKeys, setMapEntry } = require('./unityYaml');
|
|
4
|
+
const { PLAYER, applyIdentity } = require('./projectSettings');
|
|
5
|
+
|
|
6
|
+
const TEXT_EXTENSIONS = new Set(['.cs', '.asmdef', '.asmref', '.prefab', '.unity', '.asset', '.mat', '.json', '.md', '.txt',
|
|
7
|
+
'.xml', '.ruleset', '.yml', '.uss', '.uxml']);
|
|
8
|
+
const SKIPPED = new Set(['Library', 'Temp', 'Logs', 'UserSettings', '.git', '.cmpose']);
|
|
9
|
+
const TEMPLATE_NAMESPACE = 'Kturker.UnityBase';
|
|
10
|
+
|
|
11
|
+
// One pass, longest 'from' first, so a replacement is never replaced again
|
|
12
|
+
function renamer(pairs) {
|
|
13
|
+
const sorted = [...pairs].sort((a, b) => b.from.length - a.from.length);
|
|
14
|
+
const lookup = new Map(sorted.map((p) => [p.from, p.to]));
|
|
15
|
+
const re = new RegExp(sorted.map((p) => escapeRe(p.from)).join('|'), 'g');
|
|
16
|
+
return (s) => s.replace(re, (m) => lookup.get(m));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Every file and folder below dir outside the skipped top-level folders; folders are listed too
|
|
20
|
+
async function walk(abs, rel, out) {
|
|
21
|
+
for (const entry of await fs.readdir(abs, { withFileTypes: true })) {
|
|
22
|
+
if (!rel && SKIPPED.has(entry.name)) continue;
|
|
23
|
+
if (entry.name === '.git') continue;
|
|
24
|
+
const r = rel ? `${rel}/${entry.name}` : entry.name;
|
|
25
|
+
out.push({ rel: r, dir: entry.isDirectory() });
|
|
26
|
+
if (entry.isDirectory()) await walk(path.join(abs, entry.name), r, out);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function applyGameIdentity(projectDir, { companyName, projectName, appName, packageName, minSdk, iosVersion }) {
|
|
31
|
+
const rename = renamer([
|
|
32
|
+
{ from: TEMPLATE_NAMESPACE, to: `${companyName}.${projectName}` },
|
|
33
|
+
{ from: 'UnityBase', to: projectName },
|
|
34
|
+
{ from: 'unitybase', to: projectName.toLowerCase() }
|
|
35
|
+
]);
|
|
36
|
+
const entries = [];
|
|
37
|
+
await walk(projectDir, '', entries);
|
|
38
|
+
|
|
39
|
+
// 1. content
|
|
40
|
+
for (const e of entries) {
|
|
41
|
+
if (e.dir || !TEXT_EXTENSIONS.has(path.extname(e.rel))) continue;
|
|
42
|
+
const file = path.join(projectDir, e.rel);
|
|
43
|
+
const text = await fs.readFile(file, 'utf8');
|
|
44
|
+
const next = rename(text);
|
|
45
|
+
if (next !== text) await fs.writeFile(file, next);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 2. names, deepest first; a .meta carries the template namespace in its name too, so it follows its asset
|
|
49
|
+
const namePart = renamer([{ from: TEMPLATE_NAMESPACE, to: `${companyName}.${projectName}` }]);
|
|
50
|
+
let renamedFiles = 0;
|
|
51
|
+
const named = entries.filter((e) => path.posix.basename(e.rel).includes(TEMPLATE_NAMESPACE))
|
|
52
|
+
.sort((a, b) => b.rel.split('/').length - a.rel.split('/').length);
|
|
53
|
+
for (const e of named) {
|
|
54
|
+
const from = path.join(projectDir, e.rel);
|
|
55
|
+
const to = path.join(path.dirname(from), namePart(path.basename(from)));
|
|
56
|
+
if (await fs.pathExists(to)) throw new Error(`cannot rename ${e.rel}: target exists`);
|
|
57
|
+
await fs.move(from, to);
|
|
58
|
+
renamedFiles += 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 3. PlayerSettings
|
|
62
|
+
const settingsFile = path.join(projectDir, 'ProjectSettings', 'ProjectSettings.asset');
|
|
63
|
+
const settings = await fs.readFile(settingsFile, 'utf8');
|
|
64
|
+
let identified = applyIdentity(settings, {
|
|
65
|
+
companyName, productName: appName, bundleId: packageName, version: '1.0.0',
|
|
66
|
+
androidMinSdk: minSdk, iosTarget: iosVersion, urlScheme: projectName.toLowerCase()
|
|
67
|
+
});
|
|
68
|
+
// applyIdentity sets Android and iPhone; any other platform the template lists (Standalone …) gets the package too
|
|
69
|
+
for (const platform of getMapKeys(identified, 'applicationIdentifier', { doc: PLAYER })) {
|
|
70
|
+
if (platform !== 'Android' && platform !== 'iPhone') {
|
|
71
|
+
identified = setMapEntry(identified, 'applicationIdentifier', platform, packageName, { doc: PLAYER });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
await fs.writeFile(settingsFile, identified);
|
|
75
|
+
return { renamedFiles };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { applyGameIdentity };
|
package/src/game/ids.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
|
|
4
|
+
// Where each id group lands in the Unity template. `keys` maps the line's key to the field of the group.
|
|
5
|
+
// kind 'cs': `public const string <Key> = "<value>";`, 'yaml': ` <key>: <value>`, 'xml': the games_app_id string.
|
|
6
|
+
const ID_TARGETS = Object.freeze({
|
|
7
|
+
admob: [
|
|
8
|
+
{
|
|
9
|
+
file: 'Assets/_Project/Core/Ads/AdsConfig.cs',
|
|
10
|
+
kind: 'cs',
|
|
11
|
+
keys: {
|
|
12
|
+
BannerAndroid: 'androidBanner', InterstitialAndroid: 'androidInterstitial', RewardedAndroid: 'androidRewarded',
|
|
13
|
+
BannerIos: 'iosBanner', InterstitialIos: 'iosInterstitial', RewardedIos: 'iosRewarded'
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
file: 'Assets/GoogleMobileAds/Resources/GoogleMobileAdsSettings.asset',
|
|
18
|
+
kind: 'yaml',
|
|
19
|
+
keys: { adMobAndroidAppId: 'androidAppId', adMobIOSAppId: 'iosAppId' }
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
revenueCat: [
|
|
23
|
+
{
|
|
24
|
+
file: 'Assets/_Project/Core/Purchases/PurchasesConfig.cs',
|
|
25
|
+
kind: 'cs',
|
|
26
|
+
keys: { ApiKeyApple: 'appleApiKey', ApiKeyGoogle: 'googleApiKey' }
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
gameServices: [
|
|
30
|
+
{
|
|
31
|
+
file: 'Assets/_Project/Core/Leaderboards/LeaderboardsConfig.cs',
|
|
32
|
+
kind: 'cs',
|
|
33
|
+
keys: {
|
|
34
|
+
LeaderboardAndroid: 'leaderboardAndroid', FirstCompletionAndroid: 'firstCompletionAndroid',
|
|
35
|
+
TenCompletionsAndroid: 'tenCompletionsAndroid', LeaderboardIos: 'leaderboardIos',
|
|
36
|
+
FirstCompletionIos: 'firstCompletionIos', TenCompletionsIos: 'tenCompletionsIos'
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
file: 'Assets/_Project/Core/Leaderboards/Plugins/Android/CmposeGames.androidlib/src/main/res/values/games-ids.xml',
|
|
41
|
+
kind: 'xml',
|
|
42
|
+
keys: { games_app_id: 'androidAppId' }
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Each pattern captures what comes before the value (1) and after it (2), within one line: [ \t] instead of \s
|
|
48
|
+
// so a match never spans lines, and \r stays outside the value so CRLF files keep their line ends.
|
|
49
|
+
const PATTERNS = {
|
|
50
|
+
cs: (key) => new RegExp(`^([ \\t]*public const string ${key} = ")[^"\\r\\n]*(";)(?=[ \\t]*\\r?$)`, 'gm'),
|
|
51
|
+
yaml: (key) => new RegExp(`^([ \\t]*${key}: )[^\\r\\n]*()`, 'gm'),
|
|
52
|
+
xml: (key) => new RegExp(`(<string name="${key}"[^>]*>)[^<]*(</string>)`, 'g')
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function replaceOnce(text, file, kind, key, value) {
|
|
56
|
+
const re = PATTERNS[kind](key);
|
|
57
|
+
const n = (text.match(re) || []).length;
|
|
58
|
+
if (n !== 1) throw new Error(`${file}: ${key} found ${n} times`);
|
|
59
|
+
// A function replacer, so `$` sequences in the value are never interpreted
|
|
60
|
+
return text.replace(re, (_, before, after) => `${before}${value}${after}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Writes the given id groups into the project; every target line must be there exactly once. All files are checked
|
|
64
|
+
// before any is written, so a failure leaves the project as it was. Returns the groups written.
|
|
65
|
+
async function writeGameIds(projectDir, ids) {
|
|
66
|
+
const written = [];
|
|
67
|
+
const updates = [];
|
|
68
|
+
for (const group of Object.keys(ID_TARGETS)) {
|
|
69
|
+
const values = ids && ids[group];
|
|
70
|
+
if (!values) continue;
|
|
71
|
+
for (const { file, kind, keys } of ID_TARGETS[group]) {
|
|
72
|
+
const abs = path.join(projectDir, file);
|
|
73
|
+
let text;
|
|
74
|
+
try {
|
|
75
|
+
text = await fs.readFile(abs, 'utf8');
|
|
76
|
+
} catch (e) {
|
|
77
|
+
throw new Error(`${file}: cannot read (${e.code || e.message})`);
|
|
78
|
+
}
|
|
79
|
+
for (const [key, field] of Object.entries(keys)) text = replaceOnce(text, file, kind, key, values[field]);
|
|
80
|
+
updates.push([abs, text]);
|
|
81
|
+
}
|
|
82
|
+
written.push(group);
|
|
83
|
+
}
|
|
84
|
+
for (const [abs, text] of updates) await fs.writeFile(abs, text);
|
|
85
|
+
return written;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { ID_TARGETS, writeGameIds };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { validateGame } = require('./validate');
|
|
3
|
+
const { GAME_SCHEMA, gameContractFacts, gameContractVersion } = require('./contract');
|
|
4
|
+
const { generateGame } = require('./generate');
|
|
5
|
+
const { describeGame } = require('./describe');
|
|
6
|
+
const { cloneModules } = require('./modules');
|
|
7
|
+
const { applyGameIdentity } = require('./identity');
|
|
8
|
+
const { writeGameIds } = require('./ids');
|
|
9
|
+
const { applyGameReadme } = require('./readme');
|
|
10
|
+
const { buildGameRecord, writeGameRecord } = require('./record');
|
|
11
|
+
const { verifyGameProject } = require('./verifier');
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
validateGame,
|
|
15
|
+
generateGame,
|
|
16
|
+
describeGame,
|
|
17
|
+
GAME_SCHEMA,
|
|
18
|
+
gameContractFacts,
|
|
19
|
+
gameContractVersion,
|
|
20
|
+
cloneModules,
|
|
21
|
+
applyGameIdentity,
|
|
22
|
+
writeGameIds,
|
|
23
|
+
applyGameReadme,
|
|
24
|
+
buildGameRecord,
|
|
25
|
+
writeGameRecord,
|
|
26
|
+
verifyGameProject,
|
|
27
|
+
flags: require('./flags'),
|
|
28
|
+
guid: require('./guid'),
|
|
29
|
+
guidIntegrity: require('./guidIntegrity'),
|
|
30
|
+
unityYaml: require('./unityYaml'),
|
|
31
|
+
projectSettings: require('./projectSettings'),
|
|
32
|
+
buildScenes: require('./buildScenes'),
|
|
33
|
+
packages: require('./packages'),
|
|
34
|
+
markers: require('./markers'),
|
|
35
|
+
residue: require('./residue')
|
|
36
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const { escapeRe } = require('./unityYaml');
|
|
2
|
+
|
|
3
|
+
// C# blocks a flag owns: '// cmpose:<name>' … '// /cmpose:<name>' on their own lines (any indent).
|
|
4
|
+
// README blocks use ../readmeService's removeMarkedBlock instead.
|
|
5
|
+
function markerRes(name) {
|
|
6
|
+
const n = escapeRe(name);
|
|
7
|
+
return {
|
|
8
|
+
open: new RegExp(`^[ \\t]*// cmpose:${n}[ \\t]*\\r?$`),
|
|
9
|
+
close: new RegExp(`^[ \\t]*// /cmpose:${n}[ \\t]*\\r?$`)
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function removeLineMarkedBlock(text, name) {
|
|
14
|
+
const { open, close } = markerRes(name);
|
|
15
|
+
const out = [];
|
|
16
|
+
let inside = false;
|
|
17
|
+
for (const line of text.split('\n')) {
|
|
18
|
+
if (!inside && open.test(line)) { inside = true; continue; }
|
|
19
|
+
if (inside) { if (close.test(line)) inside = false; continue; }
|
|
20
|
+
if (close.test(line)) throw new Error(`cmpose:${name}: closing marker without an opening one`);
|
|
21
|
+
out.push(line);
|
|
22
|
+
}
|
|
23
|
+
if (inside) throw new Error(`cmpose:${name}: opening marker without a closing one`);
|
|
24
|
+
return out.join('\n');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function stripLineMarkers(text, name) {
|
|
28
|
+
const { open, close } = markerRes(name);
|
|
29
|
+
return text.split('\n').filter((line) => !open.test(line) && !close.test(line)).join('\n');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { removeLineMarkedBlock, stripLineMarkers };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const { cloneWithNewGuids } = require('./guid');
|
|
4
|
+
const { MANIFEST } = require('./flags');
|
|
5
|
+
|
|
6
|
+
// cmpose.dev/game blank modules: each one is a copy of Features/Sample with fresh guids and the Sample names
|
|
7
|
+
// renamed, registered in the Game scope (one guarded installer line inside the cmpose:features markers, its using
|
|
8
|
+
// line, the App asmdef reference). A copy carries the Sample's flag parts (Sample/Ads …), so the flag manifest gets
|
|
9
|
+
// their twins and turning a flag off removes them from every copy. Only files are edited; Unity never runs here.
|
|
10
|
+
const FEATURES = 'Assets/_Project/Features';
|
|
11
|
+
const SAMPLE_DIR = `${FEATURES}/Sample`;
|
|
12
|
+
const GAME_DIR = 'Assets/_Project/App/Game';
|
|
13
|
+
const GAME_INSTALLER = `${GAME_DIR}/GameInstaller.cs`;
|
|
14
|
+
const SAMPLE_ASSEMBLY = 'Kturker.UnityBase.Features.Sample.';
|
|
15
|
+
const SAMPLE_INSTALLER_ASSEMBLY = `${SAMPLE_ASSEMBLY}Installer`;
|
|
16
|
+
const SAMPLE_USING = `using ${SAMPLE_INSTALLER_ASSEMBLY};`;
|
|
17
|
+
const FEATURES_START = '// cmpose:features:start';
|
|
18
|
+
const FEATURES_END = '// cmpose:features:end';
|
|
19
|
+
const SAMPLE_INSTALLER_LINE = 'new SampleInstaller(';
|
|
20
|
+
const MODULE_NAME = /^[A-Za-z][A-Za-z0-9]*$/;
|
|
21
|
+
|
|
22
|
+
const pascal = (name) => name.charAt(0).toUpperCase() + name.slice(1);
|
|
23
|
+
|
|
24
|
+
// The asmdef nearest to dir, walking up to (not past) Assets
|
|
25
|
+
async function owningAsmdef(projectDir, rel) {
|
|
26
|
+
for (let dir = rel; dir && dir !== '.' && dir !== 'Assets'; dir = path.posix.dirname(dir)) {
|
|
27
|
+
const abs = path.join(projectDir, dir);
|
|
28
|
+
if (!(await fs.pathExists(abs))) continue;
|
|
29
|
+
const found = (await fs.readdir(abs)).filter((n) => n.endsWith('.asmdef')).sort();
|
|
30
|
+
if (found.length) return `${dir}/${found[0]}`;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Reads every file the wiring edits and checks its anchors before anything is written
|
|
36
|
+
async function readAnchors(projectDir, fail) {
|
|
37
|
+
if (!(await fs.pathExists(path.join(projectDir, SAMPLE_DIR)))) throw fail(SAMPLE_DIR);
|
|
38
|
+
const manifestFile = path.join(projectDir, MANIFEST);
|
|
39
|
+
if (!(await fs.pathExists(manifestFile))) throw fail(MANIFEST);
|
|
40
|
+
const manifest = JSON.parse(await fs.readFile(manifestFile, 'utf8'));
|
|
41
|
+
if (!manifest || typeof manifest.flags !== 'object') throw fail(`${MANIFEST} "flags"`);
|
|
42
|
+
|
|
43
|
+
const installerFile = path.join(projectDir, GAME_INSTALLER);
|
|
44
|
+
if (!(await fs.pathExists(installerFile))) throw fail(GAME_INSTALLER);
|
|
45
|
+
const installer = await fs.readFile(installerFile, 'utf8');
|
|
46
|
+
const eol = installer.includes('\r\n') ? '\r\n' : '\n';
|
|
47
|
+
const lines = installer.split(eol);
|
|
48
|
+
const start = lines.findIndex((l) => l.trim() === FEATURES_START);
|
|
49
|
+
const end = lines.findIndex((l) => l.trim() === FEATURES_END);
|
|
50
|
+
if (start < 0 || end < start) throw fail(`${FEATURES_START} … ${FEATURES_END} in ${GAME_INSTALLER}`);
|
|
51
|
+
const sampleLine = lines.slice(start + 1, end).find((l) => l.includes(SAMPLE_INSTALLER_LINE));
|
|
52
|
+
if (!sampleLine) throw fail(`the SampleInstaller line in ${GAME_INSTALLER}`);
|
|
53
|
+
const usingAt = lines.findIndex((l) => l.trim() === SAMPLE_USING);
|
|
54
|
+
if (usingAt < 0) throw fail(`'${SAMPLE_USING}' in ${GAME_INSTALLER}`);
|
|
55
|
+
|
|
56
|
+
const asmdefRel = await owningAsmdef(projectDir, GAME_DIR);
|
|
57
|
+
if (!asmdefRel) throw fail(`the asmdef owning ${GAME_INSTALLER}`);
|
|
58
|
+
const asmdef = JSON.parse(await fs.readFile(path.join(projectDir, asmdefRel), 'utf8'));
|
|
59
|
+
const refAt = Array.isArray(asmdef.references) ? asmdef.references.indexOf(SAMPLE_INSTALLER_ASSEMBLY) : -1;
|
|
60
|
+
if (refAt < 0) throw fail(`the ${SAMPLE_INSTALLER_ASSEMBLY} reference in ${asmdefRel}`);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
manifest, manifestFile, installerFile, lines, eol, end, usingAt,
|
|
64
|
+
indent: /^\s*/.exec(sampleLine)[0], asmdefRel, asmdef, refAt
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Every Sample part a flag (other than sample itself) removes gets the copy's twin
|
|
69
|
+
function addFlagTwins(manifest, names) {
|
|
70
|
+
for (const [flagName, flag] of Object.entries(manifest.flags)) {
|
|
71
|
+
if (flagName === 'sample' || !flag || typeof flag !== 'object') continue;
|
|
72
|
+
for (const name of names) {
|
|
73
|
+
const p = pascal(name);
|
|
74
|
+
if (Array.isArray(flag.paths)) {
|
|
75
|
+
const twins = flag.paths.filter((x) => x.startsWith(`${SAMPLE_DIR}/`))
|
|
76
|
+
.map((x) => `${FEATURES}/${p}/${x.slice(SAMPLE_DIR.length + 1)}`);
|
|
77
|
+
flag.paths.push(...twins.filter((t) => !flag.paths.includes(t)));
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(flag.asmdefs)) {
|
|
80
|
+
const twins = flag.asmdefs.filter((x) => x.startsWith(SAMPLE_ASSEMBLY))
|
|
81
|
+
.map((x) => `Kturker.UnityBase.Features.${p}.${x.slice(SAMPLE_ASSEMBLY.length)}`);
|
|
82
|
+
flag.asmdefs.push(...twins.filter((t) => !flag.asmdefs.includes(t)));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function cloneModules(projectDir, features, { seed }) {
|
|
89
|
+
const names = [...features];
|
|
90
|
+
if (!names.length) return [];
|
|
91
|
+
for (const name of names) {
|
|
92
|
+
if (typeof name !== 'string' || !MODULE_NAME.test(name)) throw new Error(`module ${name}: not a valid module name`);
|
|
93
|
+
if (pascal(name) === 'Sample') throw new Error(`module ${name}: clashes with the template's Sample`);
|
|
94
|
+
if (await fs.pathExists(path.join(projectDir, FEATURES, pascal(name)))) {
|
|
95
|
+
throw new Error(`module ${name}: ${FEATURES}/${pascal(name)} already exists`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
let anchors;
|
|
99
|
+
try {
|
|
100
|
+
anchors = await readAnchors(projectDir, (what) => new Error(`module ${names[0]}: ${what} not found in the template`));
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error instanceof SyntaxError) throw new Error(`module ${names[0]}: ${error.message}`);
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const created = [];
|
|
107
|
+
for (const name of names) {
|
|
108
|
+
const p = pascal(name);
|
|
109
|
+
// Localization keys ('sample.x') are renamed only where they are keys: string literals and JSON keys
|
|
110
|
+
// ("sample.x") and the prefabs' 'key: sample.x'; a C# member named sample ('sample.Clicked') stays.
|
|
111
|
+
// The literal "sample" is the save slot (SavedCompletionCounter's SaveSchema): each copy gets its own
|
|
112
|
+
await cloneWithNewGuids(path.join(projectDir, SAMPLE_DIR), path.join(projectDir, FEATURES, p), {
|
|
113
|
+
seed: `${seed}:${name}`,
|
|
114
|
+
rename: [{ from: 'Sample', to: p }, { from: 'sample.json', to: `${name}.json` },
|
|
115
|
+
{ from: '"sample.', to: `"${name}.` }, { from: 'key: sample.', to: `key: ${name}.` },
|
|
116
|
+
{ from: '"sample"', to: `"${name}"` }]
|
|
117
|
+
});
|
|
118
|
+
created.push(`${FEATURES}/${p}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const { manifest, manifestFile, lines, eol, end, usingAt, indent, asmdef, refAt } = anchors;
|
|
122
|
+
addFlagTwins(manifest, names);
|
|
123
|
+
await fs.writeFile(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
124
|
+
|
|
125
|
+
// Installer lines go before the end marker (in order), using lines right after the Sample one
|
|
126
|
+
const installs = names.map((n) => `${indent}if (registerViews) new ${pascal(n)}Installer(registerViews).Install(builder);`);
|
|
127
|
+
const usings = names.map((n) => `using Kturker.UnityBase.Features.${pascal(n)}.Installer;`);
|
|
128
|
+
const next = [...lines];
|
|
129
|
+
next.splice(end, 0, ...installs);
|
|
130
|
+
next.splice(usingAt + 1, 0, ...usings);
|
|
131
|
+
await fs.writeFile(anchors.installerFile, next.join(eol));
|
|
132
|
+
|
|
133
|
+
asmdef.references.splice(refAt + 1, 0, ...names.map((n) => `Kturker.UnityBase.Features.${pascal(n)}.Installer`));
|
|
134
|
+
await fs.writeFile(path.join(projectDir, anchors.asmdefRel), `${JSON.stringify(asmdef, null, 4)}\n`);
|
|
135
|
+
return created;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = { cloneModules };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
|
|
4
|
+
// A scope covers a package when it equals the package name or is a dotted prefix of it (UPM's rule)
|
|
5
|
+
function scopeInUse(scope, names) {
|
|
6
|
+
return names.some((n) => n === scope || n.startsWith(`${scope}.`));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function removePackages(manifest, ids) {
|
|
10
|
+
const out = JSON.parse(JSON.stringify(manifest));
|
|
11
|
+
const drop = new Set(ids);
|
|
12
|
+
out.dependencies = Object.fromEntries(Object.entries(out.dependencies || {}).filter(([name]) => !drop.has(name)));
|
|
13
|
+
if (Array.isArray(out.scopedRegistries)) {
|
|
14
|
+
const names = Object.keys(out.dependencies);
|
|
15
|
+
out.scopedRegistries = out.scopedRegistries
|
|
16
|
+
.map((r) => ({ ...r, scopes: (r.scopes || []).filter((s) => scopeInUse(s, names)) }))
|
|
17
|
+
.filter((r) => r.scopes.length > 0);
|
|
18
|
+
if (out.scopedRegistries.length === 0) delete out.scopedRegistries;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Unity rebuilds packages-lock.json on the next open; a stale lock would pin removed packages
|
|
24
|
+
async function removePackagesInProject(projectPath, ids) {
|
|
25
|
+
const manifestPath = path.join(projectPath, 'Packages', 'manifest.json');
|
|
26
|
+
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
|
|
27
|
+
await fs.writeFile(manifestPath, `${JSON.stringify(removePackages(manifest, ids), null, 2)}\n`);
|
|
28
|
+
await fs.remove(path.join(projectPath, 'Packages', 'packages-lock.json'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { removePackages, removePackagesInProject };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const {
|
|
2
|
+
UnityYamlError, setScalar, setMapEntry, getMapEntry, removeMapEntry, setList
|
|
3
|
+
} = require('./unityYaml');
|
|
4
|
+
|
|
5
|
+
// PlayerSettings is document '--- !u!129 &1' of ProjectSettings/ProjectSettings.asset
|
|
6
|
+
const PLAYER = { classId: 129, fileId: 1 };
|
|
7
|
+
|
|
8
|
+
// Plain YAML scalars only: no line breaks, no leading indicator or space, no ': ' or ' #', no trailing space.
|
|
9
|
+
// Whether a value is a valid name or ID is the validator's job, not this file's.
|
|
10
|
+
function plain(name, value) {
|
|
11
|
+
const s = String(value === undefined || value === null ? '' : value);
|
|
12
|
+
if (s.length === 0 || /[\r\n]/.test(s) || /^[\s\-?:,[\]{}#&*!|>'"%@`]/.test(s) || /: /.test(s) || / #/.test(s) || /\s$/.test(s)) {
|
|
13
|
+
throw new UnityYamlError(`${name}: '${s}' cannot be written as a plain YAML value`, { key: name });
|
|
14
|
+
}
|
|
15
|
+
return s;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function applyIdentity(text, { companyName, productName, bundleId, version, androidMinSdk, iosTarget, urlScheme }) {
|
|
19
|
+
if (!Number.isInteger(androidMinSdk)) {
|
|
20
|
+
throw new UnityYamlError('androidMinSdk must be an integer', { key: 'androidMinSdk' });
|
|
21
|
+
}
|
|
22
|
+
const doc = PLAYER;
|
|
23
|
+
let out = text;
|
|
24
|
+
out = setScalar(out, 'companyName', plain('companyName', companyName), { doc });
|
|
25
|
+
out = setScalar(out, 'productName', plain('productName', productName), { doc });
|
|
26
|
+
out = setScalar(out, 'bundleVersion', plain('version', version), { doc });
|
|
27
|
+
out = setScalar(out, 'overrideDefaultApplicationIdentifier', '1', { doc });
|
|
28
|
+
out = setMapEntry(out, 'applicationIdentifier', 'Android', plain('bundleId', bundleId), { doc });
|
|
29
|
+
out = setMapEntry(out, 'applicationIdentifier', 'iPhone', plain('bundleId', bundleId), { doc });
|
|
30
|
+
out = setScalar(out, 'AndroidMinSdkVersion', String(androidMinSdk), { doc });
|
|
31
|
+
out = setScalar(out, 'iOSTargetOSVersionString', plain('iosTarget', iosTarget), { doc });
|
|
32
|
+
out = setList(out, 'iOSURLSchemes', urlScheme ? [plain('urlScheme', urlScheme)] : [], { doc });
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const SYMBOL = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
37
|
+
|
|
38
|
+
// Unity 6 keeps scripting defines per build target, ';'-separated; an empty target has no entry
|
|
39
|
+
function setDefines(text, target, symbols) {
|
|
40
|
+
for (const s of symbols) {
|
|
41
|
+
if (!SYMBOL.test(s)) throw new UnityYamlError(`scripting define '${s}' is not a valid symbol`, { key: target });
|
|
42
|
+
}
|
|
43
|
+
return symbols.length
|
|
44
|
+
? setMapEntry(text, 'scriptingDefineSymbols', target, symbols.join(';'), { doc: PLAYER })
|
|
45
|
+
: removeMapEntry(text, 'scriptingDefineSymbols', target, { doc: PLAYER });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getDefines(text, target) {
|
|
49
|
+
const value = getMapEntry(text, 'scriptingDefineSymbols', target, { doc: PLAYER });
|
|
50
|
+
return value ? value.split(';').filter(Boolean) : [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { PLAYER, applyIdentity, setDefines, getDefines };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const { removeMarkedBlock } = require('../readmeService');
|
|
5
|
+
const { GAME_SCHEMA } = require('./contract');
|
|
6
|
+
|
|
7
|
+
// The generated README keeps the <!-- cmpose:<flag> --> sections of the flags that are on; other marked
|
|
8
|
+
// sections (cmpose:ci) are not flags and stay. Returns false when the project has no README.
|
|
9
|
+
async function applyGameReadme(projectDir, { flags }) {
|
|
10
|
+
const file = path.join(projectDir, 'README.md');
|
|
11
|
+
if (!(await fs.pathExists(file))) return false;
|
|
12
|
+
const on = new Set(flags);
|
|
13
|
+
const before = await fs.readFile(file, 'utf8');
|
|
14
|
+
let text = before;
|
|
15
|
+
for (const flag of GAME_SCHEMA.flags) if (!on.has(flag)) text = removeMarkedBlock(text, `cmpose:${flag}`);
|
|
16
|
+
if (text !== before) await fs.writeFile(file, text);
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { applyGameReadme };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const { GAME_SCHEMA } = require('./contract');
|
|
5
|
+
|
|
6
|
+
const RECORD_PATH = path.join('.cmpose', 'generation.json');
|
|
7
|
+
|
|
8
|
+
// Whether each ID group's real values were written ('custom'), the template's test IDs stay ('test') or its
|
|
9
|
+
// flag is off ('off'). The values themselves are never recorded.
|
|
10
|
+
function idStatuses(flags, ids) {
|
|
11
|
+
const out = {};
|
|
12
|
+
for (const [group, { flag }] of Object.entries(GAME_SCHEMA.idGroups)) {
|
|
13
|
+
if (!flags.includes(flag)) out[group] = 'off';
|
|
14
|
+
else out[group] = ids && ids[group] ? 'custom' : 'test';
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The .cmpose/generation.json content of a generated Unity game (no personal data, no ID values);
|
|
20
|
+
* generatorVersion names the caller (e.g. mcp@0.2.0), null when not given */
|
|
21
|
+
function buildGameRecord({ template, coreVersion, generatorVersion = null, value, now = new Date() }) {
|
|
22
|
+
return {
|
|
23
|
+
generator: '@cmpose/core',
|
|
24
|
+
coreVersion,
|
|
25
|
+
generatorVersion: generatorVersion || null,
|
|
26
|
+
engine: 'unity',
|
|
27
|
+
unityVersion: GAME_SCHEMA.unityVersion,
|
|
28
|
+
templateType: value.templateType,
|
|
29
|
+
render: value.render,
|
|
30
|
+
flags: value.flags.slice(),
|
|
31
|
+
features: value.features.slice(),
|
|
32
|
+
ci: value.ci === true,
|
|
33
|
+
minSdk: value.minSdk,
|
|
34
|
+
iosVersion: value.iosVersion,
|
|
35
|
+
ids: idStatuses(value.flags, value.ids),
|
|
36
|
+
template: { repo: template.repo, ref: template.ref, commit: template.commit },
|
|
37
|
+
generatedAt: now.toISOString()
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function writeGameRecord(projectDir, record) {
|
|
42
|
+
const file = path.join(projectDir, RECORD_PATH);
|
|
43
|
+
await fs.outputFile(file, `${JSON.stringify(record, null, 2)}\n`);
|
|
44
|
+
return file;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { RECORD_PATH, buildGameRecord, writeGameRecord };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
|
|
4
|
+
// Unity caches and local state never ship and are never scanned
|
|
5
|
+
const ALWAYS_SKIPPED = new Set(['Library', 'Temp', 'Logs', 'UserSettings', 'obj', '.git']);
|
|
6
|
+
const TEXT_EXTENSIONS = new Set(['.cs', '.asmdef', '.asmref', '.json', '.asset', '.unity', '.prefab', '.mat', '.xml',
|
|
7
|
+
'.plist', '.xcprivacy', '.md', '.txt', '.gradle', '.properties', '.mm', '.m', '.h', '.swift', '.meta', '.yml', '.yaml']);
|
|
8
|
+
|
|
9
|
+
async function walk(abs, rel, out) {
|
|
10
|
+
for (const entry of await fs.readdir(abs, { withFileTypes: true })) {
|
|
11
|
+
const r = rel ? `${rel}/${entry.name}` : entry.name;
|
|
12
|
+
if (entry.isDirectory()) {
|
|
13
|
+
if (!rel && ALWAYS_SKIPPED.has(entry.name)) continue;
|
|
14
|
+
if (entry.name === '.git') continue;
|
|
15
|
+
await walk(path.join(abs, entry.name), r, out);
|
|
16
|
+
} else if (TEXT_EXTENSIONS.has(path.extname(entry.name))) {
|
|
17
|
+
out.push(r);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ignoredBy = (prefixes, rel) => prefixes.some((p) => rel === p || rel.startsWith(p));
|
|
23
|
+
|
|
24
|
+
async function findResidue(dir, rules, { ignore = [] } = {}) {
|
|
25
|
+
const files = [];
|
|
26
|
+
await walk(dir, '', files);
|
|
27
|
+
const problems = new Set();
|
|
28
|
+
for (const rel of files.sort()) {
|
|
29
|
+
if (ignoredBy(ignore, rel)) continue;
|
|
30
|
+
const text = await fs.readFile(path.join(dir, rel), 'utf8');
|
|
31
|
+
for (const rule of rules) {
|
|
32
|
+
if (rule.extensions && !rule.extensions.includes(path.extname(rel))) continue;
|
|
33
|
+
if (ignoredBy(rule.ignore || [], rel)) continue;
|
|
34
|
+
for (const token of rule.tokens) if (text.includes(token)) problems.add(`${rule.name} ${token} in ${rel}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return [...problems].sort();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { findResidue };
|