This commit is contained in:
33333-33333 2026-06-02 21:43:57 +09:00
commit 8ee6a87d44
7 changed files with 2317 additions and 560 deletions

1876
app.js

File diff suppressed because it is too large Load diff

View file

@ -9,9 +9,7 @@
<body>
<div class="app">
<canvas id="worldCanvas" aria-label="Island map"></canvas>
<div id="islandClickHint" class="islandClickHint">Left-click: select · Right-click: clear</div>
<button id="openEditor" class="drawDockButton" type="button" title="Open Pixel Studio">✎ DRAW</button>
<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>
<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>
@ -36,7 +34,7 @@
</label>
</div>
<button id="createAccount" class="miniButton" type="button">Generate account</button>
<small id="accountNote">Save + Place creates a local ID, name, and password. Change the password after creation.</small>
<small id="accountNote">Save + Place creates a local account for island publishing.</small>
</div>
</header>
@ -67,30 +65,22 @@
<div class="drawerBody">
<section id="tab-draw" class="tabPanel active">
<div class="card editorCard heroEditor">
<div class="quickSetupBar twoCols">
<label class="field inlineField">Canvas size
<select id="assetSize">
<option value="8" selected>8×8</option>
<option value="16">16×16</option>
<option value="32">32×32</option>
<option value="64">64×64</option>
</select>
</label>
<label class="field inlineField">Role
<select id="assetCategory">
<option value="human">Human</option>
<option value="animal">Animal</option>
<option value="nature" selected>Nature</option>
<option value="building">Building</option>
<option value="ship">Ship</option>
<option value="other">Other</option>
</select>
</label>
<div class="creationMetaRow" aria-label="Work setup">
<input id="assetName" class="metaInput metaName" type="text" maxlength="32" placeholder="Name" aria-label="Name" />
<input id="assetHeight" class="metaInput metaDimension" type="number" min="1" max="64" step="1" value="8" placeholder="H" aria-label="Height" />
<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 selected>Role</option>
<option value="human">Human</option>
<option value="animal">Animal</option>
<option value="bird">Bird</option>
<option value="nature">Nature</option>
<option value="building">Building</option>
<option value="ship">Ship</option>
<option value="other">Other</option>
</select>
</div>
<label class="field compactNameField inlineField">Name
<input id="assetName" type="text" maxlength="32" placeholder="Tiny bakery, round tree, island pup..." />
</label>
<div class="editorTop compactEditorTop">
<div id="sideSwitcher" class="sideSwitcher" hidden aria-label="Sprite direction">
<button id="editLeft" title="Left-facing sprite">◀ Left</button>
@ -133,8 +123,8 @@
<div class="advancedRow">
<button id="toggleAdvanced" class="tool toolAdvanced" type="button"> Advanced</button>
<button id="toolLight" class="tool toolLight advancedOnly" hidden>✹ Light</button>
<button id="toolDepth" class="tool toolDepth advancedOnly" hidden>▨ Depth</button>
<button id="toolParticle" class="tool toolParticle advancedOnly" hidden>⁕ Particle</button>
<button id="toolDepth" class="tool toolDepth advancedOnly" hidden>▨ Depth</button>
<button id="depthHigh" class="tool advancedOnly" type="button" hidden>▲ High</button>
<button id="depthLow" class="tool advancedOnly" type="button" hidden>▼ Low</button>
<div id="particleDirectionWrap" class="particleControl particleControlGroup advancedOnly" hidden>
@ -148,7 +138,7 @@
</label>
<small id="particleRangeStatus">Particle: paint cells like Light/Depth. Shift/right-click clears a cell.</small>
</div>
<span id="advancedHint" class="hint" hidden>Advanced tools stay below the basic draw row. Select tool: left-click selects, right-click clears selection. Particle paints emitter cells with palette color and direction. Shift/right-click clears a particle cell.</span>
<span id="advancedHint" class="hint" hidden>Advanced tools stay below the basic draw row. Particle paints emitter cells with palette color and direction. Shift/right-click clears a particle cell.</span>
</div>
<input id="paintColor" type="color" value="#6bd06b" hidden />
<div id="editHint" class="hint">Palette color is used for pixels, lights, particles, and depth marks. Shortcuts: B/E/F/I/L/R/S, Ctrl/Cmd+Z, arrows move a selection.</div>
@ -157,7 +147,7 @@
<div class="card stack summonCard">
<div class="cardTitle">Finish</div>
<div class="actionRow finishActions">
<button id="saveAndPlace" class="primary bigPrimary">Save + Place on Island</button>
<button id="saveAndPlace" class="primary bigPrimary"><span>Save + Place on Island</span><span id="finishQuotaBadge" class="quotaBadge finishQuotaBadge" aria-live="polite"></span></button>
<button id="saveAsset" class="secondary">Save to Collection</button>
</div>
</div>

View file

@ -7,9 +7,11 @@
return Array.isArray(pixels) ? [...pixels] : [];
}
function floodFill(sourcePixels, size, x, y, colorCode) {
function floodFill(sourcePixels, width, x, y, colorCode, height = width) {
const w = Math.max(1, Math.round(Number(width) || 1));
const h = Math.max(1, Math.round(Number(height) || w));
const pixels = clonePixels(sourcePixels);
const target = pixels[y * size + x] || null;
const target = pixels[y * w + x] || null;
const replacement = colorCode || null;
if (target === replacement) return { pixels, changed: false, count: 0, cells: [] };
@ -17,8 +19,8 @@
const cells = [];
while (stack.length) {
const [cx, cy] = stack.pop();
if (cx < 0 || cy < 0 || cx >= size || cy >= size) continue;
const index = cy * size + cx;
if (cx < 0 || cy < 0 || cx >= w || cy >= h) continue;
const index = cy * w + cx;
if ((pixels[index] || null) !== target) continue;
pixels[index] = replacement;
cells.push({ x: cx, y: cy });

View file

@ -168,8 +168,20 @@
return out;
}
function normalizeEncodedPlane(value, size, emptyChar = '.') {
const total = Math.max(1, Number(size) || 1) ** 2;
function assetWidth(asset) {
const size = Math.max(1, Number(asset?.size) || 16);
return Math.max(1, Number(asset?.width ?? asset?.w ?? size) || size);
}
function assetHeight(asset) {
const size = Math.max(1, Number(asset?.size) || 16);
return Math.max(1, Number(asset?.height ?? asset?.ht ?? size) || size);
}
function normalizeEncodedPlane(value, width, emptyChar = '.', height = width) {
const w = Math.max(1, Number(width) || 1);
const h = Math.max(1, Number(height) || w);
const total = w * h;
const source = typeof value === 'string' ? value : Array.isArray(value) ? value.map((v) => v || emptyChar).join('') : '';
return (source + emptyChar.repeat(total)).slice(0, total);
}
@ -184,15 +196,17 @@
return best;
}
function cropPlane(encoded, size, emptyChar = '.', options = {}) {
const text = normalizeEncodedPlane(encoded, size, emptyChar);
let minX = size;
let minY = size;
function cropPlane(encoded, width, emptyChar = '.', options = {}, height = width) {
const w0 = Math.max(1, Number(width) || 1);
const h0 = Math.max(1, Number(height) || w0);
const text = normalizeEncodedPlane(encoded, w0, emptyChar, h0);
let minX = w0;
let minY = h0;
let maxX = -1;
let maxY = -1;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (text[y * size + x] !== emptyChar) {
for (let y = 0; y < h0; y++) {
for (let x = 0; x < w0; x++) {
if (text[y * w0 + x] !== emptyChar) {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
@ -204,7 +218,7 @@
const w = maxX - minX + 1;
const h = maxY - minY + 1;
let cropped = '';
for (let y = minY; y <= maxY; y++) cropped += text.slice(y * size + minX, y * size + minX + w);
for (let y = minY; y <= maxY; y++) cropped += text.slice(y * w0 + minX, y * w0 + minX + w);
const candidates = [
{ b: [minX, minY, w, h], e: 'raw', v: cropped },
{ b: [minX, minY, w, h], e: 'rle', v: rleEncode(cropped) }
@ -213,9 +227,15 @@
return chooseSmallest(candidates);
}
function expandPlane(packed, size, emptyChar = '.', baseEncoded = null) {
const total = Math.max(1, Number(size) || 1) ** 2;
function expandPlane(packed, width, emptyChar = '.', baseEncoded = null, height = width) {
const w0 = Math.max(1, Number(width) || 1);
const h0 = Math.max(1, Number(height) || w0);
const total = w0 * h0;
const out = Array(total).fill(emptyChar);
if (baseEncoded) {
const base = normalizeEncodedPlane(baseEncoded, w0, emptyChar, h0);
for (let i = 0; i < Math.min(out.length, base.length); i++) out[i] = base[i] || emptyChar;
}
if (!packed || !packed.b) return out.join('');
const [x0, y0, w, h] = packed.b.map((v) => Math.max(0, Number(v) || 0));
let value = '';
@ -227,27 +247,31 @@
const src = y * w + x;
const dx = x0 + x;
const dy = y0 + y;
if (dx >= 0 && dy >= 0 && dx < size && dy < size && src < value.length) out[dy * size + dx] = value[src] || emptyChar;
if (dx >= 0 && dy >= 0 && dx < w0 && dy < h0 && src < value.length) out[dy * w0 + dx] = value[src] || emptyChar;
}
}
return out.join('');
}
function planeForAsset(asset) {
const size = Math.max(1, Number(asset?.size) || 16);
return normalizeEncodedPlane(asset?.faces?.right || asset?.pixels || '', size, '.');
const w = assetWidth(asset);
const h = assetHeight(asset);
return normalizeEncodedPlane(asset?.faces?.right || asset?.pixels || '', w, '.', h);
}
function selectPixelPlanePack(asset, assetMap) {
const size = Math.max(1, Number(asset.size) || 16);
const w = assetWidth(asset);
const h = assetHeight(asset);
const right = planeForAsset(asset);
return cropPlane(right, size, '.', { bitPack: true });
return cropPlane(right, w, '.', { bitPack: true }, h);
}
function packAsset(asset, assetMap = null) {
const size = Math.max(1, Number(asset.size) || 16);
const width = assetWidth(asset);
const height = assetHeight(asset);
const size = Math.max(width, height, Math.max(1, Number(asset.size) || 16));
const category = asset.category === 'dynamic' ? 'dynamic' : 'static';
const depth = asset.meta?.depthPixels ? normalizeEncodedPlane(asset.meta.depthPixels, size, '.') : '';
const depth = asset.meta?.depthPixels ? normalizeEncodedPlane(asset.meta.depthPixels, width, '.', height) : '';
const lights = Array.isArray(asset.meta?.lightPixels)
? asset.meta.lightPixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || asset.meta?.lightColor || '']).filter((p) => p[2])
: [];
@ -255,7 +279,7 @@
? asset.meta.particlePixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || '', p.dir || 'up']).filter((p) => p[2])
: [];
const meta = {};
if (depth && /[1\-]/.test(depth)) meta.d = cropPlane(depth, size, '.');
if (depth && /[1\-]/.test(depth)) meta.d = cropPlane(depth, width, '.', {}, height);
if (lights.length) meta.l = lights;
if (particles.length) meta.pt = particles;
if (asset.meta?.lightColor) meta.lc = asset.meta.lightColor;
@ -272,6 +296,8 @@
c: category,
t: asset.subtype || (category === 'dynamic' ? 'human' : 'other'),
s: size,
w: width !== size ? width : undefined,
ht: height !== size ? height : undefined,
p: selectPixelPlanePack(asset, assetMap),
f: category === 'dynamic' ? { l: 'mirror' } : null,
pa: asset.parentAssetId || null,
@ -279,6 +305,8 @@
ca: asset.createdAt || Date.now(),
ua: asset.updatedAt || asset.createdAt || Date.now(),
au: asset.author || 'Local Artist',
ow: asset.ownerAccountId || null,
v: Number(asset.version) || 1,
m: Object.keys(meta).length ? meta : null
};
}
@ -286,8 +314,10 @@
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 pixels = expandPlane(packed.p, size, '.');
const pixels = expandPlane(packed.p, width, '.', null, height);
if (pixels == null) return null;
const metaPacked = packed.m || {};
const lightPixels = Array.isArray(metaPacked.l)
@ -306,7 +336,7 @@
hasParticles: Boolean(particleConfig) || particlePixels.length > 0,
particlePixels,
particleConfig,
depthPixels: metaPacked.d ? expandPlane(metaPacked.d, size, '.') : null,
depthPixels: metaPacked.d ? expandPlane(metaPacked.d, width, '.', null, height) : null,
door: Array.isArray(metaPacked.dr) ? { x: Number(metaPacked.dr[0]) || 0, y: Number(metaPacked.dr[1]) || 0 } : null
};
return {
@ -315,6 +345,8 @@
category,
subtype: packed.t || (category === 'dynamic' ? 'human' : 'other'),
size,
width,
height,
pixels,
faces: category === 'dynamic' ? { right: pixels, left: 'mirror' } : null,
parentAssetId: packed.pa || null,
@ -322,6 +354,8 @@
createdAt: packed.ca || Date.now(),
updatedAt: packed.ua || packed.ca || Date.now(),
author: packed.au || 'Local Artist',
ownerAccountId: packed.ow || packed.ownerAccountId || '',
version: Number(packed.v || packed.version) || 1,
meta,
contentHash: packed.h || null
};
@ -335,26 +369,28 @@
const meta = {};
if (item.publishedAt) meta.pu = item.publishedAt;
if (item.status && item.status !== 'active') meta.st = item.status;
if (item.ownerAccountId) meta.ow = item.ownerAccountId;
return [item.id, item.assetId, Number(item.x) || 0, Number(item.y) || 0, item.placedAt || Date.now(), Number(item.version) || 1, Object.keys(meta).length ? meta : null];
}
function unpackPlacement(row) {
if (!Array.isArray(row)) return row;
const meta = row[6] || {};
return { id: row[0], assetId: row[1], x: row[2], y: row[3], placedAt: row[4], version: row[5] || 1, publishedAt: meta.pu || row[4], status: meta.st || 'active' };
return { id: row[0], assetId: row[1], x: row[2], y: row[3], placedAt: row[4], version: row[5] || 1, publishedAt: meta.pu || row[4], status: meta.st || 'active', ownerAccountId: meta.ow || '' };
}
function packDynamic(item) {
const meta = {};
if (item.publishedAt) meta.pu = item.publishedAt;
if (item.status && item.status !== 'active') meta.st = item.status;
if (item.ownerAccountId) meta.ow = item.ownerAccountId;
return [item.id, item.assetId, Number(item.homeX) || 0, Number(item.homeY) || 0, item.createdAt || Date.now(), Number(item.version) || 1, Object.keys(meta).length ? meta : null];
}
function unpackDynamic(row) {
if (!Array.isArray(row)) return row;
const meta = row[6] || {};
return { id: row[0], assetId: row[1], homeX: row[2], homeY: row[3], createdAt: row[4], version: row[5] || 1, publishedAt: meta.pu || row[4], status: meta.st || 'active' };
return { id: row[0], assetId: row[1], homeX: row[2], homeY: row[3], createdAt: row[4], version: row[5] || 1, publishedAt: meta.pu || row[4], status: meta.st || 'active', ownerAccountId: meta.ow || '' };
}
function assetMapFor(assets) {
@ -381,7 +417,10 @@
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 }
sync: state.sync || { lastEventId: null },
worldMode: state.worldMode === 'shared' ? 'shared' : 'local',
serverSync: state.serverSync || { lastServerEventId: null, pendingCommands: [] },
tombstones: state.tombstones || { assets: {}, objects: {} }
};
}
@ -403,7 +442,10 @@
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 }
sync: input.sync || { lastEventId: null },
worldMode: input.worldMode === 'shared' ? 'shared' : 'local',
serverSync: input.serverSync || { lastServerEventId: null, pendingCommands: [] },
tombstones: input.tombstones || { assets: {}, objects: {} }
};
}
@ -421,7 +463,7 @@
}
function assetManifest(state) {
return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, s: asset.size, 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') {

131
server/test_world_policy.py Normal file
View file

@ -0,0 +1,131 @@
import unittest
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from world_policy import (
ACTIVE,
VIOLATION_HIDDEN,
apply_command,
apply_server_event,
can_delete_asset,
can_import_full_state,
can_modify_object,
)
def base_state():
return {
"worldMode": "shared",
"assets": [
{"id": "asset-a", "ownerAccountId": "alice", "author": "Alice", "version": 1},
{"id": "asset-b", "ownerAccountId": "bob", "author": "Bob", "version": 1},
],
"placed": [
{"id": "obj-a", "assetId": "asset-a", "ownerAccountId": "alice", "x": 1, "y": 1, "version": 1, "status": ACTIVE}
],
"dynamicSummons": [
{"id": "dyn-b", "assetId": "asset-b", "ownerAccountId": "bob", "homeX": 2, "homeY": 2, "version": 1, "status": ACTIVE}
],
"objectVotes": {},
"assetVotes": {},
"hiddenObjects": {},
"hiddenAssets": {},
"moderationReports": [],
"tombstones": {"assets": {}, "objects": {}},
"serverSync": {},
}
class WorldPolicyTest(unittest.TestCase):
def test_non_owner_cannot_delete_static_object(self):
state = base_state()
allowed, meta = can_modify_object(state, {"id": "bob"}, "obj-a")
self.assertFalse(allowed)
self.assertEqual(meta["reason"], "not_object_owner")
result = apply_command(state, {"id": "bob"}, {"type": "object.delete", "kind": "static", "objectId": "obj-a"}, at=100)
self.assertFalse(result["accepted"])
self.assertEqual(result["reason"], "not_object_owner")
self.assertEqual(len(state["placed"]), 1)
def test_non_owner_cannot_move_dynamic_object(self):
state = base_state()
command = {
"type": "object.move",
"kind": "dynamic",
"object": {"id": "dyn-b", "assetId": "asset-b", "homeX": 9, "homeY": 9, "version": 1},
}
result = apply_command(state, {"id": "alice"}, command, at=100)
self.assertFalse(result["accepted"])
self.assertEqual(result["reason"], "not_object_owner")
def test_owner_can_move_and_delete_own_object(self):
state = base_state()
move = apply_command(
state,
{"id": "bob"},
{"type": "object.move", "kind": "dynamic", "object": {"id": "dyn-b", "assetId": "asset-b", "homeX": 9, "homeY": 9, "version": 1}},
at=100,
)
self.assertTrue(move["accepted"])
self.assertTrue(apply_server_event(state, move["event"]))
self.assertEqual(state["dynamicSummons"][0]["homeX"], 9)
self.assertEqual(state["dynamicSummons"][0]["ownerAccountId"], "bob")
delete = apply_command(state, {"id": "bob"}, {"type": "object.delete", "kind": "dynamic", "objectId": "dyn-b"}, at=200)
self.assertTrue(delete["accepted"])
self.assertTrue(apply_server_event(state, delete["event"]))
self.assertEqual(state["dynamicSummons"], [])
self.assertIn("dyn-b", state["tombstones"]["objects"])
def test_admin_can_violation_hide_any_object(self):
state = base_state()
result = apply_command(state, {"id": "mod", "admin": True}, {"type": "admin.hide_violation", "objectId": "obj-a"}, at=100)
self.assertTrue(result["accepted"])
self.assertTrue(apply_server_event(state, result["event"]))
self.assertEqual(state["placed"][0]["status"], VIOLATION_HIDDEN)
self.assertTrue(state["placed"][0]["permanentHidden"])
def test_deleted_object_cannot_be_resurrected_by_stale_upsert(self):
state = base_state()
delete = apply_command(state, {"id": "alice"}, {"type": "object.delete", "kind": "static", "objectId": "obj-a"}, at=100)
self.assertTrue(apply_server_event(state, delete["event"]))
stale = {
"serverEventId": "sev_90_object_upsert",
"serverAt": 90,
"actorAccountId": "alice",
"type": "object.upsert",
"kind": "static",
"object": {"id": "obj-a", "assetId": "asset-a", "ownerAccountId": "alice", "x": 7, "y": 7, "version": 1},
"objectVersion": 1,
}
self.assertFalse(apply_server_event(state, stale))
self.assertEqual(state["placed"], [])
def test_asset_delete_cascades_and_tombstones_owned_objects(self):
state = base_state()
allowed, _ = can_delete_asset(state, {"id": "alice"}, "asset-a")
self.assertTrue(allowed)
result = apply_command(state, {"id": "alice"}, {"type": "asset.delete", "assetId": "asset-a"}, at=100)
self.assertTrue(result["accepted"])
self.assertTrue(apply_server_event(state, result["event"]))
self.assertNotIn("asset-a", [asset["id"] for asset in state["assets"]])
self.assertEqual(state["placed"], [])
self.assertIn("asset-a", state["tombstones"]["assets"])
self.assertIn("obj-a", state["tombstones"]["objects"])
self.assertEqual(len(state["dynamicSummons"]), 1)
def test_shared_full_state_import_is_rejected(self):
state = base_state()
allowed, meta = can_import_full_state(state, {"id": "alice"})
self.assertFalse(allowed)
self.assertEqual(meta["reason"], "shared_full_import_disabled")
local_state = {**state, "worldMode": "local"}
allowed, _ = can_import_full_state(local_state, {"id": "alice"})
self.assertTrue(allowed)
if __name__ == "__main__":
unittest.main()

299
server/world_policy.py Normal file
View file

@ -0,0 +1,299 @@
#!/usr/bin/env python3
"""Authoritative world mutation policy for Pixel Island.
The browser may preview local changes, but shared worlds should apply only the
server events produced here. This module focuses on ownership and destructive
mutation safety; rotation and moderation policy remain separate.
"""
from __future__ import annotations
import time
from copy import deepcopy
from typing import Any, Dict, Iterable, List, MutableMapping, Optional, Tuple
ACTIVE = "active"
VIOLATION_HIDDEN = "violation_hidden"
OBJECT_KEYS = {"static": "placed", "dynamic": "dynamicSummons"}
def now_ms() -> int:
return int(time.time() * 1000)
def account_id(account: MutableMapping[str, Any] | None) -> str:
return str((account or {}).get("id") or "")
def is_admin(account: MutableMapping[str, Any] | None) -> bool:
return bool((account or {}).get("admin") or (account or {}).get("role") == "admin")
def normalize_tombstones(state: MutableMapping[str, Any]) -> MutableMapping[str, Any]:
tombstones = state.setdefault("tombstones", {})
tombstones.setdefault("assets", {})
tombstones.setdefault("objects", {})
return tombstones
def iter_objects(state: MutableMapping[str, Any]) -> Iterable[Tuple[str, MutableMapping[str, Any]]]:
for kind, key in OBJECT_KEYS.items():
for obj in state.get(key) or []:
if isinstance(obj, MutableMapping) and obj.get("id"):
yield kind, obj
def find_object(state: MutableMapping[str, Any], object_id: str, kind: Optional[str] = None) -> Tuple[Optional[str], Optional[MutableMapping[str, Any]]]:
kinds = [kind] if kind in OBJECT_KEYS else list(OBJECT_KEYS)
for item_kind in kinds:
for obj in state.get(OBJECT_KEYS[item_kind]) or []:
if str(obj.get("id") or "") == str(object_id):
return item_kind, obj
return None, None
def find_asset(state: MutableMapping[str, Any], asset_id: str) -> Optional[MutableMapping[str, Any]]:
for asset in state.get("assets") or []:
if isinstance(asset, MutableMapping) and str(asset.get("id") or "") == str(asset_id):
return asset
return None
def owner_of_object(state: MutableMapping[str, Any], obj: MutableMapping[str, Any]) -> str:
if obj.get("ownerAccountId"):
return str(obj.get("ownerAccountId"))
asset = find_asset(state, str(obj.get("assetId") or ""))
return str((asset or {}).get("ownerAccountId") or "")
def can_modify_object(state: MutableMapping[str, Any], account: MutableMapping[str, Any] | None, object_id: str) -> Tuple[bool, Dict[str, Any]]:
actor = account_id(account)
if not actor:
return False, {"reason": "account_required"}
kind, obj = find_object(state, object_id)
if not obj:
return False, {"reason": "object_not_found"}
owner = owner_of_object(state, obj)
if actor == owner or is_admin(account):
return True, {"reason": None, "kind": kind, "ownerAccountId": owner}
return False, {"reason": "not_object_owner", "ownerAccountId": owner}
def can_delete_asset(state: MutableMapping[str, Any], account: MutableMapping[str, Any] | None, asset_id: str) -> Tuple[bool, Dict[str, Any]]:
actor = account_id(account)
if not actor:
return False, {"reason": "account_required"}
asset = find_asset(state, asset_id)
if not asset:
return False, {"reason": "asset_not_found"}
owner = str(asset.get("ownerAccountId") or "")
if actor == owner or is_admin(account):
return True, {"reason": None, "ownerAccountId": owner}
return False, {"reason": "not_asset_owner", "ownerAccountId": owner}
def is_object_tombstoned(state: MutableMapping[str, Any], object_id: str, version: int = 0) -> bool:
tomb = normalize_tombstones(state)["objects"].get(str(object_id))
return bool(tomb and int(tomb.get("version") or 0) >= int(version or 0))
def is_asset_tombstoned(state: MutableMapping[str, Any], asset_id: str, version: int = 0) -> bool:
tomb = normalize_tombstones(state)["assets"].get(str(asset_id))
return bool(tomb and int(tomb.get("version") or 0) >= int(version or 0))
def _server_event(event_type: str, actor: str, payload: Dict[str, Any], at: Optional[int] = None) -> Dict[str, Any]:
stamp = at or now_ms()
return {
"serverEventId": f"sev_{stamp}_{event_type.replace('.', '_')}",
"serverAt": stamp,
"actorAccountId": actor,
"type": event_type,
**payload,
}
def _accepted(event: Dict[str, Any]) -> Dict[str, Any]:
return {"accepted": True, "event": event}
def _rejected(reason: str, **extra: Any) -> Dict[str, Any]:
return {"accepted": False, "reason": reason, **extra}
def _copy_owned_asset(asset: MutableMapping[str, Any], owner: str) -> Dict[str, Any]:
copied = deepcopy(dict(asset))
copied["ownerAccountId"] = owner
copied.setdefault("author", owner)
copied.setdefault("version", 1)
return copied
def _copy_owned_object(obj: MutableMapping[str, Any], owner: str) -> Dict[str, Any]:
copied = deepcopy(dict(obj))
copied["ownerAccountId"] = owner
copied["version"] = int(copied.get("version") or 0) + 1
copied.setdefault("status", ACTIVE)
return copied
def apply_command(state: MutableMapping[str, Any], account: MutableMapping[str, Any] | None, command: MutableMapping[str, Any], at: Optional[int] = None) -> Dict[str, Any]:
"""Validate a client command and return a server event without mutating state."""
actor = account_id(account)
if not actor:
return _rejected("account_required")
command_type = str(command.get("type") or "")
if command_type == "asset.create":
asset = command.get("asset")
if not isinstance(asset, MutableMapping) or not asset.get("id"):
return _rejected("invalid_asset")
if is_asset_tombstoned(state, str(asset["id"]), int(asset.get("version") or 1)):
return _rejected("asset_tombstoned")
return _accepted(_server_event("asset.upsert", actor, {"asset": _copy_owned_asset(asset, actor)}, at))
if command_type in {"object.publish", "object.move"}:
kind = str(command.get("kind") or "static")
obj = command.get("object")
if kind not in OBJECT_KEYS or not isinstance(obj, MutableMapping) or not obj.get("id"):
return _rejected("invalid_object")
asset = find_asset(state, str(obj.get("assetId") or ""))
if not asset:
return _rejected("asset_not_found")
_, existing = find_object(state, str(obj["id"]), kind)
if existing:
allowed, meta = can_modify_object(state, account, str(obj["id"]))
if not allowed:
return _rejected(meta["reason"], **meta)
owner = owner_of_object(state, existing)
else:
if str(asset.get("ownerAccountId") or "") != actor and not is_admin(account):
return _rejected("not_asset_owner", ownerAccountId=asset.get("ownerAccountId"))
owner = actor
next_object = _copy_owned_object(obj, owner)
if is_object_tombstoned(state, str(next_object["id"]), int(next_object.get("version") or 1)):
return _rejected("object_tombstoned")
return _accepted(_server_event("object.upsert", actor, {"kind": kind, "object": next_object, "objectVersion": next_object["version"]}, at))
if command_type == "object.delete":
object_id = str(command.get("objectId") or "")
kind = str(command.get("kind") or "")
allowed, meta = can_modify_object(state, account, object_id)
if not allowed:
return _rejected(meta["reason"], **meta)
found_kind, obj = find_object(state, object_id, kind if kind in OBJECT_KEYS else None)
version = int((obj or {}).get("version") or 0) + 1
tombstone = {"id": object_id, "deletedAt": at or now_ms(), "deletedBy": actor, "version": version}
return _accepted(_server_event("object.delete", actor, {"kind": found_kind, "objectId": object_id, "objectVersion": version, "tombstone": tombstone}, at))
if command_type == "asset.delete":
asset_id = str(command.get("assetId") or "")
allowed, meta = can_delete_asset(state, account, asset_id)
if not allowed:
return _rejected(meta["reason"], **meta)
asset = find_asset(state, asset_id) or {}
version = int(asset.get("version") or 0) + 1
object_tombstones: List[Dict[str, Any]] = []
for _, obj in iter_objects(state):
if str(obj.get("assetId") or "") == asset_id and (is_admin(account) or owner_of_object(state, obj) == actor):
object_tombstones.append({"id": str(obj["id"]), "deletedAt": at or now_ms(), "deletedBy": actor, "version": int(obj.get("version") or 0) + 1})
tombstone = {"id": asset_id, "deletedAt": at or now_ms(), "deletedBy": actor, "version": version}
return _accepted(_server_event("asset.delete", actor, {"assetId": asset_id, "assetVersion": version, "tombstone": tombstone, "objectTombstones": object_tombstones}, at))
if command_type == "admin.hide_violation":
if not is_admin(account):
return _rejected("admin_required")
object_id = str(command.get("objectId") or "")
kind, obj = find_object(state, object_id)
if not obj:
return _rejected("object_not_found")
next_object = deepcopy(dict(obj))
next_object["status"] = VIOLATION_HIDDEN
next_object["permanentHidden"] = True
next_object["hiddenReason"] = "moderation_violation"
next_object["version"] = int(next_object.get("version") or 0) + 1
return _accepted(_server_event("object.upsert", actor, {"kind": kind, "object": next_object, "objectVersion": next_object["version"]}, at))
return _rejected("unknown_command")
def apply_server_event(state: MutableMapping[str, Any], event: MutableMapping[str, Any]) -> bool:
"""Apply only server-issued events. Returns True when state changed."""
if not event or not event.get("serverEventId") or not event.get("serverAt"):
return False
normalize_tombstones(state)
event_type = str(event.get("type") or "")
if event_type == "asset.upsert":
asset = event.get("asset")
if not isinstance(asset, MutableMapping) or not asset.get("id"):
return False
if is_asset_tombstoned(state, str(asset["id"]), int(asset.get("version") or 1)):
return False
assets = state.setdefault("assets", [])
index = next((i for i, item in enumerate(assets) if item.get("id") == asset.get("id")), -1)
if index >= 0:
assets[index] = deepcopy(dict(asset))
else:
assets.insert(0, deepcopy(dict(asset)))
state.setdefault("serverSync", {})["lastServerEventId"] = event["serverEventId"]
return True
if event_type == "object.upsert":
obj = event.get("object")
kind = str(event.get("kind") or "static")
if kind not in OBJECT_KEYS or not isinstance(obj, MutableMapping) or not obj.get("id"):
return False
if is_object_tombstoned(state, str(obj["id"]), int(obj.get("version") or event.get("objectVersion") or 1)):
return False
rows = state.setdefault(OBJECT_KEYS[kind], [])
index = next((i for i, item in enumerate(rows) if item.get("id") == obj.get("id")), -1)
if index >= 0:
rows[index] = deepcopy(dict(obj))
else:
rows.append(deepcopy(dict(obj)))
state.setdefault("serverSync", {})["lastServerEventId"] = event["serverEventId"]
return True
if event_type == "object.delete":
object_id = str(event.get("objectId") or "")
kind = str(event.get("kind") or "")
if not object_id or kind not in OBJECT_KEYS:
return False
tombstone = event.get("tombstone") or {"id": object_id, "deletedAt": event["serverAt"], "deletedBy": event.get("actorAccountId"), "version": event.get("objectVersion") or 1}
state["tombstones"]["objects"][object_id] = tombstone
state[OBJECT_KEYS[kind]] = [obj for obj in state.get(OBJECT_KEYS[kind]) or [] if str(obj.get("id") or "") != object_id]
state.setdefault("objectVotes", {}).pop(object_id, None)
state.setdefault("hiddenObjects", {}).pop(object_id, None)
state["moderationReports"] = [report for report in state.get("moderationReports") or [] if report.get("objectId") != object_id]
state.setdefault("serverSync", {})["lastServerEventId"] = event["serverEventId"]
return True
if event_type == "asset.delete":
asset_id = str(event.get("assetId") or "")
if not asset_id:
return False
state["tombstones"]["assets"][asset_id] = event.get("tombstone") or {"id": asset_id, "deletedAt": event["serverAt"], "deletedBy": event.get("actorAccountId"), "version": event.get("assetVersion") or 1}
for tombstone in event.get("objectTombstones") or []:
if tombstone.get("id"):
state["tombstones"]["objects"][str(tombstone["id"])] = tombstone
removed_object_ids = {str(t.get("id")) for t in event.get("objectTombstones") or [] if t.get("id")}
state["assets"] = [asset for asset in state.get("assets") or [] if str(asset.get("id") or "") != asset_id]
for key in OBJECT_KEYS.values():
state[key] = [obj for obj in state.get(key) or [] if str(obj.get("id") or "") not in removed_object_ids]
state.setdefault("assetVotes", {}).pop(asset_id, None)
state.setdefault("hiddenAssets", {}).pop(asset_id, None)
for object_id in removed_object_ids:
state.setdefault("objectVotes", {}).pop(object_id, None)
state.setdefault("hiddenObjects", {}).pop(object_id, None)
state["moderationReports"] = [report for report in state.get("moderationReports") or [] if report.get("objectId") not in removed_object_ids]
state.setdefault("serverSync", {})["lastServerEventId"] = event["serverEventId"]
return True
return False
def can_import_full_state(state: MutableMapping[str, Any], account: MutableMapping[str, Any] | None) -> Tuple[bool, Dict[str, Any]]:
if state.get("worldMode") == "shared":
return False, {"reason": "shared_full_import_disabled"}
return True, {"reason": None}

View file

@ -1313,9 +1313,9 @@ body, button, input, select, textarea { font-size: 15px; }
.islandClickHint { bottom: 96px !important; }
.selectionBubble {
position: absolute !important;
left: 0 !important;
top: 0 !important;
position: fixed !important;
left: var(--bubble-left, 0px) !important;
top: var(--bubble-top, 0px) !important;
z-index: 36 !important;
min-width: 138px !important;
max-width: 224px !important;
@ -1484,12 +1484,415 @@ body, button, input, select, textarea { font-size: 15px; }
.selectionBubble {
position: fixed !important;
z-index: 50 !important;
pointer-events: auto !important;
}
.selectionBubble.belowSprite::after { display: none !important; }
.analogClock {
top: 218px;
right: 30px;
top: 14px;
right: 18px;
}
@media (max-width: 760px) {
.analogClock { top: 176px; right: 12px; transform: scale(.82); transform-origin: top right; }
.analogClock { top: 8px; right: 10px; transform: scale(.72); transform-origin: top right; }
}
/* Settings tab alignment */
#tab-settings .card {
text-align: center;
}
#tab-settings .hint {
margin-left: auto;
margin-right: auto;
max-width: 34ch;
}
#tab-settings .toggleList {
align-items: center;
}
#tab-settings .checkRow,
#tab-settings .displayLimitField {
justify-content: center;
text-align: center;
}
#tab-settings .displayLimitField input {
margin-left: auto;
margin-right: auto;
}
.tabs .tab[data-tab="settings"] {
justify-content: center;
align-items: center;
text-align: center;
}
/* v17 requested UI tightening */
.drawQuotaBadge {
position: fixed;
z-index: 27;
left: calc(50% + 154px);
bottom: 34px;
transform: translateX(-50%);
}
.drawDockButton[hidden] + .drawQuotaBadge { display: none !important; }
.quotaBadge {
display: inline-grid;
place-items: center;
min-height: 28px;
padding: 5px 10px;
border: 2px solid rgba(36,48,68,.72);
border-radius: 999px;
background: #fff8e4;
box-shadow: 3px 3px 0 rgba(36,48,68,.16);
color: #253044;
font-size: 11px;
font-weight: 950;
line-height: 1;
white-space: nowrap;
}
.quotaBadge.quotaEmpty { background: #ffe2e7; }
.finishActions {
grid-template-columns: minmax(0, 1.3fr) auto minmax(0, .9fr) !important;
align-items: center;
}
.finishQuotaBadge { justify-self: center; }
.dimensionField {
grid-template-columns: 58px minmax(0, 1fr) !important;
}
.drawerHead {
padding: 6px 10px 5px !important;
}
.drawerHead h1 { font-size: 20px !important; }
.drawerHead p { margin-top: 1px !important; font-size: 11px !important; }
.tabs { padding: 5px 7px !important; gap: 5px !important; }
.tab { padding: 6px 4px !important; }
.drawerBody { padding: 6px !important; }
.card.editorCard.heroEditor { padding-top: 7px !important; }
.quickSetupBar.twoCols {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px 8px !important;
margin-bottom: 4px !important;
}
.compactNameField.inlineField { margin-top: 4px !important; }
.compactEditorTop { margin-bottom: 2px !important; }
.paintLayout {
gap: 6px !important;
margin: 2px 0 6px !important;
grid-template-columns: minmax(220px, 360px) minmax(150px, 1fr) !important;
}
.palettePanel { gap: 4px !important; }
.paletteGrid {
grid-template-columns: repeat(5, minmax(28px, 1fr)) !important;
gap: 3px !important;
padding: 4px !important;
max-height: 360px;
}
.paletteSwatch, .paletteGrid button {
min-height: 28px !important;
}
.paletteSwatch::after { font-size: 8px !important; }
#tab-settings .card {
display: grid;
justify-items: center;
text-align: center;
}
#tab-settings .toggleList {
width: min(100%, 340px);
display: grid;
justify-items: center;
}
#tab-settings .checkRow,
#tab-settings .displayLimitField {
width: 100%;
display: flex;
justify-content: center;
text-align: center;
}
@media (max-width: 760px) {
.drawQuotaBadge {
left: 50%;
bottom: 78px;
transform: translateX(-50%);
}
.finishActions { grid-template-columns: 1fr !important; }
.quickSetupBar.twoCols { grid-template-columns: 1fr 1fr !important; }
.paintLayout { grid-template-columns: 1fr !important; }
.paletteGrid { grid-template-columns: repeat(8, minmax(26px, 1fr)) !important; }
}
/* v18 requested UI + palette corrections */
.drawDockButton,
#saveAndPlace.bigPrimary {
display: inline-flex !important;
align-items: center;
justify-content: center;
gap: 8px;
white-space: nowrap;
}
.drawDockButton { min-width: 176px; }
.drawQuotaBadge,
.finishQuotaBadge {
position: static !important;
transform: none !important;
justify-self: auto !important;
display: inline-grid !important;
min-height: 20px !important;
padding: 3px 6px !important;
font-size: 9px !important;
box-shadow: none !important;
border-width: 1px !important;
background: rgba(255, 248, 228, .92) !important;
}
.drawDockButton[hidden] .drawQuotaBadge { display: none !important; }
.finishActions {
grid-template-columns: minmax(0, 1.4fr) minmax(0, .9fr) !important;
}
.paletteGrid {
grid-template-columns: repeat(10, minmax(18px, 1fr)) !important;
gap: 2px !important;
padding: 3px !important;
max-height: none !important;
overflow: visible !important;
}
.paletteSwatch,
.paletteGrid button {
min-height: 24px !important;
aspect-ratio: 1 / 1;
}
.paletteSwatch::after { font-size: 7px !important; right: 1px !important; }
@media (max-width: 760px) {
.drawDockButton { min-width: 156px; }
.finishActions { grid-template-columns: 1fr !important; }
.paletteGrid { grid-template-columns: repeat(10, minmax(20px, 1fr)) !important; }
}
/* v19 requested create-layout cleanup */
.studioDrawer {
width: min(1060px, calc(100vw - 24px)) !important;
}
.card.editorCard.heroEditor {
padding: 10px 10px 12px !important;
}
.creationMetaRow {
display: grid;
grid-template-columns: minmax(230px, 1.55fr) 96px 24px 96px minmax(160px, .75fr);
align-items: center;
gap: 12px;
margin: 2px 0 12px;
}
.creationMetaRow .metaInput,
.creationMetaRow select.metaInput {
height: 64px;
margin: 0 !important;
border: 3px solid var(--line);
background: #fffaf0;
color: var(--ink);
font-size: 24px;
font-weight: 850;
letter-spacing: .04em;
padding: 10px 18px;
box-sizing: border-box;
}
.creationMetaRow .metaDimension {
text-align: center;
padding-left: 8px;
padding-right: 8px;
}
.creationMetaRow .metaRole {
text-align: center;
text-align-last: center;
}
.creationMetaRow .dimensionSeparator {
display: grid;
place-items: center;
color: #e33a4d;
font-size: 25px;
font-weight: 900;
line-height: 1;
}
.creationMetaRow input::placeholder {
color: rgba(91, 105, 132, .64);
font-weight: 520;
opacity: 1;
}
.creationMetaRow select:invalid,
.creationMetaRow select.placeholderRole {
color: rgba(91, 105, 132, .64);
font-weight: 520;
}
.compactEditorTop {
display: none !important;
}
.paintLayout {
display: grid !important;
grid-template-columns: minmax(420px, 2.2fr) minmax(250px, 340px) !important;
gap: 24px !important;
align-items: start !important;
margin: 0 0 12px !important;
}
#paintCanvas {
width: 100% !important;
max-width: 650px !important;
aspect-ratio: 1 / 1 !important;
justify-self: stretch !important;
border-width: 4px !important;
}
.palettePanel {
width: 100% !important;
align-self: start !important;
overflow: hidden !important;
}
.paletteGrid {
width: 100% !important;
box-sizing: border-box !important;
display: grid !important;
grid-template-columns: repeat(10, minmax(0, 1fr)) !important;
gap: 3px !important;
padding: 6px !important;
margin: 0 !important;
border: 3px solid rgba(36,48,68,.34) !important;
background: #fff6df !important;
overflow: hidden !important;
align-content: start !important;
}
.paletteSwatch,
.paletteGrid button {
min-width: 0 !important;
min-height: 0 !important;
width: 100% !important;
aspect-ratio: 1 / 1 !important;
box-sizing: border-box !important;
padding: 0 !important;
}
.paletteSwatch::after {
font-size: 6px !important;
right: 1px !important;
bottom: -1px !important;
}
.editorToolRow,
.editorHistoryRow {
max-width: 650px;
}
.advancedRow,
#editHint {
max-width: 650px;
}
@media (max-width: 820px) {
.studioDrawer { width: min(100vw - 16px, 680px) !important; }
.creationMetaRow {
grid-template-columns: 1fr 70px 18px 70px;
}
.creationMetaRow .metaRole {
grid-column: 1 / -1;
}
.creationMetaRow .metaInput,
.creationMetaRow select.metaInput {
height: 52px;
font-size: 19px;
}
.paintLayout { grid-template-columns: 1fr !important; gap: 10px !important; }
.paletteGrid { grid-template-columns: repeat(10, minmax(0, 1fr)) !important; }
.editorToolRow,
.editorHistoryRow,
.advancedRow,
#editHint { max-width: none; }
}
/* v20 requested refinements */
.studioDrawer {
width: min(424px, calc(100vw - 20px)) !important;
}
.creationMetaRow {
grid-template-columns: minmax(0, 1fr) 62px 14px 62px !important;
gap: 7px !important;
margin-bottom: 8px !important;
}
.creationMetaRow .metaRole {
grid-column: 1 / -1 !important;
}
.creationMetaRow .metaInput,
.creationMetaRow select.metaInput {
height: 44px !important;
font-size: 16px !important;
padding: 7px 10px !important;
border-width: 2px !important;
}
.creationMetaRow .dimensionSeparator {
font-size: 18px !important;
}
.paintLayout {
grid-template-columns: 1fr !important;
gap: 8px !important;
}
#paintCanvas {
max-width: 100% !important;
}
.palettePanel {
max-width: 100% !important;
}
.editorToolRow,
.editorHistoryRow,
.advancedRow,
#editHint {
max-width: none !important;
}
.drawDockButton .quotaBadge,
.drawQuotaBadge {
background: rgba(255, 126, 169, .34) !important;
border-color: rgba(36, 48, 68, .62) !important;
color: #253044 !important;
}
#saveAndPlace .quotaBadge,
.finishQuotaBadge {
background: rgba(63, 158, 103, .24) !important;
border-color: rgba(22, 58, 42, .48) !important;
color: #163a2a !important;
}
.quotaBadge.quotaEmpty {
background: rgba(255, 98, 118, .34) !important;
color: #5d1f2b !important;
}
.bubbleActions {
grid-template-columns: 1fr !important;
}
.bubbleActions button,
.bubbleRemixPrimary,
#bubbleRemix,
#bubbleTeleport {
width: 100% !important;
justify-self: stretch !important;
}
@media (max-width: 760px) {
.studioDrawer { width: min(424px, calc(100vw - 12px)) !important; }
.creationMetaRow { grid-template-columns: minmax(0, 1fr) 58px 12px 58px !important; }
}
/* v21 correction: keep palette to the right of the canvas on desktop.
The previous left-drawer shrink made the editor too narrow and forced the palette below. */
@media (min-width: 761px) {
.studioDrawer {
width: min(960px, calc(100vw - 20px)) !important;
}
.paintLayout {
display: grid !important;
grid-template-columns: minmax(520px, 1fr) minmax(238px, 292px) !important;
gap: 14px !important;
align-items: start !important;
}
#paintCanvas {
width: 100% !important;
max-width: 640px !important;
justify-self: stretch !important;
}
.palettePanel {
width: 100% !important;
max-width: 292px !important;
justify-self: stretch !important;
}
.paletteGrid {
grid-template-columns: repeat(10, minmax(0, 1fr)) !important;
}
.editorToolRow,
.editorHistoryRow,
.advancedRow,
#editHint {
max-width: 640px !important;
}
}