This commit is contained in:
33333-33333 2026-06-02 02:35:22 +09:00
commit 67a7477242
9 changed files with 1116 additions and 139 deletions

View file

@ -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.

706
app.js

File diff suppressed because it is too large Load diff

View file

@ -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">

View file

@ -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 }
};

Binary file not shown.

Binary file not shown.

42
server/compression_lab.py Normal file
View 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
View 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())

View file

@ -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; }