/** * Regenerate enemy..south sprites via PixelLab API v2 (POST /map-objects). * Uses local archetype PNG as init_image for style continuity. * * Requires: PIXELLAB_API_TOKEN in the environment (https://api.pixellab.ai/mcp). * * Usage: * node scripts/pixellab-enemy-south-v2.mjs --limit=5 * node scripts/pixellab-enemy-south-v2.mjs --slug=boar_l5_5_canyon */ 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() { const a = process.argv.slice(2); let limit = Infinity; let slug = null; for (const x of a) { if (x.startsWith('--limit=')) limit = parseInt(x.slice(8), 10); if (x.startsWith('--slug=')) slug = x.slice(7); } return { limit, slug }; } function parseEnemyRows(sqlText) { const re = /VALUES\s*\(\d+,\s*'([^']+)',\s*'([^']+)',\s*'([^']+)',\s*'([^']*)'/g; const rows = []; let m; while ((m = re.exec(sqlText))) { rows.push({ type: m[1], archetype: m[2], biome: m[3], name: m[4] }); } return rows; } 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 new Promise((r) => setTimeout(r, 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 main() { const token = process.env.PIXELLAB_API_TOKEN; if (!token) { console.error('Set PIXELLAB_API_TOKEN'); process.exit(1); } const { limit, slug } = parseArgs(); const sql = fs.readFileSync(SQL, 'utf8'); let rows = parseEnemyRows(sql); if (slug) rows = rows.filter((r) => r.type === slug); rows = rows.slice(0, limit); const manifest = JSON.parse(fs.readFileSync(MANIFEST, 'utf8')); const textures = manifest.textures; let updated = 0; for (const row of rows) { const refKey = ARCHETYPE_REF[row.archetype]; const refFile = textures[refKey].file; const refPath = path.join(ROOT, 'frontend/assets', refFile); 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, }; 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.ok) { console.error('Create failed', row.type, cj); continue; } const objectId = cj.data?.object_id ?? cj.object_id ?? cj.data?.id; if (!objectId) { console.error('No object_id', row.type, cj); continue; } await pollObject(token, objectId); const southFile = `enemies/enemy.${row.type}.south.png`; const southAbs = path.join(ROOT, 'frontend/assets', southFile); await downloadMcpObject(objectId, southAbs); const texKey = `enemy.${row.type}.south`; textures[texKey] = { file: southFile, kind: 'map_object', rotation: 'south', pixellabObjectId: objectId, }; updated++; console.log('OK', row.type, objectId); } if (updated > 0) { manifest.version = (manifest.version ?? 0) + 1; fs.writeFileSync(MANIFEST, JSON.stringify(manifest, null, 2) + '\n', 'utf8'); } } main().catch((e) => { console.error(e); process.exit(1); });