@cmpose/core 0.2.0 → 0.2.2
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/package.json +1 -1
- package/src/game/guid.js +1 -1
- package/src/game/identity.js +20 -21
- package/src/game/ids.js +26 -9
- package/src/game/modules.js +23 -9
- package/src/game/record.js +7 -3
- package/src/game/unityYaml.js +19 -1
- package/src/game/validate.js +47 -21
- package/src/game/verifier.js +20 -11
- package/src/pins.js +1 -1
package/package.json
CHANGED
package/src/game/guid.js
CHANGED
|
@@ -68,4 +68,4 @@ async function cloneWithNewGuids(srcDir, dstDir, { seed, rename = [] }) {
|
|
|
68
68
|
return { map };
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
module.exports = { GUID_RE, newGuid, readMetaGuid, remapGuids, cloneWithNewGuids };
|
|
71
|
+
module.exports = { GUID_RE, newGuid, readMetaGuid, remapGuids, renamer, cloneWithNewGuids };
|
package/src/game/identity.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
2
|
const fs = require('fs-extra');
|
|
3
|
-
const {
|
|
3
|
+
const { getMapKeys, setMapEntry } = require('./unityYaml');
|
|
4
|
+
const { renamer } = require('./guid');
|
|
4
5
|
const { PLAYER, applyIdentity } = require('./projectSettings');
|
|
5
6
|
|
|
6
7
|
const TEXT_EXTENSIONS = new Set(['.cs', '.asmdef', '.asmref', '.prefab', '.unity', '.asset', '.mat', '.json', '.md', '.txt',
|
|
@@ -8,14 +9,6 @@ const TEXT_EXTENSIONS = new Set(['.cs', '.asmdef', '.asmref', '.prefab', '.unity
|
|
|
8
9
|
const SKIPPED = new Set(['Library', 'Temp', 'Logs', 'UserSettings', '.git', '.cmpose']);
|
|
9
10
|
const TEMPLATE_NAMESPACE = 'Kturker.UnityBase';
|
|
10
11
|
|
|
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
12
|
// Every file and folder below dir outside the skipped top-level folders; folders are listed too
|
|
20
13
|
async function walk(abs, rel, out) {
|
|
21
14
|
for (const entry of await fs.readdir(abs, { withFileTypes: true })) {
|
|
@@ -36,6 +29,21 @@ async function applyGameIdentity(projectDir, { companyName, projectName, appName
|
|
|
36
29
|
const entries = [];
|
|
37
30
|
await walk(projectDir, '', entries);
|
|
38
31
|
|
|
32
|
+
// Names, deepest first; a .meta carries the template namespace in its name too, so it follows its asset.
|
|
33
|
+
// Every target is checked before anything changes, so a collision leaves the project as it was.
|
|
34
|
+
const namePart = renamer([{ from: TEMPLATE_NAMESPACE, to: `${companyName}.${projectName}` }]);
|
|
35
|
+
const moves = entries.filter((e) => path.posix.basename(e.rel).includes(TEMPLATE_NAMESPACE))
|
|
36
|
+
.sort((a, b) => b.rel.split('/').length - a.rel.split('/').length)
|
|
37
|
+
.map((e) => {
|
|
38
|
+
const from = path.join(projectDir, e.rel);
|
|
39
|
+
return { rel: e.rel, from, to: path.join(path.dirname(from), namePart(path.basename(from))) };
|
|
40
|
+
});
|
|
41
|
+
const targets = new Set();
|
|
42
|
+
for (const m of moves) {
|
|
43
|
+
if (targets.has(m.to) || (await fs.pathExists(m.to))) throw new Error(`cannot rename ${m.rel}: target exists`);
|
|
44
|
+
targets.add(m.to);
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
// 1. content
|
|
40
48
|
for (const e of entries) {
|
|
41
49
|
if (e.dir || !TEXT_EXTENSIONS.has(path.extname(e.rel))) continue;
|
|
@@ -45,18 +53,9 @@ async function applyGameIdentity(projectDir, { companyName, projectName, appName
|
|
|
45
53
|
if (next !== text) await fs.writeFile(file, next);
|
|
46
54
|
}
|
|
47
55
|
|
|
48
|
-
// 2. names,
|
|
49
|
-
const
|
|
50
|
-
|
|
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
|
-
}
|
|
56
|
+
// 2. names, in the order checked above
|
|
57
|
+
for (const m of moves) await fs.move(m.from, m.to);
|
|
58
|
+
const renamedFiles = moves.length;
|
|
60
59
|
|
|
61
60
|
// 3. PlayerSettings
|
|
62
61
|
const settingsFile = path.join(projectDir, 'ProjectSettings', 'ProjectSettings.asset');
|
package/src/game/ids.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
2
|
const fs = require('fs-extra');
|
|
3
|
+
const { idProblem } = require('./validate');
|
|
3
4
|
|
|
4
5
|
// Where each id group lands in the Unity template. `keys` maps the line's key to the field of the group.
|
|
5
6
|
// kind 'cs': `public const string <Key> = "<value>";`, 'yaml': ` <key>: <value>`, 'xml': the games_app_id string.
|
|
@@ -52,22 +53,38 @@ const PATTERNS = {
|
|
|
52
53
|
xml: (key) => new RegExp(`(<string name="${key}"[^>]*>)[^<]*(</string>)`, 'g')
|
|
53
54
|
};
|
|
54
55
|
|
|
56
|
+
// One regex pass: the replacer counts the matches; a function replacer never interprets `$` sequences
|
|
55
57
|
function replaceOnce(text, file, kind, key, value) {
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
+
let n = 0;
|
|
59
|
+
const next = text.replace(PATTERNS[kind](key), (_, before, after) => {
|
|
60
|
+
n += 1;
|
|
61
|
+
return `${before}${value}${after}`;
|
|
62
|
+
});
|
|
58
63
|
if (n !== 1) throw new Error(`${file}: ${key} found ${n} times`);
|
|
59
|
-
|
|
60
|
-
return text.replace(re, (_, before, after) => `${before}${value}${after}`);
|
|
64
|
+
return next;
|
|
61
65
|
}
|
|
62
66
|
|
|
63
|
-
//
|
|
64
|
-
|
|
67
|
+
// The values land inside C#, YAML and XML lines: the same rules as validateGame, checked here too
|
|
68
|
+
function checkValues(group, values) {
|
|
69
|
+
if (!values || typeof values !== 'object' || Array.isArray(values)) throw new Error(`ids.${group} must be an object`);
|
|
70
|
+
for (const { keys } of ID_TARGETS[group]) {
|
|
71
|
+
for (const field of Object.values(keys)) {
|
|
72
|
+
const problem = idProblem(group, field, values[field]);
|
|
73
|
+
if (problem) throw new Error(`ids.${group}.${field} ${problem}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Writes the given id groups into the project; every value must pass validateGame's ID rules and every target line
|
|
79
|
+
// must be there exactly once. All are checked before any file is written, so a failure leaves the project as it was.
|
|
80
|
+
// Returns the groups written.
|
|
65
81
|
async function writeGameIds(projectDir, ids) {
|
|
82
|
+
const groups = Object.keys(ID_TARGETS).filter((group) => ids && ids[group]);
|
|
83
|
+
for (const group of groups) checkValues(group, ids[group]);
|
|
66
84
|
const written = [];
|
|
67
85
|
const updates = [];
|
|
68
|
-
for (const group of
|
|
69
|
-
const values = ids
|
|
70
|
-
if (!values) continue;
|
|
86
|
+
for (const group of groups) {
|
|
87
|
+
const values = ids[group];
|
|
71
88
|
for (const { file, kind, keys } of ID_TARGETS[group]) {
|
|
72
89
|
const abs = path.join(projectDir, file);
|
|
73
90
|
let text;
|
package/src/game/modules.js
CHANGED
|
@@ -32,12 +32,23 @@ async function owningAsmdef(projectDir, rel) {
|
|
|
32
32
|
return null;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// Writes value back as JSON in the original text's format: its indent (spaces or tab), line ending and final newline
|
|
36
|
+
function sameFormatJson(original, value) {
|
|
37
|
+
const eol = original.includes('\r\n') ? '\r\n' : '\n';
|
|
38
|
+
// A one-line file (however it ends) stays on one line; otherwise the first indented line gives the indent
|
|
39
|
+
const multiline = original.trim().includes('\n');
|
|
40
|
+
const indent = /\n([ \t]+)\S/.exec(original);
|
|
41
|
+
const body = JSON.stringify(value, null, !multiline ? 0 : (indent ? indent[1] : 2));
|
|
42
|
+
return body.replace(/\n/g, eol) + (/\r?\n$/.test(original) ? eol : '');
|
|
43
|
+
}
|
|
44
|
+
|
|
35
45
|
// Reads every file the wiring edits and checks its anchors before anything is written
|
|
36
46
|
async function readAnchors(projectDir, fail) {
|
|
37
47
|
if (!(await fs.pathExists(path.join(projectDir, SAMPLE_DIR)))) throw fail(SAMPLE_DIR);
|
|
38
48
|
const manifestFile = path.join(projectDir, MANIFEST);
|
|
39
49
|
if (!(await fs.pathExists(manifestFile))) throw fail(MANIFEST);
|
|
40
|
-
const
|
|
50
|
+
const manifestText = await fs.readFile(manifestFile, 'utf8');
|
|
51
|
+
const manifest = JSON.parse(manifestText);
|
|
41
52
|
if (!manifest || typeof manifest.flags !== 'object') throw fail(`${MANIFEST} "flags"`);
|
|
42
53
|
|
|
43
54
|
const installerFile = path.join(projectDir, GAME_INSTALLER);
|
|
@@ -55,13 +66,14 @@ async function readAnchors(projectDir, fail) {
|
|
|
55
66
|
|
|
56
67
|
const asmdefRel = await owningAsmdef(projectDir, GAME_DIR);
|
|
57
68
|
if (!asmdefRel) throw fail(`the asmdef owning ${GAME_INSTALLER}`);
|
|
58
|
-
const
|
|
69
|
+
const asmdefText = await fs.readFile(path.join(projectDir, asmdefRel), 'utf8');
|
|
70
|
+
const asmdef = JSON.parse(asmdefText);
|
|
59
71
|
const refAt = Array.isArray(asmdef.references) ? asmdef.references.indexOf(SAMPLE_INSTALLER_ASSEMBLY) : -1;
|
|
60
72
|
if (refAt < 0) throw fail(`the ${SAMPLE_INSTALLER_ASSEMBLY} reference in ${asmdefRel}`);
|
|
61
73
|
|
|
62
74
|
return {
|
|
63
|
-
manifest, manifestFile, installerFile, lines, eol, end, usingAt,
|
|
64
|
-
indent: /^\s*/.exec(sampleLine)[0], asmdefRel, asmdef, refAt
|
|
75
|
+
manifest, manifestText, manifestFile, installerFile, lines, eol, end, usingAt,
|
|
76
|
+
indent: /^\s*/.exec(sampleLine)[0], asmdefRel, asmdef, asmdefText, refAt
|
|
65
77
|
};
|
|
66
78
|
}
|
|
67
79
|
|
|
@@ -95,11 +107,13 @@ async function cloneModules(projectDir, features, { seed }) {
|
|
|
95
107
|
throw new Error(`module ${name}: ${FEATURES}/${pascal(name)} already exists`);
|
|
96
108
|
}
|
|
97
109
|
}
|
|
110
|
+
// Every module waits on the same anchors, so an anchor error names them all
|
|
111
|
+
const who = `${names.length > 1 ? 'modules' : 'module'} ${names.join(', ')}`;
|
|
98
112
|
let anchors;
|
|
99
113
|
try {
|
|
100
|
-
anchors = await readAnchors(projectDir, (what) => new Error(
|
|
114
|
+
anchors = await readAnchors(projectDir, (what) => new Error(`${who}: ${what} not found in the template`));
|
|
101
115
|
} catch (error) {
|
|
102
|
-
if (error instanceof SyntaxError) throw new Error(
|
|
116
|
+
if (error instanceof SyntaxError) throw new Error(`${who}: ${error.message}`);
|
|
103
117
|
throw error;
|
|
104
118
|
}
|
|
105
119
|
|
|
@@ -118,9 +132,9 @@ async function cloneModules(projectDir, features, { seed }) {
|
|
|
118
132
|
created.push(`${FEATURES}/${p}`);
|
|
119
133
|
}
|
|
120
134
|
|
|
121
|
-
const { manifest, manifestFile, lines, eol, end, usingAt, indent, asmdef, refAt } = anchors;
|
|
135
|
+
const { manifest, manifestText, manifestFile, lines, eol, end, usingAt, indent, asmdef, asmdefText, refAt } = anchors;
|
|
122
136
|
addFlagTwins(manifest, names);
|
|
123
|
-
await fs.writeFile(manifestFile,
|
|
137
|
+
await fs.writeFile(manifestFile, sameFormatJson(manifestText, manifest));
|
|
124
138
|
|
|
125
139
|
// Installer lines go before the end marker (in order), using lines right after the Sample one
|
|
126
140
|
const installs = names.map((n) => `${indent}if (registerViews) new ${pascal(n)}Installer(registerViews).Install(builder);`);
|
|
@@ -131,7 +145,7 @@ async function cloneModules(projectDir, features, { seed }) {
|
|
|
131
145
|
await fs.writeFile(anchors.installerFile, next.join(eol));
|
|
132
146
|
|
|
133
147
|
asmdef.references.splice(refAt + 1, 0, ...names.map((n) => `Kturker.UnityBase.Features.${pascal(n)}.Installer`));
|
|
134
|
-
await fs.writeFile(path.join(projectDir, anchors.asmdefRel),
|
|
148
|
+
await fs.writeFile(path.join(projectDir, anchors.asmdefRel), sameFormatJson(asmdefText, asmdef));
|
|
135
149
|
return created;
|
|
136
150
|
}
|
|
137
151
|
|
package/src/game/record.js
CHANGED
|
@@ -16,9 +16,13 @@ function idStatuses(flags, ids) {
|
|
|
16
16
|
return out;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// The value is meant to come from validateGame, but the record never assumes it: odd entries are dropped
|
|
20
|
+
const strings = (list) => (Array.isArray(list) ? list.filter((x) => typeof x === 'string') : []);
|
|
21
|
+
|
|
19
22
|
/** The .cmpose/generation.json content of a generated Unity game (no personal data, no ID values);
|
|
20
23
|
* generatorVersion names the caller (e.g. mcp@0.2.0), null when not given */
|
|
21
24
|
function buildGameRecord({ template, coreVersion, generatorVersion = null, value, now = new Date() }) {
|
|
25
|
+
const flags = strings(value.flags);
|
|
22
26
|
return {
|
|
23
27
|
generator: '@cmpose/core',
|
|
24
28
|
coreVersion,
|
|
@@ -27,12 +31,12 @@ function buildGameRecord({ template, coreVersion, generatorVersion = null, value
|
|
|
27
31
|
unityVersion: GAME_SCHEMA.unityVersion,
|
|
28
32
|
templateType: value.templateType,
|
|
29
33
|
render: value.render,
|
|
30
|
-
flags
|
|
31
|
-
features: value.features
|
|
34
|
+
flags,
|
|
35
|
+
features: strings(value.features),
|
|
32
36
|
ci: value.ci === true,
|
|
33
37
|
minSdk: value.minSdk,
|
|
34
38
|
iosVersion: value.iosVersion,
|
|
35
|
-
ids: idStatuses(
|
|
39
|
+
ids: idStatuses(flags, value.ids),
|
|
36
40
|
template: { repo: template.repo, ref: template.ref, commit: template.commit },
|
|
37
41
|
generatedAt: now.toISOString()
|
|
38
42
|
};
|
package/src/game/unityYaml.js
CHANGED
|
@@ -143,11 +143,29 @@ function getMapEntry(text, mapKey, subKey, { doc } = {}) {
|
|
|
143
143
|
return value;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
// The
|
|
146
|
+
// The keys of an inline map of plain scalars ('{a: 1, b: 2}'); anything nested or quoted is refused
|
|
147
|
+
function inlineMapKeys(inline, mapKey) {
|
|
148
|
+
const inner = inline.slice(1, -1).trim();
|
|
149
|
+
if (!inner) return [];
|
|
150
|
+
const refuse = () => new UnityYamlError(`${mapKey}: inline map '${inline}' is not supported`, { key: mapKey });
|
|
151
|
+
if (/[{}[\]"']/.test(inner)) throw refuse();
|
|
152
|
+
return inner.split(',').map((part) => {
|
|
153
|
+
const m = /^([^\s:][^:]*):(?:[ \t]|$)/.exec(part.trim());
|
|
154
|
+
if (!m) throw refuse();
|
|
155
|
+
return m[1].trim();
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The sub-keys of a map, block or inline, in file order ([] for an empty map)
|
|
147
160
|
function getMapKeys(text, mapKey, { doc } = {}) {
|
|
148
161
|
let keys = [];
|
|
149
162
|
withScope(text, doc, (segment) => {
|
|
150
163
|
const lines = splitLines(segment);
|
|
164
|
+
const inline = valueOf(lines[onlyOne(topLevelIndexes(lines, mapKey), mapKey)], mapKey);
|
|
165
|
+
if (inline.startsWith('{') && inline.endsWith('}')) {
|
|
166
|
+
keys = inlineMapKeys(inline, mapKey);
|
|
167
|
+
return segment;
|
|
168
|
+
}
|
|
151
169
|
const { header, end } = locateMap(lines, mapKey);
|
|
152
170
|
keys = [];
|
|
153
171
|
for (let i = header + 1; i < end; i++) {
|
package/src/game/validate.js
CHANGED
|
@@ -9,22 +9,40 @@ const KOTLIN_KEYWORDS = PV.KOTLIN_HARD_KEYWORDS;
|
|
|
9
9
|
const NAME_RE = /^[A-Za-z][A-Za-z0-9]{0,29}$/;
|
|
10
10
|
const PACKAGE_RE = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*){2,}$/;
|
|
11
11
|
const MODULE_RE = /^[a-z][a-z0-9]{0,19}$/;
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
// 'M', 'M.m' or 'M.m.0' (leading zeros allowed); normalized to 'M.m'
|
|
13
|
+
const IOS_VERSION_RE = /^(\d+)(?:\.(\d+)(?:\.(0+))?)?$/;
|
|
14
|
+
|
|
15
|
+
// Each ID's pattern and the shape an error message names
|
|
16
|
+
const ADMOB_APP = { re: /^ca-app-pub-\d{16}~\d{10}$/, shape: 'ca-app-pub-<16 digits>~<10 digits>' };
|
|
17
|
+
const ADMOB_UNIT = { re: /^ca-app-pub-\d{16}\/\d{10}$/, shape: 'ca-app-pub-<16 digits>/<10 digits>' };
|
|
18
|
+
const GPGS_ID = { re: /^[A-Za-z0-9_-]{4,64}$/, shape: '4-64 letters, digits, _ or -' };
|
|
19
|
+
const GC_ID = { re: /^[A-Za-z0-9._-]{1,100}$/, shape: '1-100 letters, digits, ., _ or -' };
|
|
14
20
|
const ID_FORMATS = {
|
|
15
21
|
admob: {
|
|
16
|
-
androidAppId:
|
|
17
|
-
androidBanner:
|
|
18
|
-
iosBanner:
|
|
22
|
+
androidAppId: ADMOB_APP, iosAppId: ADMOB_APP,
|
|
23
|
+
androidBanner: ADMOB_UNIT, androidInterstitial: ADMOB_UNIT, androidRewarded: ADMOB_UNIT,
|
|
24
|
+
iosBanner: ADMOB_UNIT, iosInterstitial: ADMOB_UNIT, iosRewarded: ADMOB_UNIT
|
|
25
|
+
},
|
|
26
|
+
revenueCat: {
|
|
27
|
+
appleApiKey: { re: /^appl_[A-Za-z0-9]{20,}$/, shape: 'appl_ followed by at least 20 letters or digits' },
|
|
28
|
+
googleApiKey: { re: /^goog_[A-Za-z0-9]{20,}$/, shape: 'goog_ followed by at least 20 letters or digits' }
|
|
19
29
|
},
|
|
20
|
-
revenueCat: { appleApiKey: /^appl_[A-Za-z0-9]{20,}$/, googleApiKey: /^goog_[A-Za-z0-9]{20,}$/ },
|
|
21
30
|
gameServices: {
|
|
22
|
-
androidAppId: /^\d{6,20}$/,
|
|
23
|
-
leaderboardAndroid:
|
|
24
|
-
leaderboardIos:
|
|
31
|
+
androidAppId: { re: /^\d{6,20}$/, shape: '6-20 digits' },
|
|
32
|
+
leaderboardAndroid: GPGS_ID, firstCompletionAndroid: GPGS_ID, tenCompletionsAndroid: GPGS_ID,
|
|
33
|
+
leaderboardIos: GC_ID, firstCompletionIos: GC_ID, tenCompletionsIos: GC_ID
|
|
25
34
|
}
|
|
26
35
|
};
|
|
27
36
|
|
|
37
|
+
// Why an ID value is rejected (null = valid). The value is written into C#, XML and YAML lines.
|
|
38
|
+
function idProblem(group, field, v) {
|
|
39
|
+
if (typeof v !== 'string') return 'must be a string';
|
|
40
|
+
if (/["\\$\r\n]/.test(v)) return 'must not contain ", \\, $ or a line break';
|
|
41
|
+
if (/replace_me/i.test(v)) return 'is a placeholder (REPLACE_ME): use your real ID';
|
|
42
|
+
const { re, shape } = ID_FORMATS[group][field];
|
|
43
|
+
return re.test(v) ? null : `must look like ${shape}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
const fail = (error) => ({ ok: false, error });
|
|
29
47
|
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
30
48
|
const inList = (list, word, ci) => list.includes(ci ? String(word).toLowerCase() : word);
|
|
@@ -39,6 +57,11 @@ function checkName(field, value) {
|
|
|
39
57
|
function parseIosVersion(v) {
|
|
40
58
|
return String(v).split('.').map(Number);
|
|
41
59
|
}
|
|
60
|
+
// '015.0' / '15.0.0' / '15' -> '15.0'; null when not a version this accepts
|
|
61
|
+
function normalizeIosVersion(v) {
|
|
62
|
+
const m = typeof v === 'string' ? IOS_VERSION_RE.exec(v) : null;
|
|
63
|
+
return m ? `${Number(m[1])}.${Number(m[2] || 0)}` : null;
|
|
64
|
+
}
|
|
42
65
|
function cmpVersion(a, b) {
|
|
43
66
|
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
44
67
|
const d = (a[i] || 0) - (b[i] || 0);
|
|
@@ -55,16 +78,16 @@ function validateIds(ids, flags) {
|
|
|
55
78
|
if (!def) return { error: `ids.${group} is not a known group (${Object.keys(S.idGroups).join(', ')})` };
|
|
56
79
|
const g = ids[group];
|
|
57
80
|
if (!isObject(g)) return { error: `ids.${group} must be an object` };
|
|
58
|
-
const missing = def.fields.filter((f) => !(f
|
|
81
|
+
const missing = def.fields.filter((f) => !Object.hasOwn(g, f));
|
|
82
|
+
if (missing.length) return { error: `ids.${group} is missing: ${missing.join(', ')}` };
|
|
59
83
|
const extra = Object.keys(g).filter((f) => !def.fields.includes(f));
|
|
60
|
-
if (
|
|
84
|
+
if (extra.length) return { error: `ids.${group} has unknown fields: ${extra.join(', ')} (known: ${def.fields.join(', ')})` };
|
|
61
85
|
if (!flags.includes(def.flag)) return { error: `ids.${group} needs the ${def.flag} flag` };
|
|
62
86
|
out[group] = {};
|
|
63
87
|
for (const f of def.fields) {
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
out[group][f] = v;
|
|
88
|
+
const problem = idProblem(group, f, g[f]);
|
|
89
|
+
if (problem) return { error: `ids.${group}.${f} ${problem}` };
|
|
90
|
+
out[group][f] = g[f];
|
|
68
91
|
}
|
|
69
92
|
}
|
|
70
93
|
return { value: out };
|
|
@@ -86,9 +109,11 @@ function validateGame(input) {
|
|
|
86
109
|
if (segments.some((s) => inList(CSHARP_KEYWORDS, s, true) || inList(KOTLIN_KEYWORDS, s, true))) return fail('packageName must not contain a keyword segment');
|
|
87
110
|
if (packageName === S.reservedPackagePrefix || packageName.startsWith(S.reservedPackagePrefix + '.')) return fail('packageName must not use the reserved com.kturker prefix');
|
|
88
111
|
|
|
89
|
-
|
|
112
|
+
// Without companyName it comes from packageName's second segment: an error then points at packageName
|
|
113
|
+
const derived = input.companyName === undefined;
|
|
114
|
+
const companyName = derived ? segments[1][0].toUpperCase() + segments[1].slice(1) : input.companyName;
|
|
90
115
|
err = checkName('companyName', companyName);
|
|
91
|
-
if (err) return fail(err);
|
|
116
|
+
if (err) return fail(derived ? `${err} (companyName "${companyName}" is derived from packageName's second segment: shorten it there or set companyName)` : err);
|
|
92
117
|
|
|
93
118
|
const templateType = input.templateType === undefined ? 'showcase' : input.templateType;
|
|
94
119
|
if (!S.templateTypes.includes(templateType)) return fail(`templateType must be one of: ${S.templateTypes.join(', ')}`);
|
|
@@ -133,9 +158,10 @@ function validateGame(input) {
|
|
|
133
158
|
let iosVersion = S.limits.iosVersionMin;
|
|
134
159
|
if (input.iosVersion !== undefined) {
|
|
135
160
|
let v = input.iosVersion;
|
|
136
|
-
if (typeof v === 'number' && Number.isFinite(v)) v =
|
|
137
|
-
|
|
138
|
-
|
|
161
|
+
if (typeof v === 'number' && Number.isFinite(v)) v = String(v);
|
|
162
|
+
v = normalizeIosVersion(v);
|
|
163
|
+
if (v === null) return fail('iosVersion must look like 15.0 (major.minor; a third part must be 0)');
|
|
164
|
+
if (cmpVersion(parseIosVersion(v), parseIosVersion(S.limits.iosVersionMin)) < 0
|
|
139
165
|
|| cmpVersion(parseIosVersion(v), parseIosVersion(limits.iosVersionMax)) > 0) {
|
|
140
166
|
return fail(`iosVersion must be from ${S.limits.iosVersionMin} to ${limits.iosVersionMax}`);
|
|
141
167
|
}
|
|
@@ -157,4 +183,4 @@ function validateGame(input) {
|
|
|
157
183
|
};
|
|
158
184
|
}
|
|
159
185
|
|
|
160
|
-
module.exports = { validateGame };
|
|
186
|
+
module.exports = { validateGame, idProblem };
|
package/src/game/verifier.js
CHANGED
|
@@ -8,29 +8,38 @@ const { escapeRe } = require('./unityYaml');
|
|
|
8
8
|
const IDENTITY_RULE = { name: 'old identity', tokens: ['Kturker.UnityBase', 'UnityBase', 'unitybase', 'com.kturker'] };
|
|
9
9
|
const MARKER_RULE = { name: 'marker', tokens: ['// cmpose:', '// /cmpose:'], extensions: ['.cs'] };
|
|
10
10
|
// Extension points stay as whole lines (// cmpose:features:start … // cmpose:features:end in the GameInstaller,
|
|
11
|
-
// // cmpose:boot-steps:start … :end in the BootInstaller): users add their own lines between them
|
|
12
|
-
|
|
11
|
+
// // cmpose:boot-steps:start … :end in the BootInstaller): users add their own lines between them. A name may be
|
|
12
|
+
// kebab-case or camelCase (// cmpose:myExt:start).
|
|
13
|
+
const KEPT_MARKER_LINE = /^[ \t]*\/\/ cmpose:[a-z][A-Za-z0-9-]*:(?:start|end)[ \t]*\r?$/gm;
|
|
13
14
|
const MARKER_PROBLEM = new RegExp(`^marker (${MARKER_RULE.tokens.map(escapeRe).join('|')}) in (.+)$`);
|
|
14
15
|
const IGNORE = ['.cmpose/'];
|
|
15
16
|
|
|
16
17
|
// A marker problem stands only when the token is still there once the kept extension-point lines are dropped.
|
|
17
|
-
// The token is one of the rule's own, so a path holding ' in ' is read whole.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
return
|
|
18
|
+
// The token is one of the rule's own, so a path holding ' in ' is read whole. Each file is read (and stripped)
|
|
19
|
+
// once, however many marker problems it has.
|
|
20
|
+
function markerCheck(projectDir) {
|
|
21
|
+
const stripped = new Map();
|
|
22
|
+
return async (problem) => {
|
|
23
|
+
const m = MARKER_PROBLEM.exec(problem);
|
|
24
|
+
if (!stripped.has(m[2])) {
|
|
25
|
+
stripped.set(m[2], (await fs.readFile(path.join(projectDir, m[2]), 'utf8')).replace(KEPT_MARKER_LINE, ''));
|
|
26
|
+
}
|
|
27
|
+
return stripped.get(m[2]).includes(m[1]);
|
|
28
|
+
};
|
|
22
29
|
}
|
|
23
30
|
|
|
24
|
-
/** Problems of a generated Unity game (empty = clean): asset guids, the template's identity,
|
|
31
|
+
/** Problems of a generated Unity game (empty = clean), as one sorted list: asset guids, the template's identity,
|
|
32
|
+
* leftover flag markers */
|
|
25
33
|
async function verifyGameProject(projectDir) {
|
|
26
34
|
const allowlist = await readGuidAllowlist(path.join(projectDir, '.cmpose', 'package-guids.txt'));
|
|
27
35
|
const guidProblems = await checkGuidIntegrity(projectDir, { allowlist });
|
|
28
36
|
const residue = await findResidue(projectDir, [IDENTITY_RULE, MARKER_RULE], { ignore: IGNORE });
|
|
29
|
-
const
|
|
37
|
+
const stillMarked = markerCheck(projectDir);
|
|
38
|
+
const problems = [...guidProblems];
|
|
30
39
|
for (const problem of residue) {
|
|
31
|
-
if (!problem.startsWith('marker ') || (await stillMarked(
|
|
40
|
+
if (!problem.startsWith('marker ') || (await stillMarked(problem))) problems.push(problem);
|
|
32
41
|
}
|
|
33
|
-
return
|
|
42
|
+
return problems.sort();
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
module.exports = { verifyGameProject };
|
package/src/pins.js
CHANGED
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
module.exports = Object.freeze({
|
|
4
4
|
cmp: Object.freeze({ repo: 'Keremturker/cmpose-template', ref: 'template-2026.10.14' }),
|
|
5
5
|
// The release step replaces the placeholder with the game template's tag
|
|
6
|
-
game: Object.freeze({ repo: 'Keremturker/cmpose-game-template', ref: 'game-template-2026.10.
|
|
6
|
+
game: Object.freeze({ repo: 'Keremturker/cmpose-game-template', ref: 'game-template-2026.10.15' })
|
|
7
7
|
});
|