Compare commits
No commits in common. "7fd7ba2e76b70dd8259d6f2a3416093847cc1f0a" and "06ab438d7b67c2b9e724d64754cc987b8c8678cb" have entirely different histories.
7fd7ba2e76
...
06ab438d7b
18 changed files with 3912 additions and 3222 deletions
|
|
@ -1,4 +1,4 @@
|
|||
# Pixel Island
|
||||
# Pixel Island Summoner
|
||||
|
||||
Browser-only prototype for a shared pixel-art island.
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
Require all denied
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
525
admin/index.php
525
admin/index.php
|
|
@ -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; ?>
|
||||
|
|
@ -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',
|
||||
];
|
||||
238
api/filedb.php
238
api/filedb.php
|
|
@ -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'];
|
||||
});
|
||||
581
api/index.php
581
api/index.php
|
|
@ -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);
|
||||
}
|
||||
57
index.html
57
index.html
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
})();
|
||||
|
|
@ -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
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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' });
|
||||
}
|
||||
};
|
||||
|
|
@ -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 };
|
||||
})();
|
||||
|
|
@ -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()
|
||||
146
styles.css
146
styles.css
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue