/** * One-shot: fill every manifest enemy..south missing pixellabObjectId (PixelLab API v2). * Requires PIXELLAB_API_TOKEN. * * node scripts/pixellab-fill-missing-south-one-shot.mjs * node scripts/pixellab-fill-missing-south-one-shot.mjs --concurrency=4 * * Wave mode (stagger creates, then shared wait, then parallel poll/download): * node scripts/pixellab-fill-missing-south-one-shot.mjs --delay-between-creates-sec=10 --post-queue-wait-sec=110 --concurrency=10 */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, '..'); const SQL = path.join(ROOT, 'backend/migrations/000006b_enemy_data.sql'); const MANIFEST = path.join(ROOT, 'frontend/public/assets/game/manifest.json'); const BASE = 'https://api.pixellab.ai/v2'; const ARCHETYPE_REF = { wolf: 'enemy.wolf', boar: 'enemy.boar', zombie: 'enemy.zombie', spider: 'enemy.spider', orc: 'enemy.orc', skeleton: 'enemy.skeleton_archer', battle_lizard: 'enemy.battle_lizard', element: 'enemy.water_element', demon: 'enemy.fire_demon', skeleton_king: 'enemy.skeleton_king', forest_warden: 'enemy.forest_warden', titan: 'enemy.lightning_titan', bandit: 'enemy.orc', cultist: 'enemy.skeleton_archer', golem: 'enemy.ice_guardian', wraith: 'enemy.zombie', treant: 'enemy.forest_warden', basilisk: 'enemy.battle_lizard', wyvern: 'enemy.battle_lizard', harpy: 'enemy.spider', manticore: 'enemy.battle_lizard', shade: 'enemy.zombie', }; const BIOME_HINT = { meadow: 'sunlit meadow greens and wildflowers', forest: 'deep forest shadows, moss and bark tones', ruins: 'grey stone dust, cracked masonry palette', canyon: 'red rock, dust, harsh sun', swamp: 'murky teal, bog mist, rotting wood', volcanic: 'ember glow, black rock, lava highlights', astral: 'cosmic violet-blue, star specks, ethereal glow', }; function parseArgs() { let concurrency = 6; let delayBetweenCreatesSec = 0; let postQueueWaitSec = 0; for (const x of process.argv.slice(2)) { if (x.startsWith('--delay-between-creates-sec=')) { delayBetweenCreatesSec = Math.max(0, parseInt(x.slice(28), 10) || 0); } if (x.startsWith('--post-queue-wait-sec=')) { postQueueWaitSec = Math.max(0, parseInt(x.slice(22), 10) || 0); } } return { concurrency, delayBetweenCreatesSec, postQueueWaitSec }; } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } function parseEnemyRows(sqlText) { const re = /VALUES\s*\(\d+,\s*'([^']+)',\s*'([^']+)',\s*'([^']+)',\s*'([^']*)'/g; const map = new Map(); let m; while ((m = re.exec(sqlText))) { map.set(m[1], { type: m[1], archetype: m[2], biome: m[3], name: m[4] }); } return map; } function buildPrompt(row) { const b = BIOME_HINT[row.biome] ?? row.biome; return ( `Pixel RPG combat sprite: ${row.name}. ` + `${row.archetype.replace(/_/g, ' ')} monster, ${b}. ` + `Low top-down view, facing south toward camera, full body, transparent background, ` + `game-ready, single black outline, medium shading — match init image proportions and style.` ); } async function pollObject(token, objectId) { const url = `${BASE}/objects/${objectId}`; for (let i = 0; i < 120; i++) { const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, }); const j = await res.json().catch(() => ({})); const data = j.data ?? j; const status = data?.status ?? data?.generation_status; if (status === 'completed' || data?.image_url || data?.download_url) { return data; } if (status === 'failed' || data?.error) { throw new Error(JSON.stringify(data?.error ?? data)); } await sleep(3000); } throw new Error('Timeout waiting for object'); } async function downloadMcpObject(objectId, destPath) { const url = `https://api.pixellab.ai/mcp/map-objects/${objectId}/download`; const res = await fetch(url); if (!res.ok) throw new Error(`download ${res.status}`); fs.mkdirSync(path.dirname(destPath), { recursive: true }); fs.writeFileSync(destPath, Buffer.from(await res.arrayBuffer())); } async function createMapObject(token, body, retries = 4) { for (let a = 0; a < retries; a++) { const cr = await fetch(`${BASE}/map-objects`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); const cj = await cr.json().catch(() => ({})); if (cr.status === 429 && a < retries - 1) { await sleep(15000 * (a + 1)); continue; } if (!cr.ok) { throw new Error(`create ${cr.status} ${JSON.stringify(cj)}`); } const objectId = cj.data?.object_id ?? cj.object_id ?? cj.data?.id; if (!objectId) throw new Error(`no object_id ${JSON.stringify(cj)}`); return objectId; } } async function runPool(concurrency, items, fn) { let ix = 0; const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => { while (true) { const j = ix++; if (j >= items.length) break; await fn(items[j], j); } }); await Promise.all(workers); } /** @returns {{ slug: string, body: object } | { error: string }} */ function prepareCreateBody(texKey, textures, byType) { const slug = texKey.slice('enemy.'.length, -'.south'.length); const row = byType.get(slug); if (!row) return { error: `No SQL row ${slug}` }; const refKey = ARCHETYPE_REF[row.archetype]; if (!refKey || !textures[refKey]?.file) { return { error: `No archetype ref ${slug} ${row.archetype} ${refKey}` }; } const refPath = path.join(ROOT, 'frontend/assets', textures[refKey].file); if (!fs.existsSync(refPath)) { return { error: `Missing ref file ${refPath}` }; } const b64 = fs.readFileSync(refPath).toString('base64'); const body = { description: buildPrompt(row), image_size: { width: 96, height: 96 }, view: 'low top-down', outline: 'single color outline', shading: 'medium shading', detail: 'medium detail', init_image: { type: 'base64', base64: b64 }, init_image_strength: 420, text_guidance_scale: 9, }; return { slug, body }; } async function finalizeSouthEntry(token, textures, texKey, slug, objectId) { await pollObject(token, objectId); const southFile = `enemies/enemy.${slug}.south.png`; const southAbs = path.join(ROOT, 'frontend/assets', southFile); await downloadMcpObject(objectId, southAbs); const entry = textures[texKey]; entry.file = southFile; entry.kind = 'map_object'; entry.rotation = 'south'; entry.pixellabObjectId = objectId; } function loadPixellabTokenFromEnvFiles() { if (process.env.PIXELLAB_API_TOKEN) return; const candidates = [ path.join(ROOT, '.env'), path.join(ROOT, 'frontend', '.env.local'), ]; for (const p of candidates) { if (!fs.existsSync(p)) continue; const text = fs.readFileSync(p, 'utf8'); for (const line of text.split(/\r?\n/)) { const t = line.trim(); if (!t || t.startsWith('#')) continue; const m = t.match(/^PIXELLAB_API_TOKEN\s*=\s*(.*)$/); if (!m) continue; let v = m[1].trim(); if ( (v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")) ) { v = v.slice(1, -1); } process.env.PIXELLAB_API_TOKEN = v; return; } } } async function main() { loadPixellabTokenFromEnvFiles(); const token = process.env.PIXELLAB_API_TOKEN; if (!token) { console.error( 'Set PIXELLAB_API_TOKEN in the environment or in repo root .env / frontend/.env.local', ); process.exit(1); } const { concurrency, delayBetweenCreatesSec, postQueueWaitSec } = parseArgs(); const sql = fs.readFileSync(SQL, 'utf8'); const byType = parseEnemyRows(sql); const manifest = JSON.parse(fs.readFileSync(MANIFEST, 'utf8')); const { textures } = manifest; const missing = Object.keys(textures) .filter((k) => k.startsWith('enemy.') && k.endsWith('.south') && !textures[k].pixellabObjectId) .sort(); if (missing.length === 0) { console.log('Nothing missing.'); return; } console.log( 'Missing count:', missing.length, 'concurrency:', concurrency, delayBetweenCreatesSec > 0 ? `wave: delayBetweenCreatesSec=${delayBetweenCreatesSec} postQueueWaitSec=${postQueueWaitSec}` : '', ); let ok = 0; let fail = 0; if (delayBetweenCreatesSec > 0) { const queued = []; for (let i = 0; i < missing.length; i++) { const texKey = missing[i]; const prep = prepareCreateBody(texKey, textures, byType); if ('error' in prep) { console.error('SKIP', texKey, prep.error); fail++; continue; } try { const objectId = await createMapObject(token, prep.body); queued.push({ texKey, slug: prep.slug, objectId }); console.log('Queued', prep.slug, objectId, `(${queued.length}/${missing.length})`); } catch (e) { console.error('FAIL create', prep.slug, e.message); fail++; } if (i < missing.length - 1 && delayBetweenCreatesSec > 0) { await sleep(delayBetweenCreatesSec * 1000); } } if (postQueueWaitSec > 0) { console.log(`Post-queue wait ${postQueueWaitSec}s…`); await sleep(postQueueWaitSec * 1000); } await runPool(concurrency, queued, async (job) => { try { await finalizeSouthEntry(token, textures, job.texKey, job.slug, job.objectId); ok++; console.log('OK', job.slug, job.objectId); } catch (e) { console.error('FAIL', job.slug, e.message); fail++; } }); } else { await runPool(concurrency, missing, async (texKey) => { const prep = prepareCreateBody(texKey, textures, byType); if ('error' in prep) { console.error('SKIP', texKey, prep.error); fail++; return; } try { const objectId = await createMapObject(token, prep.body); await finalizeSouthEntry(token, textures, texKey, prep.slug, objectId); ok++; console.log('OK', prep.slug, objectId); } catch (e) { console.error('FAIL', prep.slug, e.message); fail++; } }); } if (ok > 0) { manifest.version = (manifest.version ?? 0) + 1; fs.writeFileSync(MANIFEST, JSON.stringify(manifest, null, 2) + '\n', 'utf8'); } console.log('Done. OK', ok, 'FAIL', fail, 'manifest.version', manifest.version); } main().catch((e) => { console.error(e); process.exit(1); });