@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmpose/core",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "cmpose project generator core (Compose Multiplatform)",
5
5
  "license": "MIT",
6
6
  "repository": {
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 };
@@ -1,6 +1,7 @@
1
1
  const path = require('path');
2
2
  const fs = require('fs-extra');
3
- const { escapeRe, getMapKeys, setMapEntry } = require('./unityYaml');
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, 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
- }
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
- const re = PATTERNS[kind](key);
57
- const n = (text.match(re) || []).length;
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
- // A function replacer, so `$` sequences in the value are never interpreted
60
- return text.replace(re, (_, before, after) => `${before}${value}${after}`);
64
+ return next;
61
65
  }
62
66
 
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.
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 Object.keys(ID_TARGETS)) {
69
- const values = ids && ids[group];
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;
@@ -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 manifest = JSON.parse(await fs.readFile(manifestFile, 'utf8'));
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 asmdef = JSON.parse(await fs.readFile(path.join(projectDir, asmdefRel), 'utf8'));
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(`module ${names[0]}: ${what} not found in the template`));
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(`module ${names[0]}: ${error.message}`);
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, `${JSON.stringify(manifest, null, 2)}\n`);
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), `${JSON.stringify(asmdef, null, 4)}\n`);
148
+ await fs.writeFile(path.join(projectDir, anchors.asmdefRel), sameFormatJson(asmdefText, asmdef));
135
149
  return created;
136
150
  }
137
151
 
@@ -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: value.flags.slice(),
31
- features: value.features.slice(),
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(value.flags, value.ids),
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
  };
@@ -143,11 +143,29 @@ function getMapEntry(text, mapKey, subKey, { doc } = {}) {
143
143
  return value;
144
144
  }
145
145
 
146
- // The sub-keys of a block map, in file order ([] for an empty map)
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++) {
@@ -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
- const IOS_VERSION_RE = /^\d+(\.\d+){1,2}$/;
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: /^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}$/
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: /^[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}$/
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 in g));
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 (missing.length || extra.length) return { error: `ids.${group} needs all of: ${def.fields.join(', ')}` };
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 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;
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
- const companyName = input.companyName === undefined ? segments[1][0].toUpperCase() + segments[1].slice(1) : input.companyName;
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 = 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
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 };
@@ -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
- const KEPT_MARKER_LINE = /^[ \t]*\/\/ cmpose:[a-z][a-z0-9-]*:(?:start|end)[ \t]*\r?$/gm;
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
- 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]);
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, leftover flag markers */
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 kept = [];
37
+ const stillMarked = markerCheck(projectDir);
38
+ const problems = [...guidProblems];
30
39
  for (const problem of residue) {
31
- if (!problem.startsWith('marker ') || (await stillMarked(projectDir, problem))) kept.push(problem);
40
+ if (!problem.startsWith('marker ') || (await stillMarked(problem))) problems.push(problem);
32
41
  }
33
- return [...guidProblems, ...kept];
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.14.1' })
6
+ game: Object.freeze({ repo: 'Keremturker/cmpose-game-template', ref: 'game-template-2026.10.15' })
7
7
  });