This commit is contained in:
33333-33333 2026-06-02 23:45:38 +09:00
commit 06176fb2d5
6 changed files with 3282 additions and 153 deletions

View file

@ -12,6 +12,8 @@ from world_policy import (
can_delete_asset,
can_import_full_state,
can_modify_object,
make_dynamic_move_event,
make_world_phase_event,
)
@ -117,6 +119,27 @@ class WorldPolicyTest(unittest.TestCase):
self.assertIn("obj-a", state["tombstones"]["objects"])
self.assertEqual(len(state["dynamicSummons"]), 1)
def test_world_phase_event_updates_authoritative_clock(self):
state = base_state()
event = make_world_phase_event(at=600_000)
self.assertTrue(apply_server_event(state, event))
self.assertEqual(state["serverSync"]["clock"]["worldTimeMs"], 600_000)
self.assertEqual(state["serverSync"]["clock"]["dayMs"], 600_000)
self.assertEqual(state["serverSync"]["lastServerEventId"], event["serverEventId"])
def test_dynamic_move_event_is_server_authoritative(self):
state = base_state()
event = make_dynamic_move_event(state, "dyn-b", x=4.5, y=7.0, target_x=8.0, target_y=9.0, at=700, actor="server")
self.assertIsNotNone(event)
self.assertEqual(event["type"], "dynamic.move")
self.assertTrue(apply_server_event(state, event))
target = state["serverSync"]["dynamicTargets"]["dyn-b"]
self.assertEqual(target["targetX"], 8.0)
self.assertEqual(target["targetY"], 9.0)
self.assertEqual(state["dynamicSummons"][0]["serverState"]["x"], 4.5)
self.assertEqual(state["dynamicSummons"][0]["version"], 2)
def test_shared_full_state_import_is_rejected(self):
state = base_state()
allowed, meta = can_import_full_state(state, {"id": "alice"})

View file

@ -15,6 +15,7 @@ ACTIVE = "active"
VIOLATION_HIDDEN = "violation_hidden"
OBJECT_KEYS = {"static": "placed", "dynamic": "dynamicSummons"}
DAY_MS = 10 * 60 * 1000
def now_ms() -> int:
@ -117,6 +118,87 @@ def _accepted(event: Dict[str, Any]) -> Dict[str, Any]:
return {"accepted": True, "event": event}
def phase_key_for_time(world_time_ms: int, day_ms: int = DAY_MS) -> str:
progress = (int(world_time_ms) % int(day_ms or DAY_MS)) / float(day_ms or DAY_MS)
if progress < 0.10:
return "pre_dawn"
if progress < 0.20:
return "sunrise"
if progress < 0.60:
return "day"
if progress < 0.72:
return "sunset"
return "night"
def make_world_phase_event(at: Optional[int] = None, day_ms: int = DAY_MS) -> Dict[str, Any]:
"""Create the authoritative day/night clock event emitted by the server."""
stamp = at or now_ms()
cycle = int(day_ms or DAY_MS)
world_time_ms = int(stamp)
progress = (world_time_ms % cycle) / float(cycle)
return _server_event(
"world.phase",
"server",
{
"worldTimeMs": world_time_ms,
"dayMs": cycle,
"phase": {"key": phase_key_for_time(world_time_ms, cycle), "progress": progress},
},
stamp,
)
def make_dynamic_move_event(
state: MutableMapping[str, Any],
object_id: str,
x: float,
y: float,
target_x: Optional[float] = None,
target_y: Optional[float] = None,
at: Optional[int] = None,
actor: str = "server",
) -> Optional[Dict[str, Any]]:
"""Create an authoritative dynamic-object movement event.
Clients may ask for a target, but shared-world dynamic positions are only
changed after this server event is applied.
"""
stamp = at or now_ms()
kind, obj = find_object(state, object_id, "dynamic")
if kind != "dynamic" or not obj:
return None
next_object = deepcopy(dict(obj))
target = {
"objectId": str(object_id),
"x": float(x),
"y": float(y),
"targetX": float(target_x if target_x is not None else x),
"targetY": float(target_y if target_y is not None else y),
"homeX": float(next_object.get("homeX", x)),
"homeY": float(next_object.get("homeY", y)),
"serverAt": stamp,
}
next_object["serverState"] = target
next_object["version"] = int(next_object.get("version") or 0) + 1
return _server_event(
"dynamic.move",
actor or "server",
{
"objectId": str(object_id),
"object": next_object,
"x": target["x"],
"y": target["y"],
"targetX": target["targetX"],
"targetY": target["targetY"],
"homeX": target["homeX"],
"homeY": target["homeY"],
"objectVersion": next_object["version"],
},
stamp,
)
def _rejected(reason: str, **extra: Any) -> Dict[str, Any]:
return {"accepted": False, "reason": reason, **extra}
@ -164,7 +246,7 @@ def apply_command(state: MutableMapping[str, Any], account: MutableMapping[str,
if existing:
allowed, meta = can_modify_object(state, account, str(obj["id"]))
if not allowed:
return _rejected(meta["reason"], **meta)
return _rejected(meta["reason"], **{k: v for k, v in meta.items() if k != "reason"})
owner = owner_of_object(state, existing)
else:
if str(asset.get("ownerAccountId") or "") != actor and not is_admin(account):
@ -180,7 +262,7 @@ def apply_command(state: MutableMapping[str, Any], account: MutableMapping[str,
kind = str(command.get("kind") or "")
allowed, meta = can_modify_object(state, account, object_id)
if not allowed:
return _rejected(meta["reason"], **meta)
return _rejected(meta["reason"], **{k: v for k, v in meta.items() if k != "reason"})
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}
@ -190,7 +272,7 @@ def apply_command(state: MutableMapping[str, Any], account: MutableMapping[str,
asset_id = str(command.get("assetId") or "")
allowed, meta = can_delete_asset(state, account, asset_id)
if not allowed:
return _rejected(meta["reason"], **meta)
return _rejected(meta["reason"], **{k: v for k, v in meta.items() if k != "reason"})
asset = find_asset(state, asset_id) or {}
version = int(asset.get("version") or 0) + 1
object_tombstones: List[Dict[str, Any]] = []
@ -224,6 +306,55 @@ def apply_server_event(state: MutableMapping[str, Any], event: MutableMapping[st
normalize_tombstones(state)
event_type = str(event.get("type") or "")
if event_type == "world.phase":
clock = state.setdefault("serverSync", {}).setdefault("clock", {})
day_ms = int(event.get("dayMs") or DAY_MS)
world_time_ms = int(event.get("worldTimeMs") or event.get("serverNow") or event.get("serverAt") or now_ms())
clock.clear()
clock.update({
"worldTimeMs": world_time_ms,
"syncedAt": int(event.get("serverAt") or now_ms()),
"dayMs": day_ms,
"phase": event.get("phase") or {"key": phase_key_for_time(world_time_ms, day_ms), "progress": (world_time_ms % day_ms) / float(day_ms)},
"serverEventId": event["serverEventId"],
})
state["serverSync"]["lastServerEventId"] = event["serverEventId"]
return True
if event_type == "dynamic.move":
object_id = str(event.get("objectId") or event.get("dynamicId") or "")
obj = event.get("object")
if not object_id and isinstance(obj, MutableMapping):
object_id = str(obj.get("id") or "")
if not object_id:
return False
object_version = int((obj or {}).get("version") or event.get("objectVersion") or 1) if isinstance(obj, MutableMapping) else int(event.get("objectVersion") or 1)
if is_object_tombstoned(state, object_id, object_version):
return False
target = {
"objectId": object_id,
"x": float(event.get("x", (obj or {}).get("serverState", {}).get("x", (obj or {}).get("homeX", 0))) if isinstance(obj, MutableMapping) else event.get("x", 0)),
"y": float(event.get("y", (obj or {}).get("serverState", {}).get("y", (obj or {}).get("homeY", 0))) if isinstance(obj, MutableMapping) else event.get("y", 0)),
"targetX": float(event.get("targetX", event.get("x", 0))),
"targetY": float(event.get("targetY", event.get("y", 0))),
"homeX": float(event.get("homeX", (obj or {}).get("homeX", 0)) if isinstance(obj, MutableMapping) else event.get("homeX", 0)),
"homeY": float(event.get("homeY", (obj or {}).get("homeY", 0)) if isinstance(obj, MutableMapping) else event.get("homeY", 0)),
"serverAt": int(event.get("serverAt") or now_ms()),
}
server_sync = state.setdefault("serverSync", {})
server_sync.setdefault("dynamicTargets", {})[object_id] = target
if isinstance(obj, MutableMapping) and obj.get("id"):
rows = state.setdefault("dynamicSummons", [])
next_object = deepcopy(dict(obj))
next_object["serverState"] = target
index = next((i for i, item in enumerate(rows) if str(item.get("id") or "") == object_id), -1)
if index >= 0:
rows[index] = next_object
else:
rows.append(next_object)
server_sync["lastServerEventId"] = event["serverEventId"]
return True
if event_type == "asset.upsert":
asset = event.get("asset")
if not isinstance(asset, MutableMapping) or not asset.get("id"):
@ -265,6 +396,7 @@ def apply_server_event(state: MutableMapping[str, Any], event: MutableMapping[st
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.setdefault("serverSync", {}).setdefault("dynamicTargets", {}).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
@ -283,9 +415,11 @@ def apply_server_event(state: MutableMapping[str, Any], event: MutableMapping[st
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)
dynamic_targets = state.setdefault("serverSync", {}).setdefault("dynamicTargets", {})
for object_id in removed_object_ids:
state.setdefault("objectVotes", {}).pop(object_id, None)
state.setdefault("hiddenObjects", {}).pop(object_id, None)
dynamic_targets.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