updated
This commit is contained in:
parent
94571943cf
commit
8ee6a87d44
7 changed files with 2317 additions and 560 deletions
50
index.html
50
index.html
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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
131
server/test_world_policy.py
Normal 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
299
server/world_policy.py
Normal 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}
|
||||
415
styles.css
415
styles.css
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue