Add GUI-managed fake TCP clients
Introduce GUI test tooling that spawns synthetic TCP clients for local relay testing. Adds server/fake_player.py implementing FakePlayerClient and FakePlayerManager which create TCP connections to 127.0.0.1:7777, parse newline-delimited JSON, receive server-assigned playerId, and send one idle transform per second for cell 0B000F99. Integrates the manager into server/dev_server_app.py: start/stop lifecycle handling, Add/Remove buttons, table snapshots, logging bridge, and clean shutdown of fake clients. Updates server/README.md and docs/dev-log.md with usage, testing notes, and known issues. Includes basic error handling and thread-safe snapshots; movement scripts beyond Idle and full in-game validation are noted as next steps.
This commit is contained in:
@@ -978,6 +978,66 @@ Player position: X=2048.00, Y=2048.00, Z=0.00, AngleZ=0.00
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-02 - GUI Fake Client Manager
|
||||
|
||||
### Summary
|
||||
|
||||
Added GUI-managed synthetic TCP clients for local relay testing. Fake clients
|
||||
connect through the normal server socket, receive a welcome `playerId`, send one
|
||||
idle transform per second for `F4TTestCell01`, and disconnect cleanly from the
|
||||
Fake Clients tab or when the GUI server stops.
|
||||
|
||||
### Files Changed
|
||||
|
||||
- `server/fake_player.py`
|
||||
- `server/dev_server_app.py`
|
||||
- `server/README.md`
|
||||
- `docs/dev-log.md`
|
||||
|
||||
### Details
|
||||
|
||||
- Added `FakePlayerClient` and `FakePlayerManager` as GUI test tooling only.
|
||||
- Fake clients use TCP `127.0.0.1:7777` and newline-separated JSON packets, so
|
||||
the server sees them as normal external clients.
|
||||
- The Fake Clients tab now supports `+ Add Fake Client` and
|
||||
`- Remove Selected`.
|
||||
- Fake-client lifecycle logs are routed to the GUI console with a
|
||||
`[FakeClient]` prefix through the Qt signal bridge.
|
||||
- Stopping or closing the GUI disconnects all fake clients before stopping the
|
||||
server.
|
||||
- The Fallout 4 plugin, network protocol, terminal server launcher, and
|
||||
standalone `fake_client.py` were left unchanged.
|
||||
|
||||
### Testing
|
||||
|
||||
- Ran `python -m py_compile server.py server_core.py client_session.py
|
||||
fake_client.py dev_server_app.py fake_player.py` from `server`; it succeeded.
|
||||
- Ran `python server.py` from `server` and confirmed the terminal launcher
|
||||
reached the expected listening state.
|
||||
- Ran a headless fake-client smoke test on an alternate local port after
|
||||
`127.0.0.1:7777` was temporarily held by another process. The fake client
|
||||
received `playerId` 1, reached `Connected`, and the server recorded the idle
|
||||
transform for `0B000F99`.
|
||||
- Imported `dev_server_app.py` successfully to confirm the GUI module and
|
||||
PySide6 dependency load in the current Python environment.
|
||||
- Not yet run: interactive `python dev_server_app.py` GUI clicks.
|
||||
- Not yet run: Fallout 4/F4SE validation that a real client receives the fake
|
||||
client's idle transform in `F4TTestCell01`.
|
||||
|
||||
### Known Issues
|
||||
|
||||
- No movement scripts beyond `Idle` are implemented yet.
|
||||
- Full Fallout 4/F4SE validation still needs to be run manually.
|
||||
|
||||
### Next Steps
|
||||
|
||||
- Add selectable fake-client movement scripts after the idle-client milestone is
|
||||
validated.
|
||||
- Consider fake-client controls for script start/stop once multiple scripts
|
||||
exist.
|
||||
|
||||
---
|
||||
|
||||
## Entry Template
|
||||
|
||||
Use this format for future updates:
|
||||
|
||||
+21
-3
@@ -38,6 +38,12 @@ Then launch Fallout 4 through F4SE and move the player. The plugin should send
|
||||
local player transform packets, and this server should print matching
|
||||
`transform` packets.
|
||||
|
||||
In the developer GUI, the Fake Clients tab can create simple synthetic TCP
|
||||
clients for local relay testing. Click `Start Server`, then click
|
||||
`+ Add Fake Client`. Each fake client connects to `127.0.0.1:7777`, receives a
|
||||
server-assigned `playerId`, appears in both GUI client tables, and sends one
|
||||
idle transform per second for `F4TTestCell01`.
|
||||
|
||||
If the server is not running, the plugin should log a warning and Fallout 4
|
||||
should continue launching normally.
|
||||
|
||||
@@ -49,6 +55,9 @@ should continue launching normally.
|
||||
- `client_session.py`: `ClientSession` state model for connected clients.
|
||||
- `dev_server_app.py`: PySide6 developer GUI that starts/stops the reusable
|
||||
server core, displays logs, shows live stats, and lists connected clients.
|
||||
- `fake_player.py`: GUI fake-player test tooling. It owns background synthetic
|
||||
TCP clients that connect through the normal server socket and send idle
|
||||
transform packets.
|
||||
- `fake_client.py`: lightweight receiver for testing welcome, transform, and
|
||||
disconnect packets without launching a second Fallout 4 instance.
|
||||
- `requirements.txt`: optional Python dependencies for the desktop developer
|
||||
@@ -97,7 +106,9 @@ python dev_server_app.py
|
||||
```
|
||||
|
||||
Click `Start Server`, then run `python fake_client.py` or launch Fallout 4
|
||||
through F4SE.
|
||||
through F4SE. You can also click `+ Add Fake Client` to create a GUI-managed
|
||||
synthetic client that sends an idle transform once per second. Select a fake
|
||||
client row and click `- Remove Selected` to disconnect it cleanly.
|
||||
|
||||
Expected behavior:
|
||||
|
||||
@@ -111,6 +122,9 @@ Expected behavior:
|
||||
sender.
|
||||
- `fake_client.py` receives broadcast transform packets.
|
||||
- `fake_client.py` stores remote player state by `playerId`.
|
||||
- GUI fake clients connect as ordinary external TCP clients, receive a
|
||||
`welcome` packet, store their assigned `playerId`, and send idle transforms
|
||||
for cell `0B000F99`.
|
||||
- When a client disconnects, the server removes that client and broadcasts a
|
||||
`disconnect` packet with the departed `playerId` to the remaining clients.
|
||||
- `fake_client.py` removes disconnected players from its in-memory
|
||||
@@ -125,7 +139,7 @@ does not spawn remote actors or send movement.
|
||||
From inside this folder:
|
||||
|
||||
```bash
|
||||
python -m py_compile server.py server_core.py client_session.py fake_client.py dev_server_app.py
|
||||
python -m py_compile server.py server_core.py client_session.py fake_client.py dev_server_app.py fake_player.py
|
||||
```
|
||||
|
||||
Then run the terminal server:
|
||||
@@ -148,6 +162,9 @@ python dev_server_app.py
|
||||
```
|
||||
|
||||
Click `Start Server`, then connect `fake_client.py` or the Fallout 4 plugin.
|
||||
To validate GUI fake clients, click `+ Add Fake Client` and confirm the fake
|
||||
client appears in the Fake Clients table and Connected Clients table with an
|
||||
assigned player ID. Click `- Remove Selected` and confirm it disconnects.
|
||||
|
||||
For in-game validation, launch Fallout 4 through F4SE and confirm the plugin
|
||||
connects, receives a `welcome` packet, sends transforms, and receives relayed
|
||||
@@ -168,7 +185,8 @@ remote transforms or disconnect packets when another client is connected.
|
||||
- Reusable server core with thread-safe client/stat snapshots
|
||||
- Log callbacks for future server console UI
|
||||
- PySide6 developer server GUI with live logs, stats, connected-client table,
|
||||
and a placeholder Fake Clients tab
|
||||
and a Fake Clients tab that can spawn idle synthetic TCP clients
|
||||
- GUI-managed fake clients that receive welcome packets and send idle transforms
|
||||
|
||||
## Working In Fake Client Only
|
||||
|
||||
|
||||
+72
-26
@@ -4,7 +4,7 @@ import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QObject, QTimer, Signal
|
||||
from PySide6.QtCore import QObject, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import QCloseEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -14,7 +14,6 @@ from PySide6.QtWidgets import (
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QTableWidget,
|
||||
@@ -24,6 +23,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from fake_player import FakePlayerManager
|
||||
from server_core import FalloutTogetherServer
|
||||
|
||||
|
||||
@@ -42,6 +42,11 @@ class DevServerWindow(QMainWindow):
|
||||
self.log_bridge = ServerLogBridge()
|
||||
self.log_bridge.message_received.connect(self._append_log)
|
||||
self.server.add_log_listener(self._handle_server_log)
|
||||
self.fake_player_manager = FakePlayerManager(
|
||||
host=self.server.host,
|
||||
port=self.server.port,
|
||||
log_callback=self._handle_fake_client_log,
|
||||
)
|
||||
|
||||
self.stat_labels: dict[str, QLabel] = {}
|
||||
|
||||
@@ -57,6 +62,7 @@ class DevServerWindow(QMainWindow):
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None:
|
||||
self.refresh_timer.stop()
|
||||
self.fake_player_manager.stop_all()
|
||||
self.server.remove_log_listener(self._handle_server_log)
|
||||
self.server.stop()
|
||||
event.accept()
|
||||
@@ -157,11 +163,11 @@ class DevServerWindow(QMainWindow):
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
self.add_fake_client_button = QPushButton("+ Add Fake Client")
|
||||
self.add_fake_client_button.clicked.connect(self._show_fake_clients_not_implemented)
|
||||
self.add_fake_client_button.clicked.connect(self._add_fake_client)
|
||||
button_layout.addWidget(self.add_fake_client_button)
|
||||
|
||||
self.remove_fake_client_button = QPushButton("- Remove Selected")
|
||||
self.remove_fake_client_button.clicked.connect(self._show_fake_clients_not_implemented)
|
||||
self.remove_fake_client_button.clicked.connect(self._remove_selected_fake_client)
|
||||
button_layout.addWidget(self.remove_fake_client_button)
|
||||
|
||||
button_layout.addStretch()
|
||||
@@ -178,22 +184,9 @@ class DevServerWindow(QMainWindow):
|
||||
self.fake_clients_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||||
layout.addWidget(self.fake_clients_table)
|
||||
|
||||
placeholder = [
|
||||
"Fake Client 1",
|
||||
"-",
|
||||
"Not implemented yet",
|
||||
"None",
|
||||
"-",
|
||||
"-",
|
||||
]
|
||||
self.fake_clients_table.setRowCount(1)
|
||||
for column, value in enumerate(placeholder):
|
||||
self.fake_clients_table.setItem(0, column, self._create_table_item(value))
|
||||
|
||||
# TODO: Add a fake client manager that can own multiple test clients.
|
||||
# TODO: Add movement scripts once fake clients can be spawned safely.
|
||||
# TODO: Wire add/remove fake client controls without duplicating networking logic.
|
||||
# TODO: Add script controls for selecting and starting/stopping movement scripts.
|
||||
# TODO: Add future fake-client scripts: Walk Circle, Walk Square,
|
||||
# Jump Loop, Sneak Toggle, Teleport Test, Leave Cell / Return, and
|
||||
# Disconnect After Delay.
|
||||
|
||||
return tab
|
||||
|
||||
@@ -209,6 +202,7 @@ class DevServerWindow(QMainWindow):
|
||||
|
||||
def _stop_server(self) -> None:
|
||||
if self.server.is_running():
|
||||
self.fake_player_manager.stop_all()
|
||||
self.server.stop()
|
||||
self._append_log("Server stopped.")
|
||||
|
||||
@@ -217,16 +211,38 @@ class DevServerWindow(QMainWindow):
|
||||
def _copy_console(self) -> None:
|
||||
QApplication.clipboard().setText(self.console.toPlainText())
|
||||
|
||||
def _show_fake_clients_not_implemented(self) -> None:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Fake Clients",
|
||||
"Fake client spawning is not implemented yet.",
|
||||
)
|
||||
def _add_fake_client(self) -> None:
|
||||
if not self.server.is_running():
|
||||
self._append_log("Start the server before adding fake clients.")
|
||||
return
|
||||
|
||||
self.fake_player_manager.add_client()
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _remove_selected_fake_client(self) -> None:
|
||||
selected_items = self.fake_clients_table.selectedItems()
|
||||
if not selected_items:
|
||||
self._append_log("No fake client selected.")
|
||||
return
|
||||
|
||||
row = selected_items[0].row()
|
||||
name_item = self.fake_clients_table.item(row, 0)
|
||||
if name_item is None:
|
||||
return
|
||||
|
||||
client_key = name_item.data(Qt.ItemDataRole.UserRole)
|
||||
if client_key is None:
|
||||
return
|
||||
|
||||
self.fake_player_manager.remove_client(int(client_key))
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _handle_server_log(self, message: str) -> None:
|
||||
self.log_bridge.message_received.emit(message)
|
||||
|
||||
def _handle_fake_client_log(self, message: str) -> None:
|
||||
self.log_bridge.message_received.emit(message)
|
||||
|
||||
def _append_log(self, message: str) -> None:
|
||||
timestamp = time.strftime("%H:%M:%S", time.localtime())
|
||||
self.console.appendPlainText(f"[{timestamp}] {message}")
|
||||
@@ -250,6 +266,7 @@ class DevServerWindow(QMainWindow):
|
||||
self.stop_button.setEnabled(is_running)
|
||||
|
||||
self._refresh_clients_table(clients)
|
||||
self._refresh_fake_clients_table()
|
||||
|
||||
def _refresh_clients_table(self, clients: list[dict[str, Any]]) -> None:
|
||||
self.clients_table.setRowCount(len(clients))
|
||||
@@ -281,6 +298,27 @@ class DevServerWindow(QMainWindow):
|
||||
for column, value in enumerate(values):
|
||||
self.clients_table.setItem(row, column, self._create_table_item(str(value)))
|
||||
|
||||
def _refresh_fake_clients_table(self) -> None:
|
||||
snapshots = self.fake_player_manager.get_snapshots()
|
||||
self.fake_clients_table.setRowCount(len(snapshots))
|
||||
|
||||
for row, snapshot in enumerate(snapshots):
|
||||
player_id = snapshot.get("playerId")
|
||||
values = [
|
||||
snapshot.get("name", "-"),
|
||||
player_id if player_id is not None else "-",
|
||||
snapshot.get("status", "-"),
|
||||
snapshot.get("script", "-"),
|
||||
snapshot.get("cellId", "-"),
|
||||
self._format_fake_position(snapshot.get("position")),
|
||||
]
|
||||
|
||||
for column, value in enumerate(values):
|
||||
item = self._create_table_item(str(value))
|
||||
if column == 0:
|
||||
item.setData(Qt.ItemDataRole.UserRole, snapshot.get("clientKey"))
|
||||
self.fake_clients_table.setItem(row, column, item)
|
||||
|
||||
@staticmethod
|
||||
def _create_table_item(value: str) -> QTableWidgetItem:
|
||||
return QTableWidgetItem(value)
|
||||
@@ -316,6 +354,14 @@ class DevServerWindow(QMainWindow):
|
||||
|
||||
return f"{x:.2f}, {y:.2f}, {z:.2f}"
|
||||
|
||||
@staticmethod
|
||||
def _format_fake_position(position: Any) -> str:
|
||||
try:
|
||||
x, y, z = position
|
||||
return f"{float(x):.1f}, {float(y):.1f}, {float(z):.1f}"
|
||||
except (TypeError, ValueError):
|
||||
return "-"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
PORT = 7777
|
||||
SCRIPT_IDLE = "Idle"
|
||||
STATUS_CONNECTING = "Connecting"
|
||||
STATUS_CONNECTED = "Connected"
|
||||
STATUS_DISCONNECTED = "Disconnected"
|
||||
STATUS_ERROR = "Error"
|
||||
IDLE_SEND_INTERVAL_SECONDS = 1.0
|
||||
SOCKET_TIMEOUT_SECONDS = 0.2
|
||||
STOP_JOIN_TIMEOUT_SECONDS = 2.0
|
||||
|
||||
IDLE_CELL_ID = "0B000F99"
|
||||
IDLE_WORLDSPACE_ID = ""
|
||||
IDLE_X = -1792.0
|
||||
IDLE_Y = -1344.0
|
||||
IDLE_Z = 0.0
|
||||
IDLE_ANGLE_Z = 3.93
|
||||
|
||||
|
||||
class FakePlayerClient:
|
||||
def __init__(
|
||||
self,
|
||||
client_key: int,
|
||||
name: str,
|
||||
*,
|
||||
host: str = HOST,
|
||||
port: int = PORT,
|
||||
log_callback: Callable[[str], None] | None = None,
|
||||
) -> None:
|
||||
self.client_key = client_key
|
||||
self.name = name
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.script = SCRIPT_IDLE
|
||||
self.cell_id = IDLE_CELL_ID
|
||||
self.position = (IDLE_X, IDLE_Y, IDLE_Z)
|
||||
|
||||
self._log_callback = log_callback
|
||||
self._lock = threading.RLock()
|
||||
self._stop_event = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._socket: socket.socket | None = None
|
||||
self._player_id: int | None = None
|
||||
self._status = STATUS_DISCONNECTED
|
||||
self._last_error: str | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
with self._lock:
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
return
|
||||
|
||||
self._stop_event.clear()
|
||||
self._status = STATUS_CONNECTING
|
||||
self._last_error = None
|
||||
self._thread = threading.Thread(target=self._run, name=self.name, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
self._close_socket()
|
||||
|
||||
thread = self._thread
|
||||
if thread is not None and thread is not threading.current_thread():
|
||||
thread.join(timeout=STOP_JOIN_TIMEOUT_SECONDS)
|
||||
|
||||
with self._lock:
|
||||
if self._status != STATUS_ERROR:
|
||||
self._status = STATUS_DISCONNECTED
|
||||
|
||||
def get_snapshot(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"clientKey": self.client_key,
|
||||
"name": self.name,
|
||||
"playerId": self._player_id,
|
||||
"status": self._status,
|
||||
"script": self.script,
|
||||
"cellId": self.cell_id,
|
||||
"position": self.position,
|
||||
"lastError": self._last_error,
|
||||
}
|
||||
|
||||
def _run(self) -> None:
|
||||
self._log(f"{self.name} connecting...")
|
||||
try:
|
||||
connection = socket.create_connection((self.host, self.port), timeout=5.0)
|
||||
connection.settimeout(SOCKET_TIMEOUT_SECONDS)
|
||||
with self._lock:
|
||||
self._socket = connection
|
||||
|
||||
self._receive_and_send_loop(connection)
|
||||
except (ConnectionRefusedError, TimeoutError, OSError) as error:
|
||||
if not self._stop_event.is_set():
|
||||
self._set_error(str(error))
|
||||
self._log(f"{self.name} error: {error}")
|
||||
finally:
|
||||
self._close_socket()
|
||||
with self._lock:
|
||||
if self._status != STATUS_ERROR:
|
||||
self._status = STATUS_DISCONNECTED
|
||||
|
||||
self._log(f"{self.name} disconnected.")
|
||||
|
||||
def _receive_and_send_loop(self, connection: socket.socket) -> None:
|
||||
buffer = ""
|
||||
next_transform_time = time.time()
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
chunk: bytes | None
|
||||
try:
|
||||
chunk = connection.recv(4096)
|
||||
except socket.timeout:
|
||||
chunk = None
|
||||
except ConnectionResetError:
|
||||
break
|
||||
except OSError:
|
||||
if not self._stop_event.is_set():
|
||||
raise
|
||||
break
|
||||
|
||||
if chunk is None:
|
||||
pass
|
||||
elif chunk:
|
||||
buffer += chunk.decode("utf-8", errors="replace")
|
||||
while "\n" in buffer:
|
||||
line, buffer = buffer.split("\n", 1)
|
||||
self._handle_line(line.strip())
|
||||
else:
|
||||
break
|
||||
|
||||
now = time.time()
|
||||
if self._is_connected() and now >= next_transform_time:
|
||||
self._send_idle_transform(connection)
|
||||
next_transform_time = now + IDLE_SEND_INTERVAL_SECONDS
|
||||
|
||||
def _handle_line(self, line: str) -> None:
|
||||
if not line:
|
||||
return
|
||||
|
||||
try:
|
||||
packet = json.loads(line)
|
||||
except json.JSONDecodeError as error:
|
||||
self._log(f"{self.name} received invalid JSON: {error}: {line}")
|
||||
return
|
||||
|
||||
if not isinstance(packet, dict):
|
||||
self._log(f"{self.name} received non-object packet: {packet}")
|
||||
return
|
||||
|
||||
if packet.get("type") == "welcome":
|
||||
self._handle_welcome(packet)
|
||||
|
||||
def _handle_welcome(self, packet: dict[str, Any]) -> None:
|
||||
try:
|
||||
player_id = int(packet["playerId"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
self._log(f"{self.name} received malformed welcome packet: {packet}")
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
self._player_id = player_id
|
||||
self._status = STATUS_CONNECTED
|
||||
|
||||
self._log(f"{self.name} assigned playerId {player_id}.")
|
||||
|
||||
def _send_idle_transform(self, connection: socket.socket) -> None:
|
||||
packet = {
|
||||
"type": "transform",
|
||||
"x": IDLE_X,
|
||||
"y": IDLE_Y,
|
||||
"z": IDLE_Z,
|
||||
"angleZ": IDLE_ANGLE_Z,
|
||||
"movementType": "normal",
|
||||
"cellId": IDLE_CELL_ID,
|
||||
"worldspaceId": IDLE_WORLDSPACE_ID,
|
||||
"clientTime": time.time(),
|
||||
"isMoving": False,
|
||||
"isSprinting": False,
|
||||
"isSneaking": False,
|
||||
"isJumping": False,
|
||||
"weaponDrawn": False,
|
||||
"movementSpeed": 0.0,
|
||||
}
|
||||
encoded = json.dumps(packet, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||
connection.sendall(encoded)
|
||||
|
||||
def _is_connected(self) -> bool:
|
||||
with self._lock:
|
||||
return self._status == STATUS_CONNECTED
|
||||
|
||||
def _set_error(self, message: str) -> None:
|
||||
with self._lock:
|
||||
self._status = STATUS_ERROR
|
||||
self._last_error = message
|
||||
|
||||
def _close_socket(self) -> None:
|
||||
with self._lock:
|
||||
connection = self._socket
|
||||
self._socket = None
|
||||
|
||||
if connection is None:
|
||||
return
|
||||
|
||||
try:
|
||||
connection.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
connection.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _log(self, message: str) -> None:
|
||||
if self._log_callback is None:
|
||||
return
|
||||
|
||||
self._log_callback(message)
|
||||
|
||||
|
||||
class FakePlayerManager:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
host: str = HOST,
|
||||
port: int = PORT,
|
||||
log_callback: Callable[[str], None] | None = None,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self._log_callback = log_callback
|
||||
self._lock = threading.RLock()
|
||||
self._next_client_key = 1
|
||||
self._clients: dict[int, FakePlayerClient] = {}
|
||||
|
||||
def add_client(self) -> FakePlayerClient:
|
||||
with self._lock:
|
||||
client_key = self._next_client_key
|
||||
self._next_client_key += 1
|
||||
client = FakePlayerClient(
|
||||
client_key,
|
||||
f"Fake Client {client_key}",
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
log_callback=self._log,
|
||||
)
|
||||
self._clients[client_key] = client
|
||||
|
||||
client.start()
|
||||
return client
|
||||
|
||||
def remove_client(self, client_key: int) -> None:
|
||||
with self._lock:
|
||||
client = self._clients.pop(client_key, None)
|
||||
|
||||
if client is not None:
|
||||
client.stop()
|
||||
|
||||
def stop_all(self) -> None:
|
||||
with self._lock:
|
||||
clients = list(self._clients.values())
|
||||
self._clients.clear()
|
||||
|
||||
for client in clients:
|
||||
client.stop()
|
||||
|
||||
def get_snapshots(self) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
clients = list(self._clients.values())
|
||||
|
||||
return [client.get_snapshot() for client in clients]
|
||||
|
||||
def _log(self, message: str) -> None:
|
||||
if self._log_callback is None:
|
||||
return
|
||||
|
||||
self._log_callback(f"[FakeClient] {message}")
|
||||
Reference in New Issue
Block a user