From 67a747724250e180ad85658bb09814d9278ddb12 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Tue, 2 Jun 2026 02:35:22 +0900 Subject: [PATCH] server --- README.md | 73 ++ app.js | 706 +++++++++++++++--- index.html | 42 +- js/phase2-sync.js | 29 +- .../compression_lab.cpython-313.pyc | Bin 0 -> 2591 bytes .../rotation_worker.cpython-313.pyc | Bin 0 -> 20739 bytes server/compression_lab.py | 42 ++ server/rotation_worker.py | 316 ++++++++ styles.css | 47 ++ 9 files changed, 1116 insertions(+), 139 deletions(-) create mode 100644 server/__pycache__/compression_lab.cpython-313.pyc create mode 100644 server/__pycache__/rotation_worker.cpython-313.pyc create mode 100644 server/compression_lab.py create mode 100644 server/rotation_worker.py 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 0000000000000000000000000000000000000000..ced65c6cda586629e02f6c7e00531eef1d74f77a GIT binary patch literal 2591 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 AH2?qr literal 0 HcmV?d00001 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; }