This commit is contained in:
33333-33333 2026-06-02 21:43:57 +09:00
commit 8ee6a87d44
7 changed files with 2317 additions and 560 deletions

131
server/test_world_policy.py Normal file
View file

@ -0,0 +1,131 @@
import unittest
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from world_policy import (
ACTIVE,
VIOLATION_HIDDEN,
apply_command,
apply_server_event,
can_delete_asset,
can_import_full_state,
can_modify_object,
)
def base_state():
return {
"worldMode": "shared",
"assets": [
{"id": "asset-a", "ownerAccountId": "alice", "author": "Alice", "version": 1},
{"id": "asset-b", "ownerAccountId": "bob", "author": "Bob", "version": 1},
],
"placed": [
{"id": "obj-a", "assetId": "asset-a", "ownerAccountId": "alice", "x": 1, "y": 1, "version": 1, "status": ACTIVE}
],
"dynamicSummons": [
{"id": "dyn-b", "assetId": "asset-b", "ownerAccountId": "bob", "homeX": 2, "homeY": 2, "version": 1, "status": ACTIVE}
],
"objectVotes": {},
"assetVotes": {},
"hiddenObjects": {},
"hiddenAssets": {},
"moderationReports": [],
"tombstones": {"assets": {}, "objects": {}},
"serverSync": {},
}
class WorldPolicyTest(unittest.TestCase):
def test_non_owner_cannot_delete_static_object(self):
state = base_state()
allowed, meta = can_modify_object(state, {"id": "bob"}, "obj-a")
self.assertFalse(allowed)
self.assertEqual(meta["reason"], "not_object_owner")
result = apply_command(state, {"id": "bob"}, {"type": "object.delete", "kind": "static", "objectId": "obj-a"}, at=100)
self.assertFalse(result["accepted"])
self.assertEqual(result["reason"], "not_object_owner")
self.assertEqual(len(state["placed"]), 1)
def test_non_owner_cannot_move_dynamic_object(self):
state = base_state()
command = {
"type": "object.move",
"kind": "dynamic",
"object": {"id": "dyn-b", "assetId": "asset-b", "homeX": 9, "homeY": 9, "version": 1},
}
result = apply_command(state, {"id": "alice"}, command, at=100)
self.assertFalse(result["accepted"])
self.assertEqual(result["reason"], "not_object_owner")
def test_owner_can_move_and_delete_own_object(self):
state = base_state()
move = apply_command(
state,
{"id": "bob"},
{"type": "object.move", "kind": "dynamic", "object": {"id": "dyn-b", "assetId": "asset-b", "homeX": 9, "homeY": 9, "version": 1}},
at=100,
)
self.assertTrue(move["accepted"])
self.assertTrue(apply_server_event(state, move["event"]))
self.assertEqual(state["dynamicSummons"][0]["homeX"], 9)
self.assertEqual(state["dynamicSummons"][0]["ownerAccountId"], "bob")
delete = apply_command(state, {"id": "bob"}, {"type": "object.delete", "kind": "dynamic", "objectId": "dyn-b"}, at=200)
self.assertTrue(delete["accepted"])
self.assertTrue(apply_server_event(state, delete["event"]))
self.assertEqual(state["dynamicSummons"], [])
self.assertIn("dyn-b", state["tombstones"]["objects"])
def test_admin_can_violation_hide_any_object(self):
state = base_state()
result = apply_command(state, {"id": "mod", "admin": True}, {"type": "admin.hide_violation", "objectId": "obj-a"}, at=100)
self.assertTrue(result["accepted"])
self.assertTrue(apply_server_event(state, result["event"]))
self.assertEqual(state["placed"][0]["status"], VIOLATION_HIDDEN)
self.assertTrue(state["placed"][0]["permanentHidden"])
def test_deleted_object_cannot_be_resurrected_by_stale_upsert(self):
state = base_state()
delete = apply_command(state, {"id": "alice"}, {"type": "object.delete", "kind": "static", "objectId": "obj-a"}, at=100)
self.assertTrue(apply_server_event(state, delete["event"]))
stale = {
"serverEventId": "sev_90_object_upsert",
"serverAt": 90,
"actorAccountId": "alice",
"type": "object.upsert",
"kind": "static",
"object": {"id": "obj-a", "assetId": "asset-a", "ownerAccountId": "alice", "x": 7, "y": 7, "version": 1},
"objectVersion": 1,
}
self.assertFalse(apply_server_event(state, stale))
self.assertEqual(state["placed"], [])
def test_asset_delete_cascades_and_tombstones_owned_objects(self):
state = base_state()
allowed, _ = can_delete_asset(state, {"id": "alice"}, "asset-a")
self.assertTrue(allowed)
result = apply_command(state, {"id": "alice"}, {"type": "asset.delete", "assetId": "asset-a"}, at=100)
self.assertTrue(result["accepted"])
self.assertTrue(apply_server_event(state, result["event"]))
self.assertNotIn("asset-a", [asset["id"] for asset in state["assets"]])
self.assertEqual(state["placed"], [])
self.assertIn("asset-a", state["tombstones"]["assets"])
self.assertIn("obj-a", state["tombstones"]["objects"])
self.assertEqual(len(state["dynamicSummons"]), 1)
def test_shared_full_state_import_is_rejected(self):
state = base_state()
allowed, meta = can_import_full_state(state, {"id": "alice"})
self.assertFalse(allowed)
self.assertEqual(meta["reason"], "shared_full_import_disabled")
local_state = {**state, "worldMode": "local"}
allowed, _ = can_import_full_state(local_state, {"id": "alice"})
self.assertTrue(allowed)
if __name__ == "__main__":
unittest.main()

299
server/world_policy.py Normal file
View file

@ -0,0 +1,299 @@
#!/usr/bin/env python3
"""Authoritative world mutation policy for Pixel Island.
The browser may preview local changes, but shared worlds should apply only the
server events produced here. This module focuses on ownership and destructive
mutation safety; rotation and moderation policy remain separate.
"""
from __future__ import annotations
import time
from copy import deepcopy
from typing import Any, Dict, Iterable, List, MutableMapping, Optional, Tuple
ACTIVE = "active"
VIOLATION_HIDDEN = "violation_hidden"
OBJECT_KEYS = {"static": "placed", "dynamic": "dynamicSummons"}
def now_ms() -> int:
return int(time.time() * 1000)
def account_id(account: MutableMapping[str, Any] | None) -> str:
return str((account or {}).get("id") or "")
def is_admin(account: MutableMapping[str, Any] | None) -> bool:
return bool((account or {}).get("admin") or (account or {}).get("role") == "admin")
def normalize_tombstones(state: MutableMapping[str, Any]) -> MutableMapping[str, Any]:
tombstones = state.setdefault("tombstones", {})
tombstones.setdefault("assets", {})
tombstones.setdefault("objects", {})
return tombstones
def iter_objects(state: MutableMapping[str, Any]) -> Iterable[Tuple[str, MutableMapping[str, Any]]]:
for kind, key in OBJECT_KEYS.items():
for obj in state.get(key) or []:
if isinstance(obj, MutableMapping) and obj.get("id"):
yield kind, obj
def find_object(state: MutableMapping[str, Any], object_id: str, kind: Optional[str] = None) -> Tuple[Optional[str], Optional[MutableMapping[str, Any]]]:
kinds = [kind] if kind in OBJECT_KEYS else list(OBJECT_KEYS)
for item_kind in kinds:
for obj in state.get(OBJECT_KEYS[item_kind]) or []:
if str(obj.get("id") or "") == str(object_id):
return item_kind, obj
return None, None
def find_asset(state: MutableMapping[str, Any], asset_id: str) -> Optional[MutableMapping[str, Any]]:
for asset in state.get("assets") or []:
if isinstance(asset, MutableMapping) and str(asset.get("id") or "") == str(asset_id):
return asset
return None
def owner_of_object(state: MutableMapping[str, Any], obj: MutableMapping[str, Any]) -> str:
if obj.get("ownerAccountId"):
return str(obj.get("ownerAccountId"))
asset = find_asset(state, str(obj.get("assetId") or ""))
return str((asset or {}).get("ownerAccountId") or "")
def can_modify_object(state: MutableMapping[str, Any], account: MutableMapping[str, Any] | None, object_id: str) -> Tuple[bool, Dict[str, Any]]:
actor = account_id(account)
if not actor:
return False, {"reason": "account_required"}
kind, obj = find_object(state, object_id)
if not obj:
return False, {"reason": "object_not_found"}
owner = owner_of_object(state, obj)
if actor == owner or is_admin(account):
return True, {"reason": None, "kind": kind, "ownerAccountId": owner}
return False, {"reason": "not_object_owner", "ownerAccountId": owner}
def can_delete_asset(state: MutableMapping[str, Any], account: MutableMapping[str, Any] | None, asset_id: str) -> Tuple[bool, Dict[str, Any]]:
actor = account_id(account)
if not actor:
return False, {"reason": "account_required"}
asset = find_asset(state, asset_id)
if not asset:
return False, {"reason": "asset_not_found"}
owner = str(asset.get("ownerAccountId") or "")
if actor == owner or is_admin(account):
return True, {"reason": None, "ownerAccountId": owner}
return False, {"reason": "not_asset_owner", "ownerAccountId": owner}
def is_object_tombstoned(state: MutableMapping[str, Any], object_id: str, version: int = 0) -> bool:
tomb = normalize_tombstones(state)["objects"].get(str(object_id))
return bool(tomb and int(tomb.get("version") or 0) >= int(version or 0))
def is_asset_tombstoned(state: MutableMapping[str, Any], asset_id: str, version: int = 0) -> bool:
tomb = normalize_tombstones(state)["assets"].get(str(asset_id))
return bool(tomb and int(tomb.get("version") or 0) >= int(version or 0))
def _server_event(event_type: str, actor: str, payload: Dict[str, Any], at: Optional[int] = None) -> Dict[str, Any]:
stamp = at or now_ms()
return {
"serverEventId": f"sev_{stamp}_{event_type.replace('.', '_')}",
"serverAt": stamp,
"actorAccountId": actor,
"type": event_type,
**payload,
}
def _accepted(event: Dict[str, Any]) -> Dict[str, Any]:
return {"accepted": True, "event": event}
def _rejected(reason: str, **extra: Any) -> Dict[str, Any]:
return {"accepted": False, "reason": reason, **extra}
def _copy_owned_asset(asset: MutableMapping[str, Any], owner: str) -> Dict[str, Any]:
copied = deepcopy(dict(asset))
copied["ownerAccountId"] = owner
copied.setdefault("author", owner)
copied.setdefault("version", 1)
return copied
def _copy_owned_object(obj: MutableMapping[str, Any], owner: str) -> Dict[str, Any]:
copied = deepcopy(dict(obj))
copied["ownerAccountId"] = owner
copied["version"] = int(copied.get("version") or 0) + 1
copied.setdefault("status", ACTIVE)
return copied
def apply_command(state: MutableMapping[str, Any], account: MutableMapping[str, Any] | None, command: MutableMapping[str, Any], at: Optional[int] = None) -> Dict[str, Any]:
"""Validate a client command and return a server event without mutating state."""
actor = account_id(account)
if not actor:
return _rejected("account_required")
command_type = str(command.get("type") or "")
if command_type == "asset.create":
asset = command.get("asset")
if not isinstance(asset, MutableMapping) or not asset.get("id"):
return _rejected("invalid_asset")
if is_asset_tombstoned(state, str(asset["id"]), int(asset.get("version") or 1)):
return _rejected("asset_tombstoned")
return _accepted(_server_event("asset.upsert", actor, {"asset": _copy_owned_asset(asset, actor)}, at))
if command_type in {"object.publish", "object.move"}:
kind = str(command.get("kind") or "static")
obj = command.get("object")
if kind not in OBJECT_KEYS or not isinstance(obj, MutableMapping) or not obj.get("id"):
return _rejected("invalid_object")
asset = find_asset(state, str(obj.get("assetId") or ""))
if not asset:
return _rejected("asset_not_found")
_, existing = find_object(state, str(obj["id"]), kind)
if existing:
allowed, meta = can_modify_object(state, account, str(obj["id"]))
if not allowed:
return _rejected(meta["reason"], **meta)
owner = owner_of_object(state, existing)
else:
if str(asset.get("ownerAccountId") or "") != actor and not is_admin(account):
return _rejected("not_asset_owner", ownerAccountId=asset.get("ownerAccountId"))
owner = actor
next_object = _copy_owned_object(obj, owner)
if is_object_tombstoned(state, str(next_object["id"]), int(next_object.get("version") or 1)):
return _rejected("object_tombstoned")
return _accepted(_server_event("object.upsert", actor, {"kind": kind, "object": next_object, "objectVersion": next_object["version"]}, at))
if command_type == "object.delete":
object_id = str(command.get("objectId") or "")
kind = str(command.get("kind") or "")
allowed, meta = can_modify_object(state, account, object_id)
if not allowed:
return _rejected(meta["reason"], **meta)
found_kind, obj = find_object(state, object_id, kind if kind in OBJECT_KEYS else None)
version = int((obj or {}).get("version") or 0) + 1
tombstone = {"id": object_id, "deletedAt": at or now_ms(), "deletedBy": actor, "version": version}
return _accepted(_server_event("object.delete", actor, {"kind": found_kind, "objectId": object_id, "objectVersion": version, "tombstone": tombstone}, at))
if command_type == "asset.delete":
asset_id = str(command.get("assetId") or "")
allowed, meta = can_delete_asset(state, account, asset_id)
if not allowed:
return _rejected(meta["reason"], **meta)
asset = find_asset(state, asset_id) or {}
version = int(asset.get("version") or 0) + 1
object_tombstones: List[Dict[str, Any]] = []
for _, obj in iter_objects(state):
if str(obj.get("assetId") or "") == asset_id and (is_admin(account) or owner_of_object(state, obj) == actor):
object_tombstones.append({"id": str(obj["id"]), "deletedAt": at or now_ms(), "deletedBy": actor, "version": int(obj.get("version") or 0) + 1})
tombstone = {"id": asset_id, "deletedAt": at or now_ms(), "deletedBy": actor, "version": version}
return _accepted(_server_event("asset.delete", actor, {"assetId": asset_id, "assetVersion": version, "tombstone": tombstone, "objectTombstones": object_tombstones}, at))
if command_type == "admin.hide_violation":
if not is_admin(account):
return _rejected("admin_required")
object_id = str(command.get("objectId") or "")
kind, obj = find_object(state, object_id)
if not obj:
return _rejected("object_not_found")
next_object = deepcopy(dict(obj))
next_object["status"] = VIOLATION_HIDDEN
next_object["permanentHidden"] = True
next_object["hiddenReason"] = "moderation_violation"
next_object["version"] = int(next_object.get("version") or 0) + 1
return _accepted(_server_event("object.upsert", actor, {"kind": kind, "object": next_object, "objectVersion": next_object["version"]}, at))
return _rejected("unknown_command")
def apply_server_event(state: MutableMapping[str, Any], event: MutableMapping[str, Any]) -> bool:
"""Apply only server-issued events. Returns True when state changed."""
if not event or not event.get("serverEventId") or not event.get("serverAt"):
return False
normalize_tombstones(state)
event_type = str(event.get("type") or "")
if event_type == "asset.upsert":
asset = event.get("asset")
if not isinstance(asset, MutableMapping) or not asset.get("id"):
return False
if is_asset_tombstoned(state, str(asset["id"]), int(asset.get("version") or 1)):
return False
assets = state.setdefault("assets", [])
index = next((i for i, item in enumerate(assets) if item.get("id") == asset.get("id")), -1)
if index >= 0:
assets[index] = deepcopy(dict(asset))
else:
assets.insert(0, deepcopy(dict(asset)))
state.setdefault("serverSync", {})["lastServerEventId"] = event["serverEventId"]
return True
if event_type == "object.upsert":
obj = event.get("object")
kind = str(event.get("kind") or "static")
if kind not in OBJECT_KEYS or not isinstance(obj, MutableMapping) or not obj.get("id"):
return False
if is_object_tombstoned(state, str(obj["id"]), int(obj.get("version") or event.get("objectVersion") or 1)):
return False
rows = state.setdefault(OBJECT_KEYS[kind], [])
index = next((i for i, item in enumerate(rows) if item.get("id") == obj.get("id")), -1)
if index >= 0:
rows[index] = deepcopy(dict(obj))
else:
rows.append(deepcopy(dict(obj)))
state.setdefault("serverSync", {})["lastServerEventId"] = event["serverEventId"]
return True
if event_type == "object.delete":
object_id = str(event.get("objectId") or "")
kind = str(event.get("kind") or "")
if not object_id or kind not in OBJECT_KEYS:
return False
tombstone = event.get("tombstone") or {"id": object_id, "deletedAt": event["serverAt"], "deletedBy": event.get("actorAccountId"), "version": event.get("objectVersion") or 1}
state["tombstones"]["objects"][object_id] = tombstone
state[OBJECT_KEYS[kind]] = [obj for obj in state.get(OBJECT_KEYS[kind]) or [] if str(obj.get("id") or "") != object_id]
state.setdefault("objectVotes", {}).pop(object_id, None)
state.setdefault("hiddenObjects", {}).pop(object_id, None)
state["moderationReports"] = [report for report in state.get("moderationReports") or [] if report.get("objectId") != object_id]
state.setdefault("serverSync", {})["lastServerEventId"] = event["serverEventId"]
return True
if event_type == "asset.delete":
asset_id = str(event.get("assetId") or "")
if not asset_id:
return False
state["tombstones"]["assets"][asset_id] = event.get("tombstone") or {"id": asset_id, "deletedAt": event["serverAt"], "deletedBy": event.get("actorAccountId"), "version": event.get("assetVersion") or 1}
for tombstone in event.get("objectTombstones") or []:
if tombstone.get("id"):
state["tombstones"]["objects"][str(tombstone["id"])] = tombstone
removed_object_ids = {str(t.get("id")) for t in event.get("objectTombstones") or [] if t.get("id")}
state["assets"] = [asset for asset in state.get("assets") or [] if str(asset.get("id") or "") != asset_id]
for key in OBJECT_KEYS.values():
state[key] = [obj for obj in state.get(key) or [] if str(obj.get("id") or "") not in removed_object_ids]
state.setdefault("assetVotes", {}).pop(asset_id, None)
state.setdefault("hiddenAssets", {}).pop(asset_id, None)
for object_id in removed_object_ids:
state.setdefault("objectVotes", {}).pop(object_id, None)
state.setdefault("hiddenObjects", {}).pop(object_id, None)
state["moderationReports"] = [report for report in state.get("moderationReports") or [] if report.get("objectId") not in removed_object_ids]
state.setdefault("serverSync", {})["lastServerEventId"] = event["serverEventId"]
return True
return False
def can_import_full_state(state: MutableMapping[str, Any], account: MutableMapping[str, Any] | None) -> Tuple[bool, Dict[str, Any]]:
if state.get("worldMode") == "shared":
return False, {"reason": "shared_full_import_disabled"}
return True, {"reason": None}