server
This commit is contained in:
parent
2501a4c975
commit
67a7477242
9 changed files with 1116 additions and 139 deletions
73
README.md
73
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.
|
||||
|
|
|
|||
42
index.html
42
index.html
|
|
@ -11,6 +11,11 @@
|
|||
<canvas id="worldCanvas" aria-label="Island map"></canvas>
|
||||
|
||||
<button id="openEditor" class="edgeAdd" title="Open Pixel Studio">+</button>
|
||||
<div id="placementPreviewBar" class="placementPreviewBar" hidden>
|
||||
<button id="confirmPreviewPlace" class="primary" type="button">Place here</button>
|
||||
<button id="backToCanvas" class="secondary" type="button">Back to canvas</button>
|
||||
<span>Check how this work looks on the island. Works are permanent in Library; island placement is temporary exhibition.</span>
|
||||
</div>
|
||||
|
||||
<header class="hud topHud">
|
||||
<div>
|
||||
|
|
@ -20,6 +25,8 @@
|
|||
<label class="authorCard">
|
||||
<span>Your Name</span>
|
||||
<input id="authorName" type="text" maxlength="24" value="Local Artist" />
|
||||
<button id="createAccount" class="miniButton" type="button">Create local account</button>
|
||||
<small id="accountNote">Account required to publish. Prototype only: name + local timestamp.</small>
|
||||
</label>
|
||||
<div class="clockCard" aria-label="world clock">
|
||||
<div id="phaseLabel" class="phaseLabel">Day</div>
|
||||
|
|
@ -111,37 +118,28 @@
|
|||
<button id="outlinePaint" class="tool" type="button">Outline</button>
|
||||
<button id="flipHorizontal" class="tool" type="button">Flip H</button>
|
||||
<button id="flipVertical" class="tool" type="button">Flip V</button>
|
||||
</div>
|
||||
<div class="toolRow editorMoveRow">
|
||||
<button id="nudgeLeft" class="tool" type="button">←</button>
|
||||
<button id="nudgeUp" class="tool" type="button">↑</button>
|
||||
<button id="nudgeDown" class="tool" type="button">↓</button>
|
||||
<button id="nudgeRight" class="tool" type="button">→</button>
|
||||
<button id="clearSelection" class="tool" type="button" disabled>Clear Sel</button>
|
||||
</div>
|
||||
<div class="toolRow editorFileRow">
|
||||
<button id="exportPng" class="tool" type="button">Export PNG</button>
|
||||
<button id="importPng" class="tool" type="button">Import PNG</button>
|
||||
<input id="pngImportInput" type="file" accept="image/png,image/*" hidden />
|
||||
</div>
|
||||
<div class="advancedRow">
|
||||
<button id="toggleAdvanced" class="tool" type="button">Advanced Draw</button>
|
||||
<button id="toolLight" class="tool advancedOnly" hidden>Light</button>
|
||||
<button id="toolDepth" class="tool advancedOnly" hidden>Depth</button>
|
||||
<button id="toolParticle" class="tool advancedOnly" hidden>Particle</button>
|
||||
<button id="depthHigh" class="tool advancedOnly" type="button" hidden>High</button>
|
||||
<button id="depthLow" class="tool advancedOnly" type="button" hidden>Low</button>
|
||||
<button id="depthClear" class="tool advancedOnly" type="button" hidden>Clear</button>
|
||||
<span id="advancedHint" class="hint" hidden>Light/Depth tools are here. Depth supports High/Low/Clear. Left-drag paints the selected depth value; Shift clears.</span>
|
||||
<span id="advancedHint" class="hint" hidden>Light, Particle, and Depth tools are here. Particle uses the selected palette color; Shift/right-click clears.</span>
|
||||
</div>
|
||||
<input id="paintColor" type="color" value="#6bd06b" hidden />
|
||||
<div id="editHint" class="hint">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.</div>
|
||||
<div id="editHint" class="hint">Palette color is used for pixels, lights, particles, and depth marks. Shortcuts: B/E/F/I/L/R/S, Ctrl/Cmd+Z, arrows move a selection.</div>
|
||||
</div>
|
||||
|
||||
<div class="card stack summonCard">
|
||||
<div class="cardTitle">Finish</div>
|
||||
<div class="actionRow finishActions">
|
||||
<button id="saveAndPlace" class="primary bigPrimary">Save + Summon on Map</button>
|
||||
<button id="saveAsset" class="secondary">Save only</button>
|
||||
<button id="saveAndPlace" class="primary bigPrimary">Save + Place on Island</button>
|
||||
<button id="saveAsset" class="secondary">Save to Library</button>
|
||||
<button id="checkOnIsland" class="secondary">Check on Island</button>
|
||||
<button id="newAsset" class="secondary">New</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -154,11 +152,12 @@
|
|||
<div class="cardTitle">Library</div>
|
||||
<button id="showHiddenAssets" class="tool" type="button">Hidden</button>
|
||||
</div>
|
||||
<p class="hint">Click an asset to select it and jump to it on the map. Copy Edit creates a new derivative.</p>
|
||||
<p class="hint">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.</p>
|
||||
<div id="hiddenAssetPanel" class="hiddenAssetPanel" hidden>
|
||||
<div class="cardTitle">Hidden</div>
|
||||
<div id="hiddenAssetList" class="assetList compactAssetList"></div>
|
||||
</div>
|
||||
<div id="likedCodex" class="likedCodex"></div>
|
||||
<div id="assetList" class="assetList"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -166,13 +165,16 @@
|
|||
<section id="tab-data" class="tabPanel">
|
||||
<div class="card stack">
|
||||
<div class="cardTitle">Local save</div>
|
||||
<p class="hint">Saved to this browser only. Export JSON if you want to move or share a local world.</p>
|
||||
<p class="hint">Works are durable library assets; island placements are temporary exhibition objects. Export JSON if you want to move or test a local world.</p>
|
||||
<div class="actionRow wrap">
|
||||
<button id="exportData">Export full JSON</button>
|
||||
<button id="exportCompact">Export compact</button>
|
||||
<button id="exportSnapshot">Export snapshot</button>
|
||||
<button id="exportAssetBundle">Export asset bundle</button>
|
||||
<button id="importData">Import JSON</button>
|
||||
<button id="exportPng" type="button">Export PNG</button>
|
||||
<button id="importPng" type="button">Import PNG</button>
|
||||
<input id="pngImportInput" type="file" accept="image/png,image/*" hidden />
|
||||
<button id="resetAll" class="danger">Reset all</button>
|
||||
</div>
|
||||
<div id="syncStats" class="syncStats"></div>
|
||||
|
|
@ -182,12 +184,16 @@
|
|||
|
||||
<div class="card stack">
|
||||
<div class="cardTitle">Visual settings</div>
|
||||
<p class="hint">Turn major visual systems on or off.</p>
|
||||
<p class="hint">Turn major visual systems on or off. Server decides the public 250 exhibition slots; this local cap only filters your view.</p>
|
||||
<div class="toggleList">
|
||||
<label class="checkRow"><input id="settingLights" type="checkbox" checked /> <span>Lights & glow</span></label>
|
||||
<label class="checkRow"><input id="settingParticles" type="checkbox" checked /> <span>Particles & ambient FX</span></label>
|
||||
<label class="checkRow"><input id="settingDayNight" type="checkbox" checked /> <span>Day / night cycle</span></label>
|
||||
<label class="field displayLimitField">Island display cap
|
||||
<input id="displayLimit" type="number" min="25" max="500" step="25" value="250" />
|
||||
</label>
|
||||
</div>
|
||||
<div id="rotationStats" class="syncStats"></div>
|
||||
</div>
|
||||
|
||||
<div class="card stack">
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
};
|
||||
|
|
|
|||
BIN
server/__pycache__/compression_lab.cpython-313.pyc
Normal file
BIN
server/__pycache__/compression_lab.cpython-313.pyc
Normal file
Binary file not shown.
BIN
server/__pycache__/rotation_worker.cpython-313.pyc
Normal file
BIN
server/__pycache__/rotation_worker.cpython-313.pyc
Normal file
Binary file not shown.
42
server/compression_lab.py
Normal file
42
server/compression_lab.py
Normal file
|
|
@ -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())
|
||||
316
server/rotation_worker.py
Normal file
316
server/rotation_worker.py
Normal file
|
|
@ -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())
|
||||
47
styles.css
47
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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue