updated
This commit is contained in:
parent
94571943cf
commit
8ee6a87d44
7 changed files with 2317 additions and 560 deletions
131
server/test_world_policy.py
Normal file
131
server/test_world_policy.py
Normal 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
299
server/world_policy.py
Normal 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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue