Add fake-client dev scripts and GUI actions
Add developer-only fake-client scripts and UI actions for testing proxy behavior. Files updated: server/fake_player.py, server/dev_server_app.py, server/README.md, docs/dev-log.md. New scripts/actions: Walk Circle, Jump Once, Toggle Sneak, Leave Cell (Left Cell), Return To Cell, and Teleport Test. UI: added buttons and handlers in the DevServerWindow to trigger these actions. fake_player: implemented script/state handling, one-shot jump thread, teleport toggle, sneak flag, walk-circle movement, cell change transforms, a socket send lock, and protection for one-shot/action state. README and dev log updated with usage, testing notes, and known issues. Basic headless smoke tests and py_compile checks were performed; full in-game Fallout 4/F4SE validation remains manual.
This commit is contained in:
@@ -1095,6 +1095,72 @@ through the Python server.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-06-02 - Fake Client Test Scripts
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Added developer-only fake-client test scripts and actions to the PySide6 Fake
|
||||||
|
Clients tab. GUI-managed fake clients can now test jump state, sneak state,
|
||||||
|
circle movement, cell mismatch holding, return-to-cell behavior, and teleport
|
||||||
|
snapping without launching multiple Fallout 4 instances.
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
|
||||||
|
- `server/fake_player.py`
|
||||||
|
- `server/dev_server_app.py`
|
||||||
|
- `server/README.md`
|
||||||
|
- `docs/dev-log.md`
|
||||||
|
|
||||||
|
### Details
|
||||||
|
|
||||||
|
- Added `Walk Circle` and `Left Cell` continuous fake-client modes.
|
||||||
|
- Added one-shot `Jump Once`, `Toggle Sneak`, `Return To Cell`, and
|
||||||
|
`Teleport Test` actions.
|
||||||
|
- Kept fake clients as external TCP clients that send normal newline-separated
|
||||||
|
JSON transform packets through the existing server socket.
|
||||||
|
- Preserved the existing `Idle` and `Walk To Player` controls.
|
||||||
|
- Added Fake Clients tab buttons for `Jump Once`, `Toggle Sneak`, `Walk Circle`,
|
||||||
|
`Leave Cell`, `Return To Cell`, and `Teleport Test`.
|
||||||
|
- Kept Fake Clients table columns unchanged while continuing to refresh script,
|
||||||
|
cell, and position snapshots from the manager.
|
||||||
|
- Added a socket send lock and protected script, pose, movement flags, and
|
||||||
|
one-shot action state with the existing client lock.
|
||||||
|
- Left the Fallout 4 plugin, protocol, `server/server.py`, `server/fake_client.py`,
|
||||||
|
and server networking behavior 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.
|
||||||
|
- Checked editor diagnostics for `server/fake_player.py` and
|
||||||
|
`server/dev_server_app.py`; no linter errors were reported.
|
||||||
|
- Ran a headless smoke test on alternate local port `7788`; a GUI-managed fake
|
||||||
|
client connected through the real server socket and exercised Walk Circle,
|
||||||
|
Toggle Sneak, Jump Once, Leave Cell, Return To Cell, and Teleport Test without
|
||||||
|
disconnecting.
|
||||||
|
- Not yet run: `python server.py` terminal launcher smoke test.
|
||||||
|
- Not yet run: `python dev_server_app.py`, start the server, add/remove fake
|
||||||
|
clients, and click each Fake Clients script/action button.
|
||||||
|
- Not yet run: Fallout 4/F4SE validation inside `F4TTestCell01` that Walk
|
||||||
|
Circle, Jump Once, Toggle Sneak, Leave Cell, Return To Cell, and Teleport Test
|
||||||
|
produce the expected proxy behavior.
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
|
||||||
|
- Full Fallout 4/F4SE validation still needs to be run manually.
|
||||||
|
- Walk Circle is a simple mathematical circle only; it does not use pathfinding,
|
||||||
|
navmesh, or obstacle avoidance.
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
|
||||||
|
- Manually validate the GUI scripts with a real Fallout 4 client in
|
||||||
|
`F4TTestCell01`.
|
||||||
|
- Consider future fake-client scripts such as Walk Square, Follow Player, Sprint
|
||||||
|
Toggle, Weapon Drawn Toggle, Disconnect After Delay, and multi-fake-player
|
||||||
|
choreography.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Entry Template
|
## Entry Template
|
||||||
|
|
||||||
Use this format for future updates:
|
Use this format for future updates:
|
||||||
|
|||||||
+33
-10
@@ -55,6 +55,19 @@ Select a fake client row to use the movement controls:
|
|||||||
inside the real player. While moving, it sends normal transform packets at
|
inside the real player. While moving, it sends normal transform packets at
|
||||||
about 10 Hz with `isMoving=true`; when it reaches the stop distance, it
|
about 10 Hz with `isMoving=true`; when it reaches the stop distance, it
|
||||||
returns to low-rate idle keep-alive transforms.
|
returns to low-rate idle keep-alive transforms.
|
||||||
|
- `Walk Circle`: makes the selected fake client move continuously around a
|
||||||
|
simple circle in `F4TTestCell01`, sending normal moving transforms at about
|
||||||
|
10 Hz.
|
||||||
|
- `Jump Once`: sends a short normal-movement jump arc with `isJumping=true`,
|
||||||
|
then restores the fake client's ground Z and resumes the current script.
|
||||||
|
- `Toggle Sneak`: toggles the fake client's persistent `isSneaking` flag. It
|
||||||
|
does not move the fake client by itself.
|
||||||
|
- `Leave Cell`: sends a `cell_change` transform to fake cell `DEADBEEF` and
|
||||||
|
keeps the fake client connected in a low-rate `Left Cell` mode.
|
||||||
|
- `Return To Cell`: sends a `cell_change` transform back to `0B000F99`, restores
|
||||||
|
`worldspaceId=""`, and returns the fake client to `Idle`.
|
||||||
|
- `Teleport Test`: sends one `movementType=teleport` transform to a
|
||||||
|
deterministic test-cell offset, then returns the fake client to `Idle`.
|
||||||
|
|
||||||
These controls are developer test tooling only. They do not change the network
|
These controls are developer test tooling only. They do not change the network
|
||||||
protocol, server relay behavior, or Fallout 4 plugin behavior.
|
protocol, server relay behavior, or Fallout 4 plugin behavior.
|
||||||
@@ -123,8 +136,9 @@ python dev_server_app.py
|
|||||||
Click `Start Server`, then run `python fake_client.py` or launch Fallout 4
|
Click `Start Server`, then run `python fake_client.py` or launch Fallout 4
|
||||||
through F4SE. You can also click `+ Add Fake Client` to create a GUI-managed
|
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
|
synthetic client that sends an idle transform once per second. Select a fake
|
||||||
client row and click `Set Idle` or `Walk To Player` to control its script. Click
|
client row and click `Set Idle`, `Walk To Player`, `Walk Circle`, `Jump Once`,
|
||||||
`- Remove Selected` to disconnect it cleanly.
|
`Toggle Sneak`, `Leave Cell`, `Return To Cell`, or `Teleport Test` to control
|
||||||
|
its dev script/action. Click `- Remove Selected` to disconnect it cleanly.
|
||||||
|
|
||||||
Expected behavior:
|
Expected behavior:
|
||||||
|
|
||||||
@@ -143,6 +157,9 @@ Expected behavior:
|
|||||||
for cell `0B000F99`.
|
for cell `0B000F99`.
|
||||||
- GUI fake clients can be switched between `Idle` and `Walk To Player` scripts
|
- GUI fake clients can be switched between `Idle` and `Walk To Player` scripts
|
||||||
from the Fake Clients tab.
|
from the Fake Clients tab.
|
||||||
|
- GUI fake clients can also run `Walk Circle`, `Left Cell`, and one-shot jump,
|
||||||
|
sneak toggle, return-to-cell, and teleport test actions from the Fake Clients
|
||||||
|
tab.
|
||||||
- Walk To Player uses `FalloutTogetherServer.get_clients()` snapshots to find
|
- Walk To Player uses `FalloutTogetherServer.get_clients()` snapshots to find
|
||||||
the lowest non-fake client with a valid last transform, then walks gradually
|
the lowest non-fake client with a valid last transform, then walks gradually
|
||||||
toward that player with a simple straight-line movement test.
|
toward that player with a simple straight-line movement test.
|
||||||
@@ -185,17 +202,21 @@ python dev_server_app.py
|
|||||||
Click `Start Server`, then connect `fake_client.py` or the Fallout 4 plugin.
|
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
|
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
|
client appears in the Fake Clients table and Connected Clients table with an
|
||||||
assigned player ID. Select the fake client, click `Set Idle`, and confirm the
|
assigned player ID. Select the fake client, click each Fake Clients action, and
|
||||||
Script column stays or changes to `Idle`. Click `Walk To Player` with no real
|
confirm the Script, Cell ID, Position, and console log output update as expected.
|
||||||
Fallout 4 client connected and confirm the GUI logs that no real player target
|
Click `Walk To Player` with no real Fallout 4 client connected and confirm the
|
||||||
is available. Click `- Remove Selected` and confirm it disconnects.
|
GUI logs that no real player target is available. Click `- Remove Selected` and
|
||||||
|
confirm it disconnects.
|
||||||
|
|
||||||
For in-game validation, launch Fallout 4 through F4SE and confirm the plugin
|
For in-game validation, launch Fallout 4 through F4SE and confirm the plugin
|
||||||
connects, receives a `welcome` packet, sends transforms, and receives relayed
|
connects, receives a `welcome` packet, sends transforms, and receives relayed
|
||||||
remote transforms or disconnect packets when another client is connected. In
|
remote transforms or disconnect packets when another client is connected. In
|
||||||
`F4TTestCell01`, select a GUI fake client and click `Walk To Player`; the fake
|
`F4TTestCell01`, select a GUI fake client and click `Walk To Player`; the fake
|
||||||
client should walk smoothly toward the real player's latest transform offset and
|
client should walk smoothly toward the real player's latest transform offset and
|
||||||
then stop near that offset.
|
then stop near that offset. Test `Walk Circle`, `Jump Once`, `Toggle Sneak`,
|
||||||
|
`Leave Cell`, `Return To Cell`, and `Teleport Test` while watching the proxy to
|
||||||
|
confirm movement, jump/sneak state, cell mismatch holding, return, and teleport
|
||||||
|
snap behavior.
|
||||||
|
|
||||||
## Implemented
|
## Implemented
|
||||||
|
|
||||||
@@ -217,6 +238,8 @@ then stop near that offset.
|
|||||||
- Fake Clients tab movement controls for `Set Idle` and `Walk To Player`
|
- Fake Clients tab movement controls for `Set Idle` and `Walk To Player`
|
||||||
- GUI-managed fake clients that can walk gradually toward a real Fallout 4
|
- GUI-managed fake clients that can walk gradually toward a real Fallout 4
|
||||||
client using normal transform packets
|
client using normal transform packets
|
||||||
|
- Fake Clients tab dev scripts/actions for `Jump Once`, `Toggle Sneak`,
|
||||||
|
`Walk Circle`, `Leave Cell`, `Return To Cell`, and `Teleport Test`
|
||||||
|
|
||||||
## Working In Fake Client Only
|
## Working In Fake Client Only
|
||||||
|
|
||||||
@@ -227,9 +250,9 @@ then stop near that offset.
|
|||||||
|
|
||||||
## Planned Later
|
## Planned Later
|
||||||
|
|
||||||
- Additional fake client scripts such as Walk Circle, Walk Square, Follow
|
- Additional fake client scripts such as Walk Square, Follow Player, Sprint
|
||||||
Player, Jump Loop, Sneak Toggle, Teleport Test, Leave Cell / Return, and
|
Toggle, Weapon Drawn Toggle, Disconnect After Delay, and multi-fake-player
|
||||||
Disconnect After Delay
|
choreography
|
||||||
- Gameplay synchronization
|
- Gameplay synchronization
|
||||||
- Authentication
|
- Authentication
|
||||||
- Public matchmaking
|
- Public matchmaking
|
||||||
|
|||||||
@@ -184,9 +184,38 @@ class DevServerWindow(QMainWindow):
|
|||||||
self.walk_to_player_button.clicked.connect(self._set_selected_fake_client_walk_to_player)
|
self.walk_to_player_button.clicked.connect(self._set_selected_fake_client_walk_to_player)
|
||||||
action_layout.addWidget(self.walk_to_player_button)
|
action_layout.addWidget(self.walk_to_player_button)
|
||||||
|
|
||||||
|
self.walk_circle_button = QPushButton("Walk Circle")
|
||||||
|
self.walk_circle_button.clicked.connect(self._set_selected_fake_client_walk_circle)
|
||||||
|
action_layout.addWidget(self.walk_circle_button)
|
||||||
|
|
||||||
action_layout.addStretch()
|
action_layout.addStretch()
|
||||||
layout.addLayout(action_layout)
|
layout.addLayout(action_layout)
|
||||||
|
|
||||||
|
script_action_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
self.jump_once_button = QPushButton("Jump Once")
|
||||||
|
self.jump_once_button.clicked.connect(self._trigger_selected_fake_client_jump_once)
|
||||||
|
script_action_layout.addWidget(self.jump_once_button)
|
||||||
|
|
||||||
|
self.toggle_sneak_button = QPushButton("Toggle Sneak")
|
||||||
|
self.toggle_sneak_button.clicked.connect(self._toggle_selected_fake_client_sneak)
|
||||||
|
script_action_layout.addWidget(self.toggle_sneak_button)
|
||||||
|
|
||||||
|
self.leave_cell_button = QPushButton("Leave Cell")
|
||||||
|
self.leave_cell_button.clicked.connect(self._trigger_selected_fake_client_leave_cell)
|
||||||
|
script_action_layout.addWidget(self.leave_cell_button)
|
||||||
|
|
||||||
|
self.return_to_cell_button = QPushButton("Return To Cell")
|
||||||
|
self.return_to_cell_button.clicked.connect(self._trigger_selected_fake_client_return_to_cell)
|
||||||
|
script_action_layout.addWidget(self.return_to_cell_button)
|
||||||
|
|
||||||
|
self.teleport_test_button = QPushButton("Teleport Test")
|
||||||
|
self.teleport_test_button.clicked.connect(self._trigger_selected_fake_client_teleport_test)
|
||||||
|
script_action_layout.addWidget(self.teleport_test_button)
|
||||||
|
|
||||||
|
script_action_layout.addStretch()
|
||||||
|
layout.addLayout(script_action_layout)
|
||||||
|
|
||||||
self.fake_clients_table = QTableWidget()
|
self.fake_clients_table = QTableWidget()
|
||||||
self.fake_clients_table.setColumnCount(6)
|
self.fake_clients_table.setColumnCount(6)
|
||||||
self.fake_clients_table.setHorizontalHeaderLabels(
|
self.fake_clients_table.setHorizontalHeaderLabels(
|
||||||
@@ -198,9 +227,9 @@ class DevServerWindow(QMainWindow):
|
|||||||
self.fake_clients_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
self.fake_clients_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||||||
layout.addWidget(self.fake_clients_table)
|
layout.addWidget(self.fake_clients_table)
|
||||||
|
|
||||||
# TODO: Add future fake-client scripts: Walk Circle, Walk Square,
|
# TODO: Add future fake-client scripts: Walk Square, Follow Player,
|
||||||
# Follow Player, Jump Loop, Sneak Toggle, Teleport Test,
|
# Sprint Toggle, Weapon Drawn Toggle, Disconnect After Delay, and
|
||||||
# Leave Cell / Return, and Disconnect After Delay.
|
# Multi-fake-player choreography.
|
||||||
|
|
||||||
return tab
|
return tab
|
||||||
|
|
||||||
@@ -264,6 +293,72 @@ class DevServerWindow(QMainWindow):
|
|||||||
|
|
||||||
self._refresh_fake_clients_table()
|
self._refresh_fake_clients_table()
|
||||||
|
|
||||||
|
def _set_selected_fake_client_walk_circle(self) -> None:
|
||||||
|
client_key = self._get_selected_fake_client_key_for_control()
|
||||||
|
if client_key is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.fake_player_manager.set_client_walk_circle(client_key):
|
||||||
|
self._append_log("Select a fake client first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._refresh_fake_clients_table()
|
||||||
|
|
||||||
|
def _trigger_selected_fake_client_jump_once(self) -> None:
|
||||||
|
client_key = self._get_selected_fake_client_key_for_control()
|
||||||
|
if client_key is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.fake_player_manager.trigger_client_jump_once(client_key):
|
||||||
|
self._append_log("Select a fake client first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._refresh_fake_clients_table()
|
||||||
|
|
||||||
|
def _toggle_selected_fake_client_sneak(self) -> None:
|
||||||
|
client_key = self._get_selected_fake_client_key_for_control()
|
||||||
|
if client_key is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.fake_player_manager.toggle_client_sneak(client_key):
|
||||||
|
self._append_log("Select a fake client first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._refresh_fake_clients_table()
|
||||||
|
|
||||||
|
def _trigger_selected_fake_client_leave_cell(self) -> None:
|
||||||
|
client_key = self._get_selected_fake_client_key_for_control()
|
||||||
|
if client_key is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.fake_player_manager.trigger_client_leave_cell(client_key):
|
||||||
|
self._append_log("Select a fake client first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._refresh_fake_clients_table()
|
||||||
|
|
||||||
|
def _trigger_selected_fake_client_return_to_cell(self) -> None:
|
||||||
|
client_key = self._get_selected_fake_client_key_for_control()
|
||||||
|
if client_key is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.fake_player_manager.trigger_client_return_to_cell(client_key):
|
||||||
|
self._append_log("Select a fake client first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._refresh_fake_clients_table()
|
||||||
|
|
||||||
|
def _trigger_selected_fake_client_teleport_test(self) -> None:
|
||||||
|
client_key = self._get_selected_fake_client_key_for_control()
|
||||||
|
if client_key is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.fake_player_manager.trigger_client_teleport_test(client_key):
|
||||||
|
self._append_log("Select a fake client first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._refresh_fake_clients_table()
|
||||||
|
|
||||||
def _get_selected_fake_client_key_for_control(self) -> int | None:
|
def _get_selected_fake_client_key_for_control(self) -> int | None:
|
||||||
client_key = self._get_selected_fake_client_key()
|
client_key = self._get_selected_fake_client_key()
|
||||||
if client_key is None:
|
if client_key is None:
|
||||||
|
|||||||
+288
-5
@@ -13,6 +13,8 @@ HOST = "127.0.0.1"
|
|||||||
PORT = 7777
|
PORT = 7777
|
||||||
SCRIPT_IDLE = "Idle"
|
SCRIPT_IDLE = "Idle"
|
||||||
SCRIPT_WALK_TO_PLAYER = "Walk To Player"
|
SCRIPT_WALK_TO_PLAYER = "Walk To Player"
|
||||||
|
SCRIPT_WALK_CIRCLE = "Walk Circle"
|
||||||
|
SCRIPT_LEFT_CELL = "Left Cell"
|
||||||
STATUS_CONNECTING = "Connecting"
|
STATUS_CONNECTING = "Connecting"
|
||||||
STATUS_CONNECTED = "Connected"
|
STATUS_CONNECTED = "Connected"
|
||||||
STATUS_DISCONNECTED = "Disconnected"
|
STATUS_DISCONNECTED = "Disconnected"
|
||||||
@@ -22,16 +24,25 @@ WALK_TO_PLAYER_SEND_RATE_HZ = 10.0
|
|||||||
WALK_TO_PLAYER_SPEED = 180.0
|
WALK_TO_PLAYER_SPEED = 180.0
|
||||||
WALK_TO_PLAYER_STOP_DISTANCE = 100.0
|
WALK_TO_PLAYER_STOP_DISTANCE = 100.0
|
||||||
WALK_TO_PLAYER_OFFSET_X = 150.0
|
WALK_TO_PLAYER_OFFSET_X = 150.0
|
||||||
|
WALK_CIRCLE_RADIUS = 300.0
|
||||||
|
WALK_CIRCLE_SPEED = 180.0
|
||||||
|
WALK_CIRCLE_SEND_RATE_HZ = 10.0
|
||||||
|
JUMP_DURATION_SECONDS = 0.8
|
||||||
|
JUMP_HEIGHT = 120.0
|
||||||
|
JUMP_SEND_RATE_HZ = 10.0
|
||||||
NO_TARGET_LOG_INTERVAL_SECONDS = 5.0
|
NO_TARGET_LOG_INTERVAL_SECONDS = 5.0
|
||||||
SOCKET_TIMEOUT_SECONDS = 0.05
|
SOCKET_TIMEOUT_SECONDS = 0.05
|
||||||
STOP_JOIN_TIMEOUT_SECONDS = 2.0
|
STOP_JOIN_TIMEOUT_SECONDS = 2.0
|
||||||
|
|
||||||
IDLE_CELL_ID = "0B000F99"
|
IDLE_CELL_ID = "0B000F99"
|
||||||
IDLE_WORLDSPACE_ID = ""
|
IDLE_WORLDSPACE_ID = ""
|
||||||
|
LEFT_CELL_ID = "DEADBEEF"
|
||||||
IDLE_X = -1792.0
|
IDLE_X = -1792.0
|
||||||
IDLE_Y = -1344.0
|
IDLE_Y = -1344.0
|
||||||
IDLE_Z = 0.0
|
IDLE_Z = 0.0
|
||||||
IDLE_ANGLE_Z = 3.93
|
IDLE_ANGLE_Z = 3.93
|
||||||
|
TELEPORT_TEST_OFFSET_X = 900.0
|
||||||
|
TELEPORT_TEST_OFFSET_Y = 450.0
|
||||||
|
|
||||||
ClientSnapshotProvider = Callable[[], list[dict[str, Any]]]
|
ClientSnapshotProvider = Callable[[], list[dict[str, Any]]]
|
||||||
WalkTargetProvider = Callable[[int], dict[str, Any] | None]
|
WalkTargetProvider = Callable[[int], dict[str, Any] | None]
|
||||||
@@ -57,10 +68,13 @@ class FakePlayerClient:
|
|||||||
self.worldspace_id = IDLE_WORLDSPACE_ID
|
self.worldspace_id = IDLE_WORLDSPACE_ID
|
||||||
self.position = (IDLE_X, IDLE_Y, IDLE_Z)
|
self.position = (IDLE_X, IDLE_Y, IDLE_Z)
|
||||||
self.angle_z = IDLE_ANGLE_Z
|
self.angle_z = IDLE_ANGLE_Z
|
||||||
|
self.is_sneaking = False
|
||||||
|
self.is_jumping = False
|
||||||
|
|
||||||
self._log_callback = log_callback
|
self._log_callback = log_callback
|
||||||
self._walk_target_provider = walk_target_provider
|
self._walk_target_provider = walk_target_provider
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
|
self._send_lock = threading.Lock()
|
||||||
self._stop_event = threading.Event()
|
self._stop_event = threading.Event()
|
||||||
self._script_changed_event = threading.Event()
|
self._script_changed_event = threading.Event()
|
||||||
self._thread: threading.Thread | None = None
|
self._thread: threading.Thread | None = None
|
||||||
@@ -71,6 +85,11 @@ class FakePlayerClient:
|
|||||||
self._last_no_target_log_at = 0.0
|
self._last_no_target_log_at = 0.0
|
||||||
self._last_target_player_id: int | None = None
|
self._last_target_player_id: int | None = None
|
||||||
self._reached_target_player_id: int | None = None
|
self._reached_target_player_id: int | None = None
|
||||||
|
self._walk_circle_center = self.position
|
||||||
|
self._walk_circle_phase = 0.0
|
||||||
|
self._one_shot_active = False
|
||||||
|
self._jump_thread: threading.Thread | None = None
|
||||||
|
self._teleport_count = 0
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -91,6 +110,10 @@ class FakePlayerClient:
|
|||||||
if thread is not None and thread is not threading.current_thread():
|
if thread is not None and thread is not threading.current_thread():
|
||||||
thread.join(timeout=STOP_JOIN_TIMEOUT_SECONDS)
|
thread.join(timeout=STOP_JOIN_TIMEOUT_SECONDS)
|
||||||
|
|
||||||
|
jump_thread = self._jump_thread
|
||||||
|
if jump_thread is not None and jump_thread is not threading.current_thread():
|
||||||
|
jump_thread.join(timeout=STOP_JOIN_TIMEOUT_SECONDS)
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._status != STATUS_ERROR:
|
if self._status != STATUS_ERROR:
|
||||||
self._status = STATUS_DISCONNECTED
|
self._status = STATUS_DISCONNECTED
|
||||||
@@ -101,6 +124,103 @@ class FakePlayerClient:
|
|||||||
def set_walk_to_player(self) -> None:
|
def set_walk_to_player(self) -> None:
|
||||||
self._set_script(SCRIPT_WALK_TO_PLAYER)
|
self._set_script(SCRIPT_WALK_TO_PLAYER)
|
||||||
|
|
||||||
|
def set_walk_circle(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.cell_id = IDLE_CELL_ID
|
||||||
|
self.worldspace_id = IDLE_WORLDSPACE_ID
|
||||||
|
self._walk_circle_center = self.position
|
||||||
|
self._walk_circle_phase = 0.0
|
||||||
|
|
||||||
|
self._set_script(SCRIPT_WALK_CIRCLE)
|
||||||
|
|
||||||
|
def trigger_jump_once(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._one_shot_active:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._one_shot_active = True
|
||||||
|
previous_script = self.script
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=self._run_jump_once,
|
||||||
|
args=(previous_script,),
|
||||||
|
name=f"{self.name} Jump Once",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._jump_thread = thread
|
||||||
|
|
||||||
|
self._log(f"{self.name} triggered Jump Once.")
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def toggle_sneak(self) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
self.is_sneaking = not self.is_sneaking
|
||||||
|
is_sneaking = self.is_sneaking
|
||||||
|
|
||||||
|
self._script_changed_event.set()
|
||||||
|
self._log(f"{self.name} toggled sneak: {str(is_sneaking).lower()}.")
|
||||||
|
return is_sneaking
|
||||||
|
|
||||||
|
def trigger_leave_cell(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.script = SCRIPT_LEFT_CELL
|
||||||
|
self.cell_id = LEFT_CELL_ID
|
||||||
|
self.worldspace_id = IDLE_WORLDSPACE_ID
|
||||||
|
self._last_no_target_log_at = 0.0
|
||||||
|
self._last_target_player_id = None
|
||||||
|
self._reached_target_player_id = None
|
||||||
|
|
||||||
|
self._script_changed_event.set()
|
||||||
|
self._send_immediate_transform(
|
||||||
|
movement_type="cell_change",
|
||||||
|
is_moving=False,
|
||||||
|
movement_speed=0.0,
|
||||||
|
)
|
||||||
|
self._log(f"{self.name} left test cell.")
|
||||||
|
|
||||||
|
def trigger_return_to_cell(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.script = SCRIPT_IDLE
|
||||||
|
self.cell_id = IDLE_CELL_ID
|
||||||
|
self.worldspace_id = IDLE_WORLDSPACE_ID
|
||||||
|
self.position = (IDLE_X, IDLE_Y, IDLE_Z)
|
||||||
|
self.angle_z = IDLE_ANGLE_Z
|
||||||
|
self._last_no_target_log_at = 0.0
|
||||||
|
self._last_target_player_id = None
|
||||||
|
self._reached_target_player_id = None
|
||||||
|
|
||||||
|
self._script_changed_event.set()
|
||||||
|
self._send_immediate_transform(
|
||||||
|
movement_type="cell_change",
|
||||||
|
is_moving=False,
|
||||||
|
movement_speed=0.0,
|
||||||
|
)
|
||||||
|
self._log(f"{self.name} returned to test cell.")
|
||||||
|
|
||||||
|
def trigger_teleport_test(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.script = SCRIPT_IDLE
|
||||||
|
self.cell_id = IDLE_CELL_ID
|
||||||
|
self.worldspace_id = IDLE_WORLDSPACE_ID
|
||||||
|
current_x, current_y, current_z = self.position
|
||||||
|
direction = -1.0 if self._teleport_count % 2 else 1.0
|
||||||
|
self._teleport_count += 1
|
||||||
|
self.position = (
|
||||||
|
current_x + (TELEPORT_TEST_OFFSET_X * direction),
|
||||||
|
current_y + (TELEPORT_TEST_OFFSET_Y * direction),
|
||||||
|
current_z,
|
||||||
|
)
|
||||||
|
self._last_no_target_log_at = 0.0
|
||||||
|
self._last_target_player_id = None
|
||||||
|
self._reached_target_player_id = None
|
||||||
|
|
||||||
|
self._script_changed_event.set()
|
||||||
|
self._send_immediate_transform(
|
||||||
|
movement_type="teleport",
|
||||||
|
is_moving=False,
|
||||||
|
movement_speed=0.0,
|
||||||
|
)
|
||||||
|
self._log(f"{self.name} triggered Teleport Test.")
|
||||||
|
|
||||||
def get_snapshot(self) -> dict[str, Any]:
|
def get_snapshot(self) -> dict[str, Any]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return {
|
return {
|
||||||
@@ -218,8 +338,13 @@ class FakePlayerClient:
|
|||||||
|
|
||||||
def _send_script_transform(self, connection: socket.socket, delta_seconds: float) -> float:
|
def _send_script_transform(self, connection: socket.socket, delta_seconds: float) -> float:
|
||||||
script = self._get_script()
|
script = self._get_script()
|
||||||
|
if self._is_one_shot_active():
|
||||||
|
return SOCKET_TIMEOUT_SECONDS
|
||||||
|
|
||||||
if script == SCRIPT_WALK_TO_PLAYER:
|
if script == SCRIPT_WALK_TO_PLAYER:
|
||||||
return self._send_walk_to_player_transform(connection, delta_seconds)
|
return self._send_walk_to_player_transform(connection, delta_seconds)
|
||||||
|
if script == SCRIPT_WALK_CIRCLE:
|
||||||
|
return self._send_walk_circle_transform(connection, delta_seconds)
|
||||||
|
|
||||||
self._send_transform(connection, is_moving=False, movement_speed=0.0)
|
self._send_transform(connection, is_moving=False, movement_speed=0.0)
|
||||||
return IDLE_SEND_INTERVAL_SECONDS
|
return IDLE_SEND_INTERVAL_SECONDS
|
||||||
@@ -283,12 +408,117 @@ class FakePlayerClient:
|
|||||||
self._send_transform(connection, is_moving=is_moving, movement_speed=movement_speed)
|
self._send_transform(connection, is_moving=is_moving, movement_speed=movement_speed)
|
||||||
return walk_interval if is_moving else IDLE_SEND_INTERVAL_SECONDS
|
return walk_interval if is_moving else IDLE_SEND_INTERVAL_SECONDS
|
||||||
|
|
||||||
def _send_transform(self, connection: socket.socket, *, is_moving: bool, movement_speed: float) -> None:
|
def _send_walk_circle_transform(self, connection: socket.socket, delta_seconds: float) -> float:
|
||||||
|
walk_interval = 1.0 / WALK_CIRCLE_SEND_RATE_HZ
|
||||||
|
step_delta = max(delta_seconds, walk_interval)
|
||||||
|
angular_speed = WALK_CIRCLE_SPEED / WALK_CIRCLE_RADIUS
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self.cell_id = IDLE_CELL_ID
|
||||||
|
self.worldspace_id = IDLE_WORLDSPACE_ID
|
||||||
|
center_x, center_y, center_z = self._walk_circle_center
|
||||||
|
self._walk_circle_phase += angular_speed * step_delta
|
||||||
|
phase = self._walk_circle_phase
|
||||||
|
new_x = center_x + (WALK_CIRCLE_RADIUS * math.cos(phase))
|
||||||
|
new_y = center_y + (WALK_CIRCLE_RADIUS * math.sin(phase))
|
||||||
|
new_z = center_z
|
||||||
|
tangent_x = -math.sin(phase)
|
||||||
|
tangent_y = math.cos(phase)
|
||||||
|
self.position = (new_x, new_y, new_z)
|
||||||
|
self.angle_z = math.atan2(tangent_x, tangent_y)
|
||||||
|
|
||||||
|
self._send_transform(connection, is_moving=True, movement_speed=WALK_CIRCLE_SPEED)
|
||||||
|
return walk_interval
|
||||||
|
|
||||||
|
def _run_jump_once(self, previous_script: str) -> None:
|
||||||
|
interval_seconds = 1.0 / JUMP_SEND_RATE_HZ
|
||||||
|
steps = max(2, int(JUMP_DURATION_SECONDS * JUMP_SEND_RATE_HZ))
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
ground_x, ground_y, ground_z = self.position
|
||||||
|
|
||||||
|
try:
|
||||||
|
for step_index in range(steps + 1):
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
return
|
||||||
|
|
||||||
|
progress = step_index / steps
|
||||||
|
height = JUMP_HEIGHT * (1.0 - abs((2.0 * progress) - 1.0))
|
||||||
|
with self._lock:
|
||||||
|
self.position = (ground_x, ground_y, ground_z + height)
|
||||||
|
self.is_jumping = True
|
||||||
|
|
||||||
|
self._send_immediate_transform(
|
||||||
|
movement_type="normal",
|
||||||
|
is_moving=False,
|
||||||
|
movement_speed=0.0,
|
||||||
|
is_jumping=True,
|
||||||
|
)
|
||||||
|
time.sleep(interval_seconds)
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
self.position = (ground_x, ground_y, ground_z)
|
||||||
|
self.is_jumping = False
|
||||||
|
self._one_shot_active = False
|
||||||
|
if self.script == previous_script and previous_script not in (
|
||||||
|
SCRIPT_WALK_TO_PLAYER,
|
||||||
|
SCRIPT_WALK_CIRCLE,
|
||||||
|
SCRIPT_LEFT_CELL,
|
||||||
|
):
|
||||||
|
self.script = SCRIPT_IDLE
|
||||||
|
|
||||||
|
self._script_changed_event.set()
|
||||||
|
self._send_immediate_transform(
|
||||||
|
movement_type="normal",
|
||||||
|
is_moving=False,
|
||||||
|
movement_speed=0.0,
|
||||||
|
is_jumping=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _send_immediate_transform(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
movement_type: str,
|
||||||
|
is_moving: bool,
|
||||||
|
movement_speed: float,
|
||||||
|
is_jumping: bool | None = None,
|
||||||
|
) -> None:
|
||||||
|
with self._lock:
|
||||||
|
connection = self._socket
|
||||||
|
is_connected = self._status == STATUS_CONNECTED
|
||||||
|
|
||||||
|
if connection is None or not is_connected:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._send_transform(
|
||||||
|
connection,
|
||||||
|
movement_type=movement_type,
|
||||||
|
is_moving=is_moving,
|
||||||
|
movement_speed=movement_speed,
|
||||||
|
is_jumping=is_jumping,
|
||||||
|
)
|
||||||
|
except OSError as error:
|
||||||
|
if not self._stop_event.is_set():
|
||||||
|
self._set_error(str(error))
|
||||||
|
self._log(f"{self.name} error: {error}")
|
||||||
|
|
||||||
|
def _send_transform(
|
||||||
|
self,
|
||||||
|
connection: socket.socket,
|
||||||
|
*,
|
||||||
|
is_moving: bool,
|
||||||
|
movement_speed: float,
|
||||||
|
movement_type: str = "normal",
|
||||||
|
is_jumping: bool | None = None,
|
||||||
|
) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
x, y, z = self.position
|
x, y, z = self.position
|
||||||
angle_z = self.angle_z
|
angle_z = self.angle_z
|
||||||
cell_id = self.cell_id
|
cell_id = self.cell_id
|
||||||
worldspace_id = self.worldspace_id
|
worldspace_id = self.worldspace_id
|
||||||
|
is_sneaking = self.is_sneaking
|
||||||
|
jumping = self.is_jumping if is_jumping is None else is_jumping
|
||||||
|
|
||||||
packet = {
|
packet = {
|
||||||
"type": "transform",
|
"type": "transform",
|
||||||
@@ -296,19 +526,20 @@ class FakePlayerClient:
|
|||||||
"y": y,
|
"y": y,
|
||||||
"z": z,
|
"z": z,
|
||||||
"angleZ": angle_z,
|
"angleZ": angle_z,
|
||||||
"movementType": "normal",
|
"movementType": movement_type,
|
||||||
"cellId": cell_id,
|
"cellId": cell_id,
|
||||||
"worldspaceId": worldspace_id,
|
"worldspaceId": worldspace_id,
|
||||||
"clientTime": time.time(),
|
"clientTime": time.time(),
|
||||||
"isMoving": is_moving,
|
"isMoving": is_moving,
|
||||||
"isSprinting": False,
|
"isSprinting": False,
|
||||||
"isSneaking": False,
|
"isSneaking": is_sneaking,
|
||||||
"isJumping": False,
|
"isJumping": jumping,
|
||||||
"weaponDrawn": False,
|
"weaponDrawn": False,
|
||||||
"movementSpeed": movement_speed,
|
"movementSpeed": movement_speed,
|
||||||
}
|
}
|
||||||
encoded = json.dumps(packet, separators=(",", ":")).encode("utf-8") + b"\n"
|
encoded = json.dumps(packet, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||||
connection.sendall(encoded)
|
with self._send_lock:
|
||||||
|
connection.sendall(encoded)
|
||||||
|
|
||||||
def _get_script(self) -> str:
|
def _get_script(self) -> str:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -335,6 +566,10 @@ class FakePlayerClient:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return self._status == STATUS_CONNECTED
|
return self._status == STATUS_CONNECTED
|
||||||
|
|
||||||
|
def _is_one_shot_active(self) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
return self._one_shot_active
|
||||||
|
|
||||||
def _set_error(self, message: str) -> None:
|
def _set_error(self, message: str) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._status = STATUS_ERROR
|
self._status = STATUS_ERROR
|
||||||
@@ -422,6 +657,54 @@ class FakePlayerManager:
|
|||||||
client.set_walk_to_player()
|
client.set_walk_to_player()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def set_client_walk_circle(self, client_key: int) -> bool:
|
||||||
|
client = self._get_client(client_key)
|
||||||
|
if client is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
client.set_walk_circle()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def trigger_client_jump_once(self, client_key: int) -> bool:
|
||||||
|
client = self._get_client(client_key)
|
||||||
|
if client is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
client.trigger_jump_once()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def toggle_client_sneak(self, client_key: int) -> bool:
|
||||||
|
client = self._get_client(client_key)
|
||||||
|
if client is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
client.toggle_sneak()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def trigger_client_leave_cell(self, client_key: int) -> bool:
|
||||||
|
client = self._get_client(client_key)
|
||||||
|
if client is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
client.trigger_leave_cell()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def trigger_client_return_to_cell(self, client_key: int) -> bool:
|
||||||
|
client = self._get_client(client_key)
|
||||||
|
if client is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
client.trigger_return_to_cell()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def trigger_client_teleport_test(self, client_key: int) -> bool:
|
||||||
|
client = self._get_client(client_key)
|
||||||
|
if client is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
client.trigger_teleport_test()
|
||||||
|
return True
|
||||||
|
|
||||||
def stop_all(self) -> None:
|
def stop_all(self) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
clients = list(self._clients.values())
|
clients = list(self._clients.values())
|
||||||
|
|||||||
Reference in New Issue
Block a user