diff --git a/README.md b/README.md index 10f2337..1e0dc74 100644 --- a/README.md +++ b/README.md @@ -114,3 +114,76 @@ Open `index.html` in a modern browser. No build step or third-party dependency i - Coast foam uses two slow-rotating porous bluish-white layers. - Depth now affects phase-aware sprite shading for sun/moon exposure. - Added toggles for lights, particles, and the day-night cycle. + + +## Phase 5 UI / particle follow-up +- Coastal foam no longer uses cut-out holes. +- Walking particles are less frequent and use more visibly varied colors. +- Remix always creates a new derivative asset; Edit updates the user's own original asset. +- Advanced Draw now includes configurable particle emitter cells for any asset. +- PNG export/import moved to the Data tab. +- Nudge arrow buttons under the canvas were removed; keyboard arrow nudging remains. +- UI text is larger overall, while palette color-code labels keep their small size. + +## Rotation policy build + +This build adds count-based island rotation. + +- Default island display cap: 250 objects. +- The local display cap is adjustable from Data > Visual settings. +- Objects are not deleted when the cap is exceeded; the effective oldest objects are omitted from the island display. +- Upvotes delay rotation-out by slot adjustment. Downvotes advance rotation-out. +- Local publish / republish quota: 5 objects per author per rolling hour. +- Replacing or republishing a rotation-hidden object updates its `publishedAt` and consumes one quota slot. + +Server-side periodic rotation code is in `server/rotation_worker.py`. + +Example: + +```bash +python server/rotation_worker.py world.json --write +``` + +It performs: + +1. Permanent-hide marking for extremely downvoted objects. +2. Randomly samples 50 hidden historical objects. +3. Republishes the top 20 by upvote count. +4. Reapplies the active display cap. +5. Adds permanent-hidden objects to `adminReviewQueue` for deletion checks. + + +## Local-only rotation note +This build does not call a server from the browser. The island display cap is applied client-side, and objects over the cap are kept in local save but not drawn. Server recirculation / permanent hiding only happens when `server/rotation_worker.py` is run against exported world JSON. + +## Exhibition Policy Revision + +This build separates **Asset** and **PlacementObject** more explicitly. + +- Asset: a durable library work. Remix only copies pixels and lineage into the canvas. +- PlacementObject: a temporary island exhibition object. It can rotate out while the Asset remains in Library. +- Edit has been removed. To revise a work, remix/copy it, save a new Asset, then delete the old one if desired. +- Publishing requires a prototype local account. The first 24 hours allow 5 public placements/hour; day 2+ allows 10/hour. +- The island exhibition has 250 visible slots: 150 newest slots and 100 random revival slots. +- Upvote rank effect is capped at +50 votes. +- Capacity rotation and extreme downvote rotation use the same user-facing explanation: the island exhibition is full, but the work remains in Library. +- Server moderation can mark `permanent_hidden` / `violation_hidden`; these are hidden from authors and viewers. Admins can restore them with the Python worker. +- Particles are disabled offscreen/when zoomed out and capped at 200 active particles. + +### Server worker + +```bash +python server/rotation_worker.py world.json --write +python server/rotation_worker.py world.json --restore object123 --write +python server/rotation_worker.py world.json --hide-violation object123 --write +``` + +The Python worker is the authoritative production policy. The browser mirrors it only for local prototype use. + +### Compression lab + +```bash +python server/compression_lab.py exported_world.json +``` + +Use this to compare minified JSON, gzip, zlib, and Brotli when available. Production should use the compact asset codec plus HTTP gzip/Brotli rather than hand-rolled custom compression first. diff --git a/app.js b/app.js index 35a64b6..e42deda 100644 --- a/app.js +++ b/app.js @@ -3,8 +3,8 @@ console.info('Pixel Island Summoner loaded'); - const STORAGE_KEY = 'pixel-island-summoner:phase5c'; - const SAVE_SCHEMA = 9; + const STORAGE_KEY = 'pixel-island-summoner:phase5d'; + const SAVE_SCHEMA = 10; const WORLD_W = 144; const WORLD_H = 112; const TILE_W = 32; @@ -28,7 +28,7 @@ const EditorActions = MODULES.EditorActions || null; const Phase2Sync = MODULES.Phase2Sync || null; const EDITOR_HISTORY_LIMIT = 80; - const PHASE5_GUARDRAILS = { maxAssets: 160, maxWorldObjects: 220, maxReports: 200 }; + const PHASE5_GUARDRAILS = { maxAssets: 220, maxWorldObjects: 1000, maxReports: 200, defaultDisplayLimit: 250, newArrivalSlots: 150, revivalSlots: 100, publishLimitFirstDay: 5, publishLimitTrusted: 10, upvoteDelaySlots: 20, downvoteAdvanceSlots: 25, upvoteRankCap: 50, maxParticles: 200, particleMinZoom: 0.72 }; const $ = (id) => document.getElementById(id); const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); @@ -37,7 +37,7 @@ const lerp = (a, b, t) => a + (b - a) * t; function defaultVisualSettings() { - return { enableLights: true, enableParticles: true, enableDayNight: true }; + return { enableLights: true, enableParticles: true, enableDayNight: true, localDisplayLimit: PHASE5_GUARDRAILS.defaultDisplayLimit }; } function visualSettings() { @@ -50,6 +50,7 @@ if (els.settingLights) els.settingLights.checked = settings.enableLights !== false; if (els.settingParticles) els.settingParticles.checked = settings.enableParticles !== false; if (els.settingDayNight) els.settingDayNight.checked = settings.enableDayNight !== false; + if (els.displayLimit) els.displayLimit.value = String(getLocalDisplayLimit()); } function clearVisualEffectState() { @@ -89,6 +90,7 @@ canvas: $('worldCanvas'), openEditor: $('openEditor'), closeEditor: $('closeEditor'), + placementPreviewBar: $('placementPreviewBar'), confirmPreviewPlace: $('confirmPreviewPlace'), backToCanvas: $('backToCanvas'), createAccount: $('createAccount'), accountNote: $('accountNote'), drawer: $('studioDrawer'), tabs: [...document.querySelectorAll('.tab')], panels: [...document.querySelectorAll('.tabPanel')], @@ -98,7 +100,7 @@ selectedAssetName: $('selectedAssetName'), tileInfo: $('tileInfo'), toast: $('toast'), - selectionBubble: $('selectionBubble'), bubbleName: $('bubbleName'), bubbleAuthor: $('bubbleAuthor'), bubbleRemixFrom: $('bubbleRemixFrom'), bubbleRemixCount: $('bubbleRemixCount'), voteScore: $('voteScore'), voteUp: $('voteUp'), voteDown: $('voteDown'), bubbleRemix: $('bubbleRemix'), bubbleReport: $('bubbleReport'), bubbleHide: $('bubbleHide'), + selectionBubble: $('selectionBubble'), bubbleName: $('bubbleName'), bubbleAuthor: $('bubbleAuthor'), bubbleRemixFrom: $('bubbleRemixFrom'), bubbleRemixCount: $('bubbleRemixCount'), voteScore: $('voteScore'), voteUp: $('voteUp'), voteDown: $('voteDown'), bubbleRemix: $('bubbleRemix'), bubbleEdit: $('bubbleEdit'), bubbleReport: $('bubbleReport'), bubbleHide: $('bubbleHide'), modeInspect: $('modeInspect'), modePlace: $('modePlace'), modeErase: $('modeErase'), drawerInspect: $('drawerInspect'), drawerPlace: $('drawerPlace'), drawerErase: $('drawerErase'), @@ -114,14 +116,14 @@ sideSwitcher: $('sideSwitcher'), editRight: $('editRight'), editLeft: $('editLeft'), paintColor: $('paintColor'), toolBrush: $('toolBrush'), toolErase: $('toolErase'), toolFill: $('toolFill'), toolPick: $('toolPick'), toolLine: $('toolLine'), toolRect: $('toolRect'), toolSelect: $('toolSelect'), undoPaint: $('undoPaint'), redoPaint: $('redoPaint'), - toolLight: $('toolLight'), toolDoor: $('toolDoor'), toolDepth: $('toolDepth'), depthHigh: $('depthHigh'), depthLow: $('depthLow'), depthClear: $('depthClear'), toggleAdvanced: $('toggleAdvanced'), advancedHint: $('advancedHint'), clearPaint: $('clearPaint'), flipHorizontal: $('flipHorizontal'), flipVertical: $('flipVertical'), outlinePaint: $('outlinePaint'), clearSelection: $('clearSelection'), nudgeLeft: $('nudgeLeft'), nudgeRight: $('nudgeRight'), nudgeUp: $('nudgeUp'), nudgeDown: $('nudgeDown'), exportPng: $('exportPng'), importPng: $('importPng'), pngImportInput: $('pngImportInput'), + toolLight: $('toolLight'), toolParticle: $('toolParticle'), toolDoor: $('toolDoor'), toolDepth: $('toolDepth'), depthHigh: $('depthHigh'), depthLow: $('depthLow'), depthClear: $('depthClear'), toggleAdvanced: $('toggleAdvanced'), advancedHint: $('advancedHint'), clearPaint: $('clearPaint'), flipHorizontal: $('flipHorizontal'), flipVertical: $('flipVertical'), outlinePaint: $('outlinePaint'), clearSelection: $('clearSelection'), nudgeLeft: $('nudgeLeft'), nudgeRight: $('nudgeRight'), nudgeUp: $('nudgeUp'), nudgeDown: $('nudgeDown'), exportPng: $('exportPng'), importPng: $('importPng'), pngImportInput: $('pngImportInput'), paintCanvas: $('paintCanvas'), editHint: $('editHint'), paletteGrid: $('paletteGrid'), lightColor: $('lightColor'), staticSettingsPanel: $('staticSettingsPanel'), dynamicSettingsPanel: $('dynamicSettingsPanel'), doorMarkerHint: $('doorMarkerHint'), - settingsSummary: $('settingsSummary'), saveAsset: $('saveAsset'), saveAndPlace: $('saveAndPlace'), newAsset: $('newAsset'), - lineageNote: $('lineageNote'), assetList: $('assetList'), showHiddenAssets: $('showHiddenAssets'), hiddenAssetPanel: $('hiddenAssetPanel'), hiddenAssetList: $('hiddenAssetList'), + settingsSummary: $('settingsSummary'), saveAsset: $('saveAsset'), saveAndPlace: $('saveAndPlace'), checkOnIsland: $('checkOnIsland'), newAsset: $('newAsset'), + lineageNote: $('lineageNote'), assetList: $('assetList'), likedCodex: $('likedCodex'), showHiddenAssets: $('showHiddenAssets'), hiddenAssetPanel: $('hiddenAssetPanel'), hiddenAssetList: $('hiddenAssetList'), exportData: $('exportData'), importData: $('importData'), resetAll: $('resetAll'), dataBox: $('dataBox'), exportCompact: $('exportCompact'), exportSnapshot: $('exportSnapshot'), exportAssetBundle: $('exportAssetBundle'), syncStats: $('syncStats'), guardrailStats: $('guardrailStats'), validateWorld: $('validateWorld'), exportModerationReport: $('exportModerationReport'), clearReports: $('clearReports'), - settingLights: $('settingLights'), settingParticles: $('settingParticles'), settingDayNight: $('settingDayNight'), + settingLights: $('settingLights'), settingParticles: $('settingParticles'), settingDayNight: $('settingDayNight'), displayLimit: $('displayLimit'), rotationStats: $('rotationStats'), reportDialog: $('reportDialog'), reportReason: $('reportReason'), reportCancel: $('reportCancel'), reportSubmit: $('reportSubmit'), reportObjectName: $('reportObjectName') }; @@ -164,6 +166,7 @@ let staticKind = 'nature'; let dynamicKind = 'human'; let lightPixels = []; + let particlePixels = []; let doorPixel = { x: 8, y: 15 }; let editParentId = null; let editOriginalId = null; @@ -196,6 +199,7 @@ let selectionGesture = null; let shapeGesture = null; let shapePreview = null; + let placementPreview = null; function bootstrap() { resizeCanvas(); @@ -205,6 +209,7 @@ wireUI(); renderPalette(); hydrateAuthorUI(); + updateAccountUI(); refreshCategoryUI(); setupEditor(8, blankPixels(8), null); clearEditorHistory(); @@ -214,6 +219,7 @@ updateSyncStats(); updateGuardrailStats(); hydrateVisualSettingsUI(); + updateRotationStats(); scheduleFrame(); } @@ -232,10 +238,16 @@ els.authorName?.addEventListener('input', () => { state.authorName = (els.authorName.value || 'Local Artist').trim() || 'Local Artist'; + if (state.account) state.account.name = state.authorName; saveState(); + updateAccountUI(); renderLibrary(); }); + els.createAccount?.addEventListener('click', () => createLocalAccount()); + els.confirmPreviewPlace?.addEventListener('click', confirmPreviewPlacement); + els.backToCanvas?.addEventListener('click', cancelPlacementPreview); + els.settingLights?.addEventListener('change', () => { visualSettings().enableLights = !!els.settingLights.checked; saveState(); @@ -255,6 +267,15 @@ render(); }); + els.displayLimit?.addEventListener('change', () => { + const value = clampInt(els.displayLimit.value, 25, 500, PHASE5_GUARDRAILS.defaultDisplayLimit); + visualSettings().localDisplayLimit = value; + els.displayLimit.value = String(value); + saveState(); + renderLibrary(); + render(); + }); + [els.modeInspect, els.drawerInspect].filter(Boolean).forEach((el) => el.addEventListener('click', () => setMode('inspect'))); [els.modePlace, els.drawerPlace].filter(Boolean).forEach((el) => el.addEventListener('click', () => setMode('place'))); [els.modeErase, els.drawerErase].filter(Boolean).forEach((el) => el.addEventListener('click', () => setMode('erase'))); @@ -280,6 +301,7 @@ setupEditor(nextSize, resizedRight, null, resizedDepth); doorPixel = { x: Math.min(doorPixel.x, nextSize - 1), y: Math.min(doorPixel.y, nextSize - 1) }; lightPixels = lightPixels.filter((p) => p.x < nextSize && p.y < nextSize); + particlePixels = particlePixels.filter((p) => p.x < nextSize && p.y < nextSize); resetEditorView(); return true; }); @@ -321,13 +343,14 @@ els.toggleAdvanced?.addEventListener('click', () => { advancedDraw = !advancedDraw; toggleHidden(els.toolLight, !advancedDraw); + toggleHidden(els.toolParticle, !advancedDraw); toggleHidden(els.toolDepth, !advancedDraw); toggleHidden(els.depthHigh, !advancedDraw); toggleHidden(els.depthLow, !advancedDraw); toggleHidden(els.depthClear, !advancedDraw); toggleHidden(els.advancedHint, !advancedDraw); els.toggleAdvanced.classList.toggle('active', advancedDraw); - if (!advancedDraw && (paintTool === 'depth' || paintTool === 'light')) setPaintTool('brush'); + if (!advancedDraw && (paintTool === 'depth' || paintTool === 'light' || paintTool === 'particle')) setPaintTool('brush'); drawEditor(); }); els.clearPaint.addEventListener('click', () => { @@ -336,6 +359,7 @@ editorLeftPixels = mirrorPixels(editorPixels, editorSize); depthPixels = blankPixels(editorSize).map(() => 0); lightPixels = []; + particlePixels = []; editorSelection = null; return true; }); @@ -357,7 +381,7 @@ els.voteUp?.addEventListener('click', () => voteSelected(1)); els.voteDown?.addEventListener('click', () => voteSelected(-1)); els.bubbleRemix?.addEventListener('click', () => remixSelected()); - els.bubbleReport?.addEventListener('click', () => openReportDialog()); + els.bubbleReport?.addEventListener('click', () => openReportDialog()); els.reportCancel?.addEventListener('click', () => closeReportDialog()); els.reportSubmit?.addEventListener('click', () => submitReportDialog()); els.reportDialog?.addEventListener('click', (event) => { if (event.target === els.reportDialog) closeReportDialog(); }); @@ -377,6 +401,7 @@ els.saveAsset.addEventListener('click', saveAssetFromEditor); els.saveAndPlace.addEventListener('click', saveAndPlaceFromEditor); + els.checkOnIsland?.addEventListener('click', checkCurrentEditorOnIsland); els.newAsset.addEventListener('click', newAsset); els.exportData.addEventListener('click', exportData); els.importData.addEventListener('click', importData); @@ -457,11 +482,12 @@ toggleHidden(els.staticSettingsPanel, !isStatic); toggleHidden(els.dynamicSettingsPanel, true); toggleHidden(els.toolLight, !advancedDraw); + toggleHidden(els.toolParticle, !advancedDraw); toggleHidden(els.toolDepth, !advancedDraw); toggleHidden(els.toolDoor, role !== 'building'); toggleHidden(els.doorMarkerHint, role !== 'building'); - if (!advancedDraw && (paintTool === 'light' || paintTool === 'depth')) setPaintTool('brush'); + if (!advancedDraw && (paintTool === 'light' || paintTool === 'depth' || paintTool === 'particle')) setPaintTool('brush'); if (!isStatic && paintTool === 'door') setPaintTool('brush'); if (isStatic && role !== 'building' && paintTool === 'door') setPaintTool('brush'); if (isStatic) editingSide = 'right'; @@ -493,17 +519,21 @@ if (roleToCategory(role) === 'static') { const lightCount = lightPixels.length; const lightText = lightCount ? `${lightCount} lamp cell${lightCount === 1 ? '' : 's'}` : 'no light'; + const particleCount = particlePixels.length; + const particleText = particleCount ? `${particleCount} particle cell${particleCount === 1 ? '' : 's'}` : 'no particles'; const doorText = role === 'building' ? ` / door ${Math.round(doorPixel.x)},${Math.round(doorPixel.y)}` : ''; - els.settingsSummary.textContent = `${cap(role)} / 2× static pixels / ${lightText}${doorText}.`; + els.settingsSummary.textContent = `${cap(role)} / 2× static pixels / ${lightText} / ${particleText}${doorText}.`; } else { - els.settingsSummary.textContent = `${cap(role)} / canonical ▶ Right / ◀ Left is an auto-mirrored preview and movement-facing state.`; + const particleCount = particlePixels.length; + const particleText = particleCount ? ` / ${particleCount} particle cell${particleCount === 1 ? '' : 's'}` : ''; + els.settingsSummary.textContent = `${cap(role)} / canonical ▶ Right / ◀ Left is auto-mirrored${particleText}.`; } } function setPaintTool(tool) { paintTool = tool; - [els.toolBrush, els.toolErase, els.toolFill, els.toolPick, els.toolLine, els.toolRect, els.toolSelect, els.toolLight, els.toolDoor, els.toolDepth].filter(Boolean).forEach((button) => button.classList.remove('active')); - ({ brush: els.toolBrush, erase: els.toolErase, fill: els.toolFill, pick: els.toolPick, line: els.toolLine, rect: els.toolRect, select: els.toolSelect, light: els.toolLight, door: els.toolDoor, depth: els.toolDepth }[tool])?.classList.add('active'); + [els.toolBrush, els.toolErase, els.toolFill, els.toolPick, els.toolLine, els.toolRect, els.toolSelect, els.toolLight, els.toolParticle, els.toolDoor, els.toolDepth].filter(Boolean).forEach((button) => button.classList.remove('active')); + ({ brush: els.toolBrush, erase: els.toolErase, fill: els.toolFill, pick: els.toolPick, line: els.toolLine, rect: els.toolRect, select: els.toolSelect, light: els.toolLight, particle: els.toolParticle, door: els.toolDoor, depth: els.toolDepth }[tool])?.classList.add('active'); const hints = { brush: 'Draw pixels with the selected palette color.', erase: 'Erase pixels. Erasing also clears light markers on that cell.', @@ -513,6 +543,7 @@ rect: 'Drag to draw a rectangle. Hold Shift for a filled rectangle; right-click erases.', select: 'Drag a rectangle to select pixels, then drag or nudge the selection.', light: 'Paint light cells with the selected palette color. Hold Shift to erase light cells.', + particle: 'Paint particle emitter cells with the selected palette color. Hold Shift or right-click to erase emitters.', door: 'Click one pixel to mark a building door. Humans will enter near this point.', depth: 'Advanced: paint high or low depth. Use High/Low/Clear buttons; Shift clears.' }; @@ -702,12 +733,173 @@ } + function getLocalDisplayLimit() { + const settings = visualSettings(); + return clampInt(settings.localDisplayLimit, 25, 500, PHASE5_GUARDRAILS.defaultDisplayLimit); + } + + function getObjectRecord(kind, objectId) { + if (kind === 'static') return state.placed.find((p) => p.id === objectId) || null; + return state.dynamicSummons.find((p) => p.id === objectId) || null; + } + + function getObjectPublicAt(kind, object) { + return Number(object?.publishedAt || object?.placedAt || object?.createdAt || Date.now()); + } + + function isPermanentlyHiddenObject(object) { + return object?.status === 'permanent_hidden' || object?.status === 'violation_hidden' || object?.permanentHidden === true; + } + + function isModeratedAssetHidden(asset) { + if (!asset) return false; + if (asset.status === 'permanent_hidden' || asset.status === 'violation_hidden') return true; + const allObjects = [...(state.placed || []), ...(state.dynamicSummons || [])]; + return allObjects.some((object) => object.assetId === asset.id && isPermanentlyHiddenObject(object) && object.hiddenReason === 'moderation_violation'); + } + + function isServerSuppressedObject(object) { + return ['archived', 'permanent_hidden', 'violation_hidden'].includes(object?.status); + } + + function getObjectRotationEntry(kind, object, baseIndex = 0) { + const objectId = object?.id; + const votes = getObjectVoteCounts(objectId); + const rawUp = Number(votes.up) || 0; + const up = Math.min(rawUp, PHASE5_GUARDRAILS.upvoteRankCap); + const down = Number(votes.down) || 0; + const delay = up * PHASE5_GUARDRAILS.upvoteDelaySlots; + const advance = down * PHASE5_GUARDRAILS.downvoteAdvanceSlots; + return { + kind, + object, + id: objectId, + assetId: object?.assetId, + publicAt: getObjectPublicAt(kind, object), + baseIndex, + up, + rawUp, + down, + effectiveSlot: baseIndex + delay - advance + }; + } + + function getRotationEntries(includeLocalHidden = false) { + const entries = []; + for (const placed of state.placed || []) { + const asset = findAsset(placed.assetId); + if (!asset || isModeratedAssetHidden(asset) || state.hiddenAssets?.[asset.id] || isServerSuppressedObject(placed)) continue; + if (!includeLocalHidden && state.hiddenObjects?.[placed.id]) continue; + entries.push({ kind: 'static', object: placed }); + } + for (const summon of state.dynamicSummons || []) { + const asset = findAsset(summon.assetId); + if (!asset || isModeratedAssetHidden(asset) || state.hiddenAssets?.[asset.id] || isServerSuppressedObject(summon)) continue; + if (!includeLocalHidden && state.hiddenObjects?.[summon.id]) continue; + entries.push({ kind: 'dynamic', object: summon }); + } + entries.sort((a, b) => getObjectPublicAt(a.kind, a.object) - getObjectPublicAt(b.kind, b.object) || String(a.object.id).localeCompare(String(b.object.id))); + return entries.map((entry, index) => getObjectRotationEntry(entry.kind, entry.object, index)); + } + + function seededScore(id, salt = '') { + const hash = fnv1a(`${id}|${salt}|${Math.floor((state.lastRotationAt || Date.now()) / (24 * 60 * 60 * 1000))}`); + return parseInt(hash.slice(0, 8), 16) / 0xffffffff; + } + + function getIslandDisplayBuckets() { + const entries = getRotationEntries(false); + const localLimit = getLocalDisplayLimit(); + const newCap = Math.min(PHASE5_GUARDRAILS.newArrivalSlots, localLimit); + const revivalCap = Math.max(0, Math.min(PHASE5_GUARDRAILS.revivalSlots, localLimit - newCap)); + const newest = entries + .slice() + .sort((a, b) => b.effectiveSlot - a.effectiveSlot || b.publicAt - a.publicAt || String(b.id).localeCompare(String(a.id))) + .slice(0, newCap); + const newestIds = new Set(newest.map((entry) => entry.id)); + const revivalPool = entries.filter((entry) => !newestIds.has(entry.id)); + const revival = revivalPool + .slice() + .sort((a, b) => seededScore(b.id, 'revival') - seededScore(a.id, 'revival') || b.up - a.up || String(b.id).localeCompare(String(a.id))) + .slice(0, revivalCap); + return { newest, revival, entries, visibleIds: new Set([...newest, ...revival].map((entry) => entry.id)) }; + } + + function getVisibleRotationIdSet() { + return getIslandDisplayBuckets().visibleIds; + } + + function isWorldObjectVisibleByRotation(kind, objectId) { + const object = getObjectRecord(kind, objectId); + if (!object || isServerSuppressedObject(object) || state.hiddenObjects?.[objectId]) return false; + return getVisibleRotationIdSet().has(objectId); + } + + function updateRotationStats() { + if (!els.rotationStats) return; + const buckets = getIslandDisplayBuckets(); + const entries = buckets.entries; + const visible = buckets.visibleIds.size; + const hiddenByRotation = Math.max(0, entries.length - visible); + const limit = getLocalDisplayLimit(); + const publish = getPublishQuotaStatus(); + const quotaText = publish.accountRequired ? 'account required to publish' : `publish quota ${publish.used}/${publish.limit} this hour`; + els.rotationStats.textContent = `Island exhibition ${visible}/${entries.length} objects · newest ${buckets.newest.length}/150 · revival ${buckets.revival.length}/100 · local cap ${limit} · rotated out ${hiddenByRotation} · ${quotaText}.`; + updateAccountUI(); + } + + function getAccountAgeMs(now = Date.now()) { + return state.account?.createdAt ? now - Number(state.account.createdAt) : 0; + } + + function currentPublishLimit(now = Date.now()) { + if (!state.account?.createdAt) return 0; + return getAccountAgeMs(now) < 24 * 60 * 60 * 1000 + ? PHASE5_GUARDRAILS.publishLimitFirstDay + : PHASE5_GUARDRAILS.publishLimitTrusted; + } + + function getPublishQuotaStatus(now = Date.now()) { + state.publishLog = Array.isArray(state.publishLog) ? state.publishLog : []; + const author = currentVoterKey(); + const windowMs = 60 * 60 * 1000; + state.publishLog = state.publishLog.filter((entry) => now - Number(entry.at || 0) < windowMs * 24); + const used = state.publishLog.filter((entry) => entry.author === author && now - Number(entry.at || 0) < windowMs).length; + const limit = currentPublishLimit(now); + return { used, limit, remaining: Math.max(0, limit - used), accountRequired: !state.account?.createdAt }; + } + + function canPublishObject(action = 'publish') { + const quota = getPublishQuotaStatus(); + if (quota.accountRequired) { + toast('Create a local account before publishing works to the island.'); + updateAccountUI(); + return false; + } + if (quota.remaining <= 0) { + toast(`Publish limit reached: ${quota.limit} public placements per hour.`); + return false; + } + return true; + } + + function recordObjectPublish(kind, object, action = 'publish') { + if (!object) return; + const now = Date.now(); + object.publishedAt = now; + object.status = 'active'; + state.publishLog = Array.isArray(state.publishLog) ? state.publishLog : []; + state.publishLog.push({ at: now, author: currentVoterKey(), kind, objectId: object.id, assetId: object.assetId, action }); + state.publishLog = state.publishLog.slice(-300); + } + + function getDrawableItems(time = performance.now(), viewport = null) { const rect = viewport || getViewportWorldRect(VIEW_CULL_MARGIN); const items = []; for (const placed of state.placed) { const asset = findAsset(placed.assetId); - if (!asset || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[placed.id]) continue; + if (!asset || isModeratedAssetHidden(asset) || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[placed.id] || isPermanentlyHiddenObject(placed) || !isWorldObjectVisibleByRotation('static', placed.id)) continue; const itemX = placed.x + .5; const itemY = placed.y + .5; if (!isApproxVisible(asset, itemX, itemY, rect)) continue; @@ -715,11 +907,15 @@ } for (const runtime of dynamicRuntime) { const asset = findAsset(runtime.assetId); - if (!asset || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[runtime.id]) continue; + if (!asset || isModeratedAssetHidden(asset) || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[runtime.id] || isPermanentlyHiddenObject(runtime) || !isWorldObjectVisibleByRotation('dynamic', runtime.id)) continue; if (time < runtime.hiddenUntil) continue; if (!isApproxVisible(asset, runtime.x, runtime.y, rect)) continue; items.push({ kind: 'dynamic', asset, x: runtime.x, y: runtime.y, source: runtime }); } + if (placementPreview?.asset && placementPreview.x != null && placementPreview.y != null) { + const asset = placementPreview.asset; + items.push({ kind: asset.category === 'dynamic' ? 'dynamic' : 'static', asset, x: placementPreview.x + .5, y: placementPreview.y + .5, source: { id: 'placement-preview', preview: true }, preview: true }); + } items.sort(drawOrderCompare); return items; } @@ -795,8 +991,8 @@ return; } const tile = world.get(hoverTile.x, hoverTile.y); - const staticCount = getPlacedAtTile(hoverTile.x, hoverTile.y).length; - const dynamicCount = getDynamicHomesAtTile(hoverTile.x, hoverTile.y).length; + const staticCount = getPlacedAtTile(hoverTile.x, hoverTile.y).filter((p) => isWorldObjectVisibleByRotation('static', p.id)).length; + const dynamicCount = getDynamicHomesAtTile(hoverTile.x, hoverTile.y).filter((p) => isWorldObjectVisibleByRotation('dynamic', p.id)).length; els.tileInfo.textContent = `Tile ${hoverTile.x}, ${hoverTile.y}\nTerrain: ${cap(tile.type)}\nObjects here: ${staticCount}\nDynamic homes: ${dynamicCount}`; } @@ -807,8 +1003,8 @@ function inspectAt(x, y) { const tile = world.get(x, y); - const staticObjects = getPlacedAtTile(x, y).filter((p) => !state.hiddenObjects?.[p.id]); - const dynamicObjects = getDynamicHomesAtTile(x, y).filter((p) => !state.hiddenObjects?.[p.id]); + const staticObjects = getPlacedAtTile(x, y).filter((p) => isWorldObjectVisibleByRotation('static', p.id)); + const dynamicObjects = getDynamicHomesAtTile(x, y).filter((p) => isWorldObjectVisibleByRotation('dynamic', p.id)); // Object selection is now based on actual opaque sprite pixels only. // A plain tile click updates information but does not select hidden/transparent areas. selectedObject = null; @@ -830,6 +1026,15 @@ } function placeSelected(x, y) { + if (placementPreview?.asset) { + const tile = world.get(x, y); + if (!canPlace(placementPreview.asset, tile)) return; + placementPreview.x = x; + placementPreview.y = y; + render(); + toast('Preview set. Use Place here or Back to canvas.'); + return; + } const asset = findAsset(selectedAssetId); if (!asset) { toast('Select an asset first.'); @@ -843,21 +1048,35 @@ const existingObject = asset.category === 'static' ? state.placed.find((p) => p.assetId === asset.id) : state.dynamicSummons.find((p) => p.assetId === asset.id); + const kind = asset.category === 'dynamic' ? 'dynamic' : 'static'; + const wasLocallyHidden = existingObject ? Boolean(state.hiddenObjects?.[existingObject.id]) : false; + const wasRotationHidden = existingObject ? !isWorldObjectVisibleByRotation(kind, existingObject.id) : false; + const needsPublishSlot = !existingObject || wasLocallyHidden || wasRotationHidden; + if (needsPublishSlot && !canPublishObject(existingObject ? 'republish' : 'publish')) return; + const isNewObject = !existingObject; if (isNewObject && getWorldObjectCount() >= PHASE5_GUARDRAILS.maxWorldObjects) { - toast(`World object limit reached (${PHASE5_GUARDRAILS.maxWorldObjects}). Remove objects before summoning more.`); + toast(`Local storage object limit reached (${PHASE5_GUARDRAILS.maxWorldObjects}). Lower the display cap or remove local objects before adding more.`); return; } + let object = null; if (asset.category === 'static') { const existing = state.placed.find((p) => p.assetId === asset.id); if (existing) { existing.x = x; existing.y = y; existing.placedAt = Date.now(); existing.version = (existing.version || 1) + 1; + if (needsPublishSlot) { + delete state.hiddenObjects?.[existing.id]; + recordObjectPublish('static', existing, wasLocallyHidden || wasRotationHidden ? 'republish' : 'publish'); + } + object = existing; selectWorldObject('static', existing.id, asset.id, performance.now()); - toast(`${asset.name} moved.`); + toast(needsPublishSlot ? `${asset.name} republished.` : `${asset.name} moved.`); } else { - const placed = { id: uid(), assetId: asset.id, x, y, placedAt: Date.now(), version: 1 }; + const placed = { id: uid(), assetId: asset.id, x, y, placedAt: Date.now(), publishedAt: Date.now(), status: 'active', version: 1 }; state.placed.push(placed); + recordObjectPublish('static', placed, 'publish'); + object = placed; selectWorldObject('static', placed.id, asset.id, performance.now()); toast(`${asset.name} placed.`); } @@ -865,23 +1084,28 @@ const existing = state.dynamicSummons.find((p) => p.assetId === asset.id); if (existing) { existing.homeX = x; existing.homeY = y; existing.createdAt = Date.now(); existing.version = (existing.version || 1) + 1; - toast(`${asset.name} moved.`); + if (needsPublishSlot) { + delete state.hiddenObjects?.[existing.id]; + recordObjectPublish('dynamic', existing, wasLocallyHidden || wasRotationHidden ? 'republish' : 'publish'); + } + object = existing; + toast(needsPublishSlot ? `${asset.name} republished.` : `${asset.name} moved.`); } else { - const summon = { id: uid(), assetId: asset.id, homeX: x, homeY: y, createdAt: Date.now(), version: 1 }; + const summon = { id: uid(), assetId: asset.id, homeX: x, homeY: y, createdAt: Date.now(), publishedAt: Date.now(), status: 'active', version: 1 }; state.dynamicSummons.push(summon); + recordObjectPublish('dynamic', summon, 'publish'); + object = summon; toast(`${asset.name} summoned.`); } hydrateRuntime(); const found = state.dynamicSummons.find((p) => p.assetId === asset.id); if (found) selectWorldObject('dynamic', found.id, asset.id, performance.now()); } - const object = asset.category === 'static' - ? state.placed.find((p) => p.assetId === asset.id) - : state.dynamicSummons.find((p) => p.assetId === asset.id); rebuildWorldIndex(); - recordSyncEvent(Phase2Sync?.createObjectUpsertEvent?.(asset.category === 'dynamic' ? 'dynamic' : 'static', object)); - spawnEffects.push({ x: x + .5, y: y + .5, started: performance.now() }); + recordSyncEvent(Phase2Sync?.createObjectUpsertEvent?.(kind, object)); + if (visualSettings().enableParticles) spawnEffects.push({ x: x + .5, y: y + .5, started: performance.now() }); saveState(); + updateRotationStats(); } function canPlace(asset, tile) { @@ -974,7 +1198,7 @@ } function usesRightButtonAsPaint() { - return paintTool === 'depth' || paintTool === 'light' || paintTool === 'line' || paintTool === 'rect' || paintTool === 'erase'; + return paintTool === 'depth' || paintTool === 'light' || paintTool === 'particle' || paintTool === 'line' || paintTool === 'rect' || paintTool === 'erase'; } function onPaintPointerDown(event) { @@ -1141,6 +1365,7 @@ for (const cell of nextDisplay.cells) { const src = displayCellToCanonical(cell.x, cell.y); removeLightPixel(src.x, src.y); + removeParticlePixel(src.x, src.y); } } changed = true; @@ -1155,15 +1380,22 @@ editorPixels[index] = null; changed = true; } - const before = lightPixels.length; + const before = lightPixels.length + particlePixels.length; removeLightPixel(point.x, point.y); - changed = changed || before !== lightPixels.length; + removeParticlePixel(point.x, point.y); + changed = changed || before !== lightPixels.length + particlePixels.length; } else if (paintTool === 'light') { const before = JSON.stringify(lightPixels); if (shiftKey || button === 2) removeLightPixel(point.x, point.y); else addLightPixel(point.x, point.y, selectedColorCode); changed = JSON.stringify(lightPixels) !== before; updateSettingsSummary(); + } else if (paintTool === 'particle') { + const before = JSON.stringify(particlePixels); + if (shiftKey || button === 2) removeParticlePixel(point.x, point.y); + else addParticlePixel(point.x, point.y, selectedColorCode); + changed = JSON.stringify(particlePixels) !== before; + updateSettingsSummary(); } else if (paintTool === 'depth') { const next = shiftKey ? 0 : (button === 2 ? -1 : depthPaintMode); if (depthPixels[index] !== next) { @@ -1215,6 +1447,7 @@ rightPixels: [...editorPixels], depthPixels: [...depthPixels], lightPixels: lightPixels.map((p) => ({ ...p })), + particlePixels: particlePixels.map((p) => ({ ...p })), doorPixel: { ...doorPixel }, editingSide, selectedColorCode, @@ -1228,6 +1461,7 @@ editorLeftPixels = mirrorPixels(editorPixels, editorSize); depthPixels = normalizeDepthPixels(snapshot.depthPixels || [], editorSize); lightPixels = Array.isArray(snapshot.lightPixels) ? snapshot.lightPixels.map((p) => ({ ...p })) : []; + particlePixels = Array.isArray(snapshot.particlePixels) ? snapshot.particlePixels.map((p) => ({ ...p })) : []; doorPixel = snapshot.doorPixel ? { ...snapshot.doorPixel } : { x: Math.floor(editorSize / 2), y: editorSize - 1 }; editingSide = snapshot.editingSide || editingSide; if (snapshot.selectedColorCode) selectedColorCode = snapshot.selectedColorCode; @@ -1384,7 +1618,8 @@ baseSelection, basePixels: getDisplayEditorPixels(), baseDepthPixels: getDisplayDepthPixels(), - baseLightPixels: lightPixels.map((p) => ({ ...p })) + baseLightPixels: lightPixels.map((p) => ({ ...p })), + baseParticlePixels: particlePixels.map((p) => ({ ...p })) }; } else { selectionGesture = { mode: 'select', startX: cell.x, startY: cell.y, endX: cell.x, endY: cell.y }; @@ -1423,7 +1658,7 @@ if (dx === selectionGesture.lastDx && dy === selectionGesture.lastDy) return; selectionGesture.lastDx = dx; selectionGesture.lastDy = dy; - applySelectionMoveFromBase(selectionGesture.basePixels, selectionGesture.baseDepthPixels, selectionGesture.baseLightPixels, selectionGesture.baseSelection, dx, dy); + applySelectionMoveFromBase(selectionGesture.basePixels, selectionGesture.baseDepthPixels, selectionGesture.baseLightPixels, selectionGesture.baseParticlePixels, selectionGesture.baseSelection, dx, dy); markEditorChanged(); drawEditor(); } @@ -1513,7 +1748,7 @@ return out; } - function applySelectionMoveFromBase(basePixels, baseDepthPixels, baseLightPixels, selection, dx, dy) { + function applySelectionMoveFromBase(basePixels, baseDepthPixels, baseLightPixels, baseParticlePixels, selection, dx, dy) { const nextPixels = normalizePixels(basePixels, editorSize); const nextDepth = normalizeDepthPixels(baseDepthPixels, editorSize); const movedPixels = [...nextPixels]; @@ -1535,24 +1770,25 @@ } setDisplayEditorPixels(movedPixels); setDisplayDepthPixels(movedDepth); - lightPixels = moveDisplayLights(baseLightPixels, selection, dx, dy); + lightPixels = moveDisplayPoints(baseLightPixels, selection, dx, dy); + particlePixels = moveDisplayPoints(baseParticlePixels, selection, dx, dy); editorSelection = { x: selection.x + dx, y: selection.y + dy, w: selection.w, h: selection.h }; updateEditorSelectionButtons(); } - function moveDisplayLights(sourceLights, selection, dx, dy) { + function moveDisplayPoints(sourcePoints, selection, dx, dy) { const out = []; - for (const light of sourceLights || []) { - const shown = canonicalCellToDisplay(light.x, light.y); + for (const point of sourcePoints || []) { + const shown = canonicalCellToDisplay(point.x, point.y); if (!pointInSelection(shown.x, shown.y, selection)) { - out.push({ ...light }); + out.push({ ...point }); continue; } const nx = shown.x + dx; const ny = shown.y + dy; if (nx < 0 || ny < 0 || nx >= editorSize || ny >= editorSize) continue; const canonical = displayCellToCanonical(nx, ny); - out.push({ ...light, x: canonical.x, y: canonical.y }); + out.push({ ...point, x: canonical.x, y: canonical.y }); } return out; } @@ -1562,7 +1798,7 @@ const bounded = clampSelectionDelta(editorSelection, dx, dy); if (!bounded.dx && !bounded.dy) return false; return commitEditorMutation(() => { - applySelectionMoveFromBase(getDisplayEditorPixels(), getDisplayDepthPixels(), lightPixels.map((p) => ({ ...p })), editorSelection, bounded.dx, bounded.dy); + applySelectionMoveFromBase(getDisplayEditorPixels(), getDisplayDepthPixels(), lightPixels.map((p) => ({ ...p })), particlePixels.map((p) => ({ ...p })), editorSelection, bounded.dx, bounded.dy); return true; }); } @@ -1577,7 +1813,7 @@ const selection = { x: 0, y: 0, w: editorSize, h: editorSize }; const bounded = clampSelectionDelta(selection, dx, dy); if (!bounded.dx && !bounded.dy) return false; - applySelectionMoveFromBase(getDisplayEditorPixels(), getDisplayDepthPixels(), lightPixels.map((p) => ({ ...p })), selection, bounded.dx, bounded.dy); + applySelectionMoveFromBase(getDisplayEditorPixels(), getDisplayDepthPixels(), lightPixels.map((p) => ({ ...p })), particlePixels.map((p) => ({ ...p })), selection, bounded.dx, bounded.dy); editorSelection = null; doorPixel = { x: clamp(doorPixel.x + bounded.dx, 0, editorSize - 1), y: clamp(doorPixel.y + bounded.dy, 0, editorSize - 1) }; return true; @@ -1671,6 +1907,7 @@ editorPixels = mirrorPixels(editorPixels, editorSize); depthPixels = mirrorScalarPixels(depthPixels, editorSize); lightPixels = lightPixels.map((p) => ({ ...p, x: editorSize - 1 - p.x })); + particlePixels = particlePixels.map((p) => ({ ...p, x: editorSize - 1 - p.x })); doorPixel = { ...doorPixel, x: editorSize - 1 - doorPixel.x }; editorSelection = editorSelection ? { x: editorSize - (editorSelection.x + editorSelection.w), y: editorSelection.y, w: editorSelection.w, h: editorSelection.h } : null; return true; @@ -1682,6 +1919,7 @@ editorPixels = flipPixelsVertical(editorPixels, editorSize); depthPixels = flipScalarPixelsVertical(depthPixels, editorSize); lightPixels = lightPixels.map((p) => ({ ...p, y: editorSize - 1 - p.y })); + particlePixels = particlePixels.map((p) => ({ ...p, y: editorSize - 1 - p.y })); doorPixel = { ...doorPixel, y: editorSize - 1 - doorPixel.y }; editorSelection = editorSelection ? { x: editorSelection.x, y: editorSize - (editorSelection.y + editorSelection.h), w: editorSelection.w, h: editorSelection.h } : null; return true; @@ -1862,6 +2100,7 @@ depthPixels = Array.isArray(nextDepthPixels) || typeof nextDepthPixels === 'string' ? normalizeDepthPixels(nextDepthPixels, size) : resizeDepthPixels(depthPixels, Math.sqrt(depthPixels.length) || size, size); els.assetSize.value = String(size); lightPixels = lightPixels.filter((p) => p.x >= 0 && p.y >= 0 && p.x < size && p.y < size); + particlePixels = particlePixels.filter((p) => p.x >= 0 && p.y >= 0 && p.x < size && p.y < size); doorPixel = { x: clamp(doorPixel.x, 0, size - 1), y: clamp(doorPixel.y, 0, size - 1) }; resetEditorView(); drawEditor(); @@ -1934,6 +2173,17 @@ pctx.arc(lx, ly, Math.max(3, cell * .14), 0, Math.PI * 2); pctx.stroke(); } + for (const emitter of particlePixels) { + const shown = canonicalCellToDisplay(emitter.x, emitter.y); + const lx = (shown.x + .5) * cell; + const ly = (shown.y + .5) * cell; + const color = colorToHex(emitter.c || selectedColorCode); + pctx.fillStyle = hexToRgba(color, .32); + pctx.fillRect(lx - Math.max(3, cell * .18), ly - Math.max(3, cell * .18), Math.max(6, cell * .36), Math.max(6, cell * .36)); + pctx.strokeStyle = color; + pctx.lineWidth = 2 / editorView.zoom; + pctx.strokeRect(lx - Math.max(2, cell * .12), ly - Math.max(2, cell * .12), Math.max(4, cell * .24), Math.max(4, cell * .24)); + } if (currentRole() === 'building') { pctx.strokeStyle = '#5fb8ff'; pctx.lineWidth = 3 / editorView.zoom; @@ -1970,6 +2220,17 @@ lightPixels = lightPixels.filter((p) => !(p.x === x && p.y === y)); } + function addParticlePixel(x, y, colorCode = selectedColorCode) { + const point = { x: clamp(Math.floor(Number(x)), 0, editorSize - 1), y: clamp(Math.floor(Number(y)), 0, editorSize - 1), c: colorCode }; + const existing = particlePixels.find((p) => p.x === point.x && p.y === point.y); + if (existing) existing.c = colorCode; + else particlePixels.push(point); + } + + function removeParticlePixel(x, y) { + particlePixels = particlePixels.filter((p) => !(p.x === x && p.y === y)); + } + function alignEditorStateToBottom(size) { const shift = getBottomShift(editorPixels, size); @@ -1979,6 +2240,9 @@ lightPixels: lightPixels .map((p) => ({ ...p, y: p.y + shift })) .filter((p) => p.x >= 0 && p.x < size && p.y >= 0 && p.y < size), + particlePixels: particlePixels + .map((p) => ({ ...p, y: p.y + shift })) + .filter((p) => p.x >= 0 && p.x < size && p.y >= 0 && p.y < size), door: { x: doorPixel.x, y: clamp(doorPixel.y + shift, 0, size - 1) } }; } @@ -2024,7 +2288,7 @@ const category = roleToCategory(role); const subtype = roleToSubtype(role); const size = editorSize; - const existing = editingAssetId ? findAsset(editingAssetId) : null; + const existing = null; const paintedDots = countPixels(editorPixels); if (paintedDots < 10) { toast('Draw at least 10 pixels before saving.'); @@ -2052,7 +2316,7 @@ right: encodePixels(aligned.rightPixels), left: 'mirror' } : null, - meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, size) + meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, size, aligned.particlePixels) }; asset.contentHash = computeAssetContentHash(asset); if (existing) { @@ -2068,13 +2332,12 @@ editParentId = null; editOriginalId = null; editingAssetId = null; - editingAssetId = asset.id; saveState(); spriteCache.clear(); hydrateRuntime(); renderLibrary(); updateSelectedLabel(); - els.lineageNote.textContent = existing ? 'Updated the original asset.' : 'Saved as a new asset.'; + els.lineageNote.textContent = 'Saved as a permanent library work. Island placement is a separate temporary exhibition object.'; return asset; } @@ -2084,7 +2347,61 @@ selectedAssetId = asset.id; setMode('place'); setDrawerOpen(false); - toast('Saved. Click an island tile to summon it.'); + toast('Saved to Library. Click an island tile to place it as a temporary exhibition object.'); + } + + + + function buildEditorAssetPreview() { + const role = currentRole(); + const category = roleToCategory(role); + const subtype = roleToSubtype(role); + const size = editorSize; + if (countPixels(editorPixels) < 10) { + toast('Draw at least 10 pixels before checking on the island.'); + return null; + } + const aligned = alignEditorStateToBottom(size); + const asset = { + id: `preview:${Date.now()}`, + name: (els.assetName.value || '').trim() || 'Preview work', + category, subtype, size, author: state.authorName || 'Local Artist', + pixels: encodePixels(aligned.rightPixels), + faces: category === 'dynamic' ? { right: encodePixels(aligned.rightPixels), left: 'mirror' } : null, + meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, size, aligned.particlePixels) + }; + return asset; + } + + function checkCurrentEditorOnIsland() { + const asset = buildEditorAssetPreview(); + if (!asset) return; + placementPreview = { asset, x: null, y: null }; + setDrawerOpen(false); + setMode('place'); + if (els.placementPreviewBar) els.placementPreviewBar.hidden = false; + toast('Click a valid tile to preview. Then choose Place here or Back to canvas.'); + } + + function cancelPlacementPreview() { + placementPreview = null; + if (els.placementPreviewBar) els.placementPreviewBar.hidden = true; + setDrawerOpen(true); + render(); + } + + function confirmPreviewPlacement() { + if (!placementPreview?.asset || placementPreview.x == null || placementPreview.y == null) { + toast('Click a tile first.'); + return; + } + const asset = saveAssetFromEditor(); + if (!asset) return; + selectedAssetId = asset.id; + const { x, y } = placementPreview; + placementPreview = null; + if (els.placementPreviewBar) els.placementPreviewBar.hidden = true; + placeSelected(x, y); } function newAsset() { @@ -2096,6 +2413,7 @@ dynamicKind = 'human'; editingSide = 'right'; lightPixels = []; + particlePixels = []; depthPixels = blankPixels(8).map(() => 0); doorPixel = { x: Math.floor(editorSize / 2), y: editorSize - 1 }; setupEditor(8, blankPixels(8), null); @@ -2110,6 +2428,33 @@ if (els.authorName) els.authorName.value = state.authorName; } + function createLocalAccount() { + const name = (els.authorName?.value || state.authorName || '').trim(); + if (!name || name === 'Local Artist') { + toast('Choose a name first.'); + return; + } + state.authorName = name; + state.account = state.account || { id: `local:${uid()}`, createdAt: Date.now() }; + state.account.name = name; + saveState(); + updateAccountUI(); + toast('Local account created for prototype publishing.'); + } + + function updateAccountUI() { + if (!els.accountNote) return; + if (!state.account?.createdAt) { + els.accountNote.textContent = 'Account required to publish. This prototype only stores a local name and creation date. Works are permanent; island placements rotate.'; + els.createAccount.hidden = false; + return; + } + const quota = getPublishQuotaStatus(); + const day = getAccountAgeMs() < 24 * 60 * 60 * 1000 ? 'first day' : 'day 2+'; + els.accountNote.textContent = `Local account active (${day}). Publish quota: ${quota.used}/${quota.limit} this hour.`; + els.createAccount.hidden = true; + } + function focusAssetInWorld(asset) { const placed = state.placed.find((p) => p.assetId === asset.id); const dyn = state.dynamicSummons.find((p) => p.assetId === asset.id); @@ -2131,7 +2476,7 @@ function renderLibrary() { els.assetList.innerHTML = ''; - const visibleAssets = state.assets.filter((asset) => !state.hiddenAssets?.[asset.id]); + const visibleAssets = state.assets.filter((asset) => !isModeratedAssetHidden(asset) && !state.hiddenAssets?.[asset.id]); if (!visibleAssets.length) { els.assetList.textContent = 'No visible assets.'; renderHiddenAssets(); @@ -2145,6 +2490,7 @@ addLibrarySection('My works', mine); addLibrarySection('Others', others); renderHiddenAssets(); + renderLikedCodex(); } function addLibrarySection(title, assets) { @@ -2218,11 +2564,12 @@ down.classList.toggle('mutedVote', assetPreviousVote > 0); const hide = makeButton('Hide', (event) => { event.stopPropagation(); hideAsset(asset.id); }); hide.classList.toggle('mutedAction', assetPreviousVote >= 0); - const copy = makeButton(((asset.author || 'Local Artist') === (state.authorName || 'Local Artist')) ? 'Edit' : 'Copy Edit', (event) => { + const isMine = (asset.author || 'Local Artist') === (state.authorName || 'Local Artist'); + const remix = makeButton('Remix', (event) => { event?.stopPropagation?.(); - copyEdit(asset); + remixEdit(asset); }); - const move = makeButton('Place/Move', (event) => { + const move = isMine ? makeButton('Place/Move', (event) => { event?.stopPropagation?.(); selectedAssetId = asset.id; updateSelectedLabel(); @@ -2230,18 +2577,56 @@ setMode('place'); setDrawerOpen(false); toast('Click a valid tile to place or move it.'); - }); + }) : null; const del = makeButton('Delete', (event) => { event?.stopPropagation?.(); deleteAsset(asset); }); del.classList.add('danger'); - actions.append(up, down, hide, move, copy, del); + actions.append(up, down, hide, remix); + if (move) actions.append(move); + if (isMine) actions.append(del); meta.append(actions); card.append(preview, meta); return card; } + + function renderLikedCodex() { + if (!els.likedCodex) return; + const voter = currentVoterKey(); + const likedIds = new Set(); + for (const [assetId, votes] of Object.entries(state.assetVotes || {})) { + if ((votes?.voters || {})[voter] > 0) likedIds.add(assetId); + } + for (const [objectId, votes] of Object.entries(state.objectVotes || {})) { + if ((votes?.voters || {})[voter] > 0) { + const obj = [...(state.placed || []), ...(state.dynamicSummons || [])].find((item) => item.id === objectId); + if (obj?.assetId) likedIds.add(obj.assetId); + } + } + const liked = [...likedIds].map(findAsset).filter(Boolean).slice(0, 12); + if (!liked.length) { + els.likedCodex.innerHTML = '
Liked codex
Works you upvote will appear here.
'; + return; + } + els.likedCodex.innerHTML = '
Liked codex
'; + const list = els.likedCodex.querySelector('.likedCodexList'); + for (const asset of liked) { + const item = document.createElement('button'); + item.type = 'button'; + item.className = 'likedCodexItem'; + const canvas = document.createElement('canvas'); + canvas.width = 32; canvas.height = 32; + drawPreview(canvas, asset); + const label = document.createElement('span'); + label.textContent = asset.name; + item.append(canvas, label); + item.addEventListener('click', () => { selectedAssetId = asset.id; updateSelectedLabel(); focusAssetInWorld(asset); }); + list.append(item); + } + } + function renderHiddenAssets() { if (!els.hiddenAssetList) return; els.hiddenAssetList.innerHTML = ''; @@ -2287,6 +2672,14 @@ return out; } + function hiddenReasonLabel(object) { + if (!object) return 'Hidden locally'; + if (object.status === 'hidden_rotation') return 'Not shown on island: exhibition is full. Works remain in your Library.'; + if (object.status === 'permanent_hidden' || object.status === 'violation_hidden') return 'Hidden by moderation. This work is not visible to authors or viewers.'; + if (object.hiddenReason === 'rotation_cap' || object.hiddenReason === 'extreme_downvotes') return 'Not shown on island: exhibition is full. Works remain in your Library.'; + return 'Hidden locally'; + } + function makeHiddenObjectCard(entry) { const { kind, object, asset, report } = entry; const row = document.createElement('article'); @@ -2304,17 +2697,23 @@ meta.innerHTML = ``; meta.querySelector('strong').textContent = asset.name; meta.querySelectorAll('span')[0].textContent = `${cap(kind)} object / ${location}`; - meta.querySelectorAll('span')[1].textContent = report ? `Reported: ${report.reason}` : 'Hidden locally'; + meta.querySelectorAll('span')[1].textContent = report ? `Hidden locally after report: ${report.reason}` : hiddenReasonLabel(object); const actions = document.createElement('div'); actions.className = 'assetActions'; const restore = makeButton('Show again', (event) => { event.stopPropagation(); + if (isPermanentlyHiddenObject(object)) { + toast('This object is permanently hidden.'); + return; + } + if (!canPublishObject('republish')) return; delete state.hiddenObjects[object.id]; + recordObjectPublish(kind, object, 'republish'); saveState(); hydrateRuntime(); renderLibrary(); updateSelectionBubble(performance.now()); - toast('Object shown again.'); + toast('Object republished.'); }); actions.append(restore); meta.append(actions); @@ -2401,28 +2800,48 @@ return button; } - function copyEdit(asset) { + function loadAssetIntoEditor(asset, mode = 'remix') { setDrawerOpen(true); setTab('draw'); const mine = (asset.author || 'Local Artist') === (state.authorName || 'Local Artist'); - editingAssetId = mine ? asset.id : null; - editParentId = mine ? asset.parentAssetId : asset.id; - editOriginalId = mine ? asset.originalAssetId : (asset.originalAssetId || asset.id); - els.assetName.value = mine ? asset.name : `${asset.name} Remix`; + const editExisting = mode === 'edit' && mine; + editingAssetId = editExisting ? asset.id : null; + editParentId = editExisting ? asset.parentAssetId : asset.id; + editOriginalId = editExisting ? asset.originalAssetId : (asset.originalAssetId || asset.id); + els.assetName.value = editExisting ? asset.name : `${asset.name} Remix`; els.assetCategory.value = subtypeToRole(asset); staticKind = asset.category === 'static' ? asset.subtype : staticKind; dynamicKind = asset.category === 'dynamic' ? asset.subtype : dynamicKind; editingSide = 'right'; const right = getAssetPixels(asset, 'right'); lightPixels = (asset.meta?.lightPixels || []).map((p) => ({ x: p.x, y: p.y, c: p.c || asset.meta?.lightColor || selectedColorCode })); + particlePixels = (asset.meta?.particlePixels || []).map((p) => ({ x: p.x, y: p.y, c: p.c || selectedColorCode })); depthPixels = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size); doorPixel = asset.meta?.door || { x: Math.floor(asset.size / 2), y: asset.size - 1 }; setupEditor(asset.size, right, null, depthPixels); clearEditorHistory(); refreshCategoryUI(); - els.lineageNote.textContent = mine ? `Editing original “${asset.name}”.` : `Editing a derivative of “${asset.name}”. Save creates a new asset.`; + els.lineageNote.textContent = editExisting + ? `Editing original “${asset.name}”. Save updates this asset.` + : `Remixing “${asset.name}”. Save creates a separate new asset.`; } + function editOriginalAsset(asset) { + remixEdit(asset); + return; + + loadAssetIntoEditor(asset, 'edit'); + } + + function remixEdit(asset) { + loadAssetIntoEditor(asset, 'remix'); + } + + function copyEdit(asset) { + remixEdit(asset); + } + + function deleteAsset(asset) { const used = state.placed.some((p) => p.assetId === asset.id) || state.dynamicSummons.some((p) => p.assetId === asset.id); if (used && !confirm(`Delete “${asset.name}”? It is used in the world and will be removed there too.`)) return; @@ -2466,7 +2885,7 @@ seed: Math.random() * 9999, nextDecisionAt: 0, nextBubbleAt: 800 + Math.random() * 1500, - nextStepParticleAt: performance.now() + 110 + Math.random() * 160 + nextStepParticleAt: performance.now() + 300 + Math.random() * 280 }; }).filter(Boolean); } @@ -2532,7 +2951,7 @@ if (groundTile && groundTile.type !== 'water') { spawnGroundStepParticles(item, time, groundTile); } - item.nextStepParticleAt = time + 110 + Math.random() * 140; + item.nextStepParticleAt = time + 300 + Math.random() * 320; } if (asset.subtype === 'human' && distance < .3 && Math.random() < .004) { @@ -2715,7 +3134,7 @@ const lightSources = []; const visibleItems = drawObjects(time, lightSources, phase); if (visualSettings().enableParticles) { - spawnNatureAmbientParticles(visibleItems, time); + spawnConfiguredParticleEmitters(visibleItems, time); drawNatureDriftParticles(time); drawLandStepParticles(time); drawBubbleParticles(time); @@ -2919,20 +3338,18 @@ } function spawnGroundStepParticles(item, time, tile) { + if (Math.random() > 0.55) return; const baseColor = getTerrainSurfaceColorAt(item.x, item.y); - const count = 1 + (Math.random() < 0.5 ? 1 : 0); - for (let i = 0; i < count; i++) { - landStepParticles.push({ - x: item.x + (Math.random() - 0.5) * 0.18, - y: item.y + 0.16 + (Math.random() - 0.5) * 0.05, - vx: (Math.random() - 0.5) * 0.045 - (item.vx || 0) * 0.012, - vy: -0.015 - Math.random() * 0.02, - started: time + i * 18, - life: 260 + Math.random() * 180, - color: varyHexColor(baseColor, 26, 16, 12), - tileType: tile?.type || 'grass' - }); - } + landStepParticles.push({ + x: item.x + (Math.random() - 0.5) * 0.16, + y: item.y + 0.16 + (Math.random() - 0.5) * 0.05, + vx: (Math.random() - 0.5) * 0.04 - (item.vx || 0) * 0.01, + vy: -0.014 - Math.random() * 0.018, + started: time, + life: 240 + Math.random() * 140, + color: makeStepParticleColor(baseColor, tile?.type || 'grass'), + tileType: tile?.type || 'grass' + }); } function drawLandStepParticles(time) { @@ -2953,40 +3370,46 @@ ctx.restore(); } - function spawnNatureAmbientParticles(items, time) { - if (natureDriftParticles.length > 120) return; - const natureItems = items.filter((item) => item.asset.category === 'static' && item.asset.subtype === 'nature'); - for (const item of natureItems) { - if (Math.random() > 0.025) continue; + function spawnConfiguredParticleEmitters(items, time) { + if (view.zoom < PHASE5_GUARDRAILS.particleMinZoom) return; + if (natureDriftParticles.length >= PHASE5_GUARDRAILS.maxParticles) return; + for (const item of items) { + const emitters = item.asset.meta?.particlePixels || []; + if (!emitters.length) continue; const info = getSpriteDrawInfo(item, time, false); - natureDriftParticles.push({ - x: item.x + (Math.random() - 0.5) * 0.55, - y: item.y - 0.15 - Math.random() * 0.7, - vx: -0.012 + Math.random() * 0.024, - vy: 0.018 + Math.random() * 0.024, - started: time, - life: 1800 + Math.random() * 1800, - color: Math.random() < 0.55 ? ['#7abf64', '#99d57b', '#f4a1bb', '#ffd56e'][Math.floor(Math.random() * 4)] : ['#f7c3d7', '#ffef8b', '#8bd68f'][Math.floor(Math.random() * 3)], - sway: Math.random() * Math.PI * 2, - originDrawY: info.drawY - }); + const scale = item.asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; + for (const emitter of emitters) { + if (natureDriftParticles.length >= PHASE5_GUARDRAILS.maxParticles || Math.random() > 0.018) continue; + const ex = item.asset.category === 'dynamic' && info.side === 'left' ? item.asset.size - 1 - emitter.x : emitter.x; + const wx = info.drawX + (ex + 0.5) * scale; + const wy = info.drawY + (emitter.y + 0.5) * scale; + natureDriftParticles.push({ + x: wx + (Math.random() - 0.5) * 4, + y: wy + (Math.random() - 0.5) * 4, + vx: -3 + Math.random() * 6, + vy: 6 + Math.random() * 12, + started: time, + life: 1500 + Math.random() * 1600, + color: colorToHex(emitter.c || selectedColorCode), + sway: Math.random() * Math.PI * 2 + }); + } } } function drawNatureDriftParticles(time) { - if (!natureDriftParticles.length) return; + if (!natureDriftParticles.length || view.zoom < PHASE5_GUARDRAILS.particleMinZoom) return; ctx.save(); for (const particle of natureDriftParticles) { const age = (time - particle.started) / particle.life; if (age < 0 || age > 1) continue; - const sway = Math.sin(age * 5.5 + particle.sway) * 0.08; - const px = particle.x + particle.vx * age * 22 + sway; - const py = particle.y + particle.vy * age * 22; - const pos = tileToWorld(px, py); - pos.y -= getLiftAtCoord(px, py); + const seconds = (time - particle.started) / 1000; + const sway = Math.sin(age * 5.5 + particle.sway) * 3.5; + const x = particle.x + particle.vx * seconds + sway; + const y = particle.y + particle.vy * seconds + age * 5; ctx.globalAlpha = (1 - age) * 0.72; ctx.fillStyle = particle.color; - ctx.fillRect(Math.round(pos.x), Math.round(pos.y - age * 5), 1, 1); + ctx.fillRect(Math.round(x), Math.round(y), 1, 1); } ctx.restore(); } @@ -3067,7 +3490,7 @@ function currentVoterKey() { - return ((state.authorName || els.authorName?.value || 'Local Artist').trim() || 'Local Artist').toLowerCase(); + return String(state.account?.id || state.authorName || els.authorName?.value || 'Local Artist').trim().toLowerCase(); } function getObjectVoteCounts(objectId) { @@ -3177,6 +3600,10 @@ els.voteUp.classList.toggle('mutedVote', previous < 0); els.voteDown.classList.toggle('mutedVote', previous > 0); if (els.bubbleHide) els.bubbleHide.hidden = previous >= 0; + if (els.bubbleEdit) { + const mine = (asset.author || 'Local Artist') === (state.authorName || 'Local Artist'); + els.bubbleEdit.hidden = !mine; + } if (els.bubbleReport) { const reported = hasReportForObject(objectId); els.bubbleReport.disabled = reported; @@ -3209,6 +3636,7 @@ saveState(); updateSelectionBubble(performance.now()); renderLibrary(); + updateRotationStats(); // Server moderation TODO: when online, hide or delete objects from the server if downvotes exceed a threshold // such as down >= 5 and down - up >= 3. Do not implement server deletion in this local prototype. } @@ -3216,9 +3644,22 @@ function remixSelected() { if (!selectedObject) return; const asset = findAsset(selectedObject.assetId); - if (asset) copyEdit(asset); + if (asset) remixEdit(asset); } + function editSelectedOriginal() { + if (!selectedObject) return; + const asset = findAsset(selectedObject.assetId); + if (!asset) return; + const mine = (asset.author || 'Local Artist') === (state.authorName || 'Local Artist'); + if (!mine) { + toast('Only your own assets can be edited directly. Use Remix instead.'); + return; + } + editOriginalAsset(asset); + } + + function hideSelected() { if (!selectedObject) return; state.hiddenObjects ||= {}; @@ -3569,11 +4010,6 @@ c.beginPath(); c.arc(x, y, r, 0, Math.PI * 2); c.fill(); - c.globalCompositeOperation = 'destination-out'; - c.beginPath(); - c.arc(x + (i % 2 ? 0.4 : -0.6), y + (i % 3 ? 0.2 : -0.4), Math.max(0.6, r - 1.0), 0, Math.PI * 2); - c.fill(); - c.globalCompositeOperation = 'source-over'; } return canvas; } @@ -3691,6 +4127,19 @@ return palette[tile?.type] || '#d9d2c0'; } + + function makeStepParticleColor(baseColor, tileType) { + const presets = { + grass: ['#d7f39a', '#b7e36f', '#f0cf61', '#6fbf65'], + sand: ['#fff1ba', '#f2c879', '#ffffff', '#d4a957'], + highland: ['#d5e8a4', '#b4c77c', '#ffffff', '#7f9e6d'], + water: ['#d9f7ff', '#a8e5ff'] + }; + const list = presets[tileType] || presets.grass; + const chosen = Math.random() < 0.72 ? list[Math.floor(Math.random() * list.length)] : varyHexColor(baseColor, 58, 34, 28); + return chosen; + } + function loadState() { try { const raw = localStorage.getItem(STORAGE_KEY); @@ -3716,6 +4165,7 @@ cachePhase2State(); updateSyncStats(); updateGuardrailStats(); + updateRotationStats(); } function normalizeState(input) { @@ -3733,7 +4183,9 @@ guardrails: { ...PHASE5_GUARDRAILS, ...(input.guardrails || {}) }, eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)) : [], sync: input.sync || { lastEventId: null }, - settings: { ...defaultVisualSettings(), ...(input.settings || {}) } + settings: { ...defaultVisualSettings(), ...(input.settings || {}) }, + account: input.account || null, + publishLog: Array.isArray(input.publishLog) ? input.publishLog.slice(-300) : [] }; } @@ -3768,10 +4220,15 @@ .map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || meta.lightColor || nearestPaletteCode('#ffd86a') })) .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) : []; - return buildAssetMeta(category, subtype, normalizedDepth, lightPixels, meta.lightColor || '#ffd86a', meta.door || null, size); + const particlePixels = Array.isArray(meta.particlePixels) + ? meta.particlePixels + .map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || nearestPaletteCode('#ffffff') })) + .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) + : []; + return buildAssetMeta(category, subtype, normalizedDepth, lightPixels, meta.lightColor || '#ffd86a', meta.door || null, size, particlePixels); } - function buildAssetMeta(category, subtype, sourceDepthPixels, sourceLightPixels, lightColor, door, size = editorSize) { + function buildAssetMeta(category, subtype, sourceDepthPixels, sourceLightPixels, lightColor, door, size = editorSize, sourceParticlePixels = []) { const normalizedDepth = normalizeDepthPixels(sourceDepthPixels || [], size); const hasDepth = normalizedDepth.some(Boolean); const lightPixelsClean = Array.isArray(sourceLightPixels) @@ -3779,11 +4236,19 @@ .map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || nearestPaletteCode(lightColor || '#ffd86a') })) .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) : []; + const particlePixelsClean = Array.isArray(sourceParticlePixels) + ? sourceParticlePixels + .map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || nearestPaletteCode('#ffffff') })) + .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) + : []; const hasLight = lightPixelsClean.length > 0; + const hasParticles = particlePixelsClean.length > 0; return { hasLight, lightPixels: hasLight ? lightPixelsClean : [], lightColor: hasLight ? nearestPaletteCode(lightColor || '#ffd86a') : null, + hasParticles, + particlePixels: hasParticles ? particlePixelsClean : [], depthPixels: hasDepth ? encodeDepthPixels(normalizedDepth) : null, door: category === 'static' && subtype === 'building' && door ? { x: clampInt(door.x, 0, size - 1, Math.floor(size / 2)), y: clampInt(door.y, 0, size - 1, size - 1) } : null }; @@ -3796,6 +4261,8 @@ x: clampInt(item.x, 0, WORLD_W - 1, 0), y: clampInt(item.y, 0, WORLD_H - 1, 0), placedAt: item.placedAt || item.createdAt || Date.now(), + publishedAt: item.publishedAt || item.placedAt || item.createdAt || Date.now(), + status: item.status || 'active', version: Number(item.version) || 1 })).filter((item) => item.assetId); } @@ -3807,6 +4274,8 @@ homeX: clampInt(item.homeX ?? item.x, 0, WORLD_W - 1, 0), homeY: clampInt(item.homeY ?? item.y, 0, WORLD_H - 1, 0), createdAt: item.createdAt || item.placedAt || Date.now(), + publishedAt: item.publishedAt || item.createdAt || item.placedAt || Date.now(), + status: item.status || 'active', version: Number(item.version) || 1 })).filter((item) => item.assetId); } @@ -3844,8 +4313,8 @@ function seedState() { const assets = [ makeAsset('Hill Cottage', 'static', 'building', drawCottage(), { hasLight: true, lightPixels: [{ x: 8, y: 8 }, { x: 9, y: 8 }], lightColor: '#ffd86a', door: { x: 7, y: 14 } }), - makeAsset('Pine Cluster', 'static', 'nature', drawPineCluster()), - makeAsset('Shell Rock', 'static', 'nature', drawShellRock()), + makeAsset('Pine Cluster', 'static', 'nature', drawPineCluster(), { particlePixels: [{ x: 7, y: 4, c: nearestPaletteCode('#9bdc6d') }, { x: 10, y: 6, c: nearestPaletteCode('#f4a1bb') }] }), + makeAsset('Shell Rock', 'static', 'nature', drawShellRock(), { particlePixels: [{ x: 8, y: 9, c: nearestPaletteCode('#ffffff') }] }), makeAsset('Wave Skiff', 'static', 'ship', drawShip(), { depthPixels: drawShipDepth(), hasLight: false }), makeAsset('Fisher Kid', 'dynamic', 'human', drawFisherKidRight()), makeAsset('Moss Cat', 'dynamic', 'animal', drawCatRight()), @@ -3873,6 +4342,8 @@ moderationReports: [], guardrails: { ...PHASE5_GUARDRAILS }, settings: defaultVisualSettings(), + account: null, + publishLog: [], dynamicSummons: [ { id: uid(), assetId: idByName['Fisher Kid'], homeX: 36, homeY: 38, createdAt: Date.now(), version: 1 }, { id: uid(), assetId: idByName['Moss Cat'], homeX: 33, homeY: 40, createdAt: Date.now(), version: 1 }, @@ -3899,7 +4370,7 @@ createdAt: Date.now(), updatedAt: Date.now(), author: 'Island Team', - meta: buildAssetMeta(category, subtype, meta.depthPixels || [], meta.lightPixels || [], meta.lightColor || '#ffd86a', meta.door || null, size) + meta: buildAssetMeta(category, subtype, meta.depthPixels || [], meta.lightPixels || [], meta.lightColor || '#ffd86a', meta.door || null, size, meta.particlePixels || []) }; asset.contentHash = computeAssetContentHash(asset); return asset; @@ -4158,6 +4629,7 @@ asset.faces?.left || '', asset.meta?.depthPixels || '', JSON.stringify(asset.meta?.lightPixels || []), + JSON.stringify(asset.meta?.particlePixels || []), asset.meta?.door ? `${asset.meta.door.x},${asset.meta.door.y}` : '' ].join('|'); return `fnv1a:${fnv1a(payload)}`; diff --git a/index.html b/index.html index 0360c91..3d6d6f2 100644 --- a/index.html +++ b/index.html @@ -11,6 +11,11 @@ +
@@ -20,6 +25,8 @@
Day
@@ -111,37 +118,28 @@ -
-
- - - -
-
- - - -
+ - +
-
Palette color is used for pixels, lights, and depth marks. Shortcuts: B/E/F/I/L/R/S, Ctrl/Cmd+Z, arrows move a selection.
+
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.
Finish
- - + + +
@@ -154,11 +152,12 @@
Library
-

Click an asset to select it and jump to it on the map. Copy Edit creates a new derivative.

+

Your works are permanent here. The island is a temporary exhibition: place your own new or past works; remix only copies lineage and pixels to the canvas.

+
@@ -166,13 +165,16 @@
Local save
-

Saved to this browser only. Export JSON if you want to move or share a local world.

+

Works are durable library assets; island placements are temporary exhibition objects. Export JSON if you want to move or test a local world.

+ + +
@@ -182,12 +184,16 @@
Visual settings
-

Turn major visual systems on or off.

+

Turn major visual systems on or off. Server decides the public 250 exhibition slots; this local cap only filters your view.

+
+
diff --git a/js/phase2-sync.js b/js/phase2-sync.js index 20692a6..1ae0e8e 100644 --- a/js/phase2-sync.js +++ b/js/phase2-sync.js @@ -115,9 +115,13 @@ 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]) : []; + const particles = Array.isArray(asset.meta?.particlePixels) + ? asset.meta.particlePixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || '']).filter((p) => p[2]) + : []; const meta = {}; if (depth && /[1\-]/.test(depth)) meta.d = cropPlane(depth, size, '.'); if (lights.length) meta.l = lights; + if (particles.length) meta.pt = particles; if (asset.meta?.lightColor) meta.lc = asset.meta.lightColor; if (asset.meta?.door) meta.dr = [Number(asset.meta.door.x) || 0, Number(asset.meta.door.y) || 0]; @@ -148,10 +152,15 @@ const lightPixels = Array.isArray(metaPacked.l) ? metaPacked.l.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || metaPacked.lc || 'a' })) : []; + const particlePixels = Array.isArray(metaPacked.pt) + ? metaPacked.pt.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || 'a' })) + : []; const meta = { hasLight: lightPixels.length > 0, lightPixels, lightColor: lightPixels.length > 0 ? (metaPacked.lc || lightPixels[0]?.c || null) : null, + hasParticles: particlePixels.length > 0, + particlePixels, depthPixels: metaPacked.d ? expandPlane(metaPacked.d, size, '.') : null, door: Array.isArray(metaPacked.dr) ? { x: Number(metaPacked.dr[0]) || 0, y: Number(metaPacked.dr[1]) || 0 } : null }; @@ -174,21 +183,29 @@ } function packPlacement(item) { - return [item.id, item.assetId, Number(item.x) || 0, Number(item.y) || 0, item.placedAt || Date.now(), Number(item.version) || 1]; + const meta = {}; + if (item.publishedAt) meta.pu = item.publishedAt; + if (item.status && item.status !== 'active') meta.st = item.status; + 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; - return { id: row[0], assetId: row[1], x: row[2], y: row[3], placedAt: row[4], version: row[5] || 1 }; + 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' }; } function packDynamic(item) { - return [item.id, item.assetId, Number(item.homeX) || 0, Number(item.homeY) || 0, item.createdAt || Date.now(), Number(item.version) || 1]; + const meta = {}; + if (item.publishedAt) meta.pu = item.publishedAt; + if (item.status && item.status !== 'active') meta.st = item.status; + 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; - return { id: row[0], assetId: row[1], homeX: row[2], homeY: row[3], createdAt: row[4], version: row[5] || 1 }; + 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' }; } function compactState(state) { @@ -206,6 +223,8 @@ moderationReports: Array.isArray(state.moderationReports) ? state.moderationReports : [], guardrails: state.guardrails || null, settings: state.settings || null, + account: state.account || null, + publishLog: Array.isArray(state.publishLog) ? state.publishLog.slice(-300) : [], eventLog: Array.isArray(state.eventLog) ? state.eventLog.slice(-EVENT_LOG_LIMIT) : [], sync: state.sync || { lastEventId: null } }; @@ -226,6 +245,8 @@ moderationReports: Array.isArray(input.moderationReports) ? input.moderationReports : [], guardrails: input.guardrails || null, settings: input.settings || null, + account: input.account || null, + publishLog: Array.isArray(input.publishLog) ? input.publishLog.slice(-300) : [], eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-EVENT_LOG_LIMIT) : [], sync: input.sync || { lastEventId: null } }; diff --git a/server/__pycache__/compression_lab.cpython-313.pyc b/server/__pycache__/compression_lab.cpython-313.pyc new file mode 100644 index 0000000..ced65c6 Binary files /dev/null and b/server/__pycache__/compression_lab.cpython-313.pyc differ diff --git a/server/__pycache__/rotation_worker.cpython-313.pyc b/server/__pycache__/rotation_worker.cpython-313.pyc new file mode 100644 index 0000000..bcdbb7c Binary files /dev/null and b/server/__pycache__/rotation_worker.cpython-313.pyc differ diff --git a/server/compression_lab.py b/server/compression_lab.py new file mode 100644 index 0000000..b932d85 --- /dev/null +++ b/server/compression_lab.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Compare stronger compression candidates for Pixel Island asset JSON. + +Usage: + python server/compression_lab.py exported_world.json + +This does not change production data. It reports approximate sizes for raw JSON, +minified JSON, gzip, zlib, and brotli if the optional `brotli` module is installed. +For browser/server interchange, prefer: compact JSON -> gzip/brotli at HTTP layer. +""" +from __future__ import annotations +import argparse, gzip, json, zlib +from pathlib import Path + +try: + import brotli # type: ignore +except Exception: # pragma: no cover + brotli = None + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument('json_file', type=Path) + args = ap.parse_args() + data = json.loads(args.json_file.read_text(encoding='utf-8')) + pretty = json.dumps(data, ensure_ascii=False, indent=2).encode('utf-8') + mini = json.dumps(data, ensure_ascii=False, separators=(',', ':')).encode('utf-8') + rows = [ + ('pretty_json', len(pretty)), + ('minified_json', len(mini)), + ('gzip_9', len(gzip.compress(mini, compresslevel=9))), + ('zlib_9', len(zlib.compress(mini, level=9))), + ] + if brotli: + rows.append(('brotli_11', len(brotli.compress(mini, quality=11)))) + base = len(pretty) or 1 + for name, size in rows: + print(f'{name:14} {size:10d} bytes {size/base:6.1%} of pretty JSON') + return 0 + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/server/rotation_worker.py b/server/rotation_worker.py new file mode 100644 index 0000000..5415873 --- /dev/null +++ b/server/rotation_worker.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +"""Authoritative server-side exhibition rotation policy for Pixel Island. + +This worker treats assets as permanent library works and placements as temporary +island exhibition objects. The browser may preview the same policy locally, but +production should accept this worker/database layer as the source of truth. + +Policy: +- Account required to publish. +- First 24h: 5 public placements/hour. Day 2+: 10 placements/hour. +- Island exhibition cap: 250 active objects. +- 150 slots are newest/recent-publication slots. +- 100 slots are random revival slots. +- Newest and revival buckets are selected independently; duplicates are removed. +- Upvote rank effect is capped at +50 votes. +- Downvotes advance rotation-out. Extreme downvote hides use the same user-facing + wording as capacity rotation, unless an admin marks a violation. +- Reports are local-user hides in the client. Server-side moderation creates + permanent_hidden / violation_hidden only after admin/policy action. +- permanent_hidden can be restored by an admin. +""" +from __future__ import annotations + +import argparse +import json +import random +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Iterable, List, MutableMapping, Optional, Tuple + +DISPLAY_LIMIT = 250 +NEWEST_SLOTS = 150 +REVIVAL_SLOTS = 100 +REVIVAL_SAMPLE_SIZE = 50 +REVIVAL_PICK_COUNT = 20 +UPVOTE_DELAY_SLOTS = 20 +DOWNVOTE_ADVANCE_SLOTS = 25 +UPVOTE_RANK_CAP = 50 +FIRST_DAY_LIMIT = 5 +TRUSTED_LIMIT = 10 +ONE_HOUR_MS = 60 * 60 * 1000 +ONE_DAY_MS = 24 * ONE_HOUR_MS +EXTREME_DOWNVOTES = 10 +EXTREME_MARGIN = 8 + +ACTIVE = "active" +HIDDEN_ROTATION = "hidden_rotation" +PERMANENT_HIDDEN = "permanent_hidden" +VIOLATION_HIDDEN = "violation_hidden" + +PUBLIC_REASON_ROTATION = "island_exhibition_full" +PUBLIC_REASON_VIOLATION = "moderation_violation" + +@dataclass +class RotationConfig: + display_limit: int = DISPLAY_LIMIT + newest_slots: int = NEWEST_SLOTS + revival_slots: int = REVIVAL_SLOTS + revival_sample_size: int = REVIVAL_SAMPLE_SIZE + revival_pick_count: int = REVIVAL_PICK_COUNT + upvote_delay_slots: int = UPVOTE_DELAY_SLOTS + downvote_advance_slots: int = DOWNVOTE_ADVANCE_SLOTS + upvote_rank_cap: int = UPVOTE_RANK_CAP + extreme_downvotes: int = EXTREME_DOWNVOTES + extreme_margin: int = EXTREME_MARGIN + + +def now_ms() -> int: + return int(time.time() * 1000) + + +def object_id(obj: MutableMapping[str, Any]) -> str: + return str(obj.get("id") or "") + + +def public_at(obj: MutableMapping[str, Any]) -> int: + return int(obj.get("publishedAt") or obj.get("placedAt") or obj.get("createdAt") or 0) + + +def author_id_from_account(account: MutableMapping[str, Any] | None) -> str: + return str((account or {}).get("id") or "") + + +def account_publish_limit(account: MutableMapping[str, Any] | None, now: Optional[int] = None) -> int: + if not account or not account.get("createdAt"): + return 0 + now = now or now_ms() + created = int(account.get("createdAt") or now) + return FIRST_DAY_LIMIT if now - created < ONE_DAY_MS else TRUSTED_LIMIT + + +def can_publish(state: MutableMapping[str, Any], account: MutableMapping[str, Any] | None, now: Optional[int] = None) -> Tuple[bool, Dict[str, Any]]: + """API helper. Production publish endpoints should call this before accepting a placement.""" + now = now or now_ms() + account_id = author_id_from_account(account) + limit = account_publish_limit(account, now) + if not account_id or limit <= 0: + return False, {"reason": "account_required", "used": 0, "limit": 0} + log = [entry for entry in state.get("publishLog") or [] if now - int(entry.get("at") or 0) < ONE_DAY_MS] + used = sum(1 for entry in log if entry.get("author") == account_id and now - int(entry.get("at") or 0) < ONE_HOUR_MS) + state["publishLog"] = log + return used < limit, {"reason": None if used < limit else "quota_exceeded", "used": used, "limit": limit} + + +def record_publish(state: MutableMapping[str, Any], account: MutableMapping[str, Any], obj: MutableMapping[str, Any], kind: str, action: str, now: Optional[int] = None) -> None: + now = now or now_ms() + obj["publishedAt"] = now + obj["status"] = ACTIVE + obj.pop("hiddenReason", None) + obj.pop("hiddenAt", None) + log = state.setdefault("publishLog", []) + log.append({"at": now, "author": author_id_from_account(account), "kind": kind, "objectId": object_id(obj), "assetId": obj.get("assetId"), "action": action}) + state["publishLog"] = log[-10000:] + + +def iter_objects(state: MutableMapping[str, Any]) -> Iterable[Tuple[str, MutableMapping[str, Any]]]: + for obj in state.get("placed") or []: + if isinstance(obj, MutableMapping) and obj.get("id"): + yield "static", obj + for obj in state.get("dynamicSummons") or []: + if isinstance(obj, MutableMapping) and obj.get("id"): + yield "dynamic", obj + + +def vote_counts(state: MutableMapping[str, Any], obj_id: str) -> Tuple[int, int]: + votes = (state.get("objectVotes") or {}).get(obj_id) or {} + return int(votes.get("up") or 0), int(votes.get("down") or 0) + + +def is_moderation_hidden(obj: MutableMapping[str, Any]) -> bool: + return obj.get("status") in {PERMANENT_HIDDEN, VIOLATION_HIDDEN} or obj.get("permanentHidden") is True + + +def eligible_for_exhibition(obj: MutableMapping[str, Any]) -> bool: + return not is_moderation_hidden(obj) + + +def rotation_entry(state: MutableMapping[str, Any], kind: str, obj: MutableMapping[str, Any], base_index: int, config: RotationConfig) -> Dict[str, Any]: + up_raw, down = vote_counts(state, object_id(obj)) + up_rank = min(up_raw, config.upvote_rank_cap) + return { + "kind": kind, + "object": obj, + "id": object_id(obj), + "publicAt": public_at(obj), + "baseIndex": base_index, + "up": up_raw, + "upRank": up_rank, + "down": down, + "effectiveSlot": base_index + up_rank * config.upvote_delay_slots - down * config.downvote_advance_slots, + } + + +def rotation_entries(state: MutableMapping[str, Any], config: RotationConfig) -> List[Dict[str, Any]]: + pairs = [(kind, obj) for kind, obj in iter_objects(state) if eligible_for_exhibition(obj)] + pairs.sort(key=lambda pair: (public_at(pair[1]), object_id(pair[1]))) + return [rotation_entry(state, kind, obj, i, config) for i, (kind, obj) in enumerate(pairs)] + + +def deterministic_random_score(obj_id: str, seed: int) -> float: + rng = random.Random(f"{seed}:{obj_id}") + return rng.random() + + +def select_exhibition_buckets(state: MutableMapping[str, Any], config: RotationConfig, seed: Optional[int] = None) -> Dict[str, List[Dict[str, Any]]]: + seed = seed if seed is not None else int(state.get("lastRotationAt") or now_ms()) // ONE_DAY_MS + entries = rotation_entries(state, config) + newest = sorted(entries, key=lambda e: (e["effectiveSlot"], e["publicAt"], e["id"]), reverse=True)[:config.newest_slots] + newest_ids = {e["id"] for e in newest} + revival_pool = [e for e in entries if e["id"] not in newest_ids] + + # Periodic server revival: sample up to 50 previous/excess objects, then take 20 with + # the best capped score, while keeping randomness in the sample. + historical = [e for e in revival_pool if e["object"].get("status") == HIDDEN_ROTATION] + rng = random.Random(seed) + sample = rng.sample(historical, min(config.revival_sample_size, len(historical))) if historical else [] + picked = sorted(sample, key=lambda e: (e["upRank"], e["upRank"] - e["down"], e["publicAt"]), reverse=True)[:config.revival_pick_count] + picked_ids = {e["id"] for e in picked} + + rest = [e for e in revival_pool if e["id"] not in picked_ids] + random_rest = sorted(rest, key=lambda e: (deterministic_random_score(e["id"], seed), e["upRank"] - e["down"]), reverse=True) + revival = (picked + random_rest)[:config.revival_slots] + return {"newest": newest, "revival": revival, "entries": entries} + + +def apply_extreme_downvote_policy(state: MutableMapping[str, Any], config: RotationConfig, now: Optional[int] = None) -> List[Dict[str, Any]]: + """Hide extreme downvote cases from the island with the same public text as capacity rotation. + + This is not a violation decision. It stays reversible and is separate from admin + violation hiding. + """ + now = now or now_ms() + changed: List[Dict[str, Any]] = [] + for kind, obj in iter_objects(state): + if is_moderation_hidden(obj): + continue + up, down = vote_counts(state, object_id(obj)) + if down >= config.extreme_downvotes and down - up >= config.extreme_margin: + obj["status"] = HIDDEN_ROTATION + obj["hiddenReason"] = PUBLIC_REASON_ROTATION + obj["hiddenAt"] = now + changed.append({"objectId": object_id(obj), "assetId": obj.get("assetId"), "kind": kind, "reason": "extreme_downvotes_as_rotation", "up": up, "down": down}) + return changed + + +def apply_exhibition_cap(state: MutableMapping[str, Any], config: RotationConfig, seed: Optional[int] = None, now: Optional[int] = None) -> Dict[str, Any]: + now = now or now_ms() + buckets = select_exhibition_buckets(state, config, seed=seed) + visible_ids = {e["id"] for e in buckets["newest"] + buckets["revival"]} + newly_active = newly_hidden = 0 + for entry in buckets["entries"]: + obj = entry["object"] + if entry["id"] in visible_ids: + if obj.get("status") != ACTIVE: + newly_active += 1 + obj["status"] = ACTIVE + obj.pop("hiddenReason", None) + obj.pop("hiddenAt", None) + else: + if obj.get("status") != HIDDEN_ROTATION: + newly_hidden += 1 + obj["status"] = HIDDEN_ROTATION + obj["hiddenReason"] = PUBLIC_REASON_ROTATION + obj["hiddenAt"] = now + return {"active": len(visible_ids), "newest": len(buckets["newest"]), "revival": len(buckets["revival"]), "rotationHidden": max(0, len(buckets["entries"]) - len(visible_ids)), "newlyActive": newly_active, "newlyHidden": newly_hidden} + + +def hide_violation(state: MutableMapping[str, Any], object_ids: List[str], now: Optional[int] = None) -> List[str]: + now = now or now_ms() + hidden: List[str] = [] + for _, obj in iter_objects(state): + if object_id(obj) in object_ids: + obj["status"] = VIOLATION_HIDDEN + obj["permanentHidden"] = True + obj["hiddenReason"] = PUBLIC_REASON_VIOLATION + obj["hiddenAt"] = now + hidden.append(object_id(obj)) + return hidden + + +def restore_permanent(state: MutableMapping[str, Any], object_ids: List[str], now: Optional[int] = None) -> List[str]: + """Admin restore for permanent/violation hidden placements.""" + now = now or now_ms() + restored: List[str] = [] + for _, obj in iter_objects(state): + if object_id(obj) in object_ids and is_moderation_hidden(obj): + obj["status"] = HIDDEN_ROTATION + obj["permanentHidden"] = False + obj["hiddenReason"] = PUBLIC_REASON_ROTATION + obj["hiddenAt"] = now + restored.append(object_id(obj)) + return restored + + +def run_rotation(state: MutableMapping[str, Any], config: RotationConfig, seed: Optional[int] = None) -> Dict[str, Any]: + now = now_ms() + extreme = apply_extreme_downvote_policy(state, config, now=now) + cap = apply_exhibition_cap(state, config, seed=seed, now=now) + state["lastRotationAt"] = now + state["rotationPolicy"] = { + "displayLimit": config.display_limit, + "newestSlots": config.newest_slots, + "revivalSlots": config.revival_slots, + "upvoteRankCap": config.upvote_rank_cap, + "serverAuthoritative": True, + } + return {"extremeDownvoteHiddenAsRotation": extreme, "cap": cap, "lastRotationAt": now} + + +def load_json(path: Path) -> MutableMapping[str, Any]: + with path.open("r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, MutableMapping): + raise ValueError("World JSON root must be an object") + return data + + +def save_json(path: Path, data: MutableMapping[str, Any]) -> None: + with path.open("w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, separators=(",", ":")) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run Pixel Island exhibition rotation policy on a world JSON file.") + parser.add_argument("world_json", type=Path) + parser.add_argument("--write", action="store_true") + parser.add_argument("--out", type=Path) + parser.add_argument("--seed", type=int) + parser.add_argument("--display-limit", type=int, default=DISPLAY_LIMIT) + parser.add_argument("--newest-slots", type=int, default=NEWEST_SLOTS) + parser.add_argument("--revival-slots", type=int, default=REVIVAL_SLOTS) + parser.add_argument("--restore", nargs="*", default=None, help="Admin restore object IDs from permanent/violation hidden to rotation hidden.") + parser.add_argument("--hide-violation", nargs="*", default=None, help="Admin hide object IDs as violation_hidden.") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + state = load_json(args.world_json) + config = RotationConfig(display_limit=args.display_limit, newest_slots=args.newest_slots, revival_slots=args.revival_slots) + summary: Dict[str, Any] = {} + if args.restore: + summary["restored"] = restore_permanent(state, args.restore) + if args.hide_violation: + summary["violationHidden"] = hide_violation(state, args.hide_violation) + if not args.restore and not args.hide_violation: + summary = run_rotation(state, config, seed=args.seed) + print(json.dumps(summary, ensure_ascii=False, indent=2)) + if args.write or args.out: + save_json(args.out or args.world_json, state) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/styles.css b/styles.css index f0c5455..f8c9b9a 100644 --- a/styles.css +++ b/styles.css @@ -968,3 +968,50 @@ body, button, input, select, textarea { .toggleList { display:flex; flex-direction:column; gap:10px; } .checkRow { display:flex; align-items:center; gap:10px; font-size:13px; color:#243044; } .checkRow input { width:16px; height:16px; } + +/* Phase 5 UI polish: larger readable controls while keeping palette code labels small. */ +body, button, input, select, textarea { font-size: 15px; } +.logo { font-size: 24px; } +.subline { font-size: 14px; } +.authorCard span, .clockHint { font-size: 12px; } +.phaseLabel { font-size: 14px; } +.iconButton, .tool, .tab, button { font-size: 14px; line-height: 1.15; } +.toolHud .iconButton { font-size: 14px; min-width: 74px; padding: 10px 12px; } +.cardTitle { font-size: 17px; } +.field, .hint, .lineageNote, .syncStats { font-size: 14px; line-height: 1.45; } +.toolRow { gap: 8px; } +.editorHistoryRow { grid-template-columns: repeat(6, minmax(0, 1fr)); } +.advancedRow { grid-template-columns: repeat(6, max-content) 1fr; align-items: start; } +.assetMeta strong { font-size: 16px; } +.assetMeta span { font-size: 13px; } +.assetActions button { font-size: 12px; padding: 6px 7px; } +.selectionBubble { font-size: 14px; } +.bubbleName { font-size: 15px; } +.bubbleAuthor, .bubbleRemixFrom, .bubbleRemixCount { font-size: 12px; } +.bubbleActions button, .bubbleVotes button { font-size: 12px; padding: 6px 8px; } +.paletteGrid button, .paletteSwatch { font-size: 7px !important; } +.checkRow { font-size: 14px; } +@media (max-width: 760px) { + body, button, input, select, textarea { font-size: 14px; } + .toolHud .iconButton { min-width: 68px; } + .editorHistoryRow { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .advancedRow { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} + +.displayLimitField input { font-size: 16px; min-height: 36px; width: 110px; } +.rotationBadge { font-size: 13px; color: #4d5b72; } + + +/* exhibition/account polish */ +.authorCard { min-width: 210px; } +.authorCard small { display:block; margin-top:6px; font-size:12px; line-height:1.35; opacity:.82; } +.miniButton { margin-top:6px; padding:6px 8px; border:2px solid #243044; background:#fff3d9; box-shadow:2px 2px 0 rgba(36,48,68,.22); cursor:pointer; font-size:12px; } +.placementPreviewBar { position:fixed; left:50%; bottom:22px; transform:translateX(-50%); z-index:35; display:flex; align-items:center; gap:10px; max-width:min(760px, calc(100vw - 24px)); padding:12px 14px; border:3px solid #243044; background:#fff7de; box-shadow:5px 5px 0 rgba(36,48,68,.25); font-size:15px; } +.placementPreviewBar[hidden] { display:none; } +.likedCodex { margin:8px 0 14px; padding:10px; border:2px dashed rgba(36,48,68,.35); background:rgba(255,255,255,.45); font-size:14px; } +.likedCodexTitle { font-weight:700; margin-bottom:6px; } +.likedCodexList { display:flex; gap:8px; flex-wrap:wrap; } +.likedCodexItem { display:flex; align-items:center; gap:6px; border:2px solid rgba(36,48,68,.25); background:#fffdf5; padding:4px 6px; max-width:180px; } +.likedCodexItem canvas { width:28px; height:28px; image-rendering:pixelated; } +.hiddenReasonText { color:#6f2f3b; font-weight:700; } +.exhibitionNote { padding:8px 10px; background:#fff3d9; border:2px solid rgba(36,48,68,.2); margin-bottom:8px; font-size:14px; line-height:1.45; }