@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
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// cmpose.dev/game: Unity's serialized YAML (ProjectSettings.asset, EditorBuildSettings.asset) is edited
|
|
2
|
+
// line by line, never through a YAML library: Unity's tags (--- !u!129 &1) and layout must survive.
|
|
3
|
+
// Every editor works inside one document and refuses to guess: no match or more than one throws.
|
|
4
|
+
|
|
5
|
+
class UnityYamlError extends Error {
|
|
6
|
+
constructor(message, { key, matches } = {}) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'UnityYamlError';
|
|
9
|
+
this.key = key;
|
|
10
|
+
this.matches = matches;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function escapeRe(s) {
|
|
15
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function eolOf(text) {
|
|
19
|
+
return text.includes('\r\n') ? '\r\n' : '\n';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function findDocument(text, classId, fileId = 1) {
|
|
23
|
+
const header = new RegExp(`^--- !u!${classId} &${fileId}(?![0-9])[^\\r\\n]*`, 'm').exec(text);
|
|
24
|
+
if (!header) throw new UnityYamlError(`document !u!${classId} &${fileId} not found`);
|
|
25
|
+
const bodyStart = header.index + header[0].length;
|
|
26
|
+
const next = /^--- /m.exec(text.slice(bodyStart));
|
|
27
|
+
return { start: header.index, end: next ? bodyStart + next.index : text.length };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function withScope(text, doc, edit) {
|
|
31
|
+
const { start, end } = doc ? findDocument(text, doc.classId, doc.fileId === undefined ? 1 : doc.fileId) : { start: 0, end: text.length };
|
|
32
|
+
const segment = text.slice(start, end);
|
|
33
|
+
const result = edit(segment);
|
|
34
|
+
return result === undefined ? undefined : { text: text.slice(0, start) + result + text.slice(end), segment };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Lines keep their own line ending; the last one may have none
|
|
38
|
+
function splitLines(segment) {
|
|
39
|
+
return segment.split(/(?<=\n)/);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function body(line) {
|
|
43
|
+
return line.replace(/\r?\n$/, '');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function ending(line) {
|
|
47
|
+
const m = /\r?\n$/.exec(line);
|
|
48
|
+
return m ? m[0] : '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function topLevelIndexes(lines, key) {
|
|
52
|
+
const re = new RegExp(`^ ${escapeRe(key)}:(?:[ \\t]|$)`);
|
|
53
|
+
const found = [];
|
|
54
|
+
lines.forEach((line, i) => { if (re.test(body(line))) found.push(i); });
|
|
55
|
+
return found;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function onlyOne(found, key) {
|
|
59
|
+
if (found.length !== 1) {
|
|
60
|
+
throw new UnityYamlError(`${key}: expected 1 match, found ${found.length}`, { key, matches: found.length });
|
|
61
|
+
}
|
|
62
|
+
return found[0];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function valueOf(line, key) {
|
|
66
|
+
return body(line).slice(` ${key}:`.length).trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function setScalar(text, key, value, { doc } = {}) {
|
|
70
|
+
return withScope(text, doc, (segment) => {
|
|
71
|
+
const lines = splitLines(segment);
|
|
72
|
+
const i = onlyOne(topLevelIndexes(lines, key), key);
|
|
73
|
+
lines[i] = ` ${key}: ${value}${ending(lines[i])}`;
|
|
74
|
+
return lines.join('');
|
|
75
|
+
}).text;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getScalar(text, key, { doc } = {}) {
|
|
79
|
+
let value;
|
|
80
|
+
withScope(text, doc, (segment) => {
|
|
81
|
+
const lines = splitLines(segment);
|
|
82
|
+
const found = topLevelIndexes(lines, key);
|
|
83
|
+
if (found.length > 1) onlyOne(found, key);
|
|
84
|
+
value = found.length === 1 ? valueOf(lines[found[0]], key) : undefined;
|
|
85
|
+
return segment;
|
|
86
|
+
});
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A block map: ' key:' followed by deeper-indented lines; an empty one is written ' key: {}'
|
|
91
|
+
function locateMap(lines, mapKey) {
|
|
92
|
+
const header = onlyOne(topLevelIndexes(lines, mapKey), mapKey);
|
|
93
|
+
const inline = valueOf(lines[header], mapKey);
|
|
94
|
+
let end = header + 1;
|
|
95
|
+
if (!inline) while (end < lines.length && /^ {4,}\S/.test(body(lines[end]))) end++;
|
|
96
|
+
if (inline && !/^\{\s*\}$/.test(inline)) {
|
|
97
|
+
throw new UnityYamlError(`${mapKey}: inline map '${inline}' is not supported`, { key: mapKey });
|
|
98
|
+
}
|
|
99
|
+
return { header, end, empty: Boolean(inline) || end === header + 1 };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function entryIndexes(lines, header, end, subKey) {
|
|
103
|
+
const re = new RegExp(`^ ${escapeRe(subKey)}:(?:[ \\t]|$)`);
|
|
104
|
+
const found = [];
|
|
105
|
+
for (let i = header + 1; i < end; i++) if (re.test(body(lines[i]))) found.push(i);
|
|
106
|
+
if (found.length > 1) onlyOne(found, subKey);
|
|
107
|
+
return found;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function setMapEntry(text, mapKey, subKey, value, { doc } = {}) {
|
|
111
|
+
const eol = eolOf(text);
|
|
112
|
+
return withScope(text, doc, (segment) => {
|
|
113
|
+
const lines = splitLines(segment);
|
|
114
|
+
const { header, end } = locateMap(lines, mapKey);
|
|
115
|
+
const entry = ` ${subKey}: ${value}`;
|
|
116
|
+
if (end === header + 1) {
|
|
117
|
+
lines[header] = ` ${mapKey}:${eol}${entry}${ending(lines[header])}`;
|
|
118
|
+
return lines.join('');
|
|
119
|
+
}
|
|
120
|
+
const found = entryIndexes(lines, header, end, subKey);
|
|
121
|
+
if (found.length === 1) {
|
|
122
|
+
lines[found[0]] = entry + ending(lines[found[0]]);
|
|
123
|
+
} else {
|
|
124
|
+
// The new entry takes the last entry's line ending; at the very end of the file it has none
|
|
125
|
+
const last = end - 1;
|
|
126
|
+
const lastEnding = ending(lines[last]);
|
|
127
|
+
if (!lastEnding) lines[last] += eol;
|
|
128
|
+
lines.splice(end, 0, entry + lastEnding);
|
|
129
|
+
}
|
|
130
|
+
return lines.join('');
|
|
131
|
+
}).text;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getMapEntry(text, mapKey, subKey, { doc } = {}) {
|
|
135
|
+
let value;
|
|
136
|
+
withScope(text, doc, (segment) => {
|
|
137
|
+
const lines = splitLines(segment);
|
|
138
|
+
const { header, end } = locateMap(lines, mapKey);
|
|
139
|
+
const found = entryIndexes(lines, header, end, subKey);
|
|
140
|
+
value = found.length === 1 ? body(lines[found[0]]).slice(` ${subKey}:`.length).trim() : undefined;
|
|
141
|
+
return segment;
|
|
142
|
+
});
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The sub-keys of a block map, in file order ([] for an empty map)
|
|
147
|
+
function getMapKeys(text, mapKey, { doc } = {}) {
|
|
148
|
+
let keys = [];
|
|
149
|
+
withScope(text, doc, (segment) => {
|
|
150
|
+
const lines = splitLines(segment);
|
|
151
|
+
const { header, end } = locateMap(lines, mapKey);
|
|
152
|
+
keys = [];
|
|
153
|
+
for (let i = header + 1; i < end; i++) {
|
|
154
|
+
const m = /^ {4}([^\s:][^:]*):(?:[ \t]|$)/.exec(body(lines[i]));
|
|
155
|
+
if (m) keys.push(m[1]);
|
|
156
|
+
}
|
|
157
|
+
return segment;
|
|
158
|
+
});
|
|
159
|
+
return keys;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function removeMapEntry(text, mapKey, subKey, { doc } = {}) {
|
|
163
|
+
return withScope(text, doc, (segment) => {
|
|
164
|
+
const lines = splitLines(segment);
|
|
165
|
+
const { header, end } = locateMap(lines, mapKey);
|
|
166
|
+
const found = entryIndexes(lines, header, end, subKey);
|
|
167
|
+
if (found.length === 0) return segment;
|
|
168
|
+
lines.splice(found[0], 1);
|
|
169
|
+
if (end - 1 === header + 1) lines[header] = ` ${mapKey}: {}${ending(lines[header])}`;
|
|
170
|
+
return lines.join('');
|
|
171
|
+
}).text;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// A list under a top-level key: Unity writes its items at the key's own indent (' - item')
|
|
175
|
+
function setList(text, key, items, { doc } = {}) {
|
|
176
|
+
const eol = eolOf(text);
|
|
177
|
+
return withScope(text, doc, (segment) => {
|
|
178
|
+
const lines = splitLines(segment);
|
|
179
|
+
const header = onlyOne(topLevelIndexes(lines, key), key);
|
|
180
|
+
let end = header + 1;
|
|
181
|
+
while (end < lines.length && /^ - /.test(body(lines[end]))) end++;
|
|
182
|
+
const after = ending(lines[end - 1]);
|
|
183
|
+
const block = items.length === 0
|
|
184
|
+
? ` ${key}: []${after}`
|
|
185
|
+
: [` ${key}:`, ...items.map((item) => ` - ${item}`)].join(eol) + after;
|
|
186
|
+
lines.splice(header, end - header, block);
|
|
187
|
+
return lines.join('');
|
|
188
|
+
}).text;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = {
|
|
192
|
+
UnityYamlError, escapeRe, findDocument, setScalar, getScalar, setMapEntry, getMapEntry, getMapKeys, removeMapEntry, setList
|
|
193
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const PV = require('../ProjectValidator');
|
|
3
|
+
const limits = require('../limits');
|
|
4
|
+
const { GAME_SCHEMA: S } = require('./contract');
|
|
5
|
+
|
|
6
|
+
const CSHARP_KEYWORDS = ('abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while').split(' ');
|
|
7
|
+
const KOTLIN_KEYWORDS = PV.KOTLIN_HARD_KEYWORDS;
|
|
8
|
+
|
|
9
|
+
const NAME_RE = /^[A-Za-z][A-Za-z0-9]{0,29}$/;
|
|
10
|
+
const PACKAGE_RE = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*){2,}$/;
|
|
11
|
+
const MODULE_RE = /^[a-z][a-z0-9]{0,19}$/;
|
|
12
|
+
const IOS_VERSION_RE = /^\d+(\.\d+){1,2}$/;
|
|
13
|
+
|
|
14
|
+
const ID_FORMATS = {
|
|
15
|
+
admob: {
|
|
16
|
+
androidAppId: /^ca-app-pub-\d{16}~\d{10}$/, iosAppId: /^ca-app-pub-\d{16}~\d{10}$/,
|
|
17
|
+
androidBanner: /^ca-app-pub-\d{16}\/\d{10}$/, androidInterstitial: /^ca-app-pub-\d{16}\/\d{10}$/, androidRewarded: /^ca-app-pub-\d{16}\/\d{10}$/,
|
|
18
|
+
iosBanner: /^ca-app-pub-\d{16}\/\d{10}$/, iosInterstitial: /^ca-app-pub-\d{16}\/\d{10}$/, iosRewarded: /^ca-app-pub-\d{16}\/\d{10}$/
|
|
19
|
+
},
|
|
20
|
+
revenueCat: { appleApiKey: /^appl_[A-Za-z0-9]{20,}$/, googleApiKey: /^goog_[A-Za-z0-9]{20,}$/ },
|
|
21
|
+
gameServices: {
|
|
22
|
+
androidAppId: /^\d{6,20}$/,
|
|
23
|
+
leaderboardAndroid: /^[A-Za-z0-9_-]{4,64}$/, firstCompletionAndroid: /^[A-Za-z0-9_-]{4,64}$/, tenCompletionsAndroid: /^[A-Za-z0-9_-]{4,64}$/,
|
|
24
|
+
leaderboardIos: /^[A-Za-z0-9._-]{1,100}$/, firstCompletionIos: /^[A-Za-z0-9._-]{1,100}$/, tenCompletionsIos: /^[A-Za-z0-9._-]{1,100}$/
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const fail = (error) => ({ ok: false, error });
|
|
29
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
30
|
+
const inList = (list, word, ci) => list.includes(ci ? String(word).toLowerCase() : word);
|
|
31
|
+
|
|
32
|
+
function checkName(field, value) {
|
|
33
|
+
if (typeof value !== 'string' || !NAME_RE.test(value)) return `${field} must be 1-${S.limits.nameMax} letters or digits, starting with a letter`;
|
|
34
|
+
if (inList(CSHARP_KEYWORDS, value, false)) return `${field} must not be a C# keyword`;
|
|
35
|
+
if (S.reservedNames.includes(value)) return `${field} is reserved`;
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseIosVersion(v) {
|
|
40
|
+
return String(v).split('.').map(Number);
|
|
41
|
+
}
|
|
42
|
+
function cmpVersion(a, b) {
|
|
43
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
44
|
+
const d = (a[i] || 0) - (b[i] || 0);
|
|
45
|
+
if (d) return d;
|
|
46
|
+
}
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function validateIds(ids, flags) {
|
|
51
|
+
if (!isObject(ids)) return { error: 'ids must be an object' };
|
|
52
|
+
const out = {};
|
|
53
|
+
for (const group of Object.keys(ids)) {
|
|
54
|
+
const def = Object.hasOwn(S.idGroups, group) ? S.idGroups[group] : null;
|
|
55
|
+
if (!def) return { error: `ids.${group} is not a known group (${Object.keys(S.idGroups).join(', ')})` };
|
|
56
|
+
const g = ids[group];
|
|
57
|
+
if (!isObject(g)) return { error: `ids.${group} must be an object` };
|
|
58
|
+
const missing = def.fields.filter((f) => !(f in g));
|
|
59
|
+
const extra = Object.keys(g).filter((f) => !def.fields.includes(f));
|
|
60
|
+
if (missing.length || extra.length) return { error: `ids.${group} needs all of: ${def.fields.join(', ')}` };
|
|
61
|
+
if (!flags.includes(def.flag)) return { error: `ids.${group} needs the ${def.flag} flag` };
|
|
62
|
+
out[group] = {};
|
|
63
|
+
for (const f of def.fields) {
|
|
64
|
+
const v = g[f];
|
|
65
|
+
const path = `ids.${group}.${f}`;
|
|
66
|
+
if (typeof v !== 'string' || /["\\$\r\n]/.test(v) || /replace_me/i.test(v) || !ID_FORMATS[group][f].test(v)) return { error: `${path} has an invalid format` };
|
|
67
|
+
out[group][f] = v;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { value: out };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function validateGame(input) {
|
|
74
|
+
if (!isObject(input)) return fail('input must be an object');
|
|
75
|
+
const { projectName, appName, packageName } = input;
|
|
76
|
+
|
|
77
|
+
let err = checkName('projectName', projectName);
|
|
78
|
+
if (err) return fail(err);
|
|
79
|
+
|
|
80
|
+
if (typeof appName !== 'string' || !PV.validateAppName(appName) || appName.length > S.limits.appNameMax) return fail(PV.messages.appNameInvalid.replace('App Name', 'appName'));
|
|
81
|
+
|
|
82
|
+
if (typeof packageName !== 'string' || !PACKAGE_RE.test(packageName) || packageName.length > S.limits.packageNameMax) {
|
|
83
|
+
return fail(`packageName must look like com.company.app (lowercase, at least 3 segments, up to ${S.limits.packageNameMax} characters)`);
|
|
84
|
+
}
|
|
85
|
+
const segments = packageName.split('.');
|
|
86
|
+
if (segments.some((s) => inList(CSHARP_KEYWORDS, s, true) || inList(KOTLIN_KEYWORDS, s, true))) return fail('packageName must not contain a keyword segment');
|
|
87
|
+
if (packageName === S.reservedPackagePrefix || packageName.startsWith(S.reservedPackagePrefix + '.')) return fail('packageName must not use the reserved com.kturker prefix');
|
|
88
|
+
|
|
89
|
+
const companyName = input.companyName === undefined ? segments[1][0].toUpperCase() + segments[1].slice(1) : input.companyName;
|
|
90
|
+
err = checkName('companyName', companyName);
|
|
91
|
+
if (err) return fail(err);
|
|
92
|
+
|
|
93
|
+
const templateType = input.templateType === undefined ? 'showcase' : input.templateType;
|
|
94
|
+
if (!S.templateTypes.includes(templateType)) return fail(`templateType must be one of: ${S.templateTypes.join(', ')}`);
|
|
95
|
+
const render = input.render === undefined ? '2d' : input.render;
|
|
96
|
+
if (!S.renders.includes(render)) return fail(`render must be one of: ${S.renders.join(', ')}`);
|
|
97
|
+
|
|
98
|
+
const blank = templateType === 'blank';
|
|
99
|
+
if (!blank && input.flags !== undefined) return fail('flags apply to blank only');
|
|
100
|
+
if (!blank && input.features !== undefined) return fail('features apply to blank only');
|
|
101
|
+
|
|
102
|
+
let flags = S.flags.slice();
|
|
103
|
+
if (blank) {
|
|
104
|
+
const wanted = input.flags === undefined ? [] : input.flags;
|
|
105
|
+
if (!Array.isArray(wanted) || wanted.some((f) => !S.flags.includes(f))) return fail(`flags must be an array of: ${S.flags.join(', ')}`);
|
|
106
|
+
const set = new Set(wanted);
|
|
107
|
+
for (const f of wanted) for (const r of (Object.hasOwn(S.flagRequires, f) ? S.flagRequires[f] : [])) set.add(r);
|
|
108
|
+
flags = S.flags.filter((f) => set.has(f));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let features = [];
|
|
112
|
+
if (blank && input.features !== undefined) {
|
|
113
|
+
const list = input.features;
|
|
114
|
+
if (!Array.isArray(list)) return fail('features must be an array');
|
|
115
|
+
if (list.length > S.limits.maxModules) return fail(`features allows at most ${S.limits.maxModules} entries`);
|
|
116
|
+
for (const f of list) {
|
|
117
|
+
if (typeof f !== 'string' || !MODULE_RE.test(f)) return fail(`features entry "${f}" must be 1-${S.limits.moduleNameMax} lowercase letters or digits, starting with a letter`);
|
|
118
|
+
if (inList(CSHARP_KEYWORDS, f, true) || inList(KOTLIN_KEYWORDS, f, true)) return fail(`features entry "${f}" is a keyword`);
|
|
119
|
+
if (S.reservedModuleNames.includes(f)) return fail(`features entry "${f}" is reserved`);
|
|
120
|
+
}
|
|
121
|
+
if (new Set(list).size !== list.length) return fail('features must not contain duplicates');
|
|
122
|
+
features = list.slice();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let minSdk = S.limits.minSdkMin;
|
|
126
|
+
if (input.minSdk !== undefined) {
|
|
127
|
+
const raw = input.minSdk;
|
|
128
|
+
const n = typeof raw === 'number' ? raw : (typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : NaN);
|
|
129
|
+
if (!Number.isInteger(n) || n < S.limits.minSdkMin || n > S.limits.minSdkMax) return fail(`minSdk must be an integer from ${S.limits.minSdkMin} to ${S.limits.minSdkMax}`);
|
|
130
|
+
minSdk = n;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let iosVersion = S.limits.iosVersionMin;
|
|
134
|
+
if (input.iosVersion !== undefined) {
|
|
135
|
+
let v = input.iosVersion;
|
|
136
|
+
if (typeof v === 'number' && Number.isFinite(v)) v = Number.isInteger(v) ? `${v}.0` : String(v);
|
|
137
|
+
if (typeof v !== 'string' || !IOS_VERSION_RE.test(v)
|
|
138
|
+
|| cmpVersion(parseIosVersion(v), parseIosVersion(S.limits.iosVersionMin)) < 0
|
|
139
|
+
|| cmpVersion(parseIosVersion(v), parseIosVersion(limits.iosVersionMax)) > 0) {
|
|
140
|
+
return fail(`iosVersion must be from ${S.limits.iosVersionMin} to ${limits.iosVersionMax}`);
|
|
141
|
+
}
|
|
142
|
+
iosVersion = v;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (input.ci !== undefined && typeof input.ci !== 'boolean') return fail('ci must be a boolean');
|
|
146
|
+
|
|
147
|
+
let ids = {};
|
|
148
|
+
if (input.ids !== undefined) {
|
|
149
|
+
const r = validateIds(input.ids, flags);
|
|
150
|
+
if (r.error) return fail(r.error);
|
|
151
|
+
ids = r.value;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
ok: true,
|
|
156
|
+
value: { projectName, appName, packageName, companyName, templateType, render, flags, features, minSdk, iosVersion, ci: input.ci === true, ids }
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = { validateGame };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const { checkGuidIntegrity, readGuidAllowlist } = require('./guidIntegrity');
|
|
5
|
+
const { findResidue } = require('./residue');
|
|
6
|
+
const { escapeRe } = require('./unityYaml');
|
|
7
|
+
|
|
8
|
+
const IDENTITY_RULE = { name: 'old identity', tokens: ['Kturker.UnityBase', 'UnityBase', 'unitybase', 'com.kturker'] };
|
|
9
|
+
const MARKER_RULE = { name: 'marker', tokens: ['// cmpose:', '// /cmpose:'], extensions: ['.cs'] };
|
|
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
|
+
const KEPT_MARKER_LINE = /^[ \t]*\/\/ cmpose:[a-z][a-z0-9-]*:(?:start|end)[ \t]*\r?$/gm;
|
|
13
|
+
const MARKER_PROBLEM = new RegExp(`^marker (${MARKER_RULE.tokens.map(escapeRe).join('|')}) in (.+)$`);
|
|
14
|
+
const IGNORE = ['.cmpose/'];
|
|
15
|
+
|
|
16
|
+
// 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
|
+
async function stillMarked(projectDir, problem) {
|
|
19
|
+
const m = MARKER_PROBLEM.exec(problem);
|
|
20
|
+
const text = (await fs.readFile(path.join(projectDir, m[2]), 'utf8')).replace(KEPT_MARKER_LINE, '');
|
|
21
|
+
return text.includes(m[1]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Problems of a generated Unity game (empty = clean): asset guids, the template's identity, leftover flag markers */
|
|
25
|
+
async function verifyGameProject(projectDir) {
|
|
26
|
+
const allowlist = await readGuidAllowlist(path.join(projectDir, '.cmpose', 'package-guids.txt'));
|
|
27
|
+
const guidProblems = await checkGuidIntegrity(projectDir, { allowlist });
|
|
28
|
+
const residue = await findResidue(projectDir, [IDENTITY_RULE, MARKER_RULE], { ignore: IGNORE });
|
|
29
|
+
const kept = [];
|
|
30
|
+
for (const problem of residue) {
|
|
31
|
+
if (!problem.startsWith('marker ') || (await stillMarked(projectDir, problem))) kept.push(problem);
|
|
32
|
+
}
|
|
33
|
+
return [...guidProblems, ...kept];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = { verifyGameProject };
|
package/src/index.js
CHANGED
|
@@ -33,5 +33,6 @@ module.exports = {
|
|
|
33
33
|
generationWarnings: require('./warnings').generationWarnings,
|
|
34
34
|
generateCmpProject: require('./generate').generateCmpProject,
|
|
35
35
|
templateCache: require('./templateCache'),
|
|
36
|
+
game: require('./game'),
|
|
36
37
|
TemplateFetchError: require('./templateCache').TemplateFetchError
|
|
37
38
|
};
|
package/src/pins.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
// The template tags this core version was tested with (immutable tags)
|
|
3
3
|
module.exports = Object.freeze({
|
|
4
|
-
cmp: Object.freeze({ repo: 'Keremturker/cmpose-template', ref: 'template-2026.10.14' })
|
|
4
|
+
cmp: Object.freeze({ repo: 'Keremturker/cmpose-template', ref: 'template-2026.10.14' }),
|
|
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.14.1' })
|
|
5
7
|
});
|