This commit is contained in:
33333-33333 2026-06-04 14:37:03 +09:00
commit 80aae72feb
10 changed files with 830 additions and 5041 deletions

View file

@ -19,6 +19,9 @@ function respond(array $payload, int $status = 200): void {
}
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);
}
@ -134,9 +137,13 @@ 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
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 {
@ -178,6 +185,69 @@ function decode_json_row(?string $json): array {
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');
@ -237,6 +307,12 @@ function ensure_asset_owner(PDO $db, string $assetId, string $actorId): array {
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);
@ -252,9 +328,15 @@ function process_command(PDO $db, array $command, array $actor, array $config):
$type = (string) ($command['type'] ?? '');
if ($cmdId === '' || $type === '') return ['ok' => false, 'id' => $cmdId, 'error' => 'invalid_command'];
$stmt = $db->prepare('SELECT id FROM commands WHERE id = ?');
$stmt = $db->prepare('SELECT result_json FROM commands WHERE id = ?');
$stmt->execute([$cmdId]);
if ($stmt->fetch()) return ['ok' => true, 'id' => $cmdId, 'duplicate' => true];
$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') {
@ -277,6 +359,39 @@ function process_command(PDO $db, array $command, array $actor, array $config):
$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);
@ -284,6 +399,7 @@ function process_command(PDO $db, array $command, array $actor, array $config):
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)
@ -329,9 +445,9 @@ function process_command(PDO $db, array $command, array $actor, array $config):
return ['ok' => false, 'id' => $cmdId, 'error' => 'unsupported_command'];
}
$db->prepare('INSERT INTO commands(id, actor_account_id, created_at, applied_at) VALUES(?,?,?,?)')
->execute([$cmdId, $actor['id'], (int) ($command['createdAt'] ?? $now), $now]);
return ['ok' => true, 'id' => $cmdId];
$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 {
@ -350,6 +466,7 @@ function vote_snapshot(PDO $db, string $targetType): array {
}
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']);
@ -433,7 +550,9 @@ try {
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'];
@ -442,8 +561,15 @@ try {
$rejected[] = $result;
}
} catch (Throwable $e) {
$GLOBALS['PIXEL_ISLAND_COMMAND_CONTEXT'] = false;
if ($database->inTransaction()) $database->rollBack();
$rejected[] = ['ok' => false, 'id' => clean_id($command['id'] ?? ''), 'error' => $e->getMessage()];
$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)]);