styly-netsync-server 0.13.0__tar.gz → 0.14.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/PKG-INFO +1 -1
  2. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/pyproject.toml +1 -1
  3. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/src/styly_netsync/binary_serializer.py +8 -31
  4. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/src/styly_netsync/client.py +0 -3
  5. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/src/styly_netsync/client_simulator.py +0 -1
  6. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/src/styly_netsync/server.py +64 -59
  7. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/src/styly_netsync_server.egg-info/PKG-INFO +1 -1
  8. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/src/styly_netsync_server.egg-info/SOURCES.txt +1 -0
  9. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/tests/test_binary_serializer.py +7 -7
  10. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/tests/test_client_variable_device_store.py +9 -11
  11. styly_netsync_server-0.14.0/tests/test_nv_server_ordering.py +147 -0
  12. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/tests/test_object_sync.py +1 -0
  13. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/LICENSE +0 -0
  14. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/README.md +0 -0
  15. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/setup.cfg +0 -0
  16. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/src/styly_netsync/__init__.py +0 -0
  17. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/src/styly_netsync/__main__.py +0 -0
  18. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/src/styly_netsync/adapters.py +0 -0
  19. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/src/styly_netsync/cli.py +0 -0
  20. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/src/styly_netsync/config.py +0 -0
  21. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/src/styly_netsync/default.toml +0 -0
  22. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/src/styly_netsync/events.py +0 -0
  23. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/src/styly_netsync/logging_utils.py +0 -0
  24. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/src/styly_netsync/network_utils.py +0 -0
  25. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/src/styly_netsync/nv_sync.py +0 -0
  26. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/src/styly_netsync/rest_bridge.py +0 -0
  27. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/src/styly_netsync/types.py +0 -0
  28. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/src/styly_netsync_server.egg-info/dependency_links.txt +0 -0
  29. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/src/styly_netsync_server.egg-info/entry_points.txt +0 -0
  30. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/src/styly_netsync_server.egg-info/requires.txt +0 -0
  31. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/src/styly_netsync_server.egg-info/top_level.txt +0 -0
  32. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/tests/test_all_run_methods.py +0 -0
  33. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/tests/test_config.py +0 -0
  34. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/tests/test_discovery_probe.py +0 -0
  35. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/tests/test_logging_cli.py +0 -0
  36. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/tests/test_multi_nic.py +0 -0
  37. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/tests/test_nv_protocol.py +0 -0
  38. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/tests/test_port_error_message.py +0 -0
  39. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/tests/test_python_client.py +0 -0
  40. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/tests/test_reconnect_identity.py +0 -0
  41. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/tests/test_rest_bridge.py +0 -0
  42. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/tests/test_room_expiry.py +0 -0
  43. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/tests/test_stealth_heartbeat.py +0 -0
  44. {styly_netsync_server-0.13.0 → styly_netsync_server-0.14.0}/tests/test_timing_monotonic.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: styly-netsync-server
3
- Version: 0.13.0
3
+ Version: 0.14.0
4
4
  Summary: STYLY NetSync Server - Multiplayer framework for Location-Based Entertainment VR/MR experiences
5
5
  Author-email: "STYLY, Inc." <info@styly.inc>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "styly-netsync-server"
7
- version = "0.13.0"
7
+ version = "0.14.0"
8
8
  description = "STYLY NetSync Server - Multiplayer framework for Location-Based Entertainment VR/MR experiences"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -6,7 +6,10 @@ from typing import Any
6
6
  logger = logging.getLogger(__name__)
7
7
 
8
8
  # Message type identifiers
9
- PROTOCOL_VERSION = 5
9
+ # v6: Network Variable wire messages dropped the per-write timestamp field.
10
+ # Bumped so mixed old/new builds fail the handshake instead of silently
11
+ # misparsing NV traffic (the version byte rides on transform/object messages).
12
+ PROTOCOL_VERSION = 6
10
13
  MSG_CLIENT_TRANSFORM = 1
11
14
  MSG_ROOM_TRANSFORM = 2 # Legacy room transform with short IDs only
12
15
  MSG_RPC = 3 # Remote procedure call
@@ -686,7 +689,7 @@ def serialize_global_var_set(data: dict[str, Any]) -> bytes:
686
689
  """Serialize global variable set message
687
690
 
688
691
  Args:
689
- data: Dictionary with senderClientNo, variableName, variableValue, timestamp
692
+ data: Dictionary with senderClientNo, variableName, variableValue
690
693
  """
691
694
  buffer = bytearray()
692
695
 
@@ -704,9 +707,6 @@ def serialize_global_var_set(data: dict[str, Any]) -> bytes:
704
707
  value = data.get("variableValue", "")[:1024]
705
708
  _pack_string(buffer, value, use_ushort=True)
706
709
 
707
- # Timestamp (8 bytes double)
708
- buffer.extend(struct.pack("<d", data.get("timestamp", 0.0)))
709
-
710
710
  return bytes(buffer)
711
711
 
712
712
 
@@ -729,7 +729,6 @@ def serialize_global_var_sync(data: dict[str, Any]) -> bytes:
729
729
  for var in variables:
730
730
  _pack_string(buffer, var.get("name", "")[:64])
731
731
  _pack_string(buffer, var.get("value", "")[:1024], use_ushort=True)
732
- buffer.extend(struct.pack("<d", var.get("timestamp", 0.0)))
733
732
  buffer.extend(struct.pack("<H", var.get("lastWriterClientNo", 0)))
734
733
 
735
734
  return bytes(buffer)
@@ -739,7 +738,8 @@ def serialize_client_var_set(data: dict[str, Any]) -> bytes:
739
738
  """Serialize client variable set message
740
739
 
741
740
  Args:
742
- data: Dictionary with senderClientNo, targetClientNo, variableName, variableValue, timestamp
741
+ data: Dictionary with senderClientNo, targetClientNo, variableName,
742
+ variableValue
743
743
  """
744
744
  buffer = bytearray()
745
745
 
@@ -760,9 +760,6 @@ def serialize_client_var_set(data: dict[str, Any]) -> bytes:
760
760
  value = data.get("variableValue", "")[:1024]
761
761
  _pack_string(buffer, value, use_ushort=True)
762
762
 
763
- # Timestamp (8 bytes double)
764
- buffer.extend(struct.pack("<d", data.get("timestamp", 0.0)))
765
-
766
763
  return bytes(buffer)
767
764
 
768
765
 
@@ -770,7 +767,7 @@ def serialize_client_var_clear(data: dict[str, Any]) -> bytes:
770
767
  """Serialize client variable clear message.
771
768
 
772
769
  Args:
773
- data: Dictionary with senderClientNo and timestamp.
770
+ data: Dictionary with senderClientNo.
774
771
  """
775
772
  buffer = bytearray()
776
773
 
@@ -780,9 +777,6 @@ def serialize_client_var_clear(data: dict[str, Any]) -> bytes:
780
777
  # Sender client number (2 bytes)
781
778
  buffer.extend(struct.pack("<H", data.get("senderClientNo", 0)))
782
779
 
783
- # Timestamp (8 bytes double)
784
- buffer.extend(struct.pack("<d", data.get("timestamp", 0.0)))
785
-
786
780
  return bytes(buffer)
787
781
 
788
782
 
@@ -811,7 +805,6 @@ def serialize_client_var_sync(data: dict[str, Any]) -> bytes:
811
805
  for var in variables:
812
806
  _pack_string(buffer, var.get("name", "")[:64])
813
807
  _pack_string(buffer, var.get("value", "")[:1024], use_ushort=True)
814
- buffer.extend(struct.pack("<d", var.get("timestamp", 0.0)))
815
808
  buffer.extend(struct.pack("<H", var.get("lastWriterClientNo", 0)))
816
809
 
817
810
  return bytes(buffer)
@@ -1247,10 +1240,6 @@ def _deserialize_global_var_set(data: bytes, offset: int) -> dict[str, Any]:
1247
1240
  # Variable value
1248
1241
  result["variableValue"], offset = _unpack_string(data, offset, use_ushort=True)
1249
1242
 
1250
- # Timestamp (8 bytes double)
1251
- result["timestamp"] = struct.unpack("<d", data[offset : offset + 8])[0]
1252
- offset += 8
1253
-
1254
1243
  return result
1255
1244
 
1256
1245
 
@@ -1267,8 +1256,6 @@ def _deserialize_global_var_sync(data: bytes, offset: int) -> dict[str, Any]:
1267
1256
  var = {}
1268
1257
  var["name"], offset = _unpack_string(data, offset)
1269
1258
  var["value"], offset = _unpack_string(data, offset, use_ushort=True)
1270
- var["timestamp"] = struct.unpack("<d", data[offset : offset + 8])[0]
1271
- offset += 8
1272
1259
  var["lastWriterClientNo"] = struct.unpack("<H", data[offset : offset + 2])[0]
1273
1260
  offset += 2
1274
1261
  result["variables"].append(var)
@@ -1294,10 +1281,6 @@ def _deserialize_client_var_set(data: bytes, offset: int) -> dict[str, Any]:
1294
1281
  # Variable value
1295
1282
  result["variableValue"], offset = _unpack_string(data, offset, use_ushort=True)
1296
1283
 
1297
- # Timestamp (8 bytes double)
1298
- result["timestamp"] = struct.unpack("<d", data[offset : offset + 8])[0]
1299
- offset += 8
1300
-
1301
1284
  return result
1302
1285
 
1303
1286
 
@@ -1307,10 +1290,6 @@ def _deserialize_client_var_clear(data: bytes, offset: int) -> dict[str, Any]:
1307
1290
 
1308
1291
  # Sender client number (2 bytes)
1309
1292
  result["senderClientNo"] = struct.unpack("<H", data[offset : offset + 2])[0]
1310
- offset += 2
1311
-
1312
- # Timestamp (8 bytes double)
1313
- result["timestamp"] = struct.unpack("<d", data[offset : offset + 8])[0]
1314
1293
 
1315
1294
  return result
1316
1295
 
@@ -1336,8 +1315,6 @@ def _deserialize_client_var_sync(data: bytes, offset: int) -> dict[str, Any]:
1336
1315
  var = {}
1337
1316
  var["name"], offset = _unpack_string(data, offset)
1338
1317
  var["value"], offset = _unpack_string(data, offset, use_ushort=True)
1339
- var["timestamp"] = struct.unpack("<d", data[offset : offset + 8])[0]
1340
- offset += 8
1341
1318
  var["lastWriterClientNo"] = struct.unpack("<H", data[offset : offset + 2])[
1342
1319
  0
1343
1320
  ]
@@ -1222,7 +1222,6 @@ class net_sync_manager:
1222
1222
  "senderClientNo": self._client_no,
1223
1223
  "variableName": name,
1224
1224
  "variableValue": value,
1225
- "timestamp": time.time(),
1226
1225
  }
1227
1226
  message = binary_serializer.serialize_global_var_set(var_data)
1228
1227
  return self._enqueue_control(
@@ -1244,7 +1243,6 @@ class net_sync_manager:
1244
1243
  "targetClientNo": target_client_no,
1245
1244
  "variableName": name,
1246
1245
  "variableValue": value,
1247
- "timestamp": time.time(),
1248
1246
  }
1249
1247
  message = binary_serializer.serialize_client_var_set(var_data)
1250
1248
  return self._enqueue_control(
@@ -1263,7 +1261,6 @@ class net_sync_manager:
1263
1261
  try:
1264
1262
  clear_data = {
1265
1263
  "senderClientNo": self._client_no,
1266
- "timestamp": time.time(),
1267
1264
  }
1268
1265
  message = binary_serializer.serialize_client_var_clear(clear_data)
1269
1266
  sent = self._enqueue_control(
@@ -648,7 +648,6 @@ class NetworkTransport:
648
648
  "targetClientNo": sender_client_no, # Set variable for ourselves
649
649
  "variableName": var_name,
650
650
  "variableValue": value,
651
- "timestamp": time.time(),
652
651
  }
653
652
  binary_data = serialize_client_var_set(var_data)
654
653
  self.socket.send_multipart(
@@ -309,18 +309,24 @@ class NetSyncServer:
309
309
  # Network Variables storage
310
310
  self.global_variables: dict[str, dict[str, Any]] = (
311
311
  {}
312
- ) # room_id -> {var_name: {value, timestamp, lastWriterClientNo}}
312
+ ) # room_id -> {var_name: {value, version, lastWriterClientNo}}
313
313
  self.client_variables: dict[str, dict[str, dict[str, Any]]] = (
314
314
  {}
315
- ) # room_id -> {device_id -> {var_name: {value, timestamp, lastWriterClientNo}}}
315
+ ) # room_id -> {device_id -> {var_name: {value, version, lastWriterClientNo}}}
316
+
317
+ # Per-room monotonic write sequence. The server assigns the ordering for
318
+ # Network Variable last-writer-wins instead of trusting client-supplied
319
+ # timestamps, whose device clocks can drift on offline LAN deployments.
320
+ # The assigned sequence is stored in each variable's "version" field.
321
+ self.nv_write_seq: dict[str, int] = {} # room_id -> last assigned write seq
316
322
 
317
323
  # NV Pending buffers for coalescing (latest-wins per key)
318
324
  self.pending_global_nv: dict[str, dict[str, tuple]] = (
319
325
  {}
320
- ) # room_id -> {var_name: (sender_client_no, value, timestamp)}
326
+ ) # room_id -> {var_name: (sender_client_no, value)}
321
327
  self.pending_client_nv: dict[str, dict[tuple, tuple]] = (
322
328
  {}
323
- ) # room_id -> {(target_client_no, var_name): (sender_client_no, value, timestamp)}
329
+ ) # room_id -> {(target_client_no, var_name): (sender_client_no, value)}
324
330
 
325
331
  # NV flush cadence configuration (from config)
326
332
  self.nv_flush_interval = config.nv_flush_interval
@@ -758,6 +764,7 @@ class NetSyncServer:
758
764
  # Initialize Network Variables for the room
759
765
  self.global_variables[room_id] = {}
760
766
  self.client_variables[room_id] = {}
767
+ self.nv_write_seq[room_id] = 0
761
768
 
762
769
  # Initialize NV pending buffers
763
770
  self.pending_global_nv[room_id] = {}
@@ -1486,12 +1493,22 @@ class NetSyncServer:
1486
1493
  f"High NV request rate in room {room_id}: {len(self.nv_monitor_window[room_id])} req/s"
1487
1494
  )
1488
1495
 
1496
+ def _next_nv_seq(self, room_id: str) -> int:
1497
+ """Return the next per-room monotonic NV write sequence.
1498
+
1499
+ Caller must hold ``_rooms_lock``. This sequence is the server-assigned
1500
+ ordering authority for last-writer-wins, replacing client timestamps so
1501
+ that skewed device clocks cannot freeze or steal Network Variables.
1502
+ """
1503
+ seq = self.nv_write_seq.get(room_id, 0) + 1
1504
+ self.nv_write_seq[room_id] = seq
1505
+ return seq
1506
+
1489
1507
  def _buffer_global_var_set(self, room_id: str, data: dict[str, Any]) -> None:
1490
1508
  """Buffer global variable set request for later processing"""
1491
1509
  sender_client_no = data.get("senderClientNo", 0)
1492
1510
  var_name = data.get("variableName", "")[: self.MAX_VAR_NAME_LENGTH]
1493
1511
  var_value = data.get("variableValue", "")[: self.MAX_VAR_VALUE_LENGTH]
1494
- timestamp = data.get("timestamp", time.monotonic())
1495
1512
 
1496
1513
  if not var_name:
1497
1514
  return
@@ -1503,7 +1520,6 @@ class NetSyncServer:
1503
1520
  self.pending_global_nv[room_id][var_name] = (
1504
1521
  sender_client_no,
1505
1522
  var_value,
1506
- timestamp,
1507
1523
  )
1508
1524
 
1509
1525
  def _apply_global_var_set(
@@ -1512,7 +1528,6 @@ class NetSyncServer:
1512
1528
  sender_client_no: int,
1513
1529
  var_name: str,
1514
1530
  var_value: str,
1515
- timestamp: float,
1516
1531
  ) -> bool:
1517
1532
  """Apply global variable update (used by flush, returns True if applied)"""
1518
1533
  with self._rooms_lock:
@@ -1522,25 +1537,19 @@ class NetSyncServer:
1522
1537
  logger.warning(f"Global variable limit reached in room {room_id}")
1523
1538
  return False
1524
1539
 
1525
- # Conflict resolution: last-writer-wins with timestamp comparison
1540
+ # Skip if value unchanged (no-op)
1526
1541
  if var_name in global_vars:
1527
- existing = global_vars[var_name]
1528
- # Skip if value unchanged (no-op)
1529
- if existing.get("value") == var_value:
1542
+ if global_vars[var_name].get("value") == var_value:
1530
1543
  return False
1531
- if timestamp < existing["timestamp"] or (
1532
- timestamp == existing["timestamp"]
1533
- and sender_client_no < existing["lastWriterClientNo"]
1534
- ):
1535
- return False # Ignore older or lower priority update
1536
1544
 
1537
1545
  # Store old value for logging
1538
1546
  old_value = global_vars.get(var_name, {}).get("value", None)
1539
1547
 
1540
- # Update variable
1548
+ # Last-writer-wins ordered by the server-assigned write sequence,
1549
+ # not client timestamps (device clocks can drift offline).
1541
1550
  global_vars[var_name] = {
1542
1551
  "value": var_value,
1543
- "timestamp": timestamp,
1552
+ "version": self._next_nv_seq(room_id),
1544
1553
  "lastWriterClientNo": sender_client_no,
1545
1554
  }
1546
1555
 
@@ -1554,14 +1563,11 @@ class NetSyncServer:
1554
1563
  sender_client_no = data.get("senderClientNo", 0)
1555
1564
  var_name = data.get("variableName", "")[: self.MAX_VAR_NAME_LENGTH]
1556
1565
  var_value = data.get("variableValue", "")[: self.MAX_VAR_VALUE_LENGTH]
1557
- timestamp = data.get("timestamp", time.monotonic())
1558
1566
 
1559
1567
  if not var_name:
1560
1568
  return
1561
1569
 
1562
- if self._apply_global_var_set(
1563
- room_id, sender_client_no, var_name, var_value, timestamp
1564
- ):
1570
+ if self._apply_global_var_set(room_id, sender_client_no, var_name, var_value):
1565
1571
  # Broadcast sync to all clients
1566
1572
  self._broadcast_global_var_sync(room_id)
1567
1573
 
@@ -1571,7 +1577,6 @@ class NetSyncServer:
1571
1577
  target_client_no = data.get("targetClientNo", 0)
1572
1578
  var_name = data.get("variableName", "")[: self.MAX_VAR_NAME_LENGTH]
1573
1579
  var_value = data.get("variableValue", "")[: self.MAX_VAR_VALUE_LENGTH]
1574
- timestamp = data.get("timestamp", time.monotonic())
1575
1580
 
1576
1581
  if not var_name:
1577
1582
  return
@@ -1584,7 +1589,6 @@ class NetSyncServer:
1584
1589
  self.pending_client_nv[room_id][key] = (
1585
1590
  sender_client_no,
1586
1591
  var_value,
1587
- timestamp,
1588
1592
  )
1589
1593
 
1590
1594
  def _apply_client_var_set(
@@ -1594,7 +1598,6 @@ class NetSyncServer:
1594
1598
  target_client_no: int,
1595
1599
  var_name: str,
1596
1600
  var_value: str,
1597
- timestamp: float,
1598
1601
  ) -> bool:
1599
1602
  """Apply a client variable update addressed by volatile client number."""
1600
1603
  with self._rooms_lock:
@@ -1613,7 +1616,6 @@ class NetSyncServer:
1613
1616
  target_device_id,
1614
1617
  var_name,
1615
1618
  var_value,
1616
- timestamp,
1617
1619
  )
1618
1620
 
1619
1621
  def _apply_client_var_set_for_device(
@@ -1623,7 +1625,6 @@ class NetSyncServer:
1623
1625
  target_device_id: str,
1624
1626
  var_name: str,
1625
1627
  var_value: str,
1626
- timestamp: float,
1627
1628
  ) -> bool:
1628
1629
  """Apply a client variable update to the authoritative device-keyed store."""
1629
1630
  with self._rooms_lock:
@@ -1641,25 +1642,19 @@ class NetSyncServer:
1641
1642
  )
1642
1643
  return False
1643
1644
 
1644
- # Conflict resolution: last-writer-wins with timestamp comparison
1645
+ # Skip if value unchanged (no-op)
1645
1646
  if var_name in client_vars:
1646
- existing = client_vars[var_name]
1647
- # Skip if value unchanged (no-op)
1648
- if existing.get("value") == var_value:
1647
+ if client_vars[var_name].get("value") == var_value:
1649
1648
  return False
1650
- if timestamp < existing["timestamp"] or (
1651
- timestamp == existing["timestamp"]
1652
- and sender_client_no < existing["lastWriterClientNo"]
1653
- ):
1654
- return False # Ignore older or lower priority update
1655
1649
 
1656
1650
  # Store old value for logging
1657
1651
  old_value = client_vars.get(var_name, {}).get("value", None)
1658
1652
 
1659
- # Update variable
1653
+ # Last-writer-wins ordered by the server-assigned write sequence,
1654
+ # not client timestamps (device clocks can drift offline).
1660
1655
  client_vars[var_name] = {
1661
1656
  "value": var_value,
1662
- "timestamp": timestamp,
1657
+ "version": self._next_nv_seq(room_id),
1663
1658
  "lastWriterClientNo": sender_client_no,
1664
1659
  }
1665
1660
 
@@ -1672,7 +1667,6 @@ class NetSyncServer:
1672
1667
  self, room_id: str, device_id: str, variables: dict[str, str]
1673
1668
  ) -> tuple[int | None, dict[str, str]]:
1674
1669
  """Upsert REST-provided client variables into the device-keyed store."""
1675
- timestamp = time.time()
1676
1670
  statuses: dict[str, str] = {}
1677
1671
  changed = False
1678
1672
 
@@ -1703,12 +1697,20 @@ class NetSyncServer:
1703
1697
  device_id,
1704
1698
  var_name,
1705
1699
  var_value,
1706
- timestamp,
1707
1700
  )
1708
1701
  after = self.client_variables[room_id].get(device_id, {}).get(var_name)
1709
1702
  changed = changed or applied or before != after
1710
1703
  statuses[name] = "applied" if client_no is not None else "queued"
1711
1704
 
1705
+ # This REST write is now the authoritative latest value for the
1706
+ # key. Drop any older live write still buffered for the same
1707
+ # (client_no, var_name) so the next flush cannot resurrect a
1708
+ # stale value over it (mirrors delete/clear pruning).
1709
+ if client_no is not None:
1710
+ self._remove_pending_client_vars_locked(
1711
+ room_id, client_no, var_name
1712
+ )
1713
+
1712
1714
  if client_no is not None and changed:
1713
1715
  self._broadcast_client_var_sync(room_id, {client_no})
1714
1716
 
@@ -1823,13 +1825,12 @@ class NetSyncServer:
1823
1825
  target_client_no = data.get("targetClientNo", 0)
1824
1826
  var_name = data.get("variableName", "")[: self.MAX_VAR_NAME_LENGTH]
1825
1827
  var_value = data.get("variableValue", "")[: self.MAX_VAR_VALUE_LENGTH]
1826
- timestamp = data.get("timestamp", time.monotonic())
1827
1828
 
1828
1829
  if not var_name:
1829
1830
  return
1830
1831
 
1831
1832
  if self._apply_client_var_set(
1832
- room_id, sender_client_no, target_client_no, var_name, var_value, timestamp
1833
+ room_id, sender_client_no, target_client_no, var_name, var_value
1833
1834
  ):
1834
1835
  # Broadcast sync to all clients
1835
1836
  self._broadcast_client_var_sync(room_id)
@@ -1850,7 +1851,6 @@ class NetSyncServer:
1850
1851
  {
1851
1852
  "name": var_name,
1852
1853
  "value": var_data["value"],
1853
- "timestamp": var_data["timestamp"],
1854
1854
  "lastWriterClientNo": var_data["lastWriterClientNo"],
1855
1855
  }
1856
1856
  )
@@ -1899,7 +1899,6 @@ class NetSyncServer:
1899
1899
  {
1900
1900
  "name": var_name,
1901
1901
  "value": var_data["value"],
1902
- "timestamp": var_data["timestamp"],
1903
1902
  "lastWriterClientNo": var_data["lastWriterClientNo"],
1904
1903
  }
1905
1904
  )
@@ -1970,25 +1969,30 @@ class NetSyncServer:
1970
1969
  """Drain all pending NV updates for a room in one go."""
1971
1970
  start = time.perf_counter()
1972
1971
 
1972
+ applied_globals: list[str] = []
1973
+ applied_client_nos: set[int] = set()
1974
+
1975
+ # Drain and apply under a single lock hold. _rooms_lock is an RLock, so
1976
+ # the nested acquisitions inside _apply_* are reentrant. Holding the lock
1977
+ # across both steps prevents an immediate write (e.g. a REST upsert) from
1978
+ # interleaving in the drain/apply gap, where a stale buffered write would
1979
+ # otherwise overwrite the newer immediate one (last-writer-wins is now
1980
+ # ordered by application order, not by client timestamps).
1973
1981
  with self._rooms_lock:
1974
- pending_globals = self.pending_global_nv.get(room_id, {})
1975
- pending_clients = self.pending_client_nv.get(room_id, {})
1976
- globals_to_apply = list(pending_globals.items())
1977
- clients_to_apply = list(pending_clients.items())
1982
+ globals_to_apply = list(self.pending_global_nv.get(room_id, {}).items())
1983
+ clients_to_apply = list(self.pending_client_nv.get(room_id, {}).items())
1978
1984
  self.pending_global_nv[room_id] = {}
1979
1985
  self.pending_client_nv[room_id] = {}
1980
1986
 
1981
- applied_globals: list[str] = []
1982
- for var_name, (sender, value, ts) in globals_to_apply:
1983
- if self._apply_global_var_set(room_id, sender, var_name, value, ts):
1984
- applied_globals.append(var_name)
1987
+ for var_name, (sender, value) in globals_to_apply:
1988
+ if self._apply_global_var_set(room_id, sender, var_name, value):
1989
+ applied_globals.append(var_name)
1985
1990
 
1986
- applied_client_nos: set[int] = set()
1987
- for (target_client_no, var_name), (sender, value, ts) in clients_to_apply:
1988
- if self._apply_client_var_set(
1989
- room_id, sender, target_client_no, var_name, value, ts
1990
- ):
1991
- applied_client_nos.add(target_client_no)
1991
+ for (target_client_no, var_name), (sender, value) in clients_to_apply:
1992
+ if self._apply_client_var_set(
1993
+ room_id, sender, target_client_no, var_name, value
1994
+ ):
1995
+ applied_client_nos.add(target_client_no)
1992
1996
 
1993
1997
  # Send NV syncs via ROUTER unicast for reliable delivery
1994
1998
  if applied_globals:
@@ -2000,7 +2004,6 @@ class NetSyncServer:
2000
2004
  {
2001
2005
  "name": name,
2002
2006
  "value": d["value"],
2003
- "timestamp": d["timestamp"],
2004
2007
  "lastWriterClientNo": d["lastWriterClientNo"],
2005
2008
  }
2006
2009
  )
@@ -2434,6 +2437,8 @@ class NetSyncServer:
2434
2437
  del self.pending_global_nv[room_id]
2435
2438
  if room_id in self.pending_client_nv:
2436
2439
  del self.pending_client_nv[room_id]
2440
+ if room_id in self.nv_write_seq:
2441
+ del self.nv_write_seq[room_id]
2437
2442
  if room_id in self.room_last_nv_flush:
2438
2443
  del self.room_last_nv_flush[room_id]
2439
2444
  if room_id in self.nv_monitor_window:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: styly-netsync-server
3
- Version: 0.13.0
3
+ Version: 0.14.0
4
4
  Summary: STYLY NetSync Server - Multiplayer framework for Location-Based Entertainment VR/MR experiences
5
5
  Author-email: "STYLY, Inc." <info@styly.inc>
6
6
  License-Expression: Apache-2.0
@@ -31,6 +31,7 @@ tests/test_discovery_probe.py
31
31
  tests/test_logging_cli.py
32
32
  tests/test_multi_nic.py
33
33
  tests/test_nv_protocol.py
34
+ tests/test_nv_server_ordering.py
34
35
  tests/test_object_sync.py
35
36
  tests/test_port_error_message.py
36
37
  tests/test_python_client.py
@@ -289,11 +289,11 @@ def _reconstruct_physical_from_head_and_delta(
289
289
 
290
290
 
291
291
  class TestTransformSerializationV5:
292
- """Tests for protocol v5 transform compact serialization."""
292
+ """Tests for protocol v6 transform compact serialization."""
293
293
 
294
- def test_protocol_version_is_v5(self) -> None:
295
- """Protocol version constant should be at v5."""
296
- assert binary_serializer.PROTOCOL_VERSION == 5
294
+ def test_protocol_version_is_v6(self) -> None:
295
+ """Protocol version constant should be at v6."""
296
+ assert binary_serializer.PROTOCOL_VERSION == 6
297
297
 
298
298
  def test_client_roundtrip_without_flags_infers_valid_bits(self) -> None:
299
299
  """Serializer should infer valid bits when flags are omitted."""
@@ -418,7 +418,7 @@ class TestTransformSerializationV5:
418
418
 
419
419
  assert msg_type == binary_serializer.MSG_CLIENT_POSE
420
420
  assert decoded is not None
421
- assert decoded["protocolVersion"] == 5
421
+ assert decoded["protocolVersion"] == 6
422
422
  assert len(raw) > 0
423
423
 
424
424
  o_head = original["head"]
@@ -822,7 +822,7 @@ class TestTransformSerializationV5:
822
822
 
823
823
  assert msg_type == binary_serializer.MSG_ROOM_POSE
824
824
  assert decoded is not None
825
- assert decoded["protocolVersion"] == 5
825
+ assert decoded["protocolVersion"] == 6
826
826
  assert decoded["roomId"] == "room-v5"
827
827
  assert len(decoded["clients"]) == 2
828
828
 
@@ -1085,7 +1085,7 @@ class TestClientVariableClearSerialization:
1085
1085
 
1086
1086
  def test_roundtrip_client_variable_clear(self) -> None:
1087
1087
  """Client variable clear message round-trips correctly."""
1088
- data = {"senderClientNo": 7, "timestamp": 1234.5}
1088
+ data = {"senderClientNo": 7}
1089
1089
 
1090
1090
  serialized = binary_serializer.serialize_client_var_clear(data)
1091
1091
  msg_type, result, _ = binary_serializer.deserialize(serialized)
@@ -61,9 +61,7 @@ class TestServerClientVariableDeviceStore:
61
61
  def test_client_no_write_stores_by_device_id(self, server: NetSyncServer) -> None:
62
62
  _map_device(server, "room1", "device-a", 7)
63
63
 
64
- applied = server._apply_client_var_set(
65
- "room1", 2, 7, "score", "100", time.time()
66
- )
64
+ applied = server._apply_client_var_set("room1", 2, 7, "score", "100")
67
65
 
68
66
  assert applied is True
69
67
  assert server.client_variables["room1"]["device-a"]["score"]["value"] == "100"
@@ -75,8 +73,8 @@ class TestServerClientVariableDeviceStore:
75
73
  _map_device(server, "room1", "device-a", 7)
76
74
  _map_device(server, "room1", "device-b", 8)
77
75
 
78
- server._apply_client_var_set("room1", 2, 7, "score", "100", time.time())
79
- server._apply_client_var_set("room1", 2, 8, "score", "200", time.time())
76
+ server._apply_client_var_set("room1", 2, 7, "score", "100")
77
+ server._apply_client_var_set("room1", 2, 8, "score", "200")
80
78
 
81
79
  assert server.client_variables["room1"]["device-a"]["score"]["value"] == "100"
82
80
  assert server.client_variables["room1"]["device-b"]["score"]["value"] == "200"
@@ -101,7 +99,7 @@ class TestServerClientVariableDeviceStore:
101
99
  self, server: NetSyncServer
102
100
  ) -> None:
103
101
  _map_device(server, "room1", "device-a", 7)
104
- server._apply_client_var_set("room1", 2, 7, "score", "100", time.time())
102
+ server._apply_client_var_set("room1", 2, 7, "score", "100")
105
103
 
106
104
  payload = server._build_client_var_sync_payload("room1")
107
105
  assert payload is not None
@@ -158,7 +156,7 @@ class TestServerClientVariableDeviceStore:
158
156
  self, server: NetSyncServer
159
157
  ) -> None:
160
158
  _map_device(server, "room1", "device-a", 7)
161
- server._apply_client_var_set("room1", 2, 7, "score", "100", time.time())
159
+ server._apply_client_var_set("room1", 2, 7, "score", "100")
162
160
  server.device_id_last_seen["device-a"] = (
163
161
  time.monotonic() - server.DEVICE_ID_EXPIRY_TIME - 1.0
164
162
  )
@@ -174,7 +172,7 @@ class TestServerClientVariableDeviceStore:
174
172
  ) -> None:
175
173
  _map_device(server, "room1", "device-a", 7)
176
174
  server.client_transform_body_cache[7] = b"old"
177
- server._apply_client_var_set("room1", 2, 7, "score", "100", time.time())
175
+ server._apply_client_var_set("room1", 2, 7, "score", "100")
178
176
  server.device_id_last_seen["device-a"] = (
179
177
  time.monotonic() - server.DEVICE_ID_EXPIRY_TIME - 1.0
180
178
  )
@@ -193,8 +191,8 @@ class TestServerClientVariableDeviceStore:
193
191
  server.upsert_client_variables_for_device(
194
192
  "room1", "device-a", {"a": "1", "b": "2"}
195
193
  )
196
- server.pending_client_nv["room1"][(7, "a")] = (7, "stale", time.time())
197
- server.pending_client_nv["room1"][(8, "a")] = (8, "other", time.time())
194
+ server.pending_client_nv["room1"][(7, "a")] = (7, "stale")
195
+ server.pending_client_nv["room1"][(8, "a")] = (8, "other")
198
196
  server._send_ctrl_to_room_via_router.reset_mock()
199
197
 
200
198
  server._handle_client_var_clear(
@@ -363,7 +361,7 @@ class TestRestClientVariableDeviceStore:
363
361
  ) -> None:
364
362
  _map_device(server, "room1", "device-a", 7)
365
363
  server.upsert_client_variables_for_device("room1", "device-a", {"a": "1"})
366
- server.pending_client_nv["room1"][(7, "a")] = (7, "stale", time.time())
364
+ server.pending_client_nv["room1"][(7, "a")] = (7, "stale")
367
365
  server._send_ctrl_to_room_via_router.reset_mock()
368
366
  tc = self._client(server)
369
367
 
@@ -0,0 +1,147 @@
1
+ """Server-authoritative Network Variable ordering (issue #448).
2
+
3
+ The server assigns a per-room monotonic write sequence for last-writer-wins
4
+ instead of trusting client-supplied timestamps, so skewed device clocks on
5
+ offline LAN deployments cannot freeze or steal Network Variables.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from collections.abc import Iterator
12
+ from unittest.mock import MagicMock
13
+
14
+ import pytest
15
+
16
+ from styly_netsync.server import NetSyncServer
17
+
18
+
19
+ @pytest.fixture()
20
+ def server() -> Iterator[NetSyncServer]:
21
+ srv = NetSyncServer(enable_server_discovery=False)
22
+ srv._send_ctrl_to_room_via_router = MagicMock() # type: ignore[method-assign]
23
+ yield srv
24
+ srv.context.term()
25
+
26
+
27
+ def _map_device(
28
+ srv: NetSyncServer, room_id: str, device_id: str, client_no: int
29
+ ) -> None:
30
+ srv._initialize_room(room_id)
31
+ srv.room_device_id_to_client_no[room_id][device_id] = client_no
32
+ srv.room_client_no_to_device_id[room_id][client_no] = device_id
33
+ srv.device_id_last_seen[device_id] = time.monotonic()
34
+
35
+
36
+ class TestGlobalVariableServerOrdering:
37
+ def test_last_applied_write_wins(self, server: NetSyncServer) -> None:
38
+ server._initialize_room("room1")
39
+
40
+ assert server._apply_global_var_set("room1", 1, "score", "100") is True
41
+ assert server._apply_global_var_set("room1", 2, "score", "200") is True
42
+
43
+ stored = server.global_variables["room1"]["score"]
44
+ assert stored["value"] == "200"
45
+ assert stored["lastWriterClientNo"] == 2
46
+
47
+ def test_write_sequence_is_monotonic_and_server_assigned(
48
+ self, server: NetSyncServer
49
+ ) -> None:
50
+ server._initialize_room("room1")
51
+ assert server.nv_write_seq["room1"] == 0
52
+
53
+ server._apply_global_var_set("room1", 1, "a", "1")
54
+ server._apply_global_var_set("room1", 1, "b", "2")
55
+
56
+ assert server.global_variables["room1"]["a"]["version"] == 1
57
+ assert server.global_variables["room1"]["b"]["version"] == 2
58
+ assert server.nv_write_seq["room1"] == 2
59
+
60
+ def test_no_op_value_does_not_consume_sequence(self, server: NetSyncServer) -> None:
61
+ server._initialize_room("room1")
62
+ assert server._apply_global_var_set("room1", 1, "a", "1") is True
63
+ seq_after_first = server.nv_write_seq["room1"]
64
+
65
+ # Same value -> no-op, returns False and must not bump the sequence
66
+ assert server._apply_global_var_set("room1", 2, "a", "1") is False
67
+ assert server.nv_write_seq["room1"] == seq_after_first
68
+
69
+
70
+ class TestClientVariableServerOrdering:
71
+ def test_last_applied_write_wins(self, server: NetSyncServer) -> None:
72
+ _map_device(server, "room1", "device-a", 7)
73
+
74
+ assert server._apply_client_var_set("room1", 2, 7, "hp", "10") is True
75
+ assert server._apply_client_var_set("room1", 3, 7, "hp", "20") is True
76
+
77
+ stored = server.client_variables["room1"]["device-a"]["hp"]
78
+ assert stored["value"] == "20"
79
+ assert stored["lastWriterClientNo"] == 3
80
+
81
+ def test_rest_and_live_writes_share_one_sequence_domain(
82
+ self, server: NetSyncServer
83
+ ) -> None:
84
+ _map_device(server, "room1", "device-a", 7)
85
+
86
+ server._apply_client_var_set("room1", 2, 7, "hp", "10")
87
+ live_seq = server.client_variables["room1"]["device-a"]["hp"]["version"]
88
+
89
+ server.upsert_client_variables_for_device("room1", "device-a", {"hp": "30"})
90
+ rest_seq = server.client_variables["room1"]["device-a"]["hp"]["version"]
91
+
92
+ assert rest_seq > live_seq
93
+ assert server.client_variables["room1"]["device-a"]["hp"]["value"] == "30"
94
+
95
+
96
+ class TestLiveVsRestOrderingRegression:
97
+ """Regression: a newer REST write must not be clobbered by an older live
98
+ write that was still buffered when the REST write arrived.
99
+
100
+ Live socket writes are coalesced into a pending buffer and applied later by
101
+ ``_flush_nv_drain``; REST writes apply immediately. Once client timestamps
102
+ were removed, an out-of-order application no longer self-rejects, so the
103
+ REST path must prune any superseded buffered write for the same key.
104
+ """
105
+
106
+ def test_buffered_live_write_does_not_overwrite_newer_rest_write(
107
+ self, server: NetSyncServer
108
+ ) -> None:
109
+ _map_device(server, "room1", "device-a", 7)
110
+
111
+ # An older live client write is buffered, awaiting the next flush.
112
+ server._buffer_client_var_set(
113
+ "room1",
114
+ {
115
+ "senderClientNo": 7,
116
+ "targetClientNo": 7,
117
+ "variableName": "hp",
118
+ "variableValue": "10",
119
+ },
120
+ )
121
+
122
+ # A REST upsert applies a newer value immediately.
123
+ server.upsert_client_variables_for_device("room1", "device-a", {"hp": "20"})
124
+
125
+ # Draining must not resurrect the stale buffered "10" over the REST "20".
126
+ server._flush_nv_drain("room1")
127
+
128
+ assert server.client_variables["room1"]["device-a"]["hp"]["value"] == "20"
129
+
130
+
131
+ class TestRoomCleanupReleasesNvWriteSeq:
132
+ """Regression: room cleanup must drop nv_write_seq so per-room sequence
133
+ entries do not accumulate under room churn."""
134
+
135
+ def test_nv_write_seq_entry_is_removed_on_room_cleanup(
136
+ self, server: NetSyncServer
137
+ ) -> None:
138
+ server._initialize_room("room1")
139
+ server._apply_global_var_set("room1", 1, "a", "1")
140
+ assert "room1" in server.nv_write_seq
141
+
142
+ # Room has been empty long enough to be reclaimed by the real cleanup.
143
+ server.room_empty_since["room1"] = 0.0
144
+ server._cleanup_clients(server.EMPTY_ROOM_EXPIRY_TIME + 100.0)
145
+
146
+ assert "room1" not in server.rooms
147
+ assert "room1" not in server.nv_write_seq
@@ -262,6 +262,7 @@ class TestServerObjectOwnership:
262
262
  srv.device_id_last_seen = {}
263
263
  srv.global_variables = {}
264
264
  srv.client_variables = {}
265
+ srv.nv_write_seq = {}
265
266
  srv.pending_global_nv = {}
266
267
  srv.pending_client_nv = {}
267
268
  srv.room_last_nv_flush = {}