Compare commits

..

No commits in common. "7fd7ba2e76b70dd8259d6f2a3416093847cc1f0a" and "06ab438d7b67c2b9e724d64754cc987b8c8678cb" have entirely different histories.

18 changed files with 3912 additions and 3222 deletions

View file

@ -1,4 +1,4 @@
# Pixel Island
# Pixel Island Summoner
Browser-only prototype for a shared pixel-art island.

View file

@ -1 +0,0 @@
Require all denied

View file

@ -1,2 +0,0 @@
SQLite database files are created here on first API access.
Do not delete .htaccess or web.config. They block direct web access to this directory.

File diff suppressed because one or more lines are too long

View file

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<system.webServer>
<security>
<authorization>
<remove users="*" roles="" verbs="" />
<add accessType="Deny" users="*" />
</authorization>
</security>
</system.webServer>
</configuration>

View file

@ -1,525 +0,0 @@
<?php
declare(strict_types=1);
$config = require dirname(__DIR__) . '/api/config.php';
$token = (string)($config['admin_token'] ?? '');
$given = (string)($_GET['token'] ?? $_POST['token'] ?? '');
$ownerFilter = clean_id($_GET['owner'] ?? $_POST['owner'] ?? '');
header('X-Content-Type-Options: nosniff');
function h($s): string { return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
function now_ms(): int { return (int)floor(microtime(true) * 1000); }
function clean_id($value): string { return substr(preg_replace('/[^A-Za-z0-9_:\-.]/', '', (string)$value) ?? '', 0, 96); }
function clean_text($value, string $fallback = '', int $max = 80): string {
$text = trim((string)$value);
$text = preg_replace('/[\x00-\x1F\x7F]/u', '', $text) ?? '';
$text = preg_replace('/\s+/u', ' ', $text) ?? '';
if ($text === '') $text = $fallback;
return function_exists('mb_substr') ? mb_substr($text, 0, $max, 'UTF-8') : substr($text, 0, $max);
}
function data_dir(array $config): string { return dirname((string)$config['db_path']); }
function filedb_path(array $config): string { return data_dir($config) . DIRECTORY_SEPARATOR . 'pixel_island_filedb.json'; }
function sqlite_available(): bool { return class_exists('PDO') && in_array('sqlite', PDO::getAvailableDrivers(), true); }
function sqlite_tables_ready(PDO $db): bool {
$stmt = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('accounts','assets','objects','reports')");
$names = $stmt ? array_column($stmt->fetchAll(PDO::FETCH_ASSOC), 'name') : [];
return in_array('accounts', $names, true) && in_array('assets', $names, true) && in_array('objects', $names, true) && in_array('reports', $names, true);
}
function choose_store(array $config): array {
$sqlitePath = (string)$config['db_path'];
if (sqlite_available() && is_file($sqlitePath)) return ['type' => 'sqlite', 'path' => $sqlitePath];
$jsonPath = filedb_path($config);
if (is_file($jsonPath)) return ['type' => 'filedb', 'path' => $jsonPath];
if (sqlite_available()) return ['type' => 'sqlite_missing', 'path' => $sqlitePath];
return ['type' => 'filedb_missing', 'path' => $jsonPath];
}
function redirect_admin(string $token, string $owner = ''): void {
$url = './?token=' . rawurlencode($token);
if ($owner !== '') $url .= '&owner=' . rawurlencode($owner);
header('Location: ' . $url);
exit;
}
function decode_json_value($json): array {
if (is_array($json)) return $json;
$decoded = json_decode((string)$json, true);
return is_array($decoded) ? $decoded : [];
}
function encode_json_value(array $value): string {
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}';
}
function asset_name_from_json($json, string $fallback = 'Untitled'): string {
$asset = decode_json_value($json);
return (string)($asset['name'] ?? $fallback);
}
function thumbnail_html($json): string {
$asset = decode_json_value($json);
$pixels = (string)($asset['faces']['right'] ?? $asset['pixels'] ?? '');
if ($pixels === '') return '<div class="thumb empty"></div>';
$rows = str_contains($pixels, '|') ? explode('|', $pixels) : [];
$width = max(1, min(64, (int)($asset['width'] ?? $asset['w'] ?? $asset['size'] ?? 16)));
$height = max(1, min(64, (int)($asset['height'] ?? $asset['ht'] ?? $asset['size'] ?? $width)));
if (!$rows) {
for ($y = 0; $y < $height; $y++) $rows[] = substr($pixels, $y * $width, $width);
}
$codes = str_split('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' . "!#$%&'()*+,-/:;<=>?@[]^_`{|}~");
$colors = [
'#111827', '#374151', '#6b7280', '#d1d5db', '#fff7ed',
'#7f1d1d', '#dc2626', '#f97316', '#facc15', '#84cc16',
'#16a34a', '#14b8a6', '#06b6d4', '#2563eb', '#4f46e5',
'#7c3aed', '#c026d3', '#ec4899', '#f5d0a9', '#7c4a2d',
'#fafafa', '#f5f5f4', '#e7e5e4', '#d6d3d1', '#a8a29e', '#78716c', '#57534e', '#292524', '#0c0a09',
'#fef3c7', '#fde68a', '#d6a25f', '#9a6a3a', '#5c3b24',
'#fee2e2', '#fca5a5', '#ef4444', '#b91c1c', '#7f1d1d',
'#ffedd5', '#fdba74', '#f97316', '#c2410c', '#7c2d12',
'#fef9c3', '#fde047', '#eab308', '#a16207', '#713f12',
'#ecfccb', '#bef264', '#84cc16', '#4d7c0f', '#365314', '#dcfce7', '#86efac', '#15803d',
'#ccfbf1', '#5eead4', '#14b8a6', '#0f766e', '#134e4a',
'#cffafe', '#67e8f9', '#22d3ee', '#06b6d4', '#0e7490', '#e0f2fe', '#38bdf8',
'#dbeafe', '#93c5fd', '#3b82f6', '#2563eb', '#1d4ed8', '#1e3a8a',
'#e0e7ff', '#a5b4fc', '#6366f1', '#4f46e5', '#3730a3', '#ede9fe', '#c4b5fd', '#8b5cf6',
'#fae8ff', '#e879f9', '#c026d3', '#86198f', '#fce7f3', '#f9a8d4', '#ec4899', '#be185d'
];
$palette = array_combine($codes, array_slice($colors, 0, count($codes))) ?: [];
$cells = '';
foreach (array_slice($rows, 0, $height) as $y => $row) {
$chars = preg_split('//u', (string)$row, -1, PREG_SPLIT_NO_EMPTY) ?: [];
for ($x = 0; $x < $width; $x++) {
$ch = $chars[$x] ?? '.';
if ($ch === '.') continue;
$color = $palette[$ch] ?? sprintf('#%06x', (hexdec(substr(md5($ch), 0, 6)) & 0x7fffff) | 0x303030);
$cells .= '<i style="left:' . h(($x / $width) * 100) . '%;top:' . h(($y / $height) * 100) . '%;width:' . h(100 / $width) . '%;height:' . h(100 / $height) . '%;background:' . h($color) . '"></i>';
}
}
return '<div class="thumb" style="--w:' . h($width) . ';--h:' . h($height) . '">' . $cells . '</div>';
}
function read_filedb(string $path): array {
if (!is_file($path)) return [];
$raw = file_get_contents($path);
$db = $raw ? json_decode($raw, true) : null;
return is_array($db) ? $db : [];
}
function write_filedb(string $path, callable $mutator): void {
$dir = dirname($path);
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) throw new RuntimeException('data directory unavailable');
$fp = fopen($path, 'c+');
if (!$fp) throw new RuntimeException('file db unavailable');
try {
if (!flock($fp, LOCK_EX)) throw new RuntimeException('file db lock failed');
$raw = stream_get_contents($fp);
$db = $raw ? json_decode($raw, true) : null;
if (!is_array($db)) $db = ['schema'=>1,'accounts'=>[],'assets'=>[],'objects'=>[],'votes'=>[],'reports'=>[],'events'=>[],'commands'=>[],'nextEventId'=>1];
$mutator($db);
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, encode_json_value($db));
fflush($fp);
flock($fp, LOCK_UN);
} finally {
fclose($fp);
}
}
function sqlite_db(string $path): PDO {
$db = new PDO('sqlite:' . $path, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
$db->exec('PRAGMA busy_timeout = 5000');
return $db;
}
function ensure_sqlite_anonymous_account(PDO $db): void {
$now = now_ms();
$hash = password_hash(bin2hex(random_bytes(16)), PASSWORD_DEFAULT);
$db->prepare('INSERT OR IGNORE INTO accounts(id, name, password_hash, created_at, updated_at) VALUES(?,?,?,?,?)')
->execute(['anonymous', 'Anonymous', $hash, $now, $now]);
}
function list_sqlite_accounts(string $path): array {
$db = sqlite_db($path);
if (!sqlite_tables_ready($db)) return [];
return $db->query("SELECT a.id, a.name, a.created_at, a.updated_at, a.disabled_at,
(SELECT COUNT(*) FROM assets x WHERE x.owner_account_id = a.id AND x.deleted_at IS NULL) AS asset_count,
(SELECT COUNT(*) FROM objects o WHERE o.owner_account_id = a.id AND o.deleted_at IS NULL) AS object_count,
(SELECT COUNT(*) FROM reports r WHERE r.reporter_account_id = a.id AND r.status = 'open') AS open_report_count
FROM accounts a ORDER BY a.created_at DESC LIMIT 500")->fetchAll() ?: [];
}
function list_sqlite_assets(string $path, string $owner = ''): array {
$db = sqlite_db($path);
if (!sqlite_tables_ready($db)) return [];
$sql = "SELECT a.*, acc.name AS owner_name,
(SELECT COUNT(*) FROM objects o WHERE o.asset_id = a.id AND o.deleted_at IS NULL) AS object_count
FROM assets a LEFT JOIN accounts acc ON acc.id = a.owner_account_id WHERE a.deleted_at IS NULL";
$params = [];
if ($owner !== '') { $sql .= ' AND a.owner_account_id = ?'; $params[] = $owner; }
$sql .= ' ORDER BY a.updated_at DESC LIMIT 500';
$stmt = $db->prepare($sql);
$stmt->execute($params);
return array_map(function($row) {
$asset = decode_json_value($row['json'] ?? '');
return [
'id' => $row['id'] ?? '',
'owner_account_id' => $row['owner_account_id'] ?? '',
'owner_name' => $row['owner_name'] ?? '',
'name' => $asset['name'] ?? ($row['author_name'] ?? 'Untitled'),
'json' => $row['json'] ?? '',
'category' => $asset['category'] ?? '',
'subtype' => $asset['subtype'] ?? '',
'updated_at' => (int)($row['updated_at'] ?? 0),
'object_count' => (int)($row['object_count'] ?? 0),
];
}, $stmt->fetchAll() ?: []);
}
function list_sqlite_reports(string $path): array {
$db = sqlite_db($path);
if (!sqlite_tables_ready($db)) return [];
$rows = $db->query("SELECT r.*, o.moderation_status, a.json AS asset_json FROM reports r LEFT JOIN objects o ON o.id = r.object_id LEFT JOIN assets a ON a.id = r.asset_id WHERE r.status = 'open' ORDER BY r.created_at DESC LIMIT 200")->fetchAll();
return array_map(fn($r) => [
'id' => $r['id'] ?? '',
'object_id' => $r['object_id'] ?? '',
'asset_id' => $r['asset_id'] ?? '',
'reason' => $r['reason'] ?? '',
'reporter' => $r['reporter_account_id'] ?? '',
'created_at' => (int)($r['created_at'] ?? 0),
'moderation_status' => $r['moderation_status'] ?? 'missing',
'asset_name' => asset_name_from_json($r['asset_json'] ?? '', 'Untitled'),
'asset_json' => $r['asset_json'] ?? '',
], $rows ?: []);
}
function apply_sqlite_admin_action(string $path, array $post): void {
$db = sqlite_db($path);
if (!sqlite_tables_ready($db)) return;
$action = (string)($post['admin_action'] ?? $post['moderation_action'] ?? '');
$now = now_ms();
if ($action === 'rename_account') {
$accountId = clean_id($post['account_id'] ?? '');
$name = clean_text($post['name'] ?? '', '', 32);
if ($accountId === '' || $name === '') return;
$db->beginTransaction();
$db->prepare('UPDATE accounts SET name = ?, updated_at = ? WHERE id = ?')->execute([$name, $now, $accountId]);
$rows = $db->prepare('SELECT id, json FROM assets WHERE owner_account_id = ? AND deleted_at IS NULL');
$rows->execute([$accountId]);
foreach ($rows->fetchAll() ?: [] as $row) {
$asset = decode_json_value($row['json']);
$asset['author'] = $name;
$asset['updatedAt'] = $now;
$db->prepare('UPDATE assets SET author_name = ?, json = ?, updated_at = ? WHERE id = ?')->execute([$name, encode_json_value($asset), $now, $row['id']]);
}
$db->commit();
} elseif ($action === 'disable_account' || $action === 'enable_account') {
$accountId = clean_id($post['account_id'] ?? '');
if ($accountId !== '') $db->prepare('UPDATE accounts SET disabled_at = ?, updated_at = ? WHERE id = ?')->execute([$action === 'disable_account' ? $now : null, $now, $accountId]);
} elseif ($action === 'delete_account') {
$accountId = clean_id($post['account_id'] ?? '');
if ($accountId === '' || $accountId === 'island-team') return;
$db->beginTransaction();
ensure_sqlite_anonymous_account($db);
$assetRows = $db->prepare('SELECT id, json FROM assets WHERE owner_account_id = ?');
$assetRows->execute([$accountId]);
foreach ($assetRows->fetchAll() ?: [] as $row) {
$asset = decode_json_value($row['json'] ?? '');
$asset['ownerAccountId'] = 'anonymous';
$asset['author'] = 'Anonymous';
$asset['updatedAt'] = $now;
$db->prepare('UPDATE assets SET owner_account_id = ?, author_name = ?, json = ?, updated_at = ? WHERE id = ?')
->execute(['anonymous', 'Anonymous', encode_json_value($asset), $now, $row['id']]);
}
$objectRows = $db->prepare('SELECT id, json FROM objects WHERE owner_account_id = ?');
$objectRows->execute([$accountId]);
foreach ($objectRows->fetchAll() ?: [] as $row) {
$object = decode_json_value($row['json'] ?? '');
$object['ownerAccountId'] = 'anonymous';
$db->prepare('UPDATE objects SET owner_account_id = ?, json = ?, updated_at = ? WHERE id = ?')
->execute(['anonymous', encode_json_value($object), $now, $row['id']]);
}
$db->prepare('DELETE FROM votes WHERE voter_account_id = ?')->execute([$accountId]);
$db->prepare("UPDATE reports SET status = 'closed' WHERE reporter_account_id = ?")->execute([$accountId]);
$db->prepare('DELETE FROM accounts WHERE id = ?')->execute([$accountId]);
$db->commit();
} elseif ($action === 'rename_asset') {
$assetId = clean_id($post['asset_id'] ?? '');
$name = clean_text($post['name'] ?? '', '', 32);
if ($assetId === '' || $name === '') return;
$stmt = $db->prepare('SELECT json FROM assets WHERE id = ? AND deleted_at IS NULL');
$stmt->execute([$assetId]);
$row = $stmt->fetch();
if (!$row) return;
$asset = decode_json_value($row['json']);
$asset['name'] = $name;
$asset['updatedAt'] = $now;
$db->prepare('UPDATE assets SET json = ?, updated_at = ? WHERE id = ?')->execute([encode_json_value($asset), $now, $assetId]);
} elseif ($action === 'delete_asset') {
$assetId = clean_id($post['asset_id'] ?? '');
if ($assetId === '') return;
$db->beginTransaction();
$db->prepare('UPDATE assets SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL')->execute([$now, 'admin', $now, $assetId]);
$db->prepare('UPDATE objects SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE asset_id = ? AND deleted_at IS NULL')->execute([$now, 'admin', $now, $assetId]);
$db->prepare('DELETE FROM votes WHERE target_type = ? AND target_id = ?')->execute(['asset', $assetId]);
$db->commit();
} elseif ($action === 'hide' || $action === 'restore' || $action === 'close_reports') {
$objectId = clean_id($post['object_id'] ?? '');
if ($objectId === '') return;
if ($action === 'hide') $db->prepare("UPDATE objects SET moderation_status = 'violation_hidden', updated_at = ? WHERE id = ?")->execute([$now, $objectId]);
elseif ($action === 'restore') $db->prepare("UPDATE objects SET moderation_status = 'active', updated_at = ? WHERE id = ?")->execute([$now, $objectId]);
else $db->prepare("UPDATE reports SET status = 'closed' WHERE object_id = ?")->execute([$objectId]);
}
}
function list_filedb_accounts(string $path): array {
$db = read_filedb($path);
$out = [];
foreach (($db['accounts'] ?? []) as $account) {
$id = (string)($account['id'] ?? '');
$assets = 0; $objects = 0; $reports = 0;
foreach (($db['assets'] ?? []) as $asset) if (($asset['owner_account_id'] ?? '') === $id && empty($asset['deleted_at'])) $assets++;
foreach (($db['objects'] ?? []) as $object) if (($object['owner_account_id'] ?? '') === $id && empty($object['deleted_at'])) $objects++;
foreach (($db['reports'] ?? []) as $report) if (($report['reporter_account_id'] ?? '') === $id && ($report['status'] ?? 'open') === 'open') $reports++;
$out[] = ['id'=>$id, 'name'=>$account['name'] ?? $id, 'created_at'=>(int)($account['created_at'] ?? 0), 'updated_at'=>(int)($account['updated_at'] ?? 0), 'disabled_at'=>$account['disabled_at'] ?? null, 'asset_count'=>$assets, 'object_count'=>$objects, 'open_report_count'=>$reports];
}
usort($out, fn($a, $b) => ((int)$b['created_at']) <=> ((int)$a['created_at']));
return array_slice($out, 0, 500);
}
function list_filedb_assets(string $path, string $owner = ''): array {
$db = read_filedb($path);
$accounts = $db['accounts'] ?? [];
$out = [];
foreach (($db['assets'] ?? []) as $asset) {
if (!empty($asset['deleted_at'])) continue;
if ($owner !== '' && ($asset['owner_account_id'] ?? '') !== $owner) continue;
$id = (string)($asset['id'] ?? '');
$json = decode_json_value($asset['json'] ?? []);
$objectCount = 0;
foreach (($db['objects'] ?? []) as $object) if (($object['asset_id'] ?? '') === $id && empty($object['deleted_at'])) $objectCount++;
$ownerId = (string)($asset['owner_account_id'] ?? '');
$out[] = ['id'=>$id, 'owner_account_id'=>$ownerId, 'owner_name'=>$accounts[$ownerId]['name'] ?? '', 'name'=>$json['name'] ?? 'Untitled', 'json'=>$asset['json'] ?? [], 'category'=>$json['category'] ?? '', 'subtype'=>$json['subtype'] ?? '', 'updated_at'=>(int)($asset['updated_at'] ?? 0), 'object_count'=>$objectCount];
}
usort($out, fn($a, $b) => ((int)$b['updated_at']) <=> ((int)$a['updated_at']));
return array_slice($out, 0, 500);
}
function list_filedb_reports(string $path): array {
$db = read_filedb($path);
$out = [];
foreach (($db['reports'] ?? []) as $r) {
if (($r['status'] ?? 'open') !== 'open') continue;
$asset = ($db['assets'] ?? [])[(string)($r['asset_id'] ?? '')] ?? [];
$object = ($db['objects'] ?? [])[(string)($r['object_id'] ?? '')] ?? [];
$out[] = ['id'=>$r['id'] ?? '', 'object_id'=>$r['object_id'] ?? '', 'asset_id'=>$r['asset_id'] ?? '', 'reason'=>$r['reason'] ?? '', 'reporter'=>$r['reporter_account_id'] ?? '', 'created_at'=>(int)($r['created_at'] ?? 0), 'moderation_status'=>$object['moderation_status'] ?? 'missing', 'asset_name'=>asset_name_from_json($asset['json'] ?? [], 'Untitled'), 'asset_json'=>$asset['json'] ?? []];
}
usort($out, fn($a, $b) => ((int)$b['created_at']) <=> ((int)$a['created_at']));
return array_slice($out, 0, 200);
}
function apply_filedb_admin_action(string $path, array $post): void {
write_filedb($path, function(array &$db) use ($post) {
$action = (string)($post['admin_action'] ?? $post['moderation_action'] ?? '');
$now = now_ms();
$ensureAnonymous = function() use (&$db, $now): void {
$db['accounts'] = is_array($db['accounts'] ?? null) ? $db['accounts'] : [];
if (!isset($db['accounts']['anonymous'])) {
$db['accounts']['anonymous'] = ['id'=>'anonymous','name'=>'Anonymous','password_hash'=>password_hash(bin2hex(random_bytes(16)), PASSWORD_DEFAULT),'created_at'=>$now,'updated_at'=>$now,'disabled_at'=>null];
}
};
if ($action === 'rename_account') {
$accountId = clean_id($post['account_id'] ?? '');
$name = clean_text($post['name'] ?? '', '', 32);
if ($accountId === '' || $name === '' || !isset($db['accounts'][$accountId])) return;
$db['accounts'][$accountId]['name'] = $name;
$db['accounts'][$accountId]['updated_at'] = $now;
foreach ($db['assets'] as &$asset) {
if (($asset['owner_account_id'] ?? '') !== $accountId || !empty($asset['deleted_at'])) continue;
$asset['author_name'] = $name;
$asset['updated_at'] = $now;
$json = decode_json_value($asset['json'] ?? []);
$json['author'] = $name;
$json['updatedAt'] = $now;
$asset['json'] = $json;
}
unset($asset);
} elseif ($action === 'disable_account' || $action === 'enable_account') {
$accountId = clean_id($post['account_id'] ?? '');
if ($accountId !== '' && isset($db['accounts'][$accountId])) {
$db['accounts'][$accountId]['disabled_at'] = $action === 'disable_account' ? $now : null;
$db['accounts'][$accountId]['updated_at'] = $now;
}
} elseif ($action === 'delete_account') {
$accountId = clean_id($post['account_id'] ?? '');
if ($accountId === '' || $accountId === 'island-team') return;
$ensureAnonymous();
foreach ($db['assets'] as &$asset) if (($asset['owner_account_id'] ?? '') === $accountId) {
$asset['owner_account_id'] = 'anonymous';
$asset['author_name'] = 'Anonymous';
$asset['updated_at'] = $now;
$json = decode_json_value($asset['json'] ?? []);
$json['ownerAccountId'] = 'anonymous';
$json['author'] = 'Anonymous';
$json['updatedAt'] = $now;
$asset['json'] = $json;
}
unset($asset);
foreach ($db['objects'] as &$object) if (($object['owner_account_id'] ?? '') === $accountId) {
$object['owner_account_id'] = 'anonymous';
$object['updated_at'] = $now;
$json = decode_json_value($object['json'] ?? []);
$json['ownerAccountId'] = 'anonymous';
$object['json'] = $json;
}
unset($object);
foreach ($db['reports'] as &$report) if (($report['reporter_account_id'] ?? '') === $accountId) $report['status'] = 'closed';
unset($report);
unset($db['accounts'][$accountId]);
foreach ($db['votes'] as $key => $vote) if (($vote['voter_account_id'] ?? '') === $accountId) unset($db['votes'][$key]);
} elseif ($action === 'rename_asset') {
$assetId = clean_id($post['asset_id'] ?? '');
$name = clean_text($post['name'] ?? '', '', 32);
if ($assetId === '' || $name === '' || !isset($db['assets'][$assetId])) return;
$json = decode_json_value($db['assets'][$assetId]['json'] ?? []);
$json['name'] = $name;
$json['updatedAt'] = $now;
$db['assets'][$assetId]['json'] = $json;
$db['assets'][$assetId]['updated_at'] = $now;
} elseif ($action === 'delete_asset') {
$assetId = clean_id($post['asset_id'] ?? '');
if ($assetId === '' || !isset($db['assets'][$assetId])) return;
$db['assets'][$assetId]['deleted_at'] = $now;
$db['assets'][$assetId]['deleted_by'] = 'admin';
$db['assets'][$assetId]['updated_at'] = $now;
foreach ($db['objects'] as &$object) if (($object['asset_id'] ?? '') === $assetId && empty($object['deleted_at'])) { $object['deleted_at'] = $now; $object['deleted_by'] = 'admin'; $object['updated_at'] = $now; }
unset($object);
foreach ($db['votes'] as $key => $vote) if (($vote['target_type'] ?? '') === 'asset' && ($vote['target_id'] ?? '') === $assetId) unset($db['votes'][$key]);
} elseif ($action === 'hide' || $action === 'restore' || $action === 'close_reports') {
$objectId = clean_id($post['object_id'] ?? '');
if ($objectId === '') return;
if (($action === 'hide' || $action === 'restore') && isset($db['objects'][$objectId])) {
$db['objects'][$objectId]['moderation_status'] = $action === 'hide' ? 'violation_hidden' : 'active';
$db['objects'][$objectId]['updated_at'] = $now;
} elseif ($action === 'close_reports') {
foreach ($db['reports'] as &$report) if (($report['object_id'] ?? '') === $objectId) $report['status'] = 'closed';
unset($report);
}
}
});
}
if ($token === '') {
http_response_code(403);
echo '<!doctype html><meta charset="utf-8"><title>Admin disabled</title><h1>Admin disabled</h1>';
exit;
}
if (!hash_equals($token, $given)) {
http_response_code(403);
echo '<!doctype html><meta charset="utf-8"><title>Forbidden</title><h1>Forbidden</h1><p>Invalid admin token.</p>';
exit;
}
$store = choose_store($config);
$error = '';
$accounts = [];
$assets = [];
$reports = [];
try {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($store['type'] === 'sqlite') apply_sqlite_admin_action($store['path'], $_POST);
elseif ($store['type'] === 'filedb') apply_filedb_admin_action($store['path'], $_POST);
redirect_admin($given, clean_id($_POST['owner'] ?? ''));
}
if ($store['type'] === 'sqlite') {
$accounts = list_sqlite_accounts($store['path']);
$assets = list_sqlite_assets($store['path'], $ownerFilter);
$reports = list_sqlite_reports($store['path']);
} elseif ($store['type'] === 'filedb') {
$accounts = list_filedb_accounts($store['path']);
$assets = list_filedb_assets($store['path'], $ownerFilter);
$reports = list_filedb_reports($store['path']);
}
} catch (Throwable $e) {
$error = $e->getMessage();
}
$storeLabel = $store['type'] === 'sqlite' ? 'SQLite' : ($store['type'] === 'filedb' ? 'JSON file DB' : 'DB not initialized yet');
$baseUrl = './?token=' . rawurlencode($given);
?>
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Pixel Island Admin</title>
<script>
document.addEventListener('submit', () => {
try { sessionStorage.setItem('pixel-admin-scroll', String(window.scrollY || 0)); } catch (error) {}
});
window.addEventListener('load', () => {
try {
const y = Number(sessionStorage.getItem('pixel-admin-scroll') || 0);
sessionStorage.removeItem('pixel-admin-scroll');
if (y > 0) requestAnimationFrame(() => scrollTo(0, y));
} catch (error) {}
});
</script>
<style>
body{font-family:system-ui,sans-serif;margin:24px;background:#f7f4ea;color:#2b2b2b}h1{margin-bottom:4px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(380px,1fr));gap:12px}.card{background:#fff;border:1px solid #ddd;border-radius:10px;padding:14px;margin:10px 0}.media{display:flex;gap:14px;align-items:flex-start}.muted{color:#666;font-size:13px}.error{background:#fff1f1;border:1px solid #e3aaaa;padding:12px;border-radius:8px}code{background:#eee;padding:1px 4px;border-radius:4px}input{max-width:210px;padding:6px;margin:2px 4px 2px 0}button{margin:2px 4px 2px 0;padding:6px 9px;border:1px solid #bbb;border-radius:6px;background:#f5f5f5}.danger{background:#b92323;color:white;border:0}.ok{background:#267a3e;color:white;border:0}.warn{background:#8b5b00;color:white;border:0}.pill{display:inline-block;padding:2px 7px;border-radius:999px;background:#eee;font-size:12px}.nav a{margin-right:10px}.thumb{position:relative;flex:0 0 auto;width:128px;height:128px;background:#f2eadb;border:1px solid #ddd;image-rendering:pixelated;overflow:hidden}.thumb i{position:absolute;transform:scale(1.02);transform-origin:0 0}.thumb.empty:after{content:'no image';position:absolute;inset:0;display:grid;place-items:center;color:#777;font-size:11px}@media(max-width:520px){.grid{grid-template-columns:1fr}.media{flex-direction:column}.thumb{width:112px;height:112px}}
</style>
<h1>Pixel Island Admin</h1>
<p class="muted">Store: <?=h($storeLabel)?> / Users: <?=count($accounts)?> / Pictures: <?=count($assets)?> / Reports: <?=count($reports)?></p>
<p class="nav"><a href="#accounts">Users</a><a href="#pictures">Pictures</a><a href="#reports">Reports</a><?php if ($ownerFilter !== ''): ?><a href="<?=h($baseUrl)?>">Show all</a><?php endif; ?></p>
<?php if ($error): ?><p class="error">Admin error: <?=h($error)?></p><?php endif; ?>
<?php if ($store['type'] === 'sqlite_missing' || $store['type'] === 'filedb_missing'): ?>
<p class="muted">No DB has been initialized yet. Open <code>../api/index.php?action=health</code> and use the app once, then return here.</p>
<?php endif; ?>
<h2 id="accounts">Users</h2>
<div class="grid">
<?php foreach ($accounts as $a): ?>
<div class="card">
<h3><?=h($a['name'] ?? $a['id'])?> <?php if (!empty($a['disabled_at'])): ?><span class="pill">disabled</span><?php endif; ?></h3>
<p class="muted">ID: <code><?=h($a['id'] ?? '')?></code> / Pictures: <?=h($a['asset_count'] ?? 0)?> / Objects: <?=h($a['object_count'] ?? 0)?> / Reports: <?=h($a['open_report_count'] ?? 0)?></p>
<p><a href="<?=h($baseUrl . '&owner=' . rawurlencode((string)($a['id'] ?? '')))?>">Pictures</a></p>
<form method="post">
<input type="hidden" name="token" value="<?=h($given)?>">
<input type="hidden" name="owner" value="<?=h($ownerFilter)?>">
<input type="hidden" name="account_id" value="<?=h($a['id'] ?? '')?>">
<input name="name" value="<?=h($a['name'] ?? '')?>" maxlength="32">
<button class="ok" name="admin_action" value="rename_account">Save</button>
<?php if (empty($a['disabled_at'])): ?><button class="warn" name="admin_action" value="disable_account">Disable</button><?php else: ?><button class="ok" name="admin_action" value="enable_account">Enable</button><?php endif; ?>
<?php if (($a['id'] ?? '') !== 'island-team' && ($a['id'] ?? '') !== 'anonymous'): ?><button class="danger" name="admin_action" value="delete_account" onclick="return confirm('Delete this user? Their pictures become Anonymous.')">Delete</button><?php endif; ?>
</form>
</div>
<?php endforeach; ?>
</div>
<?php if (!$accounts): ?><p>No accounts.</p><?php endif; ?>
<h2 id="pictures">Pictures<?= $ownerFilter !== '' ? ' by ' . h($ownerFilter) : '' ?></h2>
<div class="grid">
<?php foreach ($assets as $asset): ?>
<div class="card">
<div class="media">
<?=thumbnail_html($asset['json'] ?? [])?>
<div>
<h3><?=h($asset['name'] ?? 'Untitled')?></h3>
<p class="muted">ID: <code><?=h($asset['id'] ?? '')?></code><br>User: <code><?=h($asset['owner_account_id'] ?? '')?></code> <?=h($asset['owner_name'] ?? '')?><br>Type: <?=h(trim(($asset['category'] ?? '') . ' ' . ($asset['subtype'] ?? '')))?> / Uses: <?=h($asset['object_count'] ?? 0)?></p>
<form method="post">
<input type="hidden" name="token" value="<?=h($given)?>">
<input type="hidden" name="owner" value="<?=h($ownerFilter)?>">
<input type="hidden" name="asset_id" value="<?=h($asset['id'] ?? '')?>">
<input name="name" value="<?=h($asset['name'] ?? '')?>" maxlength="32">
<button class="ok" name="admin_action" value="rename_asset">Save</button>
<button class="danger" name="admin_action" value="delete_asset" onclick="return confirm('Delete this picture?')">Delete</button>
</form>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php if (!$assets): ?><p>No pictures in this view.</p><?php endif; ?>
<h2 id="reports">Reports</h2>
<?php foreach ($reports as $r): ?>
<div class="card">
<div class="media">
<?=thumbnail_html($r['asset_json'] ?? [])?>
<div>
<h3><?=h($r['asset_name'] ?? 'Untitled')?></h3>
<p><strong>Reason:</strong> <?=h($r['reason'] ?? '')?> / <strong>By:</strong> <?=h($r['reporter'] ?? '')?></p>
<p class="muted">Object: <code><?=h($r['object_id'] ?? '')?></code> / Asset: <code><?=h($r['asset_id'] ?? '')?></code> / Status: <code><?=h($r['moderation_status'] ?? 'missing')?></code></p>
<form method="post">
<input type="hidden" name="token" value="<?=h($given)?>">
<input type="hidden" name="owner" value="<?=h($ownerFilter)?>">
<input type="hidden" name="object_id" value="<?=h($r['object_id'] ?? '')?>">
<button class="danger" name="admin_action" value="hide">Hide</button>
<button class="ok" name="admin_action" value="restore">Restore</button>
<button name="admin_action" value="close_reports">Close</button>
</form>
</div>
</div>
</div>
<?php endforeach; ?>
<?php if (!$reports): ?><p>No open reports.</p><?php endif; ?>

View file

@ -1,17 +0,0 @@
<?php
// Pixel Island shared backend configuration.
// Place this public_html folder as-is. SQLite DB is created under ../_data/ on first API access.
return [
'db_path' => getenv('PIXEL_ISLAND_DB_PATH') ?: dirname(__DIR__) . DIRECTORY_SEPARATOR . '_data' . DIRECTORY_SEPARATOR . 'pixel_island.sqlite',
'world_width' => 144,
'world_height' => 112,
'max_assets_per_account' => 220,
'max_world_objects' => 1000,
'publish_limit_first_day' => 5,
'publish_limit_trusted' => 10,
'trusted_after_ms' => 24 * 60 * 60 * 1000,
'max_json_bytes' => 5 * 1024 * 1024,
// Set an arbitrary long token here if you use admin/index.php.
// Example: 'admin_token' => 'change-this-long-random-string',
'admin_token' => '$GJwk1jkawjfkwa542jgr545kwpGfA5keajfZawj',
];

View file

@ -1,238 +0,0 @@
<?php
declare(strict_types=1);
// Fallback backend for hosts without pdo_sqlite. Uses a locked JSON file under _data/.
// Prefer SQLite for real traffic; this fallback is intended for small HTTPS test publication.
function fb_respond(array $payload, int $status = 200): void {
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function fb_fail(string $message, int $status = 400, array $extra = []): void {
if (!empty($GLOBALS['PIXEL_ISLAND_COMMAND_CONTEXT'])) throw new RuntimeException($message, $status);
fb_respond(['ok' => false, 'error' => $message] + $extra, $status);
}
function fb_now(): int { return (int) floor(microtime(true) * 1000); }
function fb_clean_text($value, string $fallback = '', int $max = 80): string {
$text = trim((string)($value ?? ''));
$text = preg_replace('/[\x00-\x1F\x7F]/u', '', $text) ?? '';
$text = preg_replace('/\s+/u', ' ', $text) ?? '';
if ($text === '') $text = $fallback;
return function_exists('mb_substr') ? mb_substr($text, 0, $max, 'UTF-8') : substr($text, 0, $max);
}
function fb_clean_id($value, string $fallback = ''): string {
$id = trim((string)($value ?? ''));
if ($id === '') return $fallback;
return substr(preg_replace('/[^A-Za-z0-9_:\-.]/', '', $id) ?? '', 0, 96);
}
function fb_request_json(int $maxBytes): array {
$raw = file_get_contents('php://input') ?: '';
if ($raw === '') return [];
if (strlen($raw) > $maxBytes) fb_fail('request_too_large', 413);
$data = json_decode($raw, true);
if (!is_array($data)) fb_fail('invalid_json', 400);
return $data;
}
function fb_default_db(): array {
return ['schema'=>1,'accounts'=>[],'assets'=>[],'objects'=>[],'votes'=>[],'reports'=>[],'events'=>[],'commands'=>[],'nextEventId'=>1];
}
function fb_seed_path(array $config): string {
return dirname($config['db_path']) . DIRECTORY_SEPARATOR . 'default_gallery_seed.json';
}
function fb_seed_defaults(array &$db, array $config): void {
$path = fb_seed_path($config);
if (!is_file($path)) return;
$seed = json_decode((string)file_get_contents($path), true);
if (!is_array($seed)) return;
$now = fb_now();
if (!isset($db['accounts']['island-team'])) $db['accounts']['island-team'] = ['id'=>'island-team','name'=>'Pixel Island','password_hash'=>password_hash(bin2hex(random_bytes(16)), PASSWORD_DEFAULT),'created_at'=>$now,'updated_at'=>$now,'disabled_at'=>null];
foreach (is_array($seed['assets'] ?? null) ? $seed['assets'] : [] as $asset) {
if (!is_array($asset)) continue;
$id = fb_clean_id($asset['id'] ?? '');
if ($id === '' || isset($db['assets'][$id])) continue;
$asset['ownerAccountId'] = 'island-team';
$asset['author'] = $asset['author'] ?? 'Pixel Island';
$db['assets'][$id]=['id'=>$id,'owner_account_id'=>'island-team','author_name'=>fb_clean_text($asset['author'] ?? 'Pixel Island','Pixel Island',32),'json'=>$asset,'version'=>max(1,(int)($asset['version'] ?? 1)),'created_at'=>(int)($asset['createdAt'] ?? $now),'updated_at'=>(int)($asset['updatedAt'] ?? $now),'deleted_at'=>null,'deleted_by'=>null,'content_hash'=>$asset['contentHash'] ?? null];
}
$insertObject = function(array $object, string $kind) use (&$db, $now): void {
$id = fb_clean_id($object['id'] ?? '');
$assetId = fb_clean_id($object['assetId'] ?? '');
if ($id === '' || $assetId === '' || isset($db['objects'][$id]) || !isset($db['assets'][$assetId]) || !empty($db['assets'][$assetId]['deleted_at'])) return;
$object['ownerAccountId']='island-team';
$object['status']='active';
$object['publishedAt']=(int)($object['publishedAt'] ?? $now);
$db['objects'][$id]=['id'=>$id,'kind'=>$kind,'asset_id'=>$assetId,'owner_account_id'=>'island-team','json'=>$object,'version'=>max(1,(int)($object['version'] ?? 1)),'published_at'=>(int)$object['publishedAt'],'updated_at'=>$now,'deleted_at'=>null,'deleted_by'=>null,'moderation_status'=>'active'];
};
foreach (is_array($seed['placed'] ?? null) ? $seed['placed'] : [] as $object) if (is_array($object)) $insertObject($object, 'static');
foreach (is_array($seed['dynamicSummons'] ?? null) ? $seed['dynamicSummons'] : [] as $object) if (is_array($object)) $insertObject($object, 'dynamic');
}
function fb_db_path(array $config): string {
return dirname($config['db_path']) . DIRECTORY_SEPARATOR . 'pixel_island_filedb.json';
}
function fb_with_db(array $config, callable $callback): void {
$path = fb_db_path($config);
$dir = dirname($path);
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) fb_fail('db_directory_unavailable', 500);
$fp = fopen($path, 'c+');
if (!$fp) fb_fail('filedb_unavailable', 500);
try {
if (!flock($fp, LOCK_EX)) fb_fail('filedb_lock_failed', 503);
$raw = stream_get_contents($fp);
$db = $raw ? json_decode($raw, true) : null;
if (!is_array($db)) $db = fb_default_db();
$result = $callback($db);
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode($db, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
fflush($fp);
flock($fp, LOCK_UN);
fclose($fp);
fb_respond($result);
} catch (Throwable $e) {
flock($fp, LOCK_UN);
fclose($fp);
fb_fail($e->getMessage(), 500);
}
}
function fb_auth(array &$db, array $account, bool $create = true): array {
$id = fb_clean_id($account['id'] ?? '');
$password = (string)($account['password'] ?? $account['pass'] ?? '');
$name = fb_clean_text($account['name'] ?? $id, $id, 32);
if ($id === '' || $password === '') fb_fail('account_required', 401);
$now = fb_now();
if (!isset($db['accounts'][$id])) {
if (!$create) fb_fail('account_not_found', 401);
$db['accounts'][$id] = ['id'=>$id,'name'=>$name,'password_hash'=>password_hash($password, PASSWORD_DEFAULT),'created_at'=>$now,'updated_at'=>$now,'disabled_at'=>null];
} else {
$row = $db['accounts'][$id];
if (!empty($row['disabled_at'])) fb_fail('account_disabled', 403);
if (!password_verify($password, $row['password_hash'] ?? '')) fb_fail('invalid_account_password', 401);
if ($name !== '' && $name !== ($row['name'] ?? '')) {
$db['accounts'][$id]['name'] = $name;
$db['accounts'][$id]['updated_at'] = $now;
}
}
return $db['accounts'][$id];
}
function fb_event(array &$db, string $type, array $event): int {
$id = (int)($db['nextEventId'] ?? 1);
$db['nextEventId'] = $id + 1;
$db['events'][] = ['id'=>$id,'type'=>$type,'json'=>$event,'created_at'=>fb_now()];
if (count($db['events']) > 5000) $db['events'] = array_slice($db['events'], -5000);
return $id;
}
function fb_normalize_asset(array $asset, array $actor): array {
$id = fb_clean_id($asset['id'] ?? '');
if ($id === '') fb_fail('asset_id_required');
$category = (($asset['category'] ?? '') === 'dynamic') ? 'dynamic' : 'static';
$w = max(1, min(64, (int)($asset['width'] ?? $asset['w'] ?? $asset['size'] ?? 16)));
$h = max(1, min(64, (int)($asset['height'] ?? $asset['ht'] ?? $asset['size'] ?? $w)));
$asset['id']=$id; $asset['name']=fb_clean_text($asset['name'] ?? 'Untitled','Untitled',32);
$asset['category']=$category; $asset['subtype']=fb_clean_text($asset['subtype'] ?? ($category==='dynamic'?'human':'other'),'other',24);
$asset['size']=max($w,$h); $asset['width']=$w; $asset['height']=$h;
$asset['author']=fb_clean_text($actor['name'] ?? $actor['id'],$actor['id'],32); $asset['ownerAccountId']=$actor['id'];
$asset['version']=max(1,(int)($asset['version'] ?? 1)); $asset['createdAt']=(int)($asset['createdAt'] ?? fb_now()); $asset['updatedAt']=fb_now();
if (!isset($asset['pixels']) && !isset($asset['faces'])) fb_fail('asset_pixels_required');
if (strlen(json_encode($asset, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) > 250000) fb_fail('asset_too_large', 413);
return $asset;
}
function fb_normalize_object(array $object, string $kind, array $actor, array $config): array {
$id=fb_clean_id($object['id'] ?? ''); $assetId=fb_clean_id($object['assetId'] ?? '');
if ($id==='' || $assetId==='') fb_fail('object_id_or_asset_required');
$object['id']=$id; $object['assetId']=$assetId; $object['ownerAccountId']=$actor['id']; $object['version']=max(1,(int)($object['version'] ?? 1)); $object['status']='active'; $object['publishedAt']=(int)($object['publishedAt'] ?? fb_now());
if ($kind==='dynamic') { $object['homeX']=max(0,min((int)$config['world_width']-1,(int)round((float)($object['homeX'] ?? $object['x'] ?? 0)))); $object['homeY']=max(0,min((int)$config['world_height']-1,(int)round((float)($object['homeY'] ?? $object['y'] ?? 0)))); $object['createdAt']=(int)($object['createdAt'] ?? fb_now()); }
else { $object['x']=max(0,min((int)$config['world_width']-1,(int)round((float)($object['x'] ?? 0)))); $object['y']=max(0,min((int)$config['world_height']-1,(int)round((float)($object['y'] ?? 0)))); $object['placedAt']=(int)($object['placedAt'] ?? fb_now()); }
return $object;
}
function fb_asset_owner(array $db, string $assetId, string $actorId): void {
$row = $db['assets'][$assetId] ?? null;
if (!$row || !empty($row['deleted_at'])) fb_fail('asset_not_found', 404);
if (($row['owner_account_id'] ?? '') !== $actorId) fb_fail('asset_owner_required', 403);
}
function fb_quota(array $db, array $actor, array $config): bool {
$now=fb_now(); $created=(int)($actor['created_at'] ?? $now); $limit=($now-$created)<(int)$config['trusted_after_ms'] ? (int)$config['publish_limit_first_day'] : (int)$config['publish_limit_trusted']; $since=$now-3600000; $used=0;
foreach ($db['objects'] as $o) if (($o['owner_account_id'] ?? '')===$actor['id'] && empty($o['deleted_at']) && (int)($o['published_at'] ?? 0) >= $since) $used++;
return $used < $limit;
}
function fb_command_result(array &$db, string $cmdId, string $actorId, int $createdAt, array $result): void {
$db['commands'][$cmdId]=['id'=>$cmdId,'actor_account_id'=>$actorId,'created_at'=>$createdAt,'applied_at'=>fb_now(),'result'=>$result];
}
function fb_process(array &$db, array $command, array $actor, array $config): array {
$cmdId=fb_clean_id($command['id'] ?? ''); $type=(string)($command['type'] ?? '');
if ($cmdId==='' || $type==='') return ['ok'=>false,'id'=>$cmdId,'error'=>'invalid_command'];
if (isset($db['commands'][$cmdId])) {
$result = is_array($db['commands'][$cmdId]['result'] ?? null) ? $db['commands'][$cmdId]['result'] : ['ok'=>true,'id'=>$cmdId];
$result['duplicate'] = true;
return $result;
}
$now=fb_now();
if ($type==='asset.create') {
$asset=fb_normalize_asset(is_array($command['asset'] ?? null)?$command['asset']:[], $actor);
if (isset($db['assets'][$asset['id']]) && ($db['assets'][$asset['id']]['owner_account_id'] ?? '') !== $actor['id']) fb_fail('asset_id_already_owned',403);
$db['assets'][$asset['id']]=['id'=>$asset['id'],'owner_account_id'=>$actor['id'],'author_name'=>$asset['author'],'json'=>$asset,'version'=>$asset['version'],'created_at'=>$asset['createdAt'],'updated_at'=>$asset['updatedAt'],'deleted_at'=>null,'deleted_by'=>null,'content_hash'=>$asset['contentHash'] ?? null];
fb_event($db,'asset.upsert',['type'=>'asset.upsert','assetId'=>$asset['id'],'actorAccountId'=>$actor['id']]);
} elseif ($type==='asset.delete') {
$assetId=fb_clean_id($command['assetId'] ?? ''); fb_asset_owner($db,$assetId,$actor['id']);
$db['assets'][$assetId]['deleted_at']=$now; $db['assets'][$assetId]['deleted_by']=$actor['id']; $db['assets'][$assetId]['updated_at']=$now;
foreach ($db['objects'] as &$o) if (($o['asset_id'] ?? '')===$assetId && empty($o['deleted_at'])) { $o['deleted_at']=$now; $o['deleted_by']=$actor['id']; $o['updated_at']=$now; } unset($o);
fb_event($db,'asset.delete',['type'=>'asset.delete','assetId'=>$assetId,'actorAccountId'=>$actor['id']]);
} elseif ($type==='publish.asset_object') {
$asset=fb_normalize_asset(is_array($command['asset'] ?? null)?$command['asset']:[], $actor);
$kind=(($command['kind'] ?? $asset['category'] ?? '')==='dynamic')?'dynamic':'static';
$object=fb_normalize_object(is_array($command['object'] ?? null)?$command['object']:[], $kind, $actor, $config);
if (($object['assetId'] ?? '') !== ($asset['id'] ?? '')) fb_fail('publish_asset_mismatch',400);
if (isset($db['assets'][$asset['id']]) && empty($db['assets'][$asset['id']]['deleted_at']) && ($db['assets'][$asset['id']]['owner_account_id'] ?? '') !== $actor['id']) fb_fail('asset_id_already_owned',403);
if (!fb_quota($db,$actor,$config)) fb_fail('publish_limit_reached',429);
$active=0; foreach($db['objects'] as $o) if (empty($o['deleted_at']) && ($o['moderation_status'] ?? 'active')==='active') $active++;
if ($active >= (int)$config['max_world_objects']) fb_fail('world_object_limit_reached',409);
$db['assets'][$asset['id']]=['id'=>$asset['id'],'owner_account_id'=>$actor['id'],'author_name'=>$asset['author'],'json'=>$asset,'version'=>$asset['version'],'created_at'=>$asset['createdAt'],'updated_at'=>$asset['updatedAt'],'deleted_at'=>null,'deleted_by'=>null,'content_hash'=>$asset['contentHash'] ?? null];
$object['publishedAt']=$now;
if ($kind==='dynamic') $object['createdAt']=$now; else $object['placedAt']=$now;
$db['objects'][$object['id']]=['id'=>$object['id'],'kind'=>$kind,'asset_id'=>$object['assetId'],'owner_account_id'=>$actor['id'],'json'=>$object,'version'=>$object['version'],'published_at'=>$now,'updated_at'=>$now,'deleted_at'=>null,'deleted_by'=>null,'moderation_status'=>'active'];
fb_event($db,'asset.upsert',['type'=>'asset.upsert','assetId'=>$asset['id'],'actorAccountId'=>$actor['id']]);
fb_event($db,'object.upsert',['type'=>'object.upsert','kind'=>$kind,'objectId'=>$object['id'],'actorAccountId'=>$actor['id']]);
$result=['ok'=>true,'id'=>$cmdId,'type'=>$type,'assetId'=>$asset['id'],'objectId'=>$object['id'],'kind'=>$kind,'publishedAt'=>$now,'object'=>$object];
fb_command_result($db,$cmdId,$actor['id'],(int)($command['createdAt'] ?? $now),$result);
return $result;
} elseif ($type==='object.publish' || $type==='object.move') {
$kind=(($command['kind'] ?? '')==='dynamic')?'dynamic':'static'; $object=fb_normalize_object(is_array($command['object'] ?? null)?$command['object']:[], $kind, $actor, $config); fb_asset_owner($db,$object['assetId'],$actor['id']);
if ($type==='object.publish' && !fb_quota($db,$actor,$config)) fb_fail('publish_limit_reached',429);
$active=0; foreach($db['objects'] as $o) if (empty($o['deleted_at']) && ($o['moderation_status'] ?? 'active')==='active') $active++;
if ($type==='object.publish' && $active >= (int)$config['max_world_objects']) fb_fail('world_object_limit_reached',409);
if ($type==='object.publish') $object['publishedAt']=$now;
$db['objects'][$object['id']]=['id'=>$object['id'],'kind'=>$kind,'asset_id'=>$object['assetId'],'owner_account_id'=>$actor['id'],'json'=>$object,'version'=>$object['version'],'published_at'=>(int)$object['publishedAt'],'updated_at'=>$now,'deleted_at'=>null,'deleted_by'=>null,'moderation_status'=>'active'];
fb_event($db,'object.upsert',['type'=>'object.upsert','kind'=>$kind,'objectId'=>$object['id'],'actorAccountId'=>$actor['id']]);
} elseif ($type==='object.delete') {
$kind=(($command['kind'] ?? '')==='dynamic')?'dynamic':'static'; $objectId=fb_clean_id($command['objectId'] ?? ''); $o=$db['objects'][$objectId] ?? null; if (!$o || ($o['kind'] ?? '')!==$kind || !empty($o['deleted_at'])) fb_fail('object_not_found',404); if (($o['owner_account_id'] ?? '')!==$actor['id']) fb_fail('object_owner_required',403);
$db['objects'][$objectId]['deleted_at']=$now; $db['objects'][$objectId]['deleted_by']=$actor['id']; $db['objects'][$objectId]['updated_at']=$now; fb_event($db,'object.delete',['type'=>'object.delete','kind'=>$kind,'objectId'=>$objectId,'actorAccountId'=>$actor['id']]);
} elseif ($type==='vote.asset' || $type==='vote.object') {
$targetType=$type==='vote.asset'?'asset':'object'; $targetId=fb_clean_id($command[$targetType.'Id'] ?? $command['targetId'] ?? ''); $value=max(-1,min(1,(int)($command['value'] ?? $command['delta'] ?? 0))); if($targetId==='') fb_fail('vote_target_required'); $key=$targetType.'|'.$targetId.'|'.$actor['id']; if($value===0) unset($db['votes'][$key]); else $db['votes'][$key]=['target_type'=>$targetType,'target_id'=>$targetId,'voter_account_id'=>$actor['id'],'value'=>$value,'updated_at'=>$now]; fb_event($db,'vote.'.$targetType,['type'=>'vote.'.$targetType,'targetId'=>$targetId,'actorAccountId'=>$actor['id'],'value'=>$value]);
} elseif ($type==='report.object') {
$objectId=fb_clean_id($command['objectId'] ?? ''); $assetId=fb_clean_id($command['assetId'] ?? ''); $kind=(($command['objectKind'] ?? $command['kind'] ?? '')==='dynamic')?'dynamic':'static'; $reason=fb_clean_text($command['reason'] ?? 'other','other',48); if($objectId==='' || $assetId==='') fb_fail('report_target_required'); $reportId=fb_clean_id($command['reportId'] ?? $command['id'] ?? ('report_'.bin2hex(random_bytes(6)))); $db['reports'][$reportId]=['id'=>$reportId,'object_id'=>$objectId,'asset_id'=>$assetId,'object_kind'=>$kind,'reporter_account_id'=>$actor['id'],'reason'=>$reason,'created_at'=>$now,'status'=>'open']; fb_event($db,'report.object',['type'=>'report.object','objectId'=>$objectId,'assetId'=>$assetId,'actorAccountId'=>$actor['id'],'reason'=>$reason]);
} else return ['ok'=>false,'id'=>$cmdId,'error'=>'unsupported_command'];
$result=['ok'=>true,'id'=>$cmdId,'type'=>$type];
fb_command_result($db,$cmdId,$actor['id'],(int)($command['createdAt'] ?? $now),$result);
return $result;
}
function fb_votes(array $db, string $targetType): array { $out=[]; foreach($db['votes'] as $v){ if(($v['target_type'] ?? '')!==$targetType || (int)($v['value'] ?? 0)===0) continue; $id=$v['target_id']; $val=(int)$v['value']; if(!isset($out[$id])) $out[$id]=['up'=>0,'down'=>0,'voters'=>[]]; if($val>0)$out[$id]['up']++; if($val<0)$out[$id]['down']++; $out[$id]['voters'][$v['voter_account_id']]=$val; } return $out; }
function fb_snapshot(array $db): array {
$assets=[]; foreach($db['assets'] as $a) if(empty($a['deleted_at'])) $assets[]=$a['json'];
usort($assets, fn($a,$b)=>(int)($b['updatedAt'] ?? 0) <=> (int)($a['updatedAt'] ?? 0));
$placed=[]; $dynamic=[]; foreach($db['objects'] as $o){ if(!empty($o['deleted_at']) || ($o['moderation_status'] ?? 'active')!=='active') continue; if(($o['kind'] ?? '')==='dynamic') $dynamic[]=$o['json']; else $placed[]=$o['json']; }
$assetT=[]; foreach($db['assets'] as $a) if(!empty($a['deleted_at'])) $assetT[$a['id']]=['id'=>$a['id'],'deletedAt'=>(int)$a['deleted_at'],'deletedBy'=>$a['deleted_by'] ?? '', 'version'=>(int)($a['version'] ?? 1)];
$objectT=[]; foreach($db['objects'] as $o) if(!empty($o['deleted_at'])) $objectT[$o['id']]=['id'=>$o['id'],'assetId'=>$o['asset_id'],'deletedAt'=>(int)$o['deleted_at'],'deletedBy'=>$o['deleted_by'] ?? '', 'version'=>(int)($o['version'] ?? 1)];
$reports=[]; foreach($db['reports'] as $r) if(($r['status'] ?? 'open')==='open') $reports[]=['id'=>$r['id'],'objectId'=>$r['object_id'],'assetId'=>$r['asset_id'],'objectKind'=>$r['object_kind'],'reporter'=>$r['reporter_account_id'],'reason'=>$r['reason'],'createdAt'=>(int)$r['created_at']];
$last=0; foreach($db['events'] as $e) $last=max($last,(int)($e['id'] ?? 0));
return ['ok'=>true,'schema'=>1,'fileDb'=>true,'serverNow'=>fb_now(),'lastEventId'=>$last,'authority'=>['publish'=>'server','objectMove'=>'server','dayNight'=>'local','dynamicMotion'=>'local'],'assets'=>array_values($assets),'placed'=>array_values($placed),'dynamicSummons'=>array_values($dynamic),'assetVotes'=>fb_votes($db,'asset'),'objectVotes'=>fb_votes($db,'object'),'moderationReports'=>$reports,'tombstones'=>['assets'=>$assetT,'objects'=>$objectT]];
}
$action = $_GET['action'] ?? '';
if ($action === 'health' || $action === '') fb_respond(['ok'=>true,'service'=>'pixel-island-api','serverNow'=>fb_now(),'sqlite'=>false,'fileDb'=>true,'note'=>'pdo_sqlite is unavailable; using locked JSON fallback']);
fb_with_db($config, function(array &$db) use ($action, $config) {
fb_seed_defaults($db, $config);
if ($action === 'snapshot') return fb_snapshot($db);
if ($action === 'account') { $data=fb_request_json((int)$config['max_json_bytes']); $account=fb_auth($db, is_array($data['account'] ?? null)?$data['account']:$data, true); return ['ok'=>true,'account'=>['id'=>$account['id'],'name'=>$account['name'],'createdAt'=>(int)$account['created_at']]]; }
if ($action === 'commands') { $data=fb_request_json((int)$config['max_json_bytes']); $actor=fb_auth($db, is_array($data['account'] ?? null)?$data['account']:[], true); $commands=is_array($data['commands'] ?? null)?$data['commands']:[]; if(count($commands)>100) fb_fail('too_many_commands',413); $applied=[]; $rejected=[]; foreach($commands as $command){ if(!is_array($command)) continue; try{ $GLOBALS['PIXEL_ISLAND_COMMAND_CONTEXT']=true; $result=fb_process($db,$command,$actor,$config); $GLOBALS['PIXEL_ISLAND_COMMAND_CONTEXT']=false; if($result['ok'] ?? false) $applied[]=$result['id']; else $rejected[]=$result; } catch(Throwable $e){ $GLOBALS['PIXEL_ISLAND_COMMAND_CONTEXT']=false; $id=fb_clean_id($command['id'] ?? ''); $result=['ok'=>false,'id'=>$id,'type'=>(string)($command['type'] ?? ''),'error'=>$e->getMessage()]; if($id!=='') fb_command_result($db,$id,$actor['id'],(int)($command['createdAt'] ?? fb_now()),$result); $rejected[]=$result; } } return ['ok'=>true,'appliedCommandIds'=>$applied,'rejectedCommands'=>$rejected,'snapshot'=>fb_snapshot($db)]; }
return ['ok'=>false,'error'=>'unknown_action'];
});

View file

@ -1,581 +0,0 @@
<?php
declare(strict_types=1);
$config = require __DIR__ . '/config.php';
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
$drivers = class_exists('PDO') ? PDO::getAvailableDrivers() : [];
if (!in_array('sqlite', $drivers, true)) {
require __DIR__ . '/filedb.php';
exit;
}
function respond(array $payload, int $status = 200): void {
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function fail(string $message, int $status = 400, array $extra = []): void {
if (!empty($GLOBALS['PIXEL_ISLAND_COMMAND_CONTEXT'])) {
throw new RuntimeException($message, $status);
}
respond(['ok' => false, 'error' => $message] + $extra, $status);
}
function request_json(int $maxBytes): array {
$raw = file_get_contents('php://input') ?: '';
if ($raw === '') return [];
if (strlen($raw) > $maxBytes) fail('request_too_large', 413);
$data = json_decode($raw, true);
if (!is_array($data)) fail('invalid_json', 400);
return $data;
}
function now_ms(): int {
return (int) floor(microtime(true) * 1000);
}
function clean_text($value, string $fallback = '', int $max = 80): string {
$text = trim((string) ($value ?? ''));
$text = preg_replace('/[\x00-\x1F\x7F]/u', '', $text) ?? '';
$text = preg_replace('/\s+/u', ' ', $text) ?? '';
if ($text === '') $text = $fallback;
if (function_exists('mb_substr')) return mb_substr($text, 0, $max, 'UTF-8');
return substr($text, 0, $max);
}
function clean_id($value, string $fallback = ''): string {
$id = trim((string) ($value ?? ''));
if ($id === '') return $fallback;
$id = preg_replace('/[^A-Za-z0-9_:\-.]/', '', $id) ?? '';
return substr($id, 0, 96);
}
function db(array $config): PDO {
$path = $config['db_path'];
$dir = dirname($path);
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) fail('db_directory_unavailable', 500);
$pdo = new PDO('sqlite:' . $path, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec('PRAGMA busy_timeout = 5000');
$pdo->exec('PRAGMA journal_mode = WAL');
migrate($pdo);
return $pdo;
}
function migrate(PDO $db): void {
$db->exec(<<<SQL
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
disabled_at INTEGER
);
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY,
owner_account_id TEXT NOT NULL,
author_name TEXT NOT NULL,
json TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER,
deleted_by TEXT,
content_hash TEXT
);
CREATE INDEX IF NOT EXISTS idx_assets_owner ON assets(owner_account_id);
CREATE TABLE IF NOT EXISTS objects (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK(kind IN ('static','dynamic')),
asset_id TEXT NOT NULL,
owner_account_id TEXT NOT NULL,
json TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
published_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER,
deleted_by TEXT,
moderation_status TEXT NOT NULL DEFAULT 'active'
);
CREATE INDEX IF NOT EXISTS idx_objects_asset ON objects(asset_id);
CREATE INDEX IF NOT EXISTS idx_objects_owner ON objects(owner_account_id);
CREATE INDEX IF NOT EXISTS idx_objects_active ON objects(kind, deleted_at, moderation_status);
CREATE TABLE IF NOT EXISTS votes (
target_type TEXT NOT NULL CHECK(target_type IN ('asset','object')),
target_id TEXT NOT NULL,
voter_account_id TEXT NOT NULL,
value INTEGER NOT NULL CHECK(value IN (-1,0,1)),
updated_at INTEGER NOT NULL,
PRIMARY KEY(target_type, target_id, voter_account_id)
);
CREATE TABLE IF NOT EXISTS reports (
id TEXT PRIMARY KEY,
object_id TEXT NOT NULL,
asset_id TEXT NOT NULL,
object_kind TEXT NOT NULL CHECK(object_kind IN ('static','dynamic')),
reporter_account_id TEXT NOT NULL,
reason TEXT NOT NULL,
created_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'open'
);
CREATE INDEX IF NOT EXISTS idx_reports_object ON reports(object_id);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
json TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS commands (
id TEXT PRIMARY KEY,
actor_account_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
applied_at INTEGER NOT NULL,
result_json TEXT
);
SQL);
$columns = [];
foreach ($db->query('PRAGMA table_info(commands)') as $row) $columns[$row['name']] = true;
if (empty($columns['result_json'])) $db->exec('ALTER TABLE commands ADD COLUMN result_json TEXT');
}
function authenticate(PDO $db, array $account, bool $createIfMissing = true): array {
$id = clean_id($account['id'] ?? '');
$password = (string) ($account['password'] ?? $account['pass'] ?? '');
$name = clean_text($account['name'] ?? $id, $id, 32);
if ($id === '' || $password === '') fail('account_required', 401);
$stmt = $db->prepare('SELECT * FROM accounts WHERE id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch();
$now = now_ms();
if (!$row) {
if (!$createIfMissing) fail('account_not_found', 401);
$hash = password_hash($password, PASSWORD_DEFAULT);
$db->prepare('INSERT INTO accounts(id, name, password_hash, created_at, updated_at) VALUES(?,?,?,?,?)')
->execute([$id, $name, $hash, $now, $now]);
return ['id' => $id, 'name' => $name, 'created_at' => $now, 'updated_at' => $now];
}
if (!empty($row['disabled_at'])) fail('account_disabled', 403);
if (!password_verify($password, $row['password_hash'])) fail('invalid_account_password', 401);
if ($name !== '' && $name !== $row['name']) {
$db->prepare('UPDATE accounts SET name = ?, updated_at = ? WHERE id = ?')->execute([$name, $now, $id]);
$row['name'] = $name;
$row['updated_at'] = $now;
}
return $row;
}
function record_event(PDO $db, string $type, array $event): int {
$now = now_ms();
$db->prepare('INSERT INTO events(type, json, created_at) VALUES(?,?,?)')
->execute([$type, json_encode($event, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), $now]);
return (int) $db->lastInsertId();
}
function decode_json_row(?string $json): array {
$decoded = json_decode((string) $json, true);
return is_array($decoded) ? $decoded : [];
}
function default_seed_path(): string {
return dirname(__DIR__) . DIRECTORY_SEPARATOR . '_data' . DIRECTORY_SEPARATOR . 'default_gallery_seed.json';
}
function read_default_seed(): array {
$path = default_seed_path();
if (!is_file($path)) return ['assets' => [], 'placed' => [], 'dynamicSummons' => []];
$raw = file_get_contents($path);
if (!is_string($raw) || $raw === '') return ['assets' => [], 'placed' => [], 'dynamicSummons' => []];
$data = json_decode($raw, true);
return is_array($data) ? $data : ['assets' => [], 'placed' => [], 'dynamicSummons' => []];
}
function seed_default_gallery(PDO $db): void {
static $done = false;
if ($done) return;
$done = true;
$seed = read_default_seed();
$assets = is_array($seed['assets'] ?? null) ? $seed['assets'] : [];
$placed = is_array($seed['placed'] ?? null) ? $seed['placed'] : [];
$dynamic = is_array($seed['dynamicSummons'] ?? null) ? $seed['dynamicSummons'] : [];
if (!$assets && !$placed && !$dynamic) return;
$now = now_ms();
$db->prepare('INSERT OR IGNORE INTO accounts(id, name, password_hash, created_at, updated_at) VALUES(?,?,?,?,?)')
->execute(['island-team', 'Pixel Island', password_hash(bin2hex(random_bytes(16)), PASSWORD_DEFAULT), $now, $now]);
foreach ($assets as $asset) {
if (!is_array($asset)) continue;
$id = clean_id($asset['id'] ?? '');
if ($id === '') continue;
$exists = $db->prepare('SELECT id FROM assets WHERE id = ?');
$exists->execute([$id]);
if ($exists->fetch()) continue;
$asset['ownerAccountId'] = 'island-team';
$asset['author'] = $asset['author'] ?? 'Pixel Island';
$asset['contentHash'] = $asset['contentHash'] ?? null;
$json = json_encode($asset, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) continue;
$db->prepare('INSERT INTO assets(id, owner_account_id, author_name, json, version, created_at, updated_at, deleted_at, deleted_by, content_hash) VALUES(?,?,?,?,?,?,?,?,?,?)')
->execute([$id, 'island-team', clean_text($asset['author'] ?? 'Pixel Island', 'Pixel Island', 32), $json, max(1, (int)($asset['version'] ?? 1)), (int)($asset['createdAt'] ?? $now), (int)($asset['updatedAt'] ?? $now), null, null, $asset['contentHash'] ?? null]);
}
$insertObject = function(array $object, string $kind) use ($db, $now): void {
$id = clean_id($object['id'] ?? '');
$assetId = clean_id($object['assetId'] ?? '');
if ($id === '' || $assetId === '') return;
$exists = $db->prepare('SELECT id FROM objects WHERE id = ?');
$exists->execute([$id]);
if ($exists->fetch()) return;
$assetExists = $db->prepare('SELECT id FROM assets WHERE id = ? AND deleted_at IS NULL');
$assetExists->execute([$assetId]);
if (!$assetExists->fetch()) return;
$object['ownerAccountId'] = 'island-team';
$object['status'] = 'active';
$object['publishedAt'] = (int)($object['publishedAt'] ?? $now);
$json = json_encode($object, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) return;
$db->prepare('INSERT INTO objects(id, kind, asset_id, owner_account_id, json, version, published_at, updated_at, deleted_at, deleted_by, moderation_status) VALUES(?,?,?,?,?,?,?,?,?,?,?)')
->execute([$id, $kind, $assetId, 'island-team', $json, max(1, (int)($object['version'] ?? 1)), (int)$object['publishedAt'], $now, null, null, 'active']);
};
foreach ($placed as $object) if (is_array($object)) $insertObject($object, 'static');
foreach ($dynamic as $object) if (is_array($object)) $insertObject($object, 'dynamic');
}
function normalize_asset(array $asset, array $actor, array $config): array {
$id = clean_id($asset['id'] ?? '');
if ($id === '') fail('asset_id_required');
$category = (($asset['category'] ?? '') === 'dynamic') ? 'dynamic' : 'static';
$width = max(1, min(64, (int) ($asset['width'] ?? $asset['w'] ?? $asset['size'] ?? 16)));
$height = max(1, min(64, (int) ($asset['height'] ?? $asset['ht'] ?? $asset['size'] ?? $width)));
$asset['id'] = $id;
$asset['name'] = clean_text($asset['name'] ?? 'Untitled', 'Untitled', 32);
$asset['category'] = $category;
$asset['subtype'] = clean_text($asset['subtype'] ?? ($category === 'dynamic' ? 'human' : 'other'), 'other', 24);
$asset['size'] = max($width, $height);
$asset['width'] = $width;
$asset['height'] = $height;
$asset['author'] = clean_text($actor['name'] ?? $actor['id'], $actor['id'], 32);
$asset['ownerAccountId'] = $actor['id'];
$asset['version'] = max(1, (int) ($asset['version'] ?? 1));
$asset['createdAt'] = (int) ($asset['createdAt'] ?? now_ms());
$asset['updatedAt'] = now_ms();
$asset['parentAssetId'] = isset($asset['parentAssetId']) ? clean_id($asset['parentAssetId'], '') : null;
$asset['originalAssetId'] = isset($asset['originalAssetId']) ? clean_id($asset['originalAssetId'], '') : null;
if (!isset($asset['pixels']) && !isset($asset['faces'])) fail('asset_pixels_required');
$json = json_encode($asset, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false || strlen($json) > 250000) fail('asset_too_large', 413);
return $asset;
}
function normalize_object(array $object, string $kind, array $actor, array $config): array {
$id = clean_id($object['id'] ?? '');
$assetId = clean_id($object['assetId'] ?? '');
if ($id === '' || $assetId === '') fail('object_id_or_asset_required');
$object['id'] = $id;
$object['assetId'] = $assetId;
$object['ownerAccountId'] = $actor['id'];
$object['version'] = max(1, (int) ($object['version'] ?? 1));
$object['status'] = 'active';
$object['publishedAt'] = (int) ($object['publishedAt'] ?? now_ms());
if ($kind === 'dynamic') {
$object['homeX'] = max(0, min((int) $config['world_width'] - 1, (int) round((float) ($object['homeX'] ?? $object['x'] ?? 0))));
$object['homeY'] = max(0, min((int) $config['world_height'] - 1, (int) round((float) ($object['homeY'] ?? $object['y'] ?? 0))));
$object['createdAt'] = (int) ($object['createdAt'] ?? now_ms());
} else {
$object['x'] = max(0, min((int) $config['world_width'] - 1, (int) round((float) ($object['x'] ?? 0))));
$object['y'] = max(0, min((int) $config['world_height'] - 1, (int) round((float) ($object['y'] ?? 0))));
$object['placedAt'] = (int) ($object['placedAt'] ?? now_ms());
}
$json = json_encode($object, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false || strlen($json) > 50000) fail('object_too_large', 413);
return $object;
}
function ensure_asset_owner(PDO $db, string $assetId, string $actorId): array {
$stmt = $db->prepare('SELECT * FROM assets WHERE id = ? AND deleted_at IS NULL');
$stmt->execute([$assetId]);
$asset = $stmt->fetch();
if (!$asset) fail('asset_not_found', 404);
if ($asset['owner_account_id'] !== $actorId) fail('asset_owner_required', 403);
return $asset;
}
function store_command_result(PDO $db, string $cmdId, string $actorId, int $createdAt, array $result): void {
$json = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$db->prepare('INSERT INTO commands(id, actor_account_id, created_at, applied_at, result_json) VALUES(?,?,?,?,?)')
->execute([$cmdId, $actorId, $createdAt, now_ms(), $json === false ? null : $json]);
}
function publish_quota_available(PDO $db, array $actor, array $config): bool {
$now = now_ms();
$created = (int) ($actor['created_at'] ?? $now);
$limit = ($now - $created) < (int) $config['trusted_after_ms'] ? (int) $config['publish_limit_first_day'] : (int) $config['publish_limit_trusted'];
$since = $now - 60 * 60 * 1000;
$stmt = $db->prepare('SELECT COUNT(*) AS c FROM objects WHERE owner_account_id = ? AND published_at >= ? AND deleted_at IS NULL');
$stmt->execute([$actor['id'], $since]);
return ((int) ($stmt->fetch()['c'] ?? 0)) < $limit;
}
function process_command(PDO $db, array $command, array $actor, array $config): array {
$cmdId = clean_id($command['id'] ?? '');
$type = (string) ($command['type'] ?? '');
if ($cmdId === '' || $type === '') return ['ok' => false, 'id' => $cmdId, 'error' => 'invalid_command'];
$stmt = $db->prepare('SELECT result_json FROM commands WHERE id = ?');
$stmt->execute([$cmdId]);
$stored = $stmt->fetch();
if ($stored) {
$result = decode_json_row($stored['result_json'] ?? '');
if (!$result) $result = ['ok' => true, 'id' => $cmdId];
$result['duplicate'] = true;
return $result;
}
$now = now_ms();
if ($type === 'asset.create') {
$asset = normalize_asset(is_array($command['asset'] ?? null) ? $command['asset'] : [], $actor, $config);
$existing = $db->prepare('SELECT owner_account_id, deleted_at FROM assets WHERE id = ?');
$existing->execute([$asset['id']]);
$row = $existing->fetch();
if ($row && $row['owner_account_id'] !== $actor['id']) fail('asset_id_already_owned', 403);
$json = json_encode($asset, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$db->prepare('INSERT INTO assets(id, owner_account_id, author_name, json, version, created_at, updated_at, deleted_at, deleted_by, content_hash)
VALUES(?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET author_name=excluded.author_name, json=excluded.json, version=excluded.version, updated_at=excluded.updated_at, deleted_at=NULL, deleted_by=NULL, content_hash=excluded.content_hash')
->execute([$asset['id'], $actor['id'], $asset['author'], $json, $asset['version'], $asset['createdAt'], $asset['updatedAt'], null, null, $asset['contentHash'] ?? null]);
record_event($db, 'asset.upsert', ['type' => 'asset.upsert', 'assetId' => $asset['id'], 'actorAccountId' => $actor['id']]);
} elseif ($type === 'asset.delete') {
$assetId = clean_id($command['assetId'] ?? '');
ensure_asset_owner($db, $assetId, $actor['id']);
$db->prepare('UPDATE assets SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE id = ?')
->execute([$now, $actor['id'], $now, $assetId]);
$db->prepare('UPDATE objects SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE asset_id = ? AND deleted_at IS NULL')
->execute([$now, $actor['id'], $now, $assetId]);
record_event($db, 'asset.delete', ['type' => 'asset.delete', 'assetId' => $assetId, 'actorAccountId' => $actor['id']]);
} elseif ($type === 'publish.asset_object') {
$asset = normalize_asset(is_array($command['asset'] ?? null) ? $command['asset'] : [], $actor, $config);
$kind = (($command['kind'] ?? $asset['category'] ?? '') === 'dynamic') ? 'dynamic' : 'static';
$object = normalize_object(is_array($command['object'] ?? null) ? $command['object'] : [], $kind, $actor, $config);
if ($object['assetId'] !== $asset['id']) fail('publish_asset_mismatch', 400);
$existingAsset = $db->prepare('SELECT owner_account_id FROM assets WHERE id = ? AND deleted_at IS NULL');
$existingAsset->execute([$asset['id']]);
$assetRow = $existingAsset->fetch();
if ($assetRow && $assetRow['owner_account_id'] !== $actor['id']) fail('asset_id_already_owned', 403);
if (!publish_quota_available($db, $actor, $config)) fail('publish_limit_reached', 429);
$activeCount = (int) $db->query("SELECT COUNT(*) AS c FROM objects WHERE deleted_at IS NULL AND moderation_status = 'active'")->fetch()['c'];
if ($activeCount >= (int) $config['max_world_objects']) fail('world_object_limit_reached', 409);
$assetJson = json_encode($asset, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($assetJson === false) fail('asset_too_large', 413);
$db->prepare('INSERT INTO assets(id, owner_account_id, author_name, json, version, created_at, updated_at, deleted_at, deleted_by, content_hash)
VALUES(?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET author_name=excluded.author_name, json=excluded.json, version=excluded.version, updated_at=excluded.updated_at, deleted_at=NULL, deleted_by=NULL, content_hash=excluded.content_hash')
->execute([$asset['id'], $actor['id'], $asset['author'], $assetJson, $asset['version'], $asset['createdAt'], $asset['updatedAt'], null, null, $asset['contentHash'] ?? null]);
$object['publishedAt'] = $now;
if ($kind === 'dynamic') $object['createdAt'] = $now;
else $object['placedAt'] = $now;
$objectJson = json_encode($object, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($objectJson === false) fail('object_too_large', 413);
$db->prepare('INSERT INTO objects(id, kind, asset_id, owner_account_id, json, version, published_at, updated_at, deleted_at, deleted_by, moderation_status)
VALUES(?,?,?,?,?,?,?,?,?,?,?)')
->execute([$object['id'], $kind, $object['assetId'], $actor['id'], $objectJson, $object['version'], $now, $now, null, null, 'active']);
record_event($db, 'asset.upsert', ['type' => 'asset.upsert', 'assetId' => $asset['id'], 'actorAccountId' => $actor['id']]);
record_event($db, 'object.upsert', ['type' => 'object.upsert', 'kind' => $kind, 'objectId' => $object['id'], 'actorAccountId' => $actor['id']]);
$result = ['ok' => true, 'id' => $cmdId, 'type' => $type, 'assetId' => $asset['id'], 'objectId' => $object['id'], 'kind' => $kind, 'publishedAt' => $now, 'object' => $object];
store_command_result($db, $cmdId, $actor['id'], (int) ($command['createdAt'] ?? $now), $result);
return $result;
} elseif ($type === 'object.publish' || $type === 'object.move') {
$kind = (($command['kind'] ?? '') === 'dynamic') ? 'dynamic' : 'static';
$object = normalize_object(is_array($command['object'] ?? null) ? $command['object'] : [], $kind, $actor, $config);
ensure_asset_owner($db, $object['assetId'], $actor['id']);
if ($type === 'object.publish' && !publish_quota_available($db, $actor, $config)) fail('publish_limit_reached', 429);
$activeCount = (int) $db->query("SELECT COUNT(*) AS c FROM objects WHERE deleted_at IS NULL AND moderation_status = 'active'")->fetch()['c'];
if ($type === 'object.publish' && $activeCount >= (int) $config['max_world_objects']) fail('world_object_limit_reached', 409);
if ($type === 'object.publish') $object['publishedAt'] = $now;
$json = json_encode($object, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$publishedAt = (int) ($object['publishedAt'] ?? $now);
$db->prepare('INSERT INTO objects(id, kind, asset_id, owner_account_id, json, version, published_at, updated_at, deleted_at, deleted_by, moderation_status)
VALUES(?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET json=excluded.json, version=excluded.version, published_at=excluded.published_at, updated_at=excluded.updated_at, deleted_at=NULL, deleted_by=NULL, moderation_status=\'active\'')
->execute([$object['id'], $kind, $object['assetId'], $actor['id'], $json, $object['version'], $publishedAt, $now, null, null, 'active']);
record_event($db, 'object.upsert', ['type' => 'object.upsert', 'kind' => $kind, 'objectId' => $object['id'], 'actorAccountId' => $actor['id']]);
} elseif ($type === 'object.delete') {
$kind = (($command['kind'] ?? '') === 'dynamic') ? 'dynamic' : 'static';
$objectId = clean_id($command['objectId'] ?? '');
$stmt = $db->prepare('SELECT * FROM objects WHERE id = ? AND kind = ? AND deleted_at IS NULL');
$stmt->execute([$objectId, $kind]);
$obj = $stmt->fetch();
if (!$obj) fail('object_not_found', 404);
if ($obj['owner_account_id'] !== $actor['id']) fail('object_owner_required', 403);
$db->prepare('UPDATE objects SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE id = ?')
->execute([$now, $actor['id'], $now, $objectId]);
record_event($db, 'object.delete', ['type' => 'object.delete', 'kind' => $kind, 'objectId' => $objectId, 'actorAccountId' => $actor['id']]);
} elseif ($type === 'vote.asset' || $type === 'vote.object') {
$targetType = $type === 'vote.asset' ? 'asset' : 'object';
$targetId = clean_id($command[$targetType . 'Id'] ?? $command['targetId'] ?? '');
$value = max(-1, min(1, (int) ($command['value'] ?? $command['delta'] ?? 0)));
if ($targetId === '') fail('vote_target_required');
if ($value === 0) {
$db->prepare('DELETE FROM votes WHERE target_type = ? AND target_id = ? AND voter_account_id = ?')->execute([$targetType, $targetId, $actor['id']]);
} else {
$db->prepare('INSERT INTO votes(target_type, target_id, voter_account_id, value, updated_at) VALUES(?,?,?,?,?)
ON CONFLICT(target_type, target_id, voter_account_id) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at')
->execute([$targetType, $targetId, $actor['id'], $value, $now]);
}
record_event($db, 'vote.' . $targetType, ['type' => 'vote.' . $targetType, 'targetId' => $targetId, 'actorAccountId' => $actor['id'], 'value' => $value]);
} elseif ($type === 'report.object') {
$objectId = clean_id($command['objectId'] ?? '');
$assetId = clean_id($command['assetId'] ?? '');
$kind = (($command['objectKind'] ?? $command['kind'] ?? '') === 'dynamic') ? 'dynamic' : 'static';
$reason = clean_text($command['reason'] ?? 'other', 'other', 48);
if ($objectId === '' || $assetId === '') fail('report_target_required');
$reportId = clean_id($command['reportId'] ?? $command['id'] ?? ('report_' . bin2hex(random_bytes(6))));
$db->prepare('INSERT OR IGNORE INTO reports(id, object_id, asset_id, object_kind, reporter_account_id, reason, created_at, status) VALUES(?,?,?,?,?,?,?,?)')
->execute([$reportId, $objectId, $assetId, $kind, $actor['id'], $reason, $now, 'open']);
record_event($db, 'report.object', ['type' => 'report.object', 'objectId' => $objectId, 'assetId' => $assetId, 'actorAccountId' => $actor['id'], 'reason' => $reason]);
} else {
return ['ok' => false, 'id' => $cmdId, 'error' => 'unsupported_command'];
}
$result = ['ok' => true, 'id' => $cmdId, 'type' => $type];
store_command_result($db, $cmdId, $actor['id'], (int) ($command['createdAt'] ?? $now), $result);
return $result;
}
function vote_snapshot(PDO $db, string $targetType): array {
$stmt = $db->prepare('SELECT target_id, voter_account_id, value FROM votes WHERE target_type = ? AND value != 0');
$stmt->execute([$targetType]);
$out = [];
while ($row = $stmt->fetch()) {
$id = $row['target_id'];
$value = (int) $row['value'];
if (!isset($out[$id])) $out[$id] = ['up' => 0, 'down' => 0, 'voters' => []];
if ($value > 0) $out[$id]['up']++;
if ($value < 0) $out[$id]['down']++;
$out[$id]['voters'][$row['voter_account_id']] = $value;
}
return $out;
}
function build_snapshot(PDO $db): array {
seed_default_gallery($db);
$assets = [];
foreach ($db->query('SELECT json FROM assets WHERE deleted_at IS NULL ORDER BY updated_at DESC LIMIT 2000') as $row) {
$asset = decode_json_row($row['json']);
if ($asset) $assets[] = $asset;
}
$placed = [];
$dynamic = [];
foreach ($db->query("SELECT kind, json FROM objects WHERE deleted_at IS NULL AND moderation_status = 'active' ORDER BY published_at DESC LIMIT 1000") as $row) {
$obj = decode_json_row($row['json']);
if (!$obj) continue;
if ($row['kind'] === 'dynamic') $dynamic[] = $obj;
else $placed[] = $obj;
}
$assetTombstones = [];
foreach ($db->query('SELECT id, deleted_at, deleted_by, version FROM assets WHERE deleted_at IS NOT NULL') as $row) {
$assetTombstones[$row['id']] = ['id' => $row['id'], 'deletedAt' => (int) $row['deleted_at'], 'deletedBy' => $row['deleted_by'], 'version' => (int) $row['version']];
}
$objectTombstones = [];
foreach ($db->query('SELECT id, asset_id, deleted_at, deleted_by, version FROM objects WHERE deleted_at IS NOT NULL') as $row) {
$objectTombstones[$row['id']] = ['id' => $row['id'], 'assetId' => $row['asset_id'], 'deletedAt' => (int) $row['deleted_at'], 'deletedBy' => $row['deleted_by'], 'version' => (int) $row['version']];
}
$reports = [];
foreach ($db->query("SELECT * FROM reports WHERE status = 'open' ORDER BY created_at DESC LIMIT 500") as $row) {
$reports[] = [
'id' => $row['id'],
'objectId' => $row['object_id'],
'assetId' => $row['asset_id'],
'objectKind' => $row['object_kind'],
'reporter' => $row['reporter_account_id'],
'reason' => $row['reason'],
'createdAt' => (int) $row['created_at'],
];
}
$last = $db->query('SELECT COALESCE(MAX(id), 0) AS id FROM events')->fetch();
return [
'ok' => true,
'schema' => 1,
'serverNow' => now_ms(),
'lastEventId' => (int) ($last['id'] ?? 0),
'authority' => ['publish' => 'server', 'objectMove' => 'server', 'dayNight' => 'local', 'dynamicMotion' => 'local'],
'assets' => $assets,
'placed' => $placed,
'dynamicSummons' => $dynamic,
'assetVotes' => vote_snapshot($db, 'asset'),
'objectVotes' => vote_snapshot($db, 'object'),
'moderationReports' => $reports,
'tombstones' => ['assets' => $assetTombstones, 'objects' => $objectTombstones],
];
}
try {
$action = $_GET['action'] ?? '';
if ($action === '') {
$path = trim((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), '/');
$action = basename($path) === 'index.php' ? '' : basename($path);
}
$database = db($config);
if ($action === 'health' || $action === '') {
respond(['ok' => true, 'service' => 'pixel-island-api', 'serverNow' => now_ms(), 'sqlite' => true]);
}
if ($action === 'snapshot') {
respond(build_snapshot($database));
}
if ($action === 'account') {
$data = request_json((int) $config['max_json_bytes']);
$account = authenticate($database, is_array($data['account'] ?? null) ? $data['account'] : $data, true);
respond(['ok' => true, 'account' => ['id' => $account['id'], 'name' => $account['name'], 'createdAt' => (int) $account['created_at']]]);
}
if ($action === 'commands') {
$data = request_json((int) $config['max_json_bytes']);
$actor = authenticate($database, is_array($data['account'] ?? null) ? $data['account'] : [], true);
$commands = is_array($data['commands'] ?? null) ? $data['commands'] : [];
if (count($commands) > 100) fail('too_many_commands', 413);
$applied = [];
$rejected = [];
foreach ($commands as $command) {
if (!is_array($command)) continue;
try {
$database->beginTransaction();
$GLOBALS['PIXEL_ISLAND_COMMAND_CONTEXT'] = true;
$result = process_command($database, $command, $actor, $config);
$GLOBALS['PIXEL_ISLAND_COMMAND_CONTEXT'] = false;
if ($result['ok'] ?? false) {
$database->commit();
$applied[] = $result['id'];
} else {
$database->rollBack();
$rejected[] = $result;
}
} catch (Throwable $e) {
$GLOBALS['PIXEL_ISLAND_COMMAND_CONTEXT'] = false;
if ($database->inTransaction()) $database->rollBack();
$id = clean_id($command['id'] ?? '');
$rejection = ['ok' => false, 'id' => $id, 'type' => (string)($command['type'] ?? ''), 'error' => $e->getMessage()];
if ($id !== '') {
try { store_command_result($database, $id, $actor['id'], (int)($command['createdAt'] ?? now_ms()), $rejection); }
catch (Throwable $ignored) {}
}
$rejected[] = $rejection;
}
}
respond(['ok' => true, 'appliedCommandIds' => $applied, 'rejectedCommands' => $rejected, 'snapshot' => build_snapshot($database)]);
}
fail('unknown_action', 404);
} catch (Throwable $e) {
fail($e->getMessage(), 500);
}

4822
app.js

File diff suppressed because it is too large Load diff

View file

@ -3,24 +3,39 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pixel Island</title>
<title>Pixel Island Summoner - Local Prototype</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div class="app">
<canvas id="worldCanvas" aria-label="Island map"></canvas>
<a class="homeLink" href="https://host.nishi.boats/~333/" aria-label="Open 333 home" title="Open 333 home">
<span class="homeLinkIcon" aria-hidden="true"></span>
<span class="homeLinkText">333</span>
</a>
<button id="openEditor" class="drawDockButton" type="button" title="Open Pixel Studio"><span>✎ DRAW</span><span id="drawQuotaBadge" class="quotaBadge drawQuotaBadge" aria-live="polite"></span></button>
<button id="placeHereButton" class="placeHereButton" type="button" hidden>PLACE HERE</button>
<div id="placementPreviewBar" class="placementPreviewBar" hidden>
<button id="confirmPreviewPlace" class="primary" type="button">Place here</button>
<button id="backToCanvas" class="secondary" type="button">Back to canvas</button>
<span>Check how this work looks on the island. Works are permanent in Collection; island placement is temporary exhibition.</span>
</div>
<header class="hud topHud islandTopBar">
<div class="brandBlock">
<div class="logo">Pixel Island</div>
<div class="subline">A rotating island exhibition for tiny pixel works.</div>
</div>
<div class="authorCard accountCard utilityControl" aria-label="Local account">
<div class="accountFields">
<label class="accountField">ID
<input id="accountId" type="text" readonly placeholder="auto" />
</label>
<label class="accountField">Name
<input id="authorName" type="text" maxlength="24" value="Local Artist" />
</label>
<label class="accountField">Pass
<input id="accountPass" type="text" maxlength="32" placeholder="auto" />
</label>
</div>
<button id="createAccount" class="miniButton" type="button">Generate account</button>
<small id="accountNote">Publish creates a local account for island publishing.</small>
</div>
</header>
<div id="analogClock" class="analogClock" aria-label="Island day-night clock" title="10 min = 1 island day">
@ -55,8 +70,8 @@
<span class="dimensionSeparator" aria-hidden="true">×</span>
<input id="assetWidth" class="metaInput metaDimension" type="number" min="1" max="64" step="1" value="8" placeholder="W" aria-label="Width" />
<select id="assetCategory" class="metaInput metaRole" aria-label="Role" required>
<option value="" disabled>Role</option>
<option value="human" selected>Human</option>
<option value="" disabled selected>Role</option>
<option value="human">Human</option>
<option value="animal">Animal</option>
<option value="bird">Bird</option>
<option value="fish">Fish</option>
@ -86,7 +101,6 @@
<button id="toolErase" class="tool toolErase" title="E">Erase</button>
<button id="toolFill" class="tool toolFill" title="F">Fill</button>
<button id="toolPick" class="tool toolPick" title="I">Pick</button>
<button id="clearPaint" class="tool toolClear danger" type="button">Clear</button>
</div>
<div class="toolRow editorUtilityRow">
<button id="toggleAdvanced" class="tool toolAdvanced" type="button"> Advanced</button>
@ -101,6 +115,7 @@
<button id="toolRect" class="tool toolRect advancedTool" title="R">Rect</button>
<button id="toolSelect" class="tool toolSelect advancedTool" title="S">Select</button>
<button id="toolDoor" class="tool toolDoor advancedTool buildingOnly">Door</button>
<button id="clearPaint" class="tool danger advancedTool">Clear</button>
</div>
<div class="toolRow advancedActionRow">
<button id="outlinePaint" class="tool toolOutline advancedTool" type="button">Outline</button>
@ -162,27 +177,8 @@
</section>
<section id="tab-settings" class="tabPanel">
<div class="card stack accountSettingsCard">
<div class="cardTitle">Account</div>
<p class="hint">Local publishing identity for saving and placing your works.</p>
<div class="authorCard accountCard utilityControl" aria-label="Local account">
<div class="accountFields">
<label class="accountField">ID
<input id="accountId" type="text" readonly placeholder="auto" />
</label>
<label class="accountField">Name
<input id="authorName" type="text" maxlength="24" value="Local Artist" />
</label>
<label class="accountField">Pass
<input id="accountPass" type="text" maxlength="32" placeholder="auto" />
</label>
</div>
<button id="createAccount" class="miniButton" type="button">Generate account</button>
<small id="accountNote">Publish creates a local account for island publishing.</small>
</div>
</div>
<div class="card stack">
<div class="cardTitle">Display</div>
<div class="cardTitle">Settings</div>
<p class="hint">Turn major visual systems on or off. Server decides the public 250 exhibition slots; this local cap only filters your view.</p>
<div class="toggleList">
<label class="checkRow"><input id="settingLights" type="checkbox" checked /> <span>Lights & glow</span></label>
@ -255,9 +251,6 @@
<script src="./js/core-utils.js"></script>
<script src="./js/module-loader.js"></script>
<script src="./js/palette.js"></script>
<script src="./js/pixel-codec.js"></script>
<script src="./js/worker-client.js"></script>
<script src="./js/state-index.js"></script>
<script src="./js/rotation-policy.js"></script>
<script src="./js/lighting.js"></script>

View file

@ -1,58 +0,0 @@
(function () {
'use strict';
const root = window.PixelIslandModules ||= {};
const BASE_COLOR_CODES = '0123456789abcdefghij';
const ADVANCED_COLOR_CODES = 'klmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + "!#$%&'()*+,-/:;<=>?@[]^_`{|}~";
const COLOR_CODES = BASE_COLOR_CODES + ADVANCED_COLOR_CODES;
const BASIC_PALETTE_COUNT = BASE_COLOR_CODES.length;
const DEFAULT_SELECTED_COLOR_CODE = BASE_COLOR_CODES.includes('a') ? 'a' : (BASE_COLOR_CODES[0] || '0');
function hslToHex(h, s, l) {
s /= 100; l /= 100;
const k = (n) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
const toHex = (value) => Math.round(255 * value).toString(16).padStart(2, '0');
return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`;
}
function buildPalette() {
const basicColors = [
'#111827', '#374151', '#6b7280', '#d1d5db', '#fff7ed',
'#7f1d1d', '#dc2626', '#f97316', '#facc15', '#84cc16',
'#16a34a', '#14b8a6', '#06b6d4', '#2563eb', '#4f46e5',
'#7c3aed', '#c026d3', '#ec4899', '#f5d0a9', '#7c4a2d'
];
const advancedColors = [
'#fafafa', '#f5f5f4', '#e7e5e4', '#d6d3d1', '#a8a29e', '#78716c', '#57534e', '#292524', '#0c0a09',
'#fef3c7', '#fde68a', '#d6a25f', '#9a6a3a', '#5c3b24',
'#fee2e2', '#fca5a5', '#ef4444', '#b91c1c', '#7f1d1d',
'#ffedd5', '#fdba74', '#f97316', '#c2410c', '#7c2d12',
'#fef9c3', '#fde047', '#eab308', '#a16207', '#713f12',
'#ecfccb', '#bef264', '#84cc16', '#4d7c0f', '#365314', '#dcfce7', '#86efac', '#15803d',
'#ccfbf1', '#5eead4', '#14b8a6', '#0f766e', '#134e4a',
'#cffafe', '#67e8f9', '#22d3ee', '#06b6d4', '#0e7490', '#e0f2fe', '#38bdf8',
'#dbeafe', '#93c5fd', '#3b82f6', '#2563eb', '#1d4ed8', '#1e3a8a',
'#e0e7ff', '#a5b4fc', '#6366f1', '#4f46e5', '#3730a3', '#ede9fe', '#c4b5fd', '#8b5cf6',
'#fae8ff', '#e879f9', '#c026d3', '#86198f', '#fce7f3', '#f9a8d4', '#ec4899', '#be185d'
];
const colors = [...basicColors, ...advancedColors];
return COLOR_CODES.split('').map((code, index) => ({ code, color: colors[index] || hslToHex((index * 47) % 360, 72, 58) }));
}
const PALETTE = buildPalette();
const PALETTE_BY_CODE = Object.fromEntries(PALETTE.map((p) => [p.code, p.color]));
root.Palette = {
BASE_COLOR_CODES,
ADVANCED_COLOR_CODES,
COLOR_CODES,
BASIC_PALETTE_COUNT,
DEFAULT_SELECTED_COLOR_CODE,
PALETTE,
PALETTE_BY_CODE,
buildPalette,
hslToHex
};
})();

View file

@ -2,18 +2,23 @@
'use strict';
const root = window.PixelIslandModules ||= {};
const FORMAT = 'pixel-island-phase2-compact-v2';
const FORMAT_V1 = 'pixel-island-phase2-compact-v1';
const SNAPSHOT_FORMAT = 'pixel-island-phase2-snapshot-v1';
const ASSET_BUNDLE_FORMAT = 'pixel-island-phase2-asset-bundle-v2';
const ASSET_BUNDLE_FORMAT_V1 = 'pixel-island-phase2-asset-bundle-v1';
const EVENT_LOG_LIMIT = 300;
const DB_NAME = 'pixel-island-phase2-cache';
const DB_VERSION = 6;
const DB_VERSION = 1;
const ASSET_STORE = 'assets';
const SNAPSHOT_STORE = 'snapshots';
const PIXEL_BLOB_STORE = 'pixelBlobs';
const OUTBOX_STORE = 'outbox';
const COLOR_CODES = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const PACK6_CHARS = `.${COLOR_CODES}`;
function isCompactState(value) {
return Boolean(value && (value.format === FORMAT || value.format === FORMAT_V1) && Array.isArray(value.assets));
}
function isAssetBundle(value) {
return Boolean(value && (value.format === ASSET_BUNDLE_FORMAT || value.format === ASSET_BUNDLE_FORMAT_V1) && Array.isArray(value.assets));
}
@ -287,7 +292,6 @@
return {
id: asset.id,
h: asset.contentHash || asset.hash || null,
bi: asset.blobId || asset.bi || null,
n: asset.name || 'Untitled',
c: category,
t: asset.subtype || (category === 'dynamic' ? 'human' : 'other'),
@ -307,58 +311,13 @@
};
}
function packAssetMetadata(asset, assetMap = null) {
const packed = packAsset(asset, assetMap);
delete packed.p;
return packed;
}
function packPixelBlob(input) {
if (!input) return null;
const width = assetWidth(input);
const height = assetHeight(input);
const payload = normalizeEncodedPlane(input.payload || input.faces?.right || input.pixels || '', width, '.', height);
const id = input.blobId || input.bi || ((input.codec || input.payload) ? input.id : null);
if (!id) return null;
return {
id,
co: input.codec || 'palette-index-v1',
w: width,
ht: height,
p: cropPlane(payload, width, '.', { bitPack: true }, height),
ca: input.createdAt || Date.now(),
ua: input.updatedAt || input.createdAt || Date.now()
};
}
function unpackPixelBlob(packed) {
if (!packed || !packed.id) return null;
const width = Math.max(1, Number(packed.w || packed.width) || 16);
const height = Math.max(1, Number(packed.ht || packed.height || width) || width);
return {
id: packed.id,
codec: packed.co || packed.codec || 'palette-index-v1',
width,
height,
payload: expandPlane(packed.p, width, '.', null, height),
createdAt: packed.ca || null,
updatedAt: packed.ua || packed.ca || null
};
}
function unpackAsset(packed, assetById = null, pixelBlobById = null) {
function unpackAsset(packed, assetById = null) {
if (!packed || !packed.id) return null;
const size = Math.max(1, Number(packed.s || packed.size) || 16);
const width = Math.max(1, Number(packed.w || packed.width || size) || size);
const height = Math.max(1, Number(packed.ht || packed.height || size) || size);
const category = packed.c === 'dynamic' || packed.category === 'dynamic' ? 'dynamic' : 'static';
const blobId = packed.bi || packed.blobId || null;
const pixelBlob = blobId && pixelBlobById
? (typeof pixelBlobById.get === 'function' ? pixelBlobById.get(blobId) : pixelBlobById[blobId])
: null;
const blobPayload = typeof pixelBlob?.payload === 'string' ? pixelBlob.payload : null;
const pixels = blobPayload ? normalizeEncodedPlane(blobPayload, width, '.', height) : expandPlane(packed.p, width, '.', null, height);
const pixels = expandPlane(packed.p, width, '.', null, height);
if (pixels == null) return null;
const metaPacked = packed.m || {};
const lightPixels = Array.isArray(metaPacked.l)
@ -388,7 +347,6 @@
size,
width,
height,
blobId,
pixels,
faces: category === 'dynamic' ? { right: pixels, left: 'mirror' } : null,
parentAssetId: packed.pa || null,
@ -403,8 +361,8 @@
};
}
function unpackAssets(rows, pixelBlobById = null) {
return (Array.isArray(rows) ? rows : []).map((row) => unpackAsset(row, null, pixelBlobById)).filter(Boolean);
function unpackAssets(rows) {
return (Array.isArray(rows) ? rows : []).map((row) => unpackAsset(row)).filter(Boolean);
}
function packPlacement(item) {
@ -439,8 +397,75 @@
return new Map((assets || []).map((asset) => [asset.id, asset]));
}
function compactState(state) {
const assets = Array.isArray(state.assets) ? state.assets : [];
const map = assetMapFor(assets);
return {
schema: 4,
format: FORMAT,
authorName: state.authorName || 'Local Artist',
assets: assets.map((asset) => packAsset(asset, map)),
placed: Array.isArray(state.placed) ? state.placed.map(packPlacement) : [],
dynamicSummons: Array.isArray(state.dynamicSummons) ? state.dynamicSummons.map(packDynamic) : [],
objectVotes: state.objectVotes || {},
assetVotes: state.assetVotes || {},
hiddenAssets: state.hiddenAssets || {},
hiddenObjects: state.hiddenObjects || {},
moderationReports: Array.isArray(state.moderationReports) ? state.moderationReports : [],
guardrails: state.guardrails || null,
settings: state.settings || null,
account: state.account || null,
publishLog: Array.isArray(state.publishLog) ? state.publishLog.slice(-300) : [],
eventLog: Array.isArray(state.eventLog) ? state.eventLog.slice(-EVENT_LOG_LIMIT) : [],
sync: state.sync || { lastEventId: null },
worldMode: state.worldMode === 'shared' ? 'shared' : 'local',
serverSync: state.serverSync || { lastServerEventId: null, pendingCommands: [] },
tombstones: state.tombstones || { assets: {}, objects: {} },
deletedSeedAssetNames: Array.isArray(state.deletedSeedAssetNames) ? state.deletedSeedAssetNames : []
};
}
function expandState(input) {
if (!isCompactState(input)) return input;
return {
schema: input.schema || 4,
authorName: input.authorName || 'Local Artist',
assets: unpackAssets(input.assets),
placed: (input.placed || []).map(unpackPlacement),
dynamicSummons: (input.dynamicSummons || []).map(unpackDynamic),
objectVotes: input.objectVotes || {},
assetVotes: input.assetVotes || {},
hiddenAssets: input.hiddenAssets || {},
hiddenObjects: input.hiddenObjects || {},
moderationReports: Array.isArray(input.moderationReports) ? input.moderationReports : [],
guardrails: input.guardrails || null,
settings: input.settings || null,
account: input.account || null,
publishLog: Array.isArray(input.publishLog) ? input.publishLog.slice(-300) : [],
eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-EVENT_LOG_LIMIT) : [],
sync: input.sync || { lastEventId: null },
worldMode: input.worldMode === 'shared' ? 'shared' : 'local',
serverSync: input.serverSync || { lastServerEventId: null, pendingCommands: [] },
tombstones: input.tombstones || { assets: {}, objects: {} },
deletedSeedAssetNames: Array.isArray(input.deletedSeedAssetNames) ? input.deletedSeedAssetNames : []
};
}
function compactSizeReport(state) {
const full = JSON.stringify({ ...state, schema: 4 });
const compact = JSON.stringify(compactState(state));
return {
fullBytes: full.length,
compactBytes: compact.length,
savedBytes: Math.max(0, full.length - compact.length),
savedPercent: full.length ? Math.round((1 - compact.length / full.length) * 1000) / 10 : 0,
assets: state.assets?.length || 0,
objects: (state.placed?.length || 0) + (state.dynamicSummons?.length || 0)
};
}
function assetManifest(state) {
return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, bi: asset.blobId || null, s: asset.size, w: asset.width || null, ht: asset.height || null, c: asset.category, t: asset.subtype, pa: asset.parentAssetId || null, oa: asset.originalAssetId || null }));
return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, s: asset.size, w: asset.width || null, ht: asset.height || null, c: asset.category, t: asset.subtype, pa: asset.parentAssetId || null, oa: asset.originalAssetId || null }));
}
function makeSnapshot(state, worldId = 'local-main') {
@ -549,17 +574,8 @@
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
const tx = request.transaction;
if (!db.objectStoreNames.contains(ASSET_STORE)) db.createObjectStore(ASSET_STORE, { keyPath: 'id' });
if (!db.objectStoreNames.contains(SNAPSHOT_STORE)) db.createObjectStore(SNAPSHOT_STORE, { keyPath: 'worldId' });
if (!db.objectStoreNames.contains(PIXEL_BLOB_STORE)) db.createObjectStore(PIXEL_BLOB_STORE, { keyPath: 'id' });
if (!db.objectStoreNames.contains(OUTBOX_STORE)) db.createObjectStore(OUTBOX_STORE, { keyPath: 'id' });
if (request.oldVersion && request.oldVersion < DB_VERSION) {
if (db.objectStoreNames.contains(ASSET_STORE)) tx.objectStore(ASSET_STORE).clear();
if (db.objectStoreNames.contains(SNAPSHOT_STORE)) tx.objectStore(SNAPSHOT_STORE).clear();
if (db.objectStoreNames.contains(PIXEL_BLOB_STORE)) tx.objectStore(PIXEL_BLOB_STORE).clear();
if (db.objectStoreNames.contains(OUTBOX_STORE)) tx.objectStore(OUTBOX_STORE).clear();
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
@ -571,62 +587,24 @@
const db = await openDb();
await new Promise((resolve, reject) => {
const tx = db.transaction(ASSET_STORE, 'readwrite');
const assetStore = tx.objectStore(ASSET_STORE);
const map = assetMapFor(assets);
for (const asset of assets) assetStore.put(packAssetMetadata(asset, map));
for (const asset of assets) tx.objectStore(ASSET_STORE).put(packAsset(asset));
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
}
async function cachePixelBlobs(pixelBlobs) {
if (!Array.isArray(pixelBlobs) || !pixelBlobs.length) return;
const db = await openDb();
await new Promise((resolve, reject) => {
const tx = db.transaction(PIXEL_BLOB_STORE, 'readwrite');
const store = tx.objectStore(PIXEL_BLOB_STORE);
for (const pixelBlob of pixelBlobs) {
const packed = packPixelBlob(pixelBlob);
if (packed) store.put(packed);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
}
async function readCachedPixelBlobs(blobIds) {
const ids = Array.from(blobIds || []).filter(Boolean);
if (!ids.length) return [];
const db = await openDb();
const rows = await Promise.all(ids.map((id) => new Promise((resolve) => {
const request = db.transaction(PIXEL_BLOB_STORE, 'readonly').objectStore(PIXEL_BLOB_STORE).get(id);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => resolve(null);
})));
db.close();
return rows.map(unpackPixelBlob).filter(Boolean);
}
async function readCachedAssets(assetIds) {
const ids = Array.from(assetIds || []);
if (!ids.length) return [];
const db = await openDb();
const assetRows = await Promise.all(ids.map((id) => new Promise((resolve) => {
const rows = await Promise.all(ids.map((id) => new Promise((resolve) => {
const request = db.transaction(ASSET_STORE, 'readonly').objectStore(ASSET_STORE).get(id);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => resolve(null);
})));
const blobIds = assetRows.map((row) => row?.bi || row?.blobId).filter(Boolean);
const blobRows = await Promise.all(blobIds.map((id) => new Promise((resolve) => {
const request = db.transaction(PIXEL_BLOB_STORE, 'readonly').objectStore(PIXEL_BLOB_STORE).get(id);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => resolve(null);
})));
db.close();
const blobById = new Map(blobRows.map(unpackPixelBlob).filter(Boolean).map((blob) => [blob.id, blob]));
return unpackAssets(assetRows.filter(Boolean), blobById);
return unpackAssets(rows.filter(Boolean));
}
async function cacheSnapshot(snapshot) {
@ -641,98 +619,14 @@
db.close();
}
async function readCachedSnapshot(worldId = 'local-main') {
const db = await openDb();
const row = await new Promise((resolve) => {
const request = db.transaction(SNAPSHOT_STORE, 'readonly').objectStore(SNAPSHOT_STORE).get(worldId);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => resolve(null);
});
db.close();
if (!row) return null;
return {
...row,
placed: Array.isArray(row.placed) ? row.placed.map(unpackPlacement) : [],
dynamicSummons: Array.isArray(row.dynamicSummons) ? row.dynamicSummons.map(unpackDynamic) : [],
hiddenObjects: row.hiddenObjects || {}
};
}
async function deleteCachedAssets(assetIds, blobIds = []) {
const assets = Array.from(assetIds || []).filter(Boolean);
const blobs = Array.from(blobIds || []).filter(Boolean);
if (!assets.length && !blobs.length) return;
const db = await openDb();
await new Promise((resolve, reject) => {
const tx = db.transaction([ASSET_STORE, PIXEL_BLOB_STORE], 'readwrite');
const assetStore = tx.objectStore(ASSET_STORE);
const blobStore = tx.objectStore(PIXEL_BLOB_STORE);
for (const id of assets) assetStore.delete(id);
for (const id of blobs) blobStore.delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
}
async function cacheOutboxCommand(command) {
if (!command?.id) return;
const db = await openDb();
await new Promise((resolve, reject) => {
const tx = db.transaction(OUTBOX_STORE, 'readwrite');
tx.objectStore(OUTBOX_STORE).put({ ...command, queuedAt: command.queuedAt || Date.now() });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
}
async function readOutboxCommands(limit = 300) {
const db = await openDb();
const rows = await new Promise((resolve) => {
const request = db.transaction(OUTBOX_STORE, 'readonly').objectStore(OUTBOX_STORE).getAll();
request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : []);
request.onerror = () => resolve([]);
});
db.close();
return rows
.sort((a, b) => Number(a.createdAt || a.queuedAt || 0) - Number(b.createdAt || b.queuedAt || 0))
.slice(-Math.max(1, Number(limit) || 300));
}
async function deleteOutboxCommands(commandIds) {
const ids = Array.from(commandIds || []).filter(Boolean);
if (!ids.length) return;
const db = await openDb();
await new Promise((resolve, reject) => {
const tx = db.transaction(OUTBOX_STORE, 'readwrite');
const store = tx.objectStore(OUTBOX_STORE);
for (const id of ids) store.delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
}
async function clearCache() {
const db = await openDb();
await new Promise((resolve, reject) => {
const tx = db.transaction([ASSET_STORE, PIXEL_BLOB_STORE, SNAPSHOT_STORE, OUTBOX_STORE], 'readwrite');
tx.objectStore(ASSET_STORE).clear();
tx.objectStore(PIXEL_BLOB_STORE).clear();
tx.objectStore(SNAPSHOT_STORE).clear();
tx.objectStore(OUTBOX_STORE).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
}
root.Phase2Sync = {
FORMAT,
FORMAT_V1,
SNAPSHOT_FORMAT,
ASSET_BUNDLE_FORMAT,
ASSET_BUNDLE_FORMAT_V1,
EVENT_LOG_LIMIT,
isCompactState,
isAssetBundle,
rleEncode,
rleDecode,
@ -743,15 +637,15 @@
cropPlane,
expandPlane,
packAsset,
packAssetMetadata,
packPixelBlob,
unpackPixelBlob,
unpackAsset,
unpackAssets,
packPlacement,
unpackPlacement,
packDynamic,
unpackDynamic,
compactState,
expandState,
compactSizeReport,
assetManifest,
makeSnapshot,
makeAssetBundle,
@ -765,15 +659,7 @@
deleteAssetOnly,
applyEvent,
cacheAssets,
cachePixelBlobs,
readCachedPixelBlobs,
readCachedAssets,
cacheSnapshot,
readCachedSnapshot,
deleteCachedAssets,
cacheOutboxCommand,
readOutboxCommands,
deleteOutboxCommands,
clearCache
cacheSnapshot
};
})();

View file

@ -1,175 +0,0 @@
(function (global) {
'use strict';
const root = global.PixelIslandModules ||= {};
const MAX_DIMENSION = 64;
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function clampInt(value, min, max, fallback = min) {
const n = Math.round(Number(value));
return Number.isFinite(n) ? clamp(n, min, max) : fallback;
}
function clampDimension(value, fallback = 8) {
return clampInt(value, 1, MAX_DIMENSION, fallback);
}
function fnv1a(value) {
let hash = 0x811c9dc5;
const text = String(value);
for (let i = 0; i < text.length; i++) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(16).padStart(8, '0');
}
function assetWidth(asset) {
return clampInt(asset?.width ?? asset?.w ?? asset?.size, 1, MAX_DIMENSION, 16);
}
function assetHeight(asset) {
return clampInt(asset?.height ?? asset?.ht ?? asset?.size, 1, MAX_DIMENSION, assetWidth(asset));
}
function assetMaxSize(asset) {
return Math.max(assetWidth(asset), assetHeight(asset));
}
function blankPixels(width, height = width) {
return Array(Math.max(1, width) * Math.max(1, height)).fill(null);
}
function parseHex(hex) {
if (typeof hex !== 'string' || !hex.startsWith('#')) return null;
const clean = hex.replace('#', '');
const full = clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean;
const value = parseInt(full, 16);
if (!Number.isFinite(value)) return null;
return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 };
}
function nearestPaletteCodeFrom(entries, color, fallback) {
if (!Array.isArray(entries) || !entries.length) return fallback || '0';
if (!color || typeof color !== 'string') return fallback || entries[0]?.code || '0';
if (entries.some((entry) => entry.code === color)) return color;
const rgb = parseHex(color);
if (!rgb) return fallback || entries[0]?.code || '0';
let best = entries[0]?.code || '0';
let bestDist = Infinity;
for (const entry of entries) {
const entryRgb = parseHex(entry.color);
if (!entryRgb) continue;
const dist = (rgb.r - entryRgb.r) ** 2 + (rgb.g - entryRgb.g) ** 2 + (rgb.b - entryRgb.b) ** 2;
if (dist < bestDist) {
bestDist = dist;
best = entry.code;
}
}
return best;
}
function normalizePixels(pixels, width, height = width, palette = root.Palette?.PALETTE || [], defaultColorCode = root.Palette?.DEFAULT_SELECTED_COLOR_CODE || 'a') {
const out = blankPixels(width, height);
const paletteByCode = new Map((palette || []).map((entry) => [entry.code, entry.color]));
if (typeof pixels === 'string') {
for (let i = 0; i < Math.min(out.length, pixels.length); i++) {
const ch = pixels[i];
out[i] = ch === '.' ? null : (paletteByCode.has(ch) ? ch : nearestPaletteCodeFrom(palette, ch, defaultColorCode));
}
return out;
}
if (!Array.isArray(pixels)) return out;
for (let i = 0; i < Math.min(out.length, pixels.length); i++) {
const value = pixels[i];
if (!value) out[i] = null;
else if (paletteByCode.has(value)) out[i] = value;
else out[i] = nearestPaletteCodeFrom(palette, value, defaultColorCode);
}
return out;
}
function encodePixels(pixels, width = null, height = null, palette = root.Palette?.PALETTE || [], defaultColorCode = root.Palette?.DEFAULT_SELECTED_COLOR_CODE || 'a') {
const w = width || Math.sqrt(pixels?.length || 0) || 8;
const h = height || w;
return normalizePixels(pixels, w, h, palette, defaultColorCode).map((value) => value || '.').join('');
}
function buildPixelBlob(encodedPixels, width, height = width, palette = root.Palette?.PALETTE || [], defaultColorCode = root.Palette?.DEFAULT_SELECTED_COLOR_CODE || 'a') {
const w = clampDimension(width, 16);
const h = clampDimension(height, w);
const payload = encodePixels(encodedPixels, w, h, palette, defaultColorCode);
const codec = 'palette-index-v1';
return {
id: computePixelBlobId(codec, w, h, payload, palette, defaultColorCode),
codec,
width: w,
height: h,
payload
};
}
function computePixelBlobId(codec, width, height, payload, palette = root.Palette?.PALETTE || [], defaultColorCode = root.Palette?.DEFAULT_SELECTED_COLOR_CODE || 'a') {
const normalizedCodec = codec || 'palette-index-v1';
const w = clampDimension(width, 16);
const h = clampDimension(height, w);
const encoded = encodePixels(payload, w, h, palette, defaultColorCode);
return `blob:${fnv1a(['pixel-blob-v1', normalizedCodec, w, h, encoded].join('|'))}`;
}
function ensureAssetBlobId(asset, palette = root.Palette?.PALETTE || [], defaultColorCode = root.Palette?.DEFAULT_SELECTED_COLOR_CODE || 'a') {
if (!asset) return asset;
const w = assetWidth(asset);
const h = assetHeight(asset);
const right = normalizePixels(asset.faces?.right || asset.pixels || blankPixels(w, h), w, h, palette, defaultColorCode);
const blob = buildPixelBlob(right, w, h, palette, defaultColorCode);
return { ...asset, blobId: asset.blobId || asset.bi || blob.id };
}
function colorToHex(value) {
if (!value) return 'rgba(0,0,0,0)';
return root.Palette?.PALETTE_BY_CODE?.[value] || value;
}
function nearestPaletteCode(color) {
return nearestPaletteCodeFrom(root.Palette?.PALETTE || [], color, root.Palette?.DEFAULT_SELECTED_COLOR_CODE || 'a');
}
function nearestBasicPaletteCode(color) {
const palette = root.Palette?.PALETTE || [];
const count = root.Palette?.BASIC_PALETTE_COUNT || palette.length;
return nearestPaletteCodeFrom(palette.slice(0, count), root.Palette?.PALETTE_BY_CODE?.[color] || color, palette[0]?.code || '0');
}
function readableTextColor(hex) {
const rgb = parseHex(hex);
if (!rgb) return '#243044';
const yiq = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000;
return yiq > 140 ? '#243044' : '#fffdf5';
}
root.PixelCodec = {
MAX_DIMENSION,
assetHeight,
assetMaxSize,
assetWidth,
blankPixels,
buildPixelBlob,
clampDimension,
clampInt,
colorToHex,
computePixelBlobId,
ensureAssetBlobId,
encodePixels,
fnv1a,
nearestBasicPaletteCode,
nearestPaletteCode,
nearestPaletteCodeFrom,
normalizePixels,
parseHex,
readableTextColor
};
})(typeof self !== 'undefined' ? self : window);

View file

@ -1,21 +0,0 @@
'use strict';
importScripts('./pixel-codec.js');
const PixelCodec = self.PixelIslandModules.PixelCodec;
self.onmessage = (event) => {
const { id, type, payload } = event.data || {};
try {
if (type === 'buildPixelBlobs') {
const palette = Array.isArray(payload?.palette) ? payload.palette : [];
const defaultColorCode = payload?.defaultColorCode || 'a';
const blobs = (payload?.assets || []).map((asset) => PixelCodec.buildPixelBlob(asset?.faces?.right || asset?.pixels || '', PixelCodec.assetWidth(asset), PixelCodec.assetHeight(asset), palette, defaultColorCode));
self.postMessage({ id, ok: true, result: blobs });
return;
}
self.postMessage({ id, ok: false, error: 'unsupported_worker_request' });
} catch (error) {
self.postMessage({ id, ok: false, error: error?.message || 'worker_error' });
}
};

View file

@ -1,53 +0,0 @@
(function () {
'use strict';
const root = window.PixelIslandModules ||= {};
let worker = null;
let nextId = 1;
const pending = new Map();
function canUseWorkers() {
return typeof Worker === 'function';
}
function getWorker() {
if (!canUseWorkers()) return null;
if (worker) return worker;
worker = new Worker('./js/pixel-worker.js');
worker.onmessage = (event) => {
const { id, ok, result, error } = event.data || {};
const entry = pending.get(id);
if (!entry) return;
pending.delete(id);
if (ok) entry.resolve(result);
else entry.reject(new Error(error || 'worker_error'));
};
worker.onerror = (event) => {
const error = new Error(event.message || 'worker_error');
for (const entry of pending.values()) entry.reject(error);
pending.clear();
worker?.terminate();
worker = null;
};
return worker;
}
function request(type, payload) {
const target = getWorker();
if (!target) return Promise.reject(new Error('workers_unavailable'));
const id = nextId++;
const promise = new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
target.postMessage({ id, type, payload });
return promise;
}
function buildPixelBlobs(assets) {
return request('buildPixelBlobs', {
assets,
palette: root.Palette?.PALETTE || [],
defaultColorCode: root.Palette?.DEFAULT_SELECTED_COLOR_CODE || 'a'
});
}
root.WorkerClient = { canUseWorkers, buildPixelBlobs };
})();

View file

@ -1,132 +0,0 @@
import json
import os
import shutil
import socket
import subprocess
import tempfile
import time
import unittest
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def free_port():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def asset(asset_id, owner="acct-a"):
return {
"id": asset_id,
"name": "Test Block",
"category": "static",
"subtype": "other",
"width": 1,
"height": 1,
"size": 1,
"pixels": "a",
"ownerAccountId": owner,
}
def publish_command(command_id, asset_id="asset-a", object_id="object-a"):
return {
"id": command_id,
"type": "publish.asset_object",
"kind": "static",
"asset": asset(asset_id),
"object": {
"id": object_id,
"assetId": asset_id,
"x": 3,
"y": 4,
"publishedAt": 1,
"version": 1,
},
}
@unittest.skipIf(shutil.which("php") is None, "PHP CLI is not installed")
class PublishApiTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.port = free_port()
env = os.environ.copy()
env["PIXEL_ISLAND_DB_PATH"] = str(Path(self.temp.name) / "pixel.sqlite")
self.proc = subprocess.Popen(
["php", "-S", f"127.0.0.1:{self.port}", "-t", str(ROOT)],
cwd=str(ROOT),
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
self.base = f"http://127.0.0.1:{self.port}/api/index.php"
deadline = time.time() + 5
while time.time() < deadline:
try:
self.get("health")
return
except Exception:
time.sleep(0.05)
self.fail("PHP test server did not start")
def tearDown(self):
self.proc.terminate()
self.proc.wait(timeout=5)
self.temp.cleanup()
def get(self, action):
with urllib.request.urlopen(f"{self.base}?action={action}", timeout=5) as response:
return json.loads(response.read().decode("utf-8"))
def post_commands(self, commands, account_id="acct-a", password="pw"):
payload = {
"account": {"id": account_id, "name": account_id, "password": password},
"commands": commands,
}
request = urllib.request.Request(
f"{self.base}?action=commands",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=5) as response:
return json.loads(response.read().decode("utf-8"))
def test_publish_asset_object_is_atomic_and_server_timestamped(self):
data = self.post_commands([publish_command("cmd-one")])
self.assertEqual(data["appliedCommandIds"], ["cmd-one"])
self.assertEqual(data["rejectedCommands"], [])
placed = data["snapshot"]["placed"]
self.assertEqual(len(placed), 1)
self.assertEqual(placed[0]["assetId"], "asset-a")
self.assertNotEqual(placed[0]["publishedAt"], 1)
self.assertEqual(data["snapshot"]["assets"][0]["ownerAccountId"], "acct-a")
def test_duplicate_publish_command_replays_without_new_object(self):
self.post_commands([publish_command("cmd-dupe")])
data = self.post_commands([publish_command("cmd-dupe")])
self.assertEqual(data["appliedCommandIds"], ["cmd-dupe"])
self.assertEqual(len(data["snapshot"]["placed"]), 1)
def test_owner_rejection_is_reported_in_batch(self):
self.post_commands([publish_command("cmd-owner", "shared-asset", "owner-object")])
stolen = publish_command("cmd-stolen", "shared-asset", "stolen-object")
data = self.post_commands([stolen], account_id="acct-b", password="pw")
self.assertEqual(data["appliedCommandIds"], [])
self.assertEqual(data["rejectedCommands"][0]["error"], "asset_id_already_owned")
def test_quota_rejection_does_not_abort_response(self):
commands = [publish_command(f"cmd-{i}", f"asset-{i}", f"object-{i}") for i in range(6)]
data = self.post_commands(commands)
self.assertEqual(len(data["appliedCommandIds"]), 5)
self.assertEqual(data["rejectedCommands"][0]["error"], "publish_limit_reached")
if __name__ == "__main__":
unittest.main()

View file

@ -324,8 +324,6 @@ button.danger.active, .iconButton.danger.active { background: var(--danger); col
.drawerBody {
overflow-y: auto;
overflow-anchor: none;
scroll-behavior: auto;
padding: 8px;
background:
linear-gradient(45deg, rgba(255,255,255,.4) 25%, transparent 25%) 0 0/18px 18px,
@ -474,13 +472,7 @@ button:disabled, .tool:disabled { opacity: .45; cursor: not-allowed; transform:
.actionRow button { flex: 1; }
.lineageNote { min-height: 18px; color: var(--muted); font-size: 12px; }
.assetList {
display: grid;
gap: 10px;
align-content: start;
grid-auto-rows: max-content;
overflow-anchor: none;
}
.assetList { display: grid; gap: 10px; }
.collectionToolbar {
display: grid;
gap: 8px;
@ -917,10 +909,8 @@ body, button, input, select, textarea {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
align-content: start;
max-height: 360px;
overflow-y: auto;
overflow-anchor: none;
padding-right: 4px;
}
.libraryEmptyNote {
@ -995,8 +985,8 @@ body, button, input, select, textarea {
text-transform: uppercase;
}
.reportDialog {
position: fixed;
z-index: 9998;
position: absolute;
z-index: 20;
inset: 0;
display: grid;
place-items: center;
@ -1052,9 +1042,8 @@ body, button, input, select, textarea { font-size: 15px; }
.authorCard { min-width: 210px; }
.authorCard small { display:block; margin-top:6px; font-size:12px; line-height:1.35; opacity:.82; }
.miniButton { margin-top:6px; padding:6px 8px; border:2px solid #243044; background:#fff3d9; box-shadow:2px 2px 0 rgba(36,48,68,.22); cursor:pointer; font-size:12px; }
.homeLink { position:fixed; left:12px; top:12px; z-index:6; display:inline-flex; align-items:center; gap:6px; padding:7px 9px; border:2px solid #243044; background:rgba(255,247,222,.92); box-shadow:3px 3px 0 rgba(36,48,68,.22); color:#243044; text-decoration:none; font-weight:800; letter-spacing:.02em; }
.homeLinkIcon { display:grid; place-items:center; width:22px; height:22px; border:2px solid currentColor; background:#fffdf5; line-height:1; }
.homeLinkText { font-size:13px; }
.placementPreviewBar { position:fixed; left:50%; bottom:22px; transform:translateX(-50%); z-index:35; display:flex; align-items:center; gap:10px; max-width:min(760px, calc(100vw - 24px)); padding:12px 14px; border:3px solid #243044; background:#fff7de; box-shadow:5px 5px 0 rgba(36,48,68,.25); font-size:15px; }
.placementPreviewBar[hidden] { display:none; }
.likedCodex { margin:8px 0 14px; padding:10px; border:2px dashed rgba(36,48,68,.35); background:rgba(255,255,255,.45); font-size:14px; }
.likedCodexTitle { font-weight:700; margin-bottom:6px; }
.likedCodexList { display:flex; gap:8px; flex-wrap:wrap; }
@ -1214,25 +1203,6 @@ body, button, input, select, textarea { font-size: 15px; }
box-shadow: none !important;
}
.clockCard.utilityControl { display: none !important; }
.accountSettingsCard .accountCard.utilityControl {
max-width: none !important;
width: 100% !important;
justify-content: space-between;
flex-wrap: wrap;
background: #fffdf5 !important;
}
.accountSettingsCard .accountFields {
grid-template-columns: minmax(90px, 1fr) minmax(120px, 1.2fr) minmax(120px, 1.2fr);
flex: 1 1 360px;
}
.accountSettingsCard .accountCard small {
max-width: none !important;
flex-basis: 100%;
}
@media (max-width: 560px) {
.accountSettingsCard .accountFields { grid-template-columns: 1fr; }
}
.editorToolRow { grid-template-columns: repeat(4, minmax(0, 1fr)) !important; }
.editorHistoryRow { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
.studioDrawer.advancedTools .editorToolRow { grid-template-columns: repeat(4, minmax(0, 1fr)) !important; }
@ -1286,6 +1256,7 @@ body, button, input, select, textarea { font-size: 15px; }
font-size: 11px;
}
.finishActions { grid-template-columns: 1.4fr 1fr !important; }
.placementPreviewBar span::after { content: " Left-click chooses a tile; right-click cancels selection."; }
@media (max-width: 980px) {
.islandTopBar { width: calc(100vw - 16px) !important; flex-wrap: wrap; justify-content: center; }
.accountCard.utilityControl { order: 3; max-width: 100% !important; }
@ -1481,7 +1452,7 @@ body, button, input, select, textarea { font-size: 15px; }
width: 4px;
height: 28px;
transform-origin: 50% 100%;
transform: translate(-50%, -100%) rotate(var(--clock-rotate, 0deg));
transform: translate(-50%, -100%) rotate(var(--clock-rotate));
background: #243044;
border-radius: 6px 6px 2px 2px;
box-shadow: 1px 0 0 rgba(255,255,255,.45);
@ -2632,7 +2603,6 @@ body, button, input, select, textarea { font-size: 15px; }
.collectionViewTabs.tabs {
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: start;
border: 2px solid var(--line);
box-shadow: 3px 3px 0 rgba(36,48,68,.12);
margin: 2px 0 0;
@ -2641,9 +2611,7 @@ body, button, input, select, textarea { font-size: 15px; }
}
.collectionViewTab.tab {
align-self: start;
min-width: 0;
min-height: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@ -2870,103 +2838,3 @@ body, button, input, select, textarea { font-size: 15px; }
.collectionSearch { flex-basis:104px !important; width:104px !important; }
.studioDrawer { z-index: var(--drawer-z) !important; }
}
/* Follow-up layout: let the palette consume the unused horizontal area below the editor and tool column. */
@media (min-width: 761px) {
#tab-draw .paintLayout {
grid-template-columns: minmax(0, 1fr) clamp(220px, 18vw, 270px) !important;
grid-template-rows: auto auto !important;
align-items: start !important;
}
#tab-draw .paintMainColumn {
display: contents !important;
}
#tab-draw .paintMainColumn #paintCanvas {
grid-column: 1 !important;
grid-row: 1 !important;
width: 100% !important;
max-width: none !important;
justify-self: stretch !important;
}
#tab-draw .drawControlPanel {
grid-column: 2 !important;
grid-row: 1 !important;
}
#tab-draw .paintMainColumn .palettePanel {
grid-column: 1 / -1 !important;
grid-row: 2 !important;
width: 100% !important;
max-width: none !important;
justify-self: stretch !important;
padding: 6px !important;
}
#tab-draw .paintMainColumn .paletteGrid,
.studioDrawer.advancedTools #tab-draw .paintMainColumn .paletteGrid {
grid-template-columns: repeat(auto-fill, minmax(28px, 1fr)) !important;
gap: 4px !important;
max-height: none !important;
overflow: visible !important;
}
#tab-draw .paintMainColumn .paletteGrid button,
#tab-draw .paintMainColumn .paletteSwatch {
min-height: 28px !important;
}
}
/* v3 placement confirmation: explicit button, never the old bottom preview bar. */
.placeHereButton {
position: fixed;
z-index: 92;
transform: translate(-50%, -100%);
padding: 9px 12px !important;
border: 3px solid var(--line) !important;
border-radius: 13px !important;
background: #fff2a8 !important;
color: #243044 !important;
box-shadow: 0 6px 0 rgba(36,48,68,.22), 0 10px 18px rgba(22,31,46,.18) !important;
font-size: 13px !important;
font-weight: 900 !important;
letter-spacing: .04em;
cursor: pointer;
}
.placeHereButton[hidden] { display: none !important; }
#confirmDialog.reportDialog { position: fixed !important; z-index: 10000 !important; }
.reportDialog { z-index: 9998; }
.assetStatus.validating { background: #fff2a8; }
.assetStatus.failed { background: #ffe2e2; color: #8b2424; }
/* Shared-only editor polish: five obvious primary draw controls. */
.editorToolRow,
.basicEditorTools,
.studioDrawer.advancedTools .editorToolRow,
.studioDrawer.advancedTools .basicEditorTools {
grid-template-columns: repeat(5, minmax(0, 1fr)) !important;
}
.basicEditorTools #toolBrush {
background: linear-gradient(180deg, #dcfce7, #86efac) !important;
color: #12422a !important;
}
.basicEditorTools #toolErase {
background: linear-gradient(180deg, #fee2e2, #fca5a5) !important;
color: #6f1515 !important;
}
.basicEditorTools #toolFill {
background: linear-gradient(180deg, #dbeafe, #93c5fd) !important;
color: #123f7a !important;
}
.basicEditorTools #toolPick {
background: linear-gradient(180deg, #fef3c7, #fde68a) !important;
color: #70470a !important;
}
.basicEditorTools #clearPaint {
background: linear-gradient(180deg, #ffe4e6, #fb7185) !important;
color: #711827 !important;
}
.basicEditorTools .tool.active {
outline: 4px solid rgba(36, 48, 68, .55) !important;
outline-offset: -6px !important;
color: #111827 !important;
}