2026-06-02 02:35:22 +09:00
|
|
|
#!/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
|
|
|
|
|
|
2026-06-02 17:19:36 +09:00
|
|
|
def __post_init__(self) -> None:
|
|
|
|
|
self.display_limit = max(0, int(self.display_limit))
|
|
|
|
|
self.newest_slots = max(0, int(self.newest_slots))
|
|
|
|
|
self.revival_slots = max(0, int(self.revival_slots))
|
|
|
|
|
if self.newest_slots + self.revival_slots > self.display_limit:
|
|
|
|
|
self.revival_slots = max(0, self.display_limit - self.newest_slots)
|
|
|
|
|
self.newest_slots = min(self.newest_slots, self.display_limit)
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def from_mapping(cls, values: MutableMapping[str, Any] | None) -> "RotationConfig":
|
|
|
|
|
data = values or {}
|
|
|
|
|
allowed = set(cls.__dataclass_fields__)
|
|
|
|
|
clean = {key: int(value) for key, value in data.items() if key in allowed and value is not None}
|
|
|
|
|
return cls(**clean)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_config(path: Optional[Path], overrides: MutableMapping[str, Any] | None = None) -> RotationConfig:
|
|
|
|
|
data: Dict[str, Any] = {}
|
|
|
|
|
if path:
|
|
|
|
|
with path.open("r", encoding="utf-8") as f:
|
|
|
|
|
loaded = json.load(f)
|
|
|
|
|
if not isinstance(loaded, MutableMapping):
|
|
|
|
|
raise ValueError("Rotation config JSON root must be an object")
|
|
|
|
|
data.update(loaded)
|
|
|
|
|
data.update({k: v for k, v in (overrides or {}).items() if v is not None})
|
|
|
|
|
return RotationConfig.from_mapping(data)
|
|
|
|
|
|
2026-06-02 02:35:22 +09:00
|
|
|
|
|
|
|
|
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)
|
2026-06-02 17:19:36 +09:00
|
|
|
parser.add_argument("--config", type=Path, help="Optional rotation policy JSON file.")
|
|
|
|
|
parser.add_argument("--display-limit", type=int)
|
|
|
|
|
parser.add_argument("--newest-slots", type=int)
|
|
|
|
|
parser.add_argument("--revival-slots", type=int)
|
2026-06-02 02:35:22 +09:00
|
|
|
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)
|
2026-06-02 17:19:36 +09:00
|
|
|
config = load_config(args.config, {"display_limit": args.display_limit, "newest_slots": args.newest_slots, "revival_slots": args.revival_slots})
|
2026-06-02 02:35:22 +09:00
|
|
|
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())
|