@clunkyard/cli 0.0.0-stage → 2.0.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/cli.cjs ADDED
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const path=require('node:path');
5
+ const {createRuntime}=require('./runtime.cjs');
6
+ const {createProject,migrate}=require('./project.cjs');
7
+ const catalogue=require('./catalogue.json');
8
+ const {version}=require('./package.json');
9
+
10
+ function parse(args) {
11
+ let project=process.cwd();const positional=[];
12
+ for(let i=0;i<args.length;i++){
13
+ if(args[i]==='--project'){
14
+ if(!args[i+1]||args[i+1].startsWith('--'))throw new Error('--project needs a directory.');
15
+ project=path.resolve(args[++i]);
16
+ }else if(args[i].startsWith('--project=')){
17
+ if(!args[i].slice(10))throw new Error('--project needs a directory.');
18
+ project=path.resolve(args[i].slice(10));
19
+ }else positional.push(args[i]);
20
+ }
21
+ return {project,action:positional.shift()||'help',args:positional};
22
+ }
23
+ async function main(argv=process.argv.slice(2)) {
24
+ const {project,action,args}=parse(argv);
25
+ if(action==='--version'||action==='version'){console.log(version);return;}
26
+ if(['help','--help','-h'].includes(action)){
27
+ console.log('Clunkyard local robot CLI\n\nclunkyard [--project DIRECTORY] doctor|setup|build|match|replay|clean|migrate\nclunkyard new LANGUAGE DIRECTORY\nclunkyard list\n\nmatch [seed] [2d|3d] [ruleset]; replay [file] [port]');return;
28
+ }
29
+ if(action==='list'){
30
+ for(const [language,row] of Object.entries(catalogue.languages))console.log(`${language.padEnd(22)} ${row.portable||['javascript','typescript'].includes(language)?'Node route':'Linux x86-64'} https://github.com/clunkyard/starter-${language}`);
31
+ return;
32
+ }
33
+ if(action==='new'){
34
+ if(args.length!==2)throw new Error('Use clunkyard new LANGUAGE DIRECTORY.');
35
+ const destination=await createProject(args[0],path.resolve(project,args[1]));
36
+ console.log(`Created ${destination}. Run npx --yes @clunkyard/cli@2 doctor, build and match there.`);return;
37
+ }
38
+ if(action==='migrate'){
39
+ migrate(project);console.log('Updated generated tooling. Robot source files are unchanged.');return;
40
+ }
41
+ await createRuntime(project,__filename).main(action,args);
42
+ }
43
+
44
+ if(require.main===module)main().catch(error=>{console.error(error.message);process.exitCode=1});
45
+ module.exports={main,parse};
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "@clunkyard/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.0",
4
+ "description": "Shared local builds, matches and replays for Clunkyard robots",
5
+ "license": "MIT",
6
+ "engines": {
7
+ "node": ">=20"
8
+ },
9
+ "bin": {
10
+ "clunkyard": "cli.cjs"
11
+ },
12
+ "files": [
13
+ "cli.cjs",
14
+ "runtime.cjs",
15
+ "project.cjs",
16
+ "archive.cjs",
17
+ "catalogue.json",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/clunkyard/sdk.git",
24
+ "directory": "packages/cli"
25
+ },
26
+ "homepage": "https://clunkyard.com/sdk/player-kit",
27
+ "bugs": {
28
+ "url": "https://github.com/clunkyard/sdk/issues"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public",
32
+ "registry": "https://registry.npmjs.org"
33
+ }
34
+ }
package/project.cjs ADDED
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto');
4
+ const {extract,readAsset}=require('./archive.cjs');
5
+ const catalogue=require('./catalogue.json');
6
+ const json=value=>JSON.stringify(value,null,2)+'\n';
7
+ const hash=data=>crypto.createHash('sha256').update(data).digest('hex');
8
+ const normalized=data=>data.toString('utf8').replace(/\r\n/g,'\n');
9
+ const known=(data,expected)=>Boolean(expected)&&(hash(data)===expected||hash(normalized(data))===expected);
10
+ const nodeLanguage=language=>['javascript','typescript'].includes(language);
11
+
12
+ function readManifest(directory) {
13
+ if(!fs.lstatSync(path.join(directory,'bot.json')).isFile())throw new Error('bot.json must be an ordinary file.');
14
+ const manifest=JSON.parse(fs.readFileSync(path.join(directory,'bot.json'),'utf8'));
15
+ if(!Object.hasOwn(catalogue.languages,manifest.language))throw new Error(`Unsupported language: ${manifest.language}`);
16
+ const entry=manifest.entry;
17
+ if(typeof entry!=='string'||!entry||entry.includes('\\')||entry.includes('\0')||path.isAbsolute(entry)||entry.split('/').some(part=>!part||part==='.'||part==='..'))throw new Error('bot.json entry must be a relative source path inside this project.');
18
+ let component=directory;
19
+ for(const part of entry.split('/')){component=path.join(component,part);if(fs.lstatSync(component).isSymbolicLink())throw new Error('bot.json entry cannot use symbolic links.');}
20
+ const file=path.join(directory,entry),relative=path.relative(fs.realpathSync(directory),fs.realpathSync(file));
21
+ if(relative==='..'||relative.startsWith('..'+path.sep)||path.isAbsolute(relative)||!fs.lstatSync(file).isFile())throw new Error('bot.json entry must be an ordinary file inside this project.');
22
+ return manifest;
23
+ }
24
+
25
+ function nodeMetadata(language) {
26
+ const sdk=catalogue.npmSDK;
27
+ const metadata={name:`clunkyard-starter-${language}`,version:'2.0.0',private:true,scripts:{
28
+ build:'npx --yes @clunkyard/cli@2 build',doctor:'npx --yes @clunkyard/cli@2 doctor',
29
+ match:'npx --yes @clunkyard/cli@2 match',replay:'npx --yes @clunkyard/cli@2 replay',
30
+ },engines:{node:'>=20'},dependencies:{clunkyard:`npm:${sdk.name}@${sdk.version}`}};
31
+ const lock={name:metadata.name,version:metadata.version,lockfileVersion:3,requires:true,packages:{
32
+ '':{name:metadata.name,version:metadata.version,dependencies:metadata.dependencies,engines:metadata.engines},
33
+ 'node_modules/clunkyard':{name:sdk.name,version:sdk.version,resolved:'https://registry.npmjs.org/@clunkyard/sdk/-/sdk-2.0.0.tgz',integrity:sdk.integrity,license:'MIT',engines:{node:'>=20'}},
34
+ }};
35
+ return {'package.json':json(metadata),'package-lock.json':json(lock)};
36
+ }
37
+
38
+ function makefile() {
39
+ return 'CLUNKYARD ?= npx --yes @clunkyard/cli@2\n\n.PHONY: all setup doctor match replay clean\nall:\n\t$(CLUNKYARD) build\nsetup:\n\t$(CLUNKYARD) setup\ndoctor:\n\t$(CLUNKYARD) doctor\nmatch:\n\t$(CLUNKYARD) match\nreplay:\n\t$(CLUNKYARD) replay\nclean:\n\t$(CLUNKYARD) clean\n';
40
+ }
41
+
42
+ function readme(language,entry) {
43
+ const row=catalogue.languages[language],portable=nodeLanguage(language)||row.portable;
44
+ const tools=row.tools.filter(tool=>!['node','make'].includes(tool));
45
+ const guide=language==='typescript'?'javascript':language==='wat'||language.endsWith('-wasm')?'wasm':language;
46
+ return `# A Clunkyard robot in ${language}
47
+
48
+ Edit \`${entry}\`, build, play a real local match and inspect its replay. Install Node 20+ with npm${tools.length?' and your existing language tools: '+tools.map(tool=>'\`'+tool+'\`').join(', '):''}.
49
+ ${portable?'This language uses the portable Node/WebAssembly route.':'Native SDK assets require Linux x86-64 and glibc 2.39+ (Ubuntu 24.04+ or WSL2 Ubuntu 24.04+).'}
50
+
51
+ \`\`\`sh
52
+ git clone https://github.com/clunkyard/starter-${language}.git
53
+ cd starter-${language}
54
+ npx --yes @clunkyard/cli@2 doctor
55
+ npx --yes @clunkyard/cli@2 build
56
+ npx --yes @clunkyard/cli@2 match
57
+ npx --yes @clunkyard/cli@2 replay
58
+ \`\`\`
59
+
60
+ If you use Make, the equivalent commands are \`make doctor\`, \`make\`, \`make match\` and \`make replay\`. The shared CLI downloads the checksum-pinned local engine and only this language's SDK${nodeLanguage(language)?' through npm':''}. Downloads, build output and temporary files stay in ignored \`.clunkyard/\`${nodeLanguage(language)?'; the npm SDK is installed in ignored \`node_modules/\`':''}. No account, Docker image, administrator access or all-language builder is needed.
61
+
62
+ ${nodeLanguage(language)?`The ordinary npm dependency is \`clunkyard@npm:@clunkyard/sdk@${catalogue.sdkVersion}\`, pinned with its integrity in \`package-lock.json\`. Existing \`require('clunkyard')\` and \`import ... from 'clunkyard'\` source works locally and online. The first build runs \`npm ci --ignore-scripts\`; you can also run \`npm ci\` yourself. Keep both package files in Git. ${language==='typescript'?'Use your installed TypeScript 6+ compiler; the CLI downloads no compiler.':''}\n\n`:''}${(row.requirements?.notes||[]).join(' ')}
63
+
64
+ The [language guide](https://clunkyard.com/sdk/${guide}) lists the adapter, official language reference and toolchain prerequisites. \`doctor\` checks your existing installation without downloading a compiler. Compiler-sensitive native bindings rebuild with your own toolchain.
65
+
66
+ ## Repeat a trial
67
+
68
+ \`\`\`sh
69
+ npx --yes @clunkyard/cli@2 match 42 2d classic
70
+ npx --yes @clunkyard/cli@2 match 77 3d zero-g
71
+ \`\`\`
72
+
73
+ Keep a baseline commit. Repeat several seeds and rulesets; compare contacts, battery, aim, shots, hits and eliminations in the replay. Local trials run programs as your user. Use hosted trials to measure arena budgets before a tournament.
74
+
75
+ The CLI packages SDK ${catalogue.sdkVersion}/protocol 2 asset identities from the immutable [${catalogue.release} release](https://github.com/clunkyard/game-kit/releases/tag/${catalogue.release}). \`@2\` receives compatible tooling fixes without copying scripts into your robot repository. To reproduce an exact tool version, use \`npx --yes @clunkyard/cli@2.0.0\` or set \`CLUNKYARD='npx --yes @clunkyard/cli@2.0.0'\` with Make. SDK/engine caches are identified by their content hashes and remain reusable across CLI updates.
76
+
77
+ ## Play online
78
+
79
+ Create or copy a bot at [clunkyard.com](https://clunkyard.com). For Clunkyard's private Git remote, install \`npm install --global @clunkyard/git@2.0.1\`, run \`clunkyard-git login\` and follow the [private Git guide](https://clunkyard.com/sdk/private-git) to clone your bot. Copy tracked robot files into that private clone while keeping its own \`.git\` directory; do not import unrelated starter history or commit generated caches. An accepted push saves a revision and requests its hosted build.
80
+
81
+ You can also create your own GitHub repository from this template, connect it in My bots and select the repository/ref for build-on-push. [WebDAV](https://clunkyard.com/manual#local-development) is available for desktop editors. Save browser changes before syncing; resolve any source conflict before publishing.
82
+
83
+ [API and adapters](https://clunkyard.com/sdk) · [Local trials](https://clunkyard.com/sdk/local-trials) · [Game manual](https://clunkyard.com/manual)
84
+ `;
85
+ }
86
+
87
+ function migrate(directory) {
88
+ const root=path.resolve(directory),manifest=readManifest(root),row=catalogue.languages[manifest.language];
89
+ const files={'Makefile':makefile(),...(nodeLanguage(manifest.language)?nodeMetadata(manifest.language):{})};
90
+ const retired=['clunkyard.cjs','clunkyard-archive.cjs','clunkyard.lock.json','clunkyard-template.json'];
91
+ const remove=[...retired,...(!nodeLanguage(manifest.language)?['package.json']:[])];
92
+ // Preflight all replacements before changing anything. A player's source,
93
+ // customized tools and dependency manifests are never overwritten blindly.
94
+ for(const name of [...new Set([...remove,...Object.keys(files),'README.md','.gitignore','Dockerfile'])]){
95
+ let stat;try{stat=fs.lstatSync(path.join(root,name))}catch(error){if(error.code==='ENOENT')continue;throw error}
96
+ if(!stat.isFile())throw new Error(`Preserving nonordinary ${name}; migration requires ordinary files.`);
97
+ }
98
+ for(const name of [...remove,...Object.keys(files)]) {
99
+ const file=path.join(root,name);
100
+ if(!fs.existsSync(file))continue;
101
+ const data=fs.readFileSync(file),old=row.boilerplate[name];
102
+ if(known(data,old)||(files[name]&&normalized(data)===files[name]))continue;
103
+ throw new Error(`Preserving customized ${name}; migrate its CLI command manually before retrying.`);
104
+ }
105
+ const docker=path.join(root,'Dockerfile');
106
+ if(fs.existsSync(docker)){
107
+ const content=fs.readFileSync(docker);
108
+ if(!known(content,row.boilerplate.Dockerfile)&&/clunkyard(?:-archive)?\.cjs|clunkyard\.lock\.json/.test(content.toString('utf8')))throw new Error('Preserving customized Dockerfile; replace its retired helper command before migrating.');
109
+ }
110
+ const guide=path.join(root,'README.md'),oldGuide=fs.existsSync(guide)?fs.readFileSync(guide,'utf8'):null;
111
+ const generated=readme(manifest.language,manifest.entry);
112
+ if(oldGuide===null||known(oldGuide,row.boilerplate['README.md']))fs.writeFileSync(guide,generated);
113
+ else if(!oldGuide.includes('## Clunkyard CLI'))fs.appendFileSync(guide,'\n## Clunkyard CLI\n\nShared tooling is available through `npx --yes @clunkyard/cli@2 doctor`, `build`, `match` and `replay`. See [the local development guide](https://clunkyard.com/sdk/player-kit) for npm installation and private Git. SDK/engine asset identities are pinned in the CLI package.\n');
114
+ const ignore=path.join(root,'.gitignore'),ignored=fs.existsSync(ignore)?fs.readFileSync(ignore,'utf8'):'';
115
+ const additions=['.clunkyard/','replays/','build/','node_modules/'].filter(line=>!ignored.split(/\r?\n/).includes(line));
116
+ if(additions.length)fs.writeFileSync(ignore,ignored+(ignored&&!ignored.endsWith('\n')?'\n':'')+additions.join('\n')+'\n');
117
+ for(const [name,data] of Object.entries(files))fs.writeFileSync(path.join(root,name),data);
118
+ if(fs.existsSync(docker)&&known(fs.readFileSync(docker),row.boilerplate.Dockerfile)){
119
+ let content=fs.readFileSync(docker,'utf8');
120
+ content=content.replace('COPY --from=node /usr/local/bin/node /usr/local/bin/node','COPY --from=node /usr/local/bin/node /usr/local/bin/node\nCOPY --from=node /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm\nRUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx');
121
+ content=content.replace('ENTRYPOINT ["node", "clunkyard.cjs"]','ENTRYPOINT ["npx", "--yes", "@clunkyard/cli@2"]');
122
+ fs.writeFileSync(docker,content);
123
+ }
124
+ for(const name of remove)if(fs.existsSync(path.join(root,name)))fs.unlinkSync(path.join(root,name));
125
+ return manifest;
126
+ }
127
+
128
+ async function createProject(language,destination) {
129
+ const row=Object.hasOwn(catalogue.languages,language)&&catalogue.languages[language];if(!row)throw new Error(`Unknown language: ${language}`);
130
+ const root=path.resolve(destination);
131
+ if(fs.existsSync(root))throw new Error('Choose a directory that does not exist yet.');
132
+ const bytes=await readAsset(row.starter);
133
+ fs.mkdirSync(path.dirname(root),{recursive:true});
134
+ fs.mkdirSync(root);
135
+ try{extract(bytes,root,row.starter.expandedBytes);migrate(root);}catch(error){fs.rmSync(root,{recursive:true,force:true});throw error;}
136
+ return root;
137
+ }
138
+
139
+ module.exports={readManifest,nodeMetadata,makefile,readme,migrate,createProject};
package/runtime.cjs ADDED
@@ -0,0 +1,201 @@
1
+ 'use strict';
2
+
3
+ const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto');
4
+ const {spawnSync}=require('node:child_process');
5
+ const {extract,readAsset}=require('./archive.cjs');
6
+ const catalogue=require('./catalogue.json');
7
+ const {readManifest}=require('./project.cjs');
8
+
9
+ function createRuntime(project,entry=path.join(__dirname,'cli.cjs')) {
10
+ const root=path.resolve(project),initial=readManifest(root),lock=catalogue.languages[initial.language];
11
+ const cache=path.join(root,'.clunkyard');
12
+ const engine=path.join(cache,'engine-'+lock.engine.sha256.slice(0,16));
13
+ const sdk=process.env.CLUNKYARD_SDK||path.join(cache,'sdk-'+lock.sdk.sha256.slice(0,16));
14
+ const npmSDK=['javascript','typescript'].includes(initial.language)?catalogue.npmSDK:null;
15
+ const manifest=()=>readManifest(root);
16
+ const sha=data=>crypto.createHash('sha256').update(data).digest('hex');
17
+ const skip=new Set(['.git','.clunkyard','build','replays','node_modules','__pycache__','obj','bin']);
18
+
19
+ function run(command, args, options={}) {
20
+ const result=spawnSync(command,args,{cwd:root,env:environment(),stdio:'inherit',...options});
21
+ if(result.error)throw new Error(`${command}: ${result.error.message}. See README.md for this language's toolchain.`);
22
+ if(result.status!==0)throw new Error(`${command} exited with status ${result.status}`);
23
+ return result;
24
+ }
25
+ function environment() {
26
+ const paths=[path.join(sdk,'lib'),process.env.LD_LIBRARY_PATH].filter(Boolean).join(path.delimiter);
27
+ return {...process.env,CLUNKYARD_SDK:sdk,CLUNKYARD_PROJECT:root,CLUNKYARD_LIB:path.join(sdk,'lib','libclunkyard.so.2'),LD_LIBRARY_PATH:paths,
28
+ TMPDIR:path.join(cache,'tmp'),NODE_PATH:npmSDK?path.join(root,'node_modules'):path.join(sdk,'bindings','javascript'),
29
+ DOTNET_CLI_TELEMETRY_OPTOUT:'1',DOTNET_NOLOGO:'1'};
30
+ }
31
+ function safeCache() {
32
+ if(fs.existsSync(cache)&&(!fs.lstatSync(cache).isDirectory()||fs.lstatSync(cache).isSymbolicLink()))throw new Error('.clunkyard must be an ordinary directory');
33
+ fs.mkdirSync(cache,{recursive:true});fs.mkdirSync(path.join(cache,'tmp'),{recursive:true});
34
+ }
35
+ async function download(asset, destination) {
36
+ if(fs.existsSync(destination)) {
37
+ if(fs.readFileSync(path.join(destination,'.release'),'utf8')===asset.sha256)return;
38
+ throw new Error(`${destination} belongs to another release. Remove it explicitly before changing SDK versions.`);
39
+ }
40
+ const bytes=await readAsset(asset);
41
+ const temporary=fs.mkdtempSync(path.join(cache,'install-'));
42
+ try{extract(bytes,temporary,asset.expandedBytes);fs.writeFileSync(path.join(temporary,'.release'),asset.sha256);fs.renameSync(temporary,destination);}finally{fs.rmSync(temporary,{recursive:true,force:true});}
43
+ }
44
+ async function setup() {
45
+ safeCache();
46
+ if(!npmSDK&&!lock.portable&&(process.platform!=='linux'||process.arch!=='x64'))throw new Error('This native SDK release supports Linux x86-64 (Ubuntu 24.04+/WSL2). Use JavaScript/Vane/Cadence/Glyph for the portable Node route; other native platforms are not yet qualified.');
47
+ await download(lock.engine,engine);
48
+ if(npmSDK)installNpmSDK();
49
+ else if(!process.env.CLUNKYARD_SDK)await download(lock.sdk,sdk);
50
+ else if(fs.readFileSync(path.join(sdk,'VERSION'),'utf8').trim()!==lock.sdkVersion)throw new Error('CLUNKYARD_SDK points to another SDK version');
51
+ }
52
+ function installNpmSDK() {
53
+ const packageFile=path.join(root,'package.json'),lockFile=path.join(root,'package-lock.json');
54
+ if(!fs.existsSync(packageFile)||!fs.existsSync(lockFile))throw new Error('JavaScript/TypeScript projects need the npm SDK dependency and package-lock.json. Run clunkyard migrate for an older generated starter, or follow the SDK npm guide.');
55
+ const metadata=JSON.parse(fs.readFileSync(packageFile)),packageLock=JSON.parse(fs.readFileSync(lockFile));
56
+ if(metadata.dependencies?.clunkyard!==`npm:${npmSDK.name}@${npmSDK.version}`||packageLock.packages?.['node_modules/clunkyard']?.integrity!==npmSDK.integrity)throw new Error('Pin clunkyard to the reviewed npm SDK alias and integrity in package.json/package-lock.json.');
57
+ const installedFile=path.join(root,'node_modules','clunkyard','package.json');
58
+ const identity=sha(fs.readFileSync(lockFile)),stamp=path.join(cache,'npm-ready');
59
+ if(fs.existsSync(stamp)&&fs.readFileSync(stamp,'utf8')===identity&&fs.existsSync(installedFile)){
60
+ const installed=JSON.parse(fs.readFileSync(installedFile));
61
+ if(installed.name===npmSDK.name&&installed.version===npmSDK.version)return;
62
+ }
63
+ const candidates=[process.env.npm_execpath,path.join(path.dirname(process.execPath),'node_modules/npm/bin/npm-cli.js'),path.resolve(path.dirname(process.execPath),'../lib/node_modules/npm/bin/npm-cli.js'),path.resolve(path.dirname(process.execPath),'../share/nodejs/npm/bin/npm-cli.js')];
64
+ const cli=candidates.find(file=>file&&fs.existsSync(file)&&/\.[cm]?js$/.test(file));
65
+ if(!cli)throw new Error('Install Node with npm, then run npm ci in this starter.');
66
+ run(process.execPath,[cli,'ci','--ignore-scripts','--no-audit','--no-fund','--registry=https://registry.npmjs.org']);
67
+ const installed=JSON.parse(fs.readFileSync(installedFile));
68
+ if(installed.name!==npmSDK.name||installed.version!==npmSDK.version)throw new Error('Installed npm SDK differs from the project dependency.');
69
+ fs.writeFileSync(stamp,identity);
70
+ }
71
+ function worker(project=root) {
72
+ Object.assign(process.env,environment());
73
+ process.env.CLUNKYARD_PROJECT=project;
74
+ const file=path.join(engine,'sandbox','worker.js');delete require.cache[require.resolve(file)];return require(file);
75
+ }
76
+ function rewrite(value) {return value.replaceAll('/opt/arena/libexec/wasm-host',path.join(sdk,'bin','wasm-host')).replaceAll('/opt/arena/',`${engine}/`).replaceAll('/opt/wasmtime/',`${sdk}/`);}
77
+ function sources(from,to) {
78
+ fs.mkdirSync(to,{recursive:true});
79
+ for(const entry of fs.readdirSync(from,{withFileTypes:true})){
80
+ if(skip.has(entry.name))continue;
81
+ const input=path.join(from,entry.name),output=path.join(to,entry.name);
82
+ if(entry.isDirectory())sources(input,output);else if(entry.isFile())fs.copyFileSync(input,output);
83
+ }
84
+ }
85
+ function rebuildBinding() {
86
+ // Compiler-specific artefacts are rebuilt by the player's own compiler.
87
+ // This downloads no compiler and keeps native modules compatible locally.
88
+ for(const binding of npmSDK?[]:(lock.rebuildBindings||[])){
89
+ const directory=path.join(sdk,'binding-src',binding);
90
+ run('make',['-C',directory,'install',`SDK=${sdk}`,`ROOT=${engine}`,`BUILD=${path.join(cache,'tmp','binding-'+binding)}`]);
91
+ }
92
+ }
93
+ function typescriptCompiler() {
94
+ // Prefer the project's compiler, then the player's installed toolchain.
95
+ // Execute its JS entry point so spaces and Windows cmd shims are harmless.
96
+ const directories=[path.join(root,'node_modules','.bin'),...(process.env.PATH||'').split(path.delimiter)];
97
+ for(const directory of directories){
98
+ for(const file of [path.join(directory,'node_modules/typescript/bin/tsc'),path.resolve(directory,'../typescript/bin/tsc'),path.resolve(directory,'../lib/node_modules/typescript/bin/tsc')]){
99
+ if(fs.existsSync(file))return [process.execPath,file];
100
+ }
101
+ const executable=path.join(directory,'tsc');
102
+ if(fs.existsSync(executable)){
103
+ const file=fs.realpathSync(executable);
104
+ if(fs.readFileSync(file,'utf8').startsWith('#!/usr/bin/env node'))return [process.execPath,file];
105
+ }
106
+ }
107
+ throw new Error('Install TypeScript 6+ (tsc on PATH), then run clunkyard doctor.');
108
+ }
109
+ async function build() {
110
+ await setup();const m=manifest(),out=path.join(cache,'build');fs.mkdirSync(out,{recursive:true});
111
+ const bindingStamp=path.join(cache,'bindings-ready'),bindingIdentity=npmSDK?.integrity||lock.sdk.sha256;
112
+ if(!fs.existsSync(bindingStamp)||fs.readFileSync(bindingStamp,'utf8')!==bindingIdentity){rebuildBinding();fs.writeFileSync(bindingStamp,bindingIdentity);}
113
+ const copied=path.join(cache,'source');fs.rmSync(copied,{recursive:true,force:true});sources(root,copied);
114
+ const action=npmSDK&&m.language==='typescript'?[...typescriptCompiler(),'--ignoreConfig','--ignoreDeprecations','6.0','--target','ES2022','--module','commonjs','--rootDir',copied,'--outDir',out,path.join(copied,m.entry)]:worker(copied).command(m.language,path.join(copied,m.entry),out,m);
115
+ if(action){const steps=Array.isArray(action[0])?action:[action];for(const step of steps){const args=step.map(rewrite);if(m.language==='c')args[0]=process.env.CC||'cc';if(m.language==='cpp')args[0]=process.env.CXX||'c++';run(args[0],args.slice(1),{cwd:copied});}}
116
+ const runtimeSource=path.join(out,'source');fs.rmSync(runtimeSource,{recursive:true,force:true});sources(copied,runtimeSource);
117
+ if(m.language==='typescript')fs.writeFileSync(path.join(out,'package.json'),'{"type":"commonjs"}\n');
118
+ fs.writeFileSync(path.join(cache,'local-bot.json'),JSON.stringify({...m,command:[process.execPath,entry,'--project',root,'stdio']},null,2)+'\n');
119
+ console.log(`Built ${m.name}. Run make match or npx --yes @clunkyard/cli@2 match.`);
120
+ }
121
+ function stdio() {
122
+ const m=manifest(),out=path.join(cache,'build'),entry=path.join(out,'source',m.entry);
123
+ let command=worker().runCommand(m.language,path.join(root,m.entry),out,m);
124
+ if(['javascript','typescript'].includes(m.language))command=[process.execPath,m.language==='typescript'?path.join(out,m.entry.replace(/\.ts$/,'.js')):entry];
125
+ if(m.language==='julia')command=['julia','--startup-file=no',entry];
126
+ if(m.language==='clojure')command=command.map(value=>value.replace('/usr/share/java/clojure.jar',clojureClasspath()));
127
+ if(m.language==='scala'&&process.env.SCALA_LIBRARY_JAR)command=command.map(value=>value.replace('/usr/share/scala-2.11/lib/scala-library.jar',process.env.SCALA_LIBRARY_JAR));
128
+ else if(m.language==='scala'&&!fs.existsSync('/usr/share/scala-2.11/lib/scala-library.jar'))command=['scala','-J--enable-native-access=ALL-UNNAMED','-cp',`${path.join(out,'classes')}:${sdk}/bindings/java/clunkyard.jar`,m.mainClass||path.basename(m.entry,'.scala')];
129
+ if(m.language==='php')command=['php','-d','ffi.enable=true','-d',`include_path=${sdk}/bindings/php`,entry];
130
+ const julia=m.language==='julia'?{JULIA_LOAD_PATH:`${sdk}/bindings/julia${path.delimiter}@stdlib`,JULIA_DEPOT_PATH:path.join(cache,'julia-depot'),OPENBLAS_NUM_THREADS:'1'}:{};
131
+ const values=command.map(rewrite);run(values[0],values.slice(1),{env:{...environment(),...julia}});
132
+ }
133
+ async function match(args) {
134
+ const seed=args[0]||'42',mode=args[1]||'2d',ruleset=args[2]||(mode==='3d'?'zero-g':'classic');
135
+ if(!/^\d{1,10}$/.test(seed)||Number(seed)>0xffffffff)throw new Error('Match seed must be an integer from 0 to 4294967295.');
136
+ await build();
137
+ fs.mkdirSync(path.join(root,'replays'),{recursive:true});
138
+ const replay=path.join(root,'replays',`trial-${seed}.json`);
139
+ run(process.execPath,[path.join(engine,'src','cli.js'),'match','--bot','@'+path.join(cache,'local-bot.json'),'--bot','@'+path.join(engine,'bots','practice','bot.json'),'--mode',mode,'--ruleset',ruleset,'--seed',seed,'--ticks','360','--turn-ms','2000','--startup-ms','60000','--replay',replay]);
140
+ const failures=JSON.parse(fs.readFileSync(replay,'utf8')).failures||[];
141
+ if(failures.length)throw new Error(`Match recorded ${failures.map(value=>`${value.botId}: ${value.code}`).join(', ')}. Inspect ${replay}.`);
142
+ }
143
+ function clojureClasspath(){
144
+ if(process.env.CLOJURE_CLASSPATH)return process.env.CLOJURE_CLASSPATH;
145
+ if(process.env.CLOJURE_JAR){if(!fs.existsSync(process.env.CLOJURE_JAR))throw new Error('CLOJURE_JAR does not exist');return process.env.CLOJURE_JAR;}
146
+ if(fs.existsSync('/usr/share/java/clojure.jar'))return '/usr/share/java/clojure.jar';
147
+ const result=spawnSync('clojure',['-Spath'],{encoding:'utf8',timeout:10000});
148
+ if(result.status===0&&result.stdout.trim())return result.stdout.trim();
149
+ throw new Error('Install Clojure or set CLOJURE_CLASSPATH / CLOJURE_JAR to its runtime. See README.md.');
150
+ }
151
+ function toolAvailable(tool){
152
+ if(npmSDK&&tool==='tsc'){try{typescriptCompiler();return true}catch{return false}}
153
+ const extensions=process.platform==='win32'?['',...(process.env.PATHEXT||'.EXE;.CMD;.BAT').split(';')]:[''];
154
+ for(const directory of (process.env.PATH||'').split(path.delimiter))for(const extension of extensions){
155
+ const file=path.join(directory||root,tool+extension);
156
+ try{if(fs.statSync(file).isFile()){fs.accessSync(file,process.platform==='win32'?fs.constants.F_OK:fs.constants.X_OK);return true}}catch{}
157
+ }
158
+ return false;
159
+ }
160
+ function doctor(){
161
+ console.log(`Clunkyard SDK ${lock.sdkVersion}; ${lock.language}; ${process.platform}/${process.arch}; Node ${process.versions.node}`);
162
+ let missing=0;
163
+ for(const tool of lock.tools.filter(tool=>tool!=='make'||(!npmSDK&&lock.rebuildBindings?.length))){
164
+ const ok=toolAvailable(tool);
165
+ console.log(`${ok?'found':'missing'} ${tool}`);if(!ok)missing++;
166
+ }
167
+ for(const check of lock.requirements?.checks||[]){
168
+ let command=[check.command];
169
+ if(npmSDK&&check.command==='tsc'){try{command=typescriptCompiler()}catch{}}
170
+ const result=spawnSync(command[0],[...command.slice(1),...check.args],{encoding:'utf8',env:environment(),timeout:10000});
171
+ const output=String(result.stdout||'')+String(result.stderr||'');
172
+ const version=check.versionPattern?Number(output.match(new RegExp(check.versionPattern))?.[1]):null;
173
+ const ok=!result.error&&result.status===0&&(!check.minimum||version>=check.minimum);
174
+ console.log(`${ok?'ok':'missing'} ${check.label}${ok?'':': '+check.hint}`);if(!ok)missing++;
175
+ }
176
+ if(lock.language==='clojure'){try{console.log('ok Clojure runtime: '+clojureClasspath())}catch(error){console.log('missing '+error.message);missing++}}
177
+ if(lock.language==='scala'&&process.env.SCALA_LIBRARY_JAR&&!fs.existsSync(process.env.SCALA_LIBRARY_JAR)){console.log('missing SCALA_LIBRARY_JAR file');missing++}
178
+ if(lock.language==='scala'&&!process.env.SCALA_LIBRARY_JAR&&!fs.existsSync('/usr/share/scala-2.11/lib/scala-library.jar')&&spawnSync('scala',['-version'],{timeout:10000}).error){console.log('missing Scala runtime; set SCALA_LIBRARY_JAR or install the scala launcher.');missing++}
179
+ const glibc=process.report?.getReport().header.glibcVersionRuntime;
180
+ if(!npmSDK&&!lock.portable&&(process.platform!=='linux'||process.arch!=='x64'||!glibc||Number(glibc.split('.')[0])<2||(Number(glibc.split('.')[0])===2&&Number(glibc.split('.')[1])<39))){console.log('unsupported native platform: use Linux x86-64 with glibc 2.39+ (Ubuntu 24.04+ or WSL2).');missing++}
181
+ for(const note of lock.requirements?.notes||[])console.log(note);
182
+ console.log(npmSDK||lock.portable?'Portable Node route.':'Native assets: Linux x86-64, glibc 2.39+; Ubuntu 24.04+/WSL2.');
183
+ console.log('No toolchains are downloaded automatically. See README.md for official installers.');
184
+ if(missing)process.exitCode=1;
185
+ }
186
+ async function main(action='build',args=[]){
187
+ if(action==='doctor')return doctor();
188
+ safeCache();
189
+ if(action==='setup')return setup();
190
+ if(action==='build')return build();
191
+ if(action==='stdio')return stdio();
192
+ if(action==='match')return match(args);
193
+ if(action==='replay'){await setup();return run(process.execPath,[path.join(engine,'src','cli.js'),'replay',path.resolve(root,args[0]||'replays/trial-42.json'),'--port',args[1]||'8787']);}
194
+ if(action==='clean'){fs.rmSync(path.join(cache,'build'),{recursive:true,force:true});fs.rmSync(path.join(cache,'bindings-ready'),{force:true});return;}
195
+ throw new Error('Use doctor, setup, build, match [seed] [2d|3d] [ruleset], replay [file] [port], or clean.');
196
+ }
197
+
198
+ return {main};
199
+ }
200
+
201
+ module.exports={createRuntime};