update
This commit is contained in:
parent
8ee6a87d44
commit
06176fb2d5
6 changed files with 3282 additions and 153 deletions
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue