Compare commits

..
5 Commits
Author SHA1 Message Date
Vladislav Khorev 0f1e0d66a3 Switch off save state 2026-11-10 21:06:52 +06:00
Vladislav Khorev 63bf981e21 Fixing minor bugs, some renaming 2026-11-10 21:06:27 +06:00
Vladislav Khorev f3cafd2bf7 Fixing minor bugs 2026-11-10 19:48:03 +06:00
Vladislav Khorev 6b8aa0efec Some fixes 2026-11-10 18:31:53 +06:00
Vladislav Khorev 28fb81ad45 Working with HTTPS 2026-11-09 23:28:18 +06:00
22 changed files with 1932 additions and 274 deletions

No files matched your search

+26 -6
View File
@@ -41,7 +41,7 @@ configured player slot connects or disconnects.
`WebsocketGameClient` implements `IGameServer`.
- It connects asynchronously to `ws://127.0.0.1:8080/`.
- It connects asynchronously to a `ws://` or `wss://` endpoint (`WebsocketEndpoint::parse`).
- Received messages are queued by the network thread and applied on the game
thread from `update()`.
- It maintains an internal `InternalGameServer` replica so existing snapshot
@@ -144,7 +144,15 @@ Networking is enabled for Windows and the Emscripten web client:
- `proj-web/CMakeLists.txt` defines `NETWORK`, compiles the shared protocol,
replica, and remote-host code, and links Emscripten's `websocket.js` library.
`WebsocketGameClient.cpp` selects the browser WebSocket API on Emscripten; it
does not use Boost.Asio or Boost.Beast there.
does not use Boost.Asio, Boost.Beast, or OpenSSL there. The browser performs
the TLS handshake and certificate/hostname validation for `wss://`.
- `proj-windows` defines
`NETWORK_TLS` and links static OpenSSL 3.4.1, which CMake downloads and builds
automatically at configure time (`cmake/ThirdParty.cmake`, section 12) using a
downloaded portable Strawberry Perl and the nmake from the installed Visual Studio
(located through `vswhere`). Builds go to `thirdparty/openssl-3.4.1/install-Debug`
and `install-Release`; later configures skip them. `NETWORK_TLS` unset (server tests)
means native `wss://` endpoints fail with a logged error.
- Linux and Android builds do not define `NETWORK` and do not compile the
network transport.
@@ -163,15 +171,24 @@ Run it from the repository root:
.\server\build\Debug\GameServer.exe --campaign battle_zone_multiplayer --location uni_exterior
```
The listener defaults to `0.0.0.0:8080`. It can be changed with:
The listener defaults to `0.0.0.0:8080` and serves plain WebSocket traffic. It can be changed with:
```text
--address IP
--port N
```
Launch the Windows game and select `battle_zone_multiplayer`. The client connects to
`127.0.0.1:8080`. Selecting another campaign continues to use the local server.
Launch the Windows game and select `battle_zone_multiplayer`. The production client
default is `wss://api.shadowoverbishkek.com/server001/`; the endpoint is currently
configured in `Game.cpp`. Selecting another campaign continues to use the local
server.
For the Web build, serve the page over HTTPS and ensure the TLS-terminating reverse
proxy presents a valid certificate for `api.shadowoverbishkek.com`, accepts the
WebSocket upgrade at `/server001/`, and forwards the upgraded connection to the
plain server listener. A page served over HTTPS cannot connect to a `ws://` endpoint
because browsers block that mixed-content request. Certificate and hostname errors
are enforced by the browser and cannot be bypassed by the C++ client.
## Verification
@@ -196,7 +213,10 @@ submission, and contiguous tick consumption.
not implemented.
- There is no authentication or reconnect/replay history.
- A hash mismatch is logged but does not yet trigger checkpoint resynchronization.
- The endpoint is hardcoded in the graphical client.
- The endpoint is hardcoded in the graphical client (`kMultiplayerServerUrl` in
`Game.cpp`, currently `wss://api.shadowoverbishkek.com/server001/`).
- TLS certificate or hostname problems are logged but deliberately do not abort the
connection. `GameServer` itself has no TLS; it must sit behind a TLS-terminating proxy.
- Multiplayer Lua and dialogue behavior remains unsupported.
- Determinism is supported for identical executable/content builds. The
floating-point simulation is not guaranteed bit-identical across platforms
+27
View File
@@ -56,3 +56,30 @@ check_and_download("https://github.com/ThePhD/sol2/archive/refs/tags/v3.3.0.zip"
# 11) SDL2_mixer
check_and_download("https://github.com/libsdl-org/SDL_mixer/archive/refs/tags/release-2.8.0.zip" "SDL_mixer-release-2.8.0.zip" "SDL_mixer-release-2.8.0" "CMakeLists.txt")
# 12) OpenSSL (TLS for wss://) and a native Windows Perl needed to build it
if(WIN32)
check_and_download("https://github.com/openssl/openssl/releases/download/openssl-3.4.1/openssl-3.4.1.tar.gz" "openssl-3.4.1.tar.gz" "openssl-3.4.1" "Configure")
# Strawberry Perl portable. The zip has no top-level folder, so it is
# extracted into its own directory. (Git's MSYS perl cannot build OpenSSL.)
set(STRAWBERRY_PERL_DIR "${THIRDPARTY_DIR}/strawberry-perl")
set(STRAWBERRY_PERL_ARCHIVE "${THIRDPARTY_DIR}/strawberry-perl-5.42.3.1-64bit-portable.zip")
if(NOT EXISTS "${STRAWBERRY_PERL_DIR}/perl/bin/perl.exe")
if(NOT EXISTS "${STRAWBERRY_PERL_ARCHIVE}")
message(STATUS "Downloading Strawberry Perl...")
file(DOWNLOAD
"https://github.com/StrawberryPerl/Perl-Dist-Strawberry/releases/download/SP_54231_64bit/strawberry-perl-5.42.3.1-64bit-portable.zip"
"${STRAWBERRY_PERL_ARCHIVE}" SHOW_PROGRESS)
endif()
message(STATUS "Extracting Strawberry Perl...")
file(MAKE_DIRECTORY "${STRAWBERRY_PERL_DIR}")
execute_process(
COMMAND ${CMAKE_COMMAND} -E tar xf "${STRAWBERRY_PERL_ARCHIVE}"
WORKING_DIRECTORY "${STRAWBERRY_PERL_DIR}"
)
if(NOT EXISTS "${STRAWBERRY_PERL_DIR}/perl/bin/perl.exe")
message(FATAL_ERROR "Strawberry Perl extraction failed")
endif()
endif()
endif()
+102
View File
@@ -726,3 +726,105 @@ set_target_properties(SDL2_mixer_external_lib PROPERTIES
INTERFACE_LINK_LIBRARIES
"SDL2_external_lib"
)
# ===========================================
# 12) OpenSSL (3.4.1) - TLS for wss:// (Windows only, static libs)
# ===========================================
if(WIN32)
set(OPENSSL_SRC_DIR "${THIRDPARTY_DIR}/openssl-3.4.1")
set(OPENSSL_BASE_DIR "${OPENSSL_SRC_DIR}/install")
set(_have_openssl TRUE)
foreach(cfg IN LISTS BUILD_CONFIGS)
if(NOT EXISTS "${OPENSSL_BASE_DIR}-${cfg}/lib/libssl.lib" OR
NOT EXISTS "${OPENSSL_BASE_DIR}-${cfg}/lib/libcrypto.lib")
set(_have_openssl FALSE)
endif()
endforeach()
if(NOT _have_openssl)
# Locate Visual Studio's vcvarsall.bat (provides cl, link, nmake).
set(_vswhere "$ENV{ProgramFiles\(x86\)}/Microsoft Visual Studio/Installer/vswhere.exe")
set(_vcvarsall "")
if(EXISTS "${_vswhere}")
execute_process(
COMMAND "${_vswhere}" -latest -products * -property installationPath
OUTPUT_VARIABLE _vs_path
OUTPUT_STRIP_TRAILING_WHITESPACE
)
if(_vs_path AND EXISTS "${_vs_path}/VC/Auxiliary/Build/vcvarsall.bat")
set(_vcvarsall "${_vs_path}/VC/Auxiliary/Build/vcvarsall.bat")
endif()
endif()
if(_vcvarsall STREQUAL "")
message(FATAL_ERROR "Visual Studio (vcvarsall.bat) not found; it is required to build OpenSSL")
endif()
if(CMAKE_SIZEOF_VOID_P EQUAL 8)
set(_ossl_vc_arch x64)
set(_ossl_target VC-WIN64A)
else()
set(_ossl_vc_arch x86)
set(_ossl_target VC-WIN32)
endif()
file(TO_NATIVE_PATH "${_vcvarsall}" _vcvarsall_native)
file(TO_NATIVE_PATH "${STRAWBERRY_PERL_DIR}/perl/bin" _perl_bin_native)
file(TO_NATIVE_PATH "${STRAWBERRY_PERL_DIR}/perl/site/bin" _perl_site_native)
foreach(cfg IN LISTS BUILD_CONFIGS)
set(_ossl_build "${OPENSSL_SRC_DIR}/build-${cfg}")
file(REMOVE_RECURSE "${_ossl_build}")
file(MAKE_DIRECTORY "${_ossl_build}")
file(TO_NATIVE_PATH "${_ossl_build}" _ossl_build_native)
file(TO_NATIVE_PATH "${OPENSSL_SRC_DIR}/Configure" _ossl_configure_native)
file(TO_NATIVE_PATH "${OPENSSL_BASE_DIR}-${cfg}" _ossl_prefix_native)
set(_ossl_cfg_flags "")
if(cfg STREQUAL "Debug")
# /MDd: link the dynamic debug CRT like the game (static libs default to /MTd)
set(_ossl_cfg_flags "--debug /MDd")
else()
set(_ossl_cfg_flags "--release /MD")
endif()
# A batch file avoids cmd.exe quoting problems.
set(_ossl_bat "${_ossl_build}/build_openssl.bat")
file(WRITE "${_ossl_bat}"
"@echo off
call \"${_vcvarsall_native}\" ${_ossl_vc_arch} || exit /b 1
set \"PATH=${_perl_bin_native};${_perl_site_native};%PATH%\"
set CL=/MP
cd /d \"${_ossl_build_native}\" || exit /b 1
perl \"${_ossl_configure_native}\" ${_ossl_target} no-shared no-asm no-tests no-docs ${_ossl_cfg_flags} --prefix=\"${_ossl_prefix_native}\" --openssldir=\"${_ossl_prefix_native}/ssl\" || exit /b 1
nmake || exit /b 1
nmake install_sw || exit /b 1
")
log("Building OpenSSL (${cfg}) - this takes several minutes ...")
execute_process(
COMMAND cmd /c "${_ossl_bat}"
RESULT_VARIABLE _ossl_res
)
if(NOT _ossl_res EQUAL 0)
message(FATAL_ERROR "OpenSSL build failed for ${cfg} (see output above)")
endif()
endforeach()
endif()
add_library(openssl_crypto_external_lib UNKNOWN IMPORTED GLOBAL)
set_target_properties(openssl_crypto_external_lib PROPERTIES
IMPORTED_LOCATION_DEBUG "${OPENSSL_BASE_DIR}-Debug/lib/libcrypto.lib"
IMPORTED_LOCATION_RELEASE "${OPENSSL_BASE_DIR}-Release/lib/libcrypto.lib"
INTERFACE_INCLUDE_DIRECTORIES
"$<IF:$<CONFIG:Debug>,${OPENSSL_BASE_DIR}-Debug/include,${OPENSSL_BASE_DIR}-Release/include>"
INTERFACE_LINK_LIBRARIES "ws2_32;crypt32;advapi32;user32;gdi32"
)
add_library(openssl_ssl_external_lib UNKNOWN IMPORTED GLOBAL)
set_target_properties(openssl_ssl_external_lib PROPERTIES
IMPORTED_LOCATION_DEBUG "${OPENSSL_BASE_DIR}-Debug/lib/libssl.lib"
IMPORTED_LOCATION_RELEASE "${OPENSSL_BASE_DIR}-Release/lib/libssl.lib"
INTERFACE_INCLUDE_DIRECTORIES
"$<IF:$<CONFIG:Debug>,${OPENSSL_BASE_DIR}-Debug/include,${OPENSSL_BASE_DIR}-Release/include>"
INTERFACE_LINK_LIBRARIES "openssl_crypto_external_lib"
)
endif()
+85
View File
@@ -0,0 +1,85 @@
# Hostile NPC Behavior
## Authority and update order
Hostile NPC behavior is simulated by `LocationGameState` and
`CharacterState`. Rendering objects do not select targets and do not change
NPC combat state.
During each authoritative simulation tick:
1. The location gathers the enabled players and NPCs visible to the
simulation.
2. Each hostile NPC selects or retains a player target.
3. Every character advances using the same tick-start observations.
4. Movement reservations and attack damage are resolved deterministically.
5. Navigation recovery events are applied by `InternalGameServer`.
The resulting state and recovery decisions are replicated through the existing
WebSocket lockstep stream. NPC target changes are simulation consequences, not
client commands.
## Eligible players and nearest selection
An eligible target must:
- exist in the campaign player map;
- be enabled;
- have positive health; and
- be within the NPC's `visibleRange` when a new target is selected.
Distance is measured in the X/Z plane. If two players are equally close,
their stable player IDs are compared lexicographically. This gives every
lockstep replica the same result regardless of hash-map iteration order.
## Ordinary target locking
Once a hostile NPC has a valid target, it keeps that target during ordinary
ticks. The target remains locked even if it moves outside `visibleRange`.
The lock ends when the player dies or becomes disabled.
When a lock ends, the NPC selects the nearest eligible player in range. If no
player qualifies, it clears the attack target, leaves combat state, and
requests a route to its `homePosition`.
## Movement blockage and target reconsideration
NPC movement uses a retry schedule after a character blocks its path. The
blocked NPC first stops and waits for its deterministic navigation retry
cooldown. When the retry event emits `forceNavigationReplan`, the authoritative
location state performs a fresh target selection across all eligible players.
This blocked-replan selection differs from ordinary target locking:
- the current target is not privileged;
- the nearest eligible player may remain the target;
- a nearer eligible player replaces the current target;
- the route is rebuilt from the NPC's current position toward the selected
player's current position.
If no eligible player is in range during blocked recovery, the NPC clears its
stale chase target and returns toward its home position instead. The blocker is
still passed to path planning so the immediate replan does not route through
the character that caused the recovery event.
## Death, disconnect, and attack safety
Dead or disabled players are excluded from target acquisition. A target that
dies or disconnects is therefore invalidated on the next authoritative
selection pass, and the NPC either switches to another player or returns home.
Attack impacts are also validated at application time. Damage is ignored if
the recorded target no longer exists, is disabled, or has already reached
zero health. This prevents a queued animation impact from damaging a stale
target.
## Regression expectations
The state and pathfinding tests should preserve these guarantees:
- nearest in-range player selection is deterministic;
- a valid ordinary lock is retained;
- blocked NPC recovery can replace the target and rebuild the chase route;
- no-target blocked recovery clears combat and routes home;
- dead and disabled players cannot be selected or damaged;
- retry cooldowns and blocker identity remain deterministic.
+7
View File
@@ -143,6 +143,7 @@ target_compile_definitions(ShadowOverBishkekDemo PRIVATE
_WIN32_WINNT=0x0A00
PNG_ENABLED
NETWORK
NETWORK_TLS
)
# Линкуем с SDL2main, если он вообще установлен
@@ -160,6 +161,7 @@ target_link_libraries(ShadowOverBishkekDemo PRIVATE
boost_external_lib
sol2_external_lib
SDL2_mixer_external_lib
openssl_ssl_external_lib
)
# Линкуем OpenGL (Windows)
@@ -172,6 +174,11 @@ if(WIN32)
../src/game_server/RemoteGameServerHost.h
../src/game_server/RemoteGameServerHost.cpp
)
if(MSVC)
set_source_files_properties(
../src/game_server/WebsocketGameClient.cpp
PROPERTIES COMPILE_OPTIONS "/bigobj")
endif()
target_link_libraries(
ShadowOverBishkekDemo PRIVATE opengl32 ws2_32 mswsock)
endif()
+24 -10
View File
@@ -2,7 +2,10 @@
"campaigns": [
{
"id": "main",
"title": { "ru": "Основная игра", "en": "Main Game" },
"title": {
"ru": "Основная игра",
"en": "Main Game"
},
"locations": [
"resources/config/locations/location_dorm.json",
"resources/config/locations/uni_exterior.json",
@@ -17,12 +20,15 @@
},
{
"id": "battle_zone",
"title": { "ru": "Зона боя (одиночная)", "en": "Battle Zone (Single Player)" },
"title": {
"ru": "Зона боя (одиночная)",
"en": "Battle Zone (Single Player)"
},
"locations": [
"resources/config/locations/battle_zone_uni_exterior.json"
"resources/config/locations/battle_zone_korea.json"
],
"startLocation": "uni_exterior",
"startMusic": "music/bishkek univer day.ogg",
"startLocation": "park_korea",
"startMusic": "music/bishkek fight calm.ogg",
"startState": "resources/config/start_state_battle_zone_singleplayer.json",
"quests": "resources/config/quests_battle_zone.json",
"tutorial": false,
@@ -30,16 +36,24 @@
},
{
"id": "battle_zone_multiplayer",
"title": { "ru": "Зона боя (сетевая)", "en": "Battle Zone (Multiplayer)" },
"title": {
"ru": "Зона боя (сетевая)",
"en": "Battle Zone (Multiplayer)"
},
"locations": [
"resources/config/locations/battle_zone_uni_exterior.json"
"resources/config/locations/battle_zone_korea.json"
],
"startLocation": "uni_exterior",
"startMusic": "music/bishkek univer day.ogg",
"startLocation": "park_korea",
"startMusic": "music/bishkek fight calm.ogg",
"startState": "resources/config/start_state_battle_zone.json",
"quests": "resources/config/quests_battle_zone.json",
"tutorial": false,
"networkPlayerSlots": ["player", "player2"],
"networkPlayerSlots": [
"player1",
"player2",
"player3",
"player4"
],
"networkCompatibility": "battle-zone-lockstep-v5",
"loadingScreen": "resources/w/ui/screen_loading.json"
}
@@ -1,5 +1,5 @@
{
"id": "uni_exterior",
"id": "park_korea",
"gameObjects": "resources/w/shelter/game_objects.json",
"interactiveObjects": "resources/w/shelter/interactive_objects.json",
"npcs": "resources/config/npcs_battle_zone.json",
+162
View File
@@ -161,6 +161,168 @@
"hp": 35,
"visibleRange" : 6.5,
"canAttack": true
},
{
"id": "ghost_07x",
"name": "Тень",
"meshTextures": {
"Shadow_NPC_Rigged": "resources/w/shadow/Shadow_BaseColor.png"
},
"animationIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationWalkPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationActionAttackPath": "resources/w/shadow/shadow_attack003.anim",
"animationStandToActionPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToStandPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToDeathPath": "resources/w/shadow/shadow_die003.anim",
"animationDeathIdlePath": "resources/w/shadow/shadow_dead003.anim",
"positionX": -5.6,
"positionY": 0.0,
"positionZ": -14.0,
"walkSpeed": 1.5,
"rotationSpeed": 8.0,
"modelScale": 1.0,
"modelCorrectionRotX": -90.0,
"modelCorrectionRotY": 180.0,
"modelCorrectionRotZ": 0.0,
"hp": 35,
"visibleRange" : 6.5,
"canAttack": true
},
{
"id": "ghost_08x",
"name": "Тень",
"meshTextures": {
"Shadow_NPC_Rigged": "resources/w/shadow/Shadow_BaseColor.png"
},
"animationIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationWalkPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationActionAttackPath": "resources/w/shadow/shadow_attack003.anim",
"animationStandToActionPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToStandPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToDeathPath": "resources/w/shadow/shadow_die003.anim",
"animationDeathIdlePath": "resources/w/shadow/shadow_dead003.anim",
"positionX": -6.4,
"positionY": 0.0,
"positionZ": -14.0,
"walkSpeed": 1.5,
"rotationSpeed": 8.0,
"modelScale": 1.0,
"modelCorrectionRotX": -90.0,
"modelCorrectionRotY": 180.0,
"modelCorrectionRotZ": 0.0,
"hp": 35,
"visibleRange" : 6.5,
"canAttack": true
},
{
"id": "ghost_09x",
"name": "Тень",
"meshTextures": {
"Shadow_NPC_Rigged": "resources/w/shadow/Shadow_BaseColor.png"
},
"animationIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationWalkPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationActionAttackPath": "resources/w/shadow/shadow_attack003.anim",
"animationStandToActionPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToStandPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToDeathPath": "resources/w/shadow/shadow_die003.anim",
"animationDeathIdlePath": "resources/w/shadow/shadow_dead003.anim",
"positionX": -5.6,
"positionY": 0.0,
"positionZ": -14.5,
"walkSpeed": 1.5,
"rotationSpeed": 8.0,
"modelScale": 1.0,
"modelCorrectionRotX": -90.0,
"modelCorrectionRotY": 180.0,
"modelCorrectionRotZ": 0.0,
"hp": 35,
"visibleRange" : 6.5,
"canAttack": true
},
{
"id": "ghost_10x",
"name": "Тень",
"meshTextures": {
"Shadow_NPC_Rigged": "resources/w/shadow/Shadow_BaseColor.png"
},
"animationIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationWalkPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationActionAttackPath": "resources/w/shadow/shadow_attack003.anim",
"animationStandToActionPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToStandPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToDeathPath": "resources/w/shadow/shadow_die003.anim",
"animationDeathIdlePath": "resources/w/shadow/shadow_dead003.anim",
"positionX": -6.4,
"positionY": 0.0,
"positionZ": -14.5,
"walkSpeed": 1.5,
"rotationSpeed": 8.0,
"modelScale": 1.0,
"modelCorrectionRotX": -90.0,
"modelCorrectionRotY": 180.0,
"modelCorrectionRotZ": 0.0,
"hp": 35,
"visibleRange" : 6.5,
"canAttack": true
},
{
"id": "ghost_11x",
"name": "Тень",
"meshTextures": {
"Shadow_NPC_Rigged": "resources/w/shadow/Shadow_BaseColor.png"
},
"animationIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationWalkPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationActionAttackPath": "resources/w/shadow/shadow_attack003.anim",
"animationStandToActionPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToStandPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToDeathPath": "resources/w/shadow/shadow_die003.anim",
"animationDeathIdlePath": "resources/w/shadow/shadow_dead003.anim",
"positionX": -5.6,
"positionY": 0.0,
"positionZ": -15.0,
"walkSpeed": 1.5,
"rotationSpeed": 8.0,
"modelScale": 1.0,
"modelCorrectionRotX": -90.0,
"modelCorrectionRotY": 180.0,
"modelCorrectionRotZ": 0.0,
"hp": 35,
"visibleRange" : 6.5,
"canAttack": true
},
{
"id": "ghost_12x",
"name": "Тень",
"meshTextures": {
"Shadow_NPC_Rigged": "resources/w/shadow/Shadow_BaseColor.png"
},
"animationIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationWalkPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationActionAttackPath": "resources/w/shadow/shadow_attack003.anim",
"animationStandToActionPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToStandPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToDeathPath": "resources/w/shadow/shadow_die003.anim",
"animationDeathIdlePath": "resources/w/shadow/shadow_dead003.anim",
"positionX": -6.4,
"positionY": 0.0,
"positionZ": -15.0,
"walkSpeed": 1.5,
"rotationSpeed": 8.0,
"modelScale": 1.0,
"modelCorrectionRotX": -90.0,
"modelCorrectionRotY": 180.0,
"modelCorrectionRotZ": 0.0,
"hp": 35,
"visibleRange" : 6.5,
"canAttack": true
}
]
}
+36 -8
View File
@@ -1,4 +1,13 @@
{
"localState": {
"locations": {
"location_dorm": {
"cameraAzimuth": -2.3499999046325684,
"cameraInclination": 1.103600025177002
}
}
},
"networkState": {
"campaignId": "main",
"chatHistory": [
[],
@@ -16,8 +25,7 @@
true
],
"currentLocationId": "",
"globalFloats": {
},
"globalFloats": {},
"globalInts": {},
"inventory": {
"items": []
@@ -494,6 +502,22 @@
-15.038200378417969
],
"hp": 100.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
10,
4,
5,
8,
11,
9,
6,
3,
2,
7
],
"navigationRetryTicks": 0,
"pathWaypoints": [
[
-6.296899795532227,
@@ -530,8 +554,6 @@
},
"state": {
"activeNavigationIndex": 0,
"cameraAzimuth": -2.3499999046325684,
"cameraInclination": 1.103600025177002,
"targetInteractNpcId": "",
"targetInteractiveObjectId": "",
"targetTeleportZoneId": "",
@@ -574,10 +596,9 @@
}
},
"money": 5500,
"playerHp": 200.0,
"playerMaxHp": 200.0,
"players": {
"player": {
"player1": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
@@ -594,6 +615,11 @@
0.0
],
"hp": 200.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [],
"navigationRetryTicks": 0,
"pathWaypoints": [
[
6.8031005859375,
@@ -764,12 +790,14 @@
}
]
},
"savedAt": "2026-10-30 14:48",
"schemaVersion": 1,
"savedAt": "2026-11-10 20:38",
"schemaVersion": 5,
"taxiIsCalled": false,
"tutorialJournalScreenOpened": false,
"tutorialNeedOpenTaxiScreen": false,
"tutorialPhoneChatScreenOpened": false,
"tutorialStep": 0,
"tutorialTaxiHintDone": false
},
"persistenceVersion": 1
}
+523 -95
View File
@@ -1,7 +1,7 @@
{
"localState": {
"locations": {
"uni_exterior": {
"park_korea": {
"cameraAzimuth": -2.3499999046325684,
"cameraInclination": 1.103600025177002
}
@@ -10,9 +10,12 @@
"networkState": {
"campaignId": "battle_zone",
"chatHistory": [
[],
[],
[]
[
],
[
],
[
]
],
"chatPreviewMsg": [
"",
@@ -27,15 +30,17 @@
"currentLocationId": "",
"globalFloats": {
},
"globalInts": {},
"globalInts": {
},
"inventory": {
"items": []
"items": [
]
},
"isDarklands": true,
"isDawn": false,
"isNight": true,
"locations": {
"uni_exterior": {
"park_korea": {
"dialogue": {
"active": false
},
@@ -44,7 +49,7 @@
"alpha": 1.0,
"isActive": true,
"isAnimating": false,
"positionX": 0.6000000238418579,
"positionX": 0.60000002384185791,
"positionY": 0.45736101269721985,
"positionZ": 3.8499999046325684,
"rotationY": 0.0,
@@ -54,7 +59,7 @@
"alpha": 1.0,
"isActive": true,
"isAnimating": false,
"positionX": 1.399999976158142,
"positionX": 1.3999999761581421,
"positionY": 0.45736101269721985,
"positionZ": 3.8499999046325684,
"rotationY": 0.0,
@@ -64,7 +69,7 @@
"alpha": 1.0,
"isActive": true,
"isAnimating": false,
"positionX": 2.200000047683716,
"positionX": 2.2000000476837158,
"positionY": 0.45736101269721985,
"positionZ": 3.8499999046325684,
"rotationY": 0.0,
@@ -85,7 +90,7 @@
"isActive": true,
"isAnimating": false,
"positionX": 0.0,
"positionY": 3.200000047683716,
"positionY": 3.2000000476837158,
"positionZ": 0.0,
"rotationY": 0.0,
"scale": 1.0
@@ -95,7 +100,7 @@
"isActive": true,
"isAnimating": false,
"positionX": -1.0,
"positionY": 0.9150000214576721,
"positionY": 0.91500002145767212,
"positionZ": -3.9000000953674316,
"rotationY": 0.0,
"scale": 1.0
@@ -105,7 +110,7 @@
"isActive": true,
"isAnimating": false,
"positionX": 0.0,
"positionY": 0.9150000214576721,
"positionY": 0.91500002145767212,
"positionZ": -3.9000000953674316,
"rotationY": 0.0,
"scale": 1.0
@@ -115,7 +120,7 @@
"isActive": true,
"isAnimating": false,
"positionX": 1.0,
"positionY": 0.9150000214576721,
"positionY": 0.91500002145767212,
"positionZ": -3.9000000953674316,
"rotationY": 0.0,
"scale": 1.0
@@ -134,7 +139,7 @@
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-12.0
],
@@ -155,14 +160,15 @@
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"pathWaypoints": [
],
"position": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-12.0
],
"requestedWalkTarget": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-12.0
],
@@ -170,7 +176,7 @@
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-12.0
]
@@ -187,7 +193,7 @@
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-12.0
],
@@ -208,14 +214,15 @@
6
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"pathWaypoints": [
],
"position": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-12.0
],
"requestedWalkTarget": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-12.0
],
@@ -223,7 +230,7 @@
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-12.0
]
@@ -240,7 +247,7 @@
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-12.5
],
@@ -261,14 +268,15 @@
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"pathWaypoints": [
],
"position": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-12.5
],
"requestedWalkTarget": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-12.5
],
@@ -276,7 +284,7 @@
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-12.5
]
@@ -293,7 +301,7 @@
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-12.5
],
@@ -314,14 +322,15 @@
6
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"pathWaypoints": [
],
"position": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-12.5
],
"requestedWalkTarget": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-12.5
],
@@ -329,7 +338,7 @@
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-12.5
]
@@ -346,7 +355,7 @@
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-13.0
],
@@ -367,14 +376,15 @@
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"pathWaypoints": [
],
"position": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-13.0
],
"requestedWalkTarget": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-13.0
],
@@ -382,7 +392,7 @@
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-13.0
]
@@ -399,7 +409,7 @@
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-13.0
],
@@ -420,14 +430,15 @@
2
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"pathWaypoints": [
],
"position": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-13.0
],
"requestedWalkTarget": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-13.0
],
@@ -435,13 +446,338 @@
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-13.0
]
},
"ghost_07x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.5999999046325684,
0.0,
-14.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
2,
3,
10,
5,
6,
8,
7,
9,
4,
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-5.5999999046325684,
0.0,
-14.0
],
"requestedWalkTarget": [
-5.5999999046325684,
0.0,
-14.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.5999999046325684,
0.0,
-14.0
]
},
"ghost_08x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.4000000953674316,
0.0,
-14.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
8,
11,
3,
10,
2,
9,
5,
4,
7,
6
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-6.4000000953674316,
0.0,
-14.0
],
"requestedWalkTarget": [
-6.4000000953674316,
0.0,
-14.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.4000000953674316,
0.0,
-14.0
]
},
"ghost_09x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.5999999046325684,
0.0,
-14.5
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
2,
4,
8,
3,
6,
10,
9,
7,
5,
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-5.5999999046325684,
0.0,
-14.5
],
"requestedWalkTarget": [
-5.5999999046325684,
0.0,
-14.5
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.5999999046325684,
0.0,
-14.5
]
},
"ghost_10x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.4000000953674316,
0.0,
-14.5
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
3,
4,
7,
10,
5,
11,
9,
2,
8,
6
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-6.4000000953674316,
0.0,
-14.5
],
"requestedWalkTarget": [
-6.4000000953674316,
0.0,
-12.5
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.4000000953674316,
0.0,
-14.5
]
},
"ghost_11x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.5999999046325684,
0.0,
-15.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
8,
7,
2,
9,
4,
10,
6,
5,
3,
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-5.5999999046325684,
0.0,
-15.0
],
"requestedWalkTarget": [
-5.5999999046325684,
0.0,
-15.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.5999999046325684,
0.0,
-15.0
]
},
"ghost_12x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.4000000953674316,
0.0,
-15.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
7,
11,
5,
4,
3,
10,
9,
8,
6,
2
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-6.4000000953674316,
0.0,
-15.0
],
"requestedWalkTarget": [
-6.4000000953674316,
0.0,
-15.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.4000000953674316,
0.0,
-15.0
]
}
},
"scriptGlobals": {},
"scriptGlobals": {
},
"state": {
"activeNavigationIndex": 0,
"targetInteractNpcId": "",
@@ -460,61 +796,23 @@
"rotation": 0.0,
"steeringAngle": 0.0,
"velocity": 0.0,
"waypoints": [],
"waypoints": [
],
"wheelAngle": 0.0
}
}
},
"teleportZones": {},
"triggerZones": {}
"teleportZones": {
},
"triggerZones": {
}
}
},
"money": 5500,
"playerHp": 200.0,
"playerMaxHp": 200.0,
"players": {
"player2": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": false,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
0.0,
0.0,
0.0
],
"hp": 200.0,
"pathWaypoints": [[
-6.4,
0.0,
13.0
]],
"position": [
-6.4,
0.0,
13.0
],
"requestedWalkTarget": [
-6.4,
0.0,
13.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.0,
"walkTarget": [
-6.4,
0.0,
13.0
]
},
"player": {
"player1": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
@@ -533,18 +831,18 @@
"hp": 200.0,
"pathWaypoints": [
[
-5.8,
-5.4,
0.0,
12.8
]
],
"position": [
-5.8,
-5.4,
0.0,
12.8
],
"requestedWalkTarget": [
-5.8,
-5.4,
0.0,
12.8
],
@@ -552,14 +850,144 @@
"targetFacingAngle": 0.0,
"visibleRange": 6.0,
"walkTarget": [
-5.8,
-5.4,
0.0,
12.8
]
},
"player2": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": false,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
0.0,
0.0,
0.0
],
"hp": 200.0,
"pathWaypoints": [
[
-6.8,
0.0,
13.0
]
],
"position": [
-6.8,
0.0,
13.0
],
"requestedWalkTarget": [
-6.8,
0.0,
13.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.0,
"walkTarget": [
-6.8,
0.0,
13.0
]
},
"player3": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": false,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
0.0,
0.0,
0.0
],
"hp": 200.0,
"pathWaypoints": [
[
-5.4,
0.0,
14.8
]
],
"position": [
-5.4,
0.0,
14.8
],
"requestedWalkTarget": [
-5.4,
0.0,
14.8
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.0,
"walkTarget": [
-5.4,
0.0,
14.8
]
},
"player4": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": false,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
0.0,
0.0,
0.0
],
"hp": 200.0,
"pathWaypoints": [
[
-6.8,
0.0,
15.0
]
],
"position": [
-6.8,
0.0,
15.0
],
"requestedWalkTarget": [
-6.8,
0.0,
15.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.0,
"walkTarget": [
-6.8,
0.0,
15.0
]
}
},
"questJournal": {
"quests": []
"quests": [
]
},
"savedAt": "2026-11-05 17:43",
"schemaVersion": 5,
@@ -1,7 +1,7 @@
{
"localState": {
"locations": {
"uni_exterior": {
"park_korea": {
"cameraAzimuth": -2.3499999046325684,
"cameraInclination": 1.103600025177002
}
@@ -10,9 +10,12 @@
"networkState": {
"campaignId": "battle_zone",
"chatHistory": [
[],
[],
[]
[
],
[
],
[
]
],
"chatPreviewMsg": [
"",
@@ -27,15 +30,17 @@
"currentLocationId": "",
"globalFloats": {
},
"globalInts": {},
"globalInts": {
},
"inventory": {
"items": []
"items": [
]
},
"isDarklands": true,
"isDawn": false,
"isNight": true,
"locations": {
"uni_exterior": {
"park_korea": {
"dialogue": {
"active": false
},
@@ -44,7 +49,7 @@
"alpha": 1.0,
"isActive": true,
"isAnimating": false,
"positionX": 0.6000000238418579,
"positionX": 0.60000002384185791,
"positionY": 0.45736101269721985,
"positionZ": 3.8499999046325684,
"rotationY": 0.0,
@@ -54,7 +59,7 @@
"alpha": 1.0,
"isActive": true,
"isAnimating": false,
"positionX": 1.399999976158142,
"positionX": 1.3999999761581421,
"positionY": 0.45736101269721985,
"positionZ": 3.8499999046325684,
"rotationY": 0.0,
@@ -64,7 +69,7 @@
"alpha": 1.0,
"isActive": true,
"isAnimating": false,
"positionX": 2.200000047683716,
"positionX": 2.2000000476837158,
"positionY": 0.45736101269721985,
"positionZ": 3.8499999046325684,
"rotationY": 0.0,
@@ -85,7 +90,7 @@
"isActive": true,
"isAnimating": false,
"positionX": 0.0,
"positionY": 3.200000047683716,
"positionY": 3.2000000476837158,
"positionZ": 0.0,
"rotationY": 0.0,
"scale": 1.0
@@ -95,7 +100,7 @@
"isActive": true,
"isAnimating": false,
"positionX": -1.0,
"positionY": 0.9150000214576721,
"positionY": 0.91500002145767212,
"positionZ": -3.9000000953674316,
"rotationY": 0.0,
"scale": 1.0
@@ -105,7 +110,7 @@
"isActive": true,
"isAnimating": false,
"positionX": 0.0,
"positionY": 0.9150000214576721,
"positionY": 0.91500002145767212,
"positionZ": -3.9000000953674316,
"rotationY": 0.0,
"scale": 1.0
@@ -115,7 +120,7 @@
"isActive": true,
"isAnimating": false,
"positionX": 1.0,
"positionY": 0.9150000214576721,
"positionY": 0.91500002145767212,
"positionZ": -3.9000000953674316,
"rotationY": 0.0,
"scale": 1.0
@@ -134,7 +139,7 @@
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-12.0
],
@@ -155,14 +160,15 @@
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"pathWaypoints": [
],
"position": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-12.0
],
"requestedWalkTarget": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-12.0
],
@@ -170,7 +176,7 @@
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-12.0
]
@@ -187,7 +193,7 @@
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-12.0
],
@@ -208,14 +214,15 @@
6
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"pathWaypoints": [
],
"position": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-12.0
],
"requestedWalkTarget": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-12.0
],
@@ -223,7 +230,7 @@
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-12.0
]
@@ -240,7 +247,7 @@
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-12.5
],
@@ -261,14 +268,15 @@
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"pathWaypoints": [
],
"position": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-12.5
],
"requestedWalkTarget": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-12.5
],
@@ -276,7 +284,7 @@
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-12.5
]
@@ -293,7 +301,7 @@
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-12.5
],
@@ -314,14 +322,15 @@
6
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"pathWaypoints": [
],
"position": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-12.5
],
"requestedWalkTarget": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-12.5
],
@@ -329,7 +338,7 @@
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-12.5
]
@@ -346,7 +355,7 @@
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-13.0
],
@@ -367,14 +376,15 @@
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"pathWaypoints": [
],
"position": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-13.0
],
"requestedWalkTarget": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-13.0
],
@@ -382,7 +392,7 @@
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.599999904632568,
-5.5999999046325684,
0.0,
-13.0
]
@@ -399,7 +409,7 @@
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-13.0
],
@@ -420,14 +430,15 @@
2
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"pathWaypoints": [
],
"position": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-13.0
],
"requestedWalkTarget": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-13.0
],
@@ -435,13 +446,338 @@
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.400000095367432,
-6.4000000953674316,
0.0,
-13.0
]
},
"ghost_07x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.5999999046325684,
0.0,
-14.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
2,
3,
10,
5,
6,
8,
7,
9,
4,
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-5.5999999046325684,
0.0,
-14.0
],
"requestedWalkTarget": [
-5.5999999046325684,
0.0,
-14.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.5999999046325684,
0.0,
-14.0
]
},
"ghost_08x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.4000000953674316,
0.0,
-14.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
8,
11,
3,
10,
2,
9,
5,
4,
7,
6
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-6.4000000953674316,
0.0,
-14.0
],
"requestedWalkTarget": [
-6.4000000953674316,
0.0,
-14.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.4000000953674316,
0.0,
-14.0
]
},
"ghost_09x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.5999999046325684,
0.0,
-14.5
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
2,
4,
8,
3,
6,
10,
9,
7,
5,
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-5.5999999046325684,
0.0,
-14.5
],
"requestedWalkTarget": [
-5.5999999046325684,
0.0,
-14.5
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.5999999046325684,
0.0,
-14.5
]
},
"ghost_10x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.4000000953674316,
0.0,
-14.5
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
3,
4,
7,
10,
5,
11,
9,
2,
8,
6
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-6.4000000953674316,
0.0,
-14.5
],
"requestedWalkTarget": [
-6.4000000953674316,
0.0,
-12.5
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.4000000953674316,
0.0,
-14.5
]
},
"ghost_11x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.5999999046325684,
0.0,
-15.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
8,
7,
2,
9,
4,
10,
6,
5,
3,
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-5.5999999046325684,
0.0,
-15.0
],
"requestedWalkTarget": [
-5.5999999046325684,
0.0,
-15.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.5999999046325684,
0.0,
-15.0
]
},
"ghost_12x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.4000000953674316,
0.0,
-15.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
7,
11,
5,
4,
3,
10,
9,
8,
6,
2
],
"navigationRetryTicks": 0,
"pathWaypoints": [
],
"position": [
-6.4000000953674316,
0.0,
-15.0
],
"requestedWalkTarget": [
-6.4000000953674316,
0.0,
-15.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.4000000953674316,
0.0,
-15.0
]
}
},
"scriptGlobals": {},
"scriptGlobals": {
},
"state": {
"activeNavigationIndex": 0,
"targetInteractNpcId": "",
@@ -460,61 +796,23 @@
"rotation": 0.0,
"steeringAngle": 0.0,
"velocity": 0.0,
"waypoints": [],
"waypoints": [
],
"wheelAngle": 0.0
}
}
},
"teleportZones": {},
"triggerZones": {}
"teleportZones": {
},
"triggerZones": {
}
}
},
"money": 5500,
"playerHp": 200.0,
"playerMaxHp": 200.0,
"players": {
"player2": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": false,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
0.0,
0.0,
0.0
],
"hp": 200.0,
"pathWaypoints": [[
-6.8,
0.0,
13.0
]],
"position": [
-6.8,
0.0,
13.0
],
"requestedWalkTarget": [
-6.8,
0.0,
13.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.0,
"walkTarget": [
-6.8,
0.0,
13.0
]
},
"player": {
"player1": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
@@ -556,10 +854,140 @@
0.0,
12.8
]
},
"player2": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": false,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
0.0,
0.0,
0.0
],
"hp": 200.0,
"pathWaypoints": [
[
-6.8,
0.0,
13.0
]
],
"position": [
-6.8,
0.0,
13.0
],
"requestedWalkTarget": [
-6.8,
0.0,
13.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.0,
"walkTarget": [
-6.8,
0.0,
13.0
]
},
"player3": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": false,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
0.0,
0.0,
0.0
],
"hp": 200.0,
"pathWaypoints": [
[
-5.4,
0.0,
14.8
]
],
"position": [
-5.4,
0.0,
14.8
],
"requestedWalkTarget": [
-5.4,
0.0,
14.8
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.0,
"walkTarget": [
-5.4,
0.0,
14.8
]
},
"player4": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": false,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
0.0,
0.0,
0.0
],
"hp": 200.0,
"pathWaypoints": [
[
-6.8,
0.0,
15.0
]
],
"position": [
-6.8,
0.0,
15.0
],
"requestedWalkTarget": [
-6.8,
0.0,
15.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.0,
"walkTarget": [
-6.8,
0.0,
15.0
]
}
},
"questJournal": {
"quests": []
"quests": [
]
},
"savedAt": "2026-11-05 17:43",
"schemaVersion": 5,
+5
View File
@@ -163,6 +163,11 @@ if(BUILD_TESTING)
target_compile_definitions(
GameServerWebsocketLockstepTests PRIVATE
NETWORK WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00)
if(MSVC)
set_source_files_properties(
"${GAME_ROOT}/src/game_server/WebsocketGameClient.cpp"
PROPERTIES COMPILE_OPTIONS "/bigobj")
endif()
target_link_libraries(
GameServerWebsocketLockstepTests PRIVATE
game_server_core ws2_32 mswsock)
+53
View File
@@ -194,6 +194,59 @@ int main()
return fail("Hostile NPC did not return home after losing its target");
}
auto blocker = std::make_shared<FRG::CharacterState>();
blocker->enabled = true;
blocker->hp = 50.f;
blocker->position = {0.8f, 0.f, 0.f};
state.npcs["blocker"] = blocker;
hostileNpc.position = Eigen::Vector3f::Zero();
hostileNpc.homePosition = {5.f, 0.f, 0.f};
hostileNpc.attackTargetId = "player";
hostileNpc.requestedWalkTarget = playerOne->position;
hostileNpc.walkTarget = playerOne->position;
hostileNpc.pathWaypoints = {playerOne->position};
hostileNpc.currentWaypointIndex = 0;
hostileNpc.movementBlockedTicks = 0;
playerOne->enabled = true;
playerOne->hp = 75.f;
playerOne->position = {4.f, 0.f, 0.f};
playerTwo->enabled = true;
playerTwo->hp = 75.f;
playerTwo->position = {2.f, 0.f, 0.f};
bool blockedRetryRequested = false;
for (int tick = 0; tick < 10 && !blockedRetryRequested; ++tick) {
const FRG::LocationSimulationEvents events =
state.update(100, combatPlayers, false);
for (const auto& event : events.characters) {
if (event.characterId == "npc" &&
event.events.forceNavigationReplan) {
blockedRetryRequested = true;
if (!state.replanCharacter(
"npc", combatPlayers,
event.events.blockingCharacterId)) {
return fail("Blocked NPC replan was rejected");
}
}
}
}
if (!blockedRetryRequested ||
hostileNpc.attackTargetId != "player2" ||
(hostileNpc.requestedWalkTarget - playerTwo->position).norm() >
FRG::CharacterState::kWalkThreshold) {
return fail("Blocked NPC did not switch to the nearest player");
}
playerOne->enabled = false;
playerTwo->enabled = false;
if (!state.replanCharacter("npc", combatPlayers, "blocker") ||
!hostileNpc.attackTargetId.empty() ||
hostileNpc.battle_state != 0 ||
(hostileNpc.requestedWalkTarget - hostileNpc.homePosition).norm() >
FRG::CharacterState::kWalkThreshold) {
return fail("Blocked NPC did not return home without an eligible target");
}
FRG::CampaignConfig battleZone;
battleZone.id = "battle_zone";
battleZone.questsPath = "resources/config/quests_battle_zone.json";
+25
View File
@@ -32,6 +32,31 @@ bool waitFor(Predicate predicate)
int main()
{
FRG::WebsocketEndpoint secureEndpoint;
require(
secureEndpoint.parse(
"wss://api.shadowoverbishkek.com/server001/"),
"secure WebSocket endpoint was rejected");
require(
secureEndpoint.host == "api.shadowoverbishkek.com" &&
secureEndpoint.port == "443" &&
secureEndpoint.path == "/server001/" &&
secureEndpoint.useTls,
"secure WebSocket endpoint was parsed incorrectly");
FRG::WebsocketEndpoint insecureEndpoint;
require(
insecureEndpoint.parse("ws://127.0.0.1:8080/"),
"insecure WebSocket endpoint was rejected");
require(
insecureEndpoint.host == "127.0.0.1" &&
insecureEndpoint.port == "8080" &&
!insecureEndpoint.useTls,
"insecure WebSocket endpoint was parsed incorrectly");
FRG::WebsocketEndpoint invalidEndpoint;
require(
!invalidEndpoint.parse("https://api.shadowoverbishkek.com/"),
"non-WebSocket endpoint was accepted");
FRG::CampaignConfig campaign;
campaign.id = "test_lockstep";
campaign.networkPlayerSlots = {"player", "player2"};
+14 -2
View File
@@ -557,7 +557,19 @@ namespace FRG
if (remoteCampaignActive) {
logger() << "[network] Campaign '" << campaignId
<< "' selected; creating WebSocket client" << std::endl;
websocketGameClient = std::make_unique<WebsocketGameClient>();
// Use "ws://127.0.0.1:8080/" for a local development server.
static const char* const kMultiplayerServerUrl =
"wss://api.shadowoverbishkek.com/server001/";
WebsocketEndpoint endpoint;
if (!endpoint.parse(kMultiplayerServerUrl)) {
logger() << "[network] Invalid multiplayer server URL: "
<< kMultiplayerServerUrl << std::endl;
remoteCampaignActive = false;
gameServer.use(internalGameServer);
gameServerHost.use(localGameServerHost);
return;
}
websocketGameClient = std::make_unique<WebsocketGameClient>(endpoint);
remoteGameServerHost =
std::make_unique<RemoteGameServerHost>(*websocketGameClient);
gameServer.use(*websocketGameClient);
@@ -680,7 +692,7 @@ namespace FRG
// Developer workflow: uncomment to (re)generate the campaign's start-state JSON
// from this freshly-rebuilt state after changing gameplay/content JSON or Lua.
// Re-comment before shipping.
saveInitialState();
//saveInitialState();
}
+3 -3
View File
@@ -890,11 +890,11 @@ namespace FRG
}
Character* Location::raycastNpcs(const Eigen::Vector3f& rayOrigin, const Eigen::Vector3f& rayDir, float maxDistance) {
// Every NPC is treated as a vertical cylinder: radius 0.25m, height 1.6m,
// Every NPC is treated as a vertical cylinder
// base at npc->position (the model's foot). Intersection = circle hit in the
// XZ plane, then clip the entry/exit t against the [yFoot, yFoot+height] slab.
static constexpr float NPC_CLICK_RADIUS = 0.25f;
static constexpr float NPC_CLICK_HEIGHT = 1.6f;
static constexpr float NPC_CLICK_RADIUS = 0.35f;
static constexpr float NPC_CLICK_HEIGHT = 1.8f;
static constexpr float NPC_PICK_EPSILON = 1e-6f;
Character* closestNpc = nullptr;
+77 -33
View File
@@ -173,11 +173,84 @@ bool LocationGameState::replanCharacter(
{
CharacterState* character = findCharacter(id, players);
if (!character || !location) return false;
if (!forcedObstacleId.empty() && !character->isPlayer &&
character->canAttack && character->hp > 0.f) {
const std::string selectedTarget =
selectNpcTarget(*character, players, false);
if (selectedTarget != character->attackTargetId) {
character->stopInPlace();
character->attackTargetId = selectedTarget;
character->battle_state = 0;
character->attack = 0;
}
if (!selectedTarget.empty()) {
const CharacterState* target = findCharacter(selectedTarget, players);
if (target) {
character->setTarget(
target->position, {}, *location, id,
collectPathObstacles(id, players, forcedObstacleId));
return true;
}
}
character->attackTargetId.clear();
character->battle_state = 0;
character->attack = 0;
const Eigen::Vector3f homeTarget(
character->homePosition.x(), 0.f, character->homePosition.z());
if ((homeTarget - character->position).norm() >
CharacterState::kWalkThreshold) {
character->setTarget(
homeTarget, {}, *location, id,
collectPathObstacles(id, players, forcedObstacleId));
}
return true;
}
character->forceReplan(
*location, id, collectPathObstacles(id, players, forcedObstacleId));
return true;
}
std::string LocationGameState::selectNpcTarget(
const CharacterState& npc,
const PlayerStateMap& players,
bool preserveValidLock) const
{
if (preserveValidLock) {
const auto locked = players.find(npc.attackTargetId);
if (locked != players.end() && locked->second &&
locked->second->enabled && locked->second->hp > 0.f)
return npc.attackTargetId;
}
std::vector<std::string> playerIds;
playerIds.reserve(players.size());
for (const auto& [id, player] : players)
if (player && player->enabled && player->hp > 0.f)
playerIds.push_back(id);
std::sort(playerIds.begin(), playerIds.end());
const float visibleRangeSquared = npc.visibleRange * npc.visibleRange;
std::string selectedTarget;
float nearestDistanceSquared = visibleRangeSquared;
for (const std::string& playerId : playerIds) {
const Eigen::Vector3f delta =
players.at(playerId)->position - npc.position;
const float distanceSquared =
delta.x() * delta.x() + delta.z() * delta.z();
if (distanceSquared <= nearestDistanceSquared &&
(selectedTarget.empty() ||
distanceSquared < nearestDistanceSquared ||
(distanceSquared == nearestDistanceSquared &&
playerId < selectedTarget))) {
selectedTarget = playerId;
nearestDistanceSquared = distanceSquared;
}
}
return selectedTarget;
}
void LocationGameState::startTaxi()
{
location->taxiCar.waypoints = location->taxiCarWaypoints;
@@ -321,13 +394,6 @@ LocationSimulationEvents LocationGameState::update(
if (player) playerIds.push_back(id);
std::sort(playerIds.begin(), playerIds.end());
std::vector<std::string> eligiblePlayerIds;
for (const std::string& id : playerIds) {
const auto& player = players.at(id);
if (player->enabled && player->hp > 0.f)
eligiblePlayerIds.push_back(id);
}
std::vector<std::string> npcIds;
npcIds.reserve(npcs.size());
for (const auto& [id, npc] : npcs)
@@ -343,30 +409,8 @@ LocationSimulationEvents LocationGameState::update(
continue;
const std::string previousTarget = npc.attackTargetId;
const auto locked = players.find(previousTarget);
const bool lockValid =
locked != players.end() && locked->second &&
locked->second->enabled && locked->second->hp > 0.f;
std::string selectedTarget = lockValid ? previousTarget : std::string();
if (!lockValid) {
float nearestDistanceSquared = npc.visibleRange * npc.visibleRange;
for (const std::string& playerId : eligiblePlayerIds) {
const Eigen::Vector3f delta =
players.at(playerId)->position - npc.position;
const float distanceSquared =
delta.x() * delta.x() + delta.z() * delta.z();
if (distanceSquared <= nearestDistanceSquared) {
if (selectedTarget.empty() ||
distanceSquared < nearestDistanceSquared ||
(distanceSquared == nearestDistanceSquared &&
playerId < selectedTarget)) {
selectedTarget = playerId;
nearestDistanceSquared = distanceSquared;
}
}
}
}
const std::string selectedTarget =
selectNpcTarget(npc, players, true);
if (selectedTarget != previousTarget) {
npc.stopInPlace();
@@ -474,7 +518,7 @@ LocationSimulationEvents LocationGameState::update(
source.events.attackDirection});
}
auto player = players.find("player");
auto player = players.find("player1");
if (!interactiveObjectsLocked &&
!location->targetInteractiveObjectId.empty() &&
player != players.end() && player->second && player->second->enabled) {
@@ -502,7 +546,7 @@ LocationSimulationEvents LocationGameState::update(
(player->second->position - npc->position).norm();
if (distance <= NPC_TALK_DISTANCE) {
player->second->stopInPlace();
npc->faceTargetId = "player";
npc->faceTargetId = "player1";
result.npcInteractionId = location->targetInteractNpcId;
location->targetInteractNpcId.clear();
}
+4
View File
@@ -86,6 +86,10 @@ struct LocationGameState {
void load(const nlohmann::json& in);
private:
std::string selectNpcTarget(
const CharacterState& npc,
const PlayerStateMap& players,
bool preserveValidLock) const;
bool updateNpcCar(std::int64_t deltaMs);
};
+1 -1
View File
@@ -172,7 +172,7 @@ namespace FRG {
<< npcId << "'\n";
return;
}
loc->faceCharacter(*npc, "player");
loc->faceCharacter(*npc, "player1");
});
api.set_function("player_walk_to",
+1 -1
View File
@@ -51,7 +51,7 @@ private:
GameSnapshot buildSnapshot() const;
GameState state_;
std::string localPlayerId_ = "player";
std::string localPlayerId_ = "player1";
std::string resourceZipFile_;
std::vector<std::string> networkPlayerSlots_;
std::int64_t battleZoneTestBotElapsedMs_ = 0;
+222 -22
View File
@@ -5,6 +5,21 @@
#else
#include <boost/asio.hpp>
#include <boost/beast.hpp>
#ifdef NETWORK_TLS
#include <wincrypt.h>
// wincrypt.h defines macros that collide with OpenSSL identifiers.
#undef X509_NAME
#undef X509_EXTENSIONS
#undef X509_CERT_PAIR
#undef PKCS7_ISSUER_AND_SERIAL
#undef OCSP_REQUEST
#undef OCSP_RESPONSE
#include <boost/asio/ssl.hpp>
#include <boost/beast/ssl.hpp>
#include <openssl/err.h>
#include <openssl/x509.h>
#include <openssl/x509_vfy.h>
#endif
#endif
#include <atomic>
#include <deque>
@@ -23,19 +38,104 @@ namespace asio = boost::asio;
namespace beast = boost::beast;
namespace websocket = beast::websocket;
using tcp = asio::ip::tcp;
#ifdef NETWORK_TLS
// Loads the Windows "ROOT" certificate store into the OpenSSL context.
void loadWindowsRootCertificates(asio::ssl::context& context)
{
HCERTSTORE store = CertOpenSystemStoreA(0, "ROOT");
if (!store) {
std::cerr << "[network] Could not open the Windows root certificate store\n";
return;
}
X509_STORE* x509Store = SSL_CTX_get_cert_store(context.native_handle());
PCCERT_CONTEXT cert = nullptr;
while ((cert = CertEnumCertificatesInStore(store, cert)) != nullptr) {
const unsigned char* data = cert->pbCertEncoded;
X509* x509 = d2i_X509(nullptr, &data, static_cast<long>(cert->cbCertEncoded));
if (x509) {
X509_STORE_add_cert(x509Store, x509);
X509_free(x509);
}
}
CertCloseStore(store, 0);
}
// Certificate problems are logged but deliberately do not abort the connection.
bool logAndAcceptCertificate(bool preverified, asio::ssl::verify_context& context)
{
if (!preverified) {
X509_STORE_CTX* storeContext = context.native_handle();
const int error = X509_STORE_CTX_get_error(storeContext);
const int depth = X509_STORE_CTX_get_error_depth(storeContext);
char subject[256] = "?";
if (X509* cert = X509_STORE_CTX_get_current_cert(storeContext))
X509_NAME_oneline(X509_get_subject_name(cert), subject, sizeof(subject));
std::cerr << "[network] TLS certificate warning (depth " << depth
<< ", subject " << subject << "): "
<< X509_verify_cert_error_string(error)
<< " - continuing anyway\n";
}
return true;
}
#endif
#endif
} // namespace
bool WebsocketEndpoint::parse(const std::string& url)
{
bool tls = false;
std::string rest;
if (url.rfind("wss://", 0) == 0) {
tls = true;
rest = url.substr(6);
} else if (url.rfind("ws://", 0) == 0) {
rest = url.substr(5);
} else {
return false;
}
std::string authority = rest;
std::string newPath = "/";
const std::size_t slash = rest.find('/');
if (slash != std::string::npos) {
authority = rest.substr(0, slash);
newPath = rest.substr(slash);
}
std::string newHost = authority;
std::string newPort = tls ? "443" : "80";
if (!authority.empty() && authority.front() == '[') { // [ipv6]:port
const std::size_t close = authority.find(']');
if (close == std::string::npos) return false;
newHost = authority.substr(1, close - 1);
if (close + 1 < authority.size()) {
if (authority[close + 1] != ':') return false;
newPort = authority.substr(close + 2);
}
} else {
const std::size_t colon = authority.rfind(':');
if (colon != std::string::npos) {
newHost = authority.substr(0, colon);
newPort = authority.substr(colon + 1);
}
}
if (newHost.empty() || newPort.empty()) return false;
host = std::move(newHost);
port = std::move(newPort);
path = std::move(newPath);
useTls = tls;
return true;
}
class WebsocketGameClient::Impl {
public:
#ifdef __EMSCRIPTEN__
Impl(std::string host, std::string port, std::string path)
Impl(std::string host, std::string port, std::string path, bool useTls)
: host_(std::move(host))
, port_(std::move(port))
, path_(std::move(path))
{
url_ = "ws://" + host_ + ':' + port_ +
url_ = std::string(useTls ? "wss://" : "ws://") + host_ + ':' + port_ +
(path_.empty() || path_.front() == '/' ? path_ : "/" + path_);
std::cout << "[network] Opening " << url_ << std::endl;
@@ -45,7 +145,7 @@ public:
attributes.createOnMainThread = EM_TRUE;
socket_ = emscripten_websocket_new(&attributes);
if (socket_ <= 0) {
fail("create WebSocket");
fail("create WebSocket", socket_);
return;
}
@@ -105,11 +205,21 @@ private:
}
static EM_BOOL onClose(
int, const EmscriptenWebSocketCloseEvent*, void* userData)
int, const EmscriptenWebSocketCloseEvent* event, void* userData)
{
auto* self = static_cast<Impl*>(userData);
self->connected_ = false;
if (!self->closing_) self->fail("WebSocket close");
if (!self->closing_) {
std::cerr << "[network] WebSocket closed";
if (event) {
std::cerr << " (code " << event->code;
if (event->reason[0] != '\0')
std::cerr << ", reason: " << event->reason;
std::cerr << ')';
}
std::cerr << '\n';
self->connectionFailed_ = true;
}
return EM_TRUE;
}
@@ -126,11 +236,13 @@ private:
return EM_TRUE;
}
void fail(const char* operation)
void fail(const char* operation, int result = 0)
{
connected_ = false;
connectionFailed_ = true;
std::cerr << "[network] " << operation << " failed" << std::endl;
std::cerr << "[network] " << operation << " failed";
if (result != 0) std::cerr << " (result " << result << ')';
std::cerr << std::endl;
}
std::string host_;
@@ -143,14 +255,38 @@ private:
bool closing_ = false;
std::vector<std::string> messages_;
#else
Impl(std::string host, std::string port, std::string path)
: resolver_(io_)
Impl(std::string host, std::string port, std::string path, bool useTls)
:
#ifdef NETWORK_TLS
sslContext_(asio::ssl::context::tls_client)
, resolver_(io_)
, secureSocket_(io_, sslContext_)
#else
resolver_(io_)
#endif
, socket_(io_)
, host_(std::move(host))
, port_(std::move(port))
, path_(std::move(path))
, useTls_(useTls)
{
std::cout << "[network] Resolving ws://" << host_ << ':' << port_
#ifdef NETWORK_TLS
sslContext_.set_options(
asio::ssl::context::default_workarounds |
asio::ssl::context::no_sslv2 | asio::ssl::context::no_sslv3 |
asio::ssl::context::no_tlsv1 | asio::ssl::context::no_tlsv1_1);
loadWindowsRootCertificates(sslContext_);
sslContext_.set_verify_mode(asio::ssl::verify_peer);
sslContext_.set_verify_callback(&logAndAcceptCertificate);
#else
if (useTls_) {
std::cerr << "[network] wss:// requested but TLS support was not "
"compiled in (NETWORK_TLS)" << std::endl;
connectionFailed_.store(true);
return;
}
#endif
std::cout << "[network] Resolving " << scheme() << host_ << ':' << port_
<< path_ << std::endl;
resolve();
thread_ = std::thread([this]() { io_.run(); });
@@ -184,6 +320,20 @@ private:
}
private:
const char* scheme() const { return useTls_ ? "wss://" : "ws://"; }
template <class F>
void withStream(F&& f)
{
#ifdef NETWORK_TLS
if (useTls_) {
f(secureSocket_);
return;
}
#endif
f(socket_);
}
void resolve()
{
resolver_.async_resolve(
@@ -192,34 +342,68 @@ private:
if (error) return fail("resolve", error);
std::cout << "[network] WebSocket host resolved; connecting"
<< std::endl;
withStream([&](auto& stream) {
asio::async_connect(
socket_.next_layer(), results,
beast::get_lowest_layer(stream), results,
[this](beast::error_code connectError,
const tcp::endpoint&) {
if (connectError) return fail("connect", connectError);
std::cout << "[network] TCP connected; starting WebSocket handshake"
<< std::endl;
socket_.set_option(
std::cout << "[network] TCP connected" << std::endl;
startTlsOrWebsocketHandshake();
});
});
});
}
void startTlsOrWebsocketHandshake()
{
#ifdef NETWORK_TLS
if (useTls_) {
SSL* ssl = secureSocket_.next_layer().native_handle();
// SNI, and hostname verification (failures are only logged).
SSL_set_tlsext_host_name(ssl, const_cast<char*>(host_.c_str()));
SSL_set1_host(ssl, host_.c_str());
std::cout << "[network] Starting TLS handshake" << std::endl;
secureSocket_.next_layer().async_handshake(
asio::ssl::stream_base::client,
[this](beast::error_code tlsError) {
if (tlsError) return fail("TLS handshake", tlsError);
std::cout << "[network] TLS established" << std::endl;
startWebsocketHandshake();
});
return;
}
#endif
startWebsocketHandshake();
}
void startWebsocketHandshake()
{
const bool defaultPort = port_ == (useTls_ ? "443" : "80");
const std::string hostHeader = defaultPort ? host_ : host_ + ':' + port_;
std::cout << "[network] Starting WebSocket handshake" << std::endl;
withStream([&](auto& stream) {
stream.set_option(
websocket::stream_base::timeout::suggested(
beast::role_type::client));
socket_.async_handshake(
host_, path_,
stream.async_handshake(
hostHeader, path_,
[this](beast::error_code handshakeError) {
if (handshakeError)
return fail("handshake", handshakeError);
connected_.store(true);
std::cout
<< "[network] Connected to ws://"
<< "[network] Connected to " << scheme()
<< host_ << ':' << port_ << path_ << std::endl;
read();
});
});
});
}
void read()
{
socket_.async_read(
withStream([this](auto& stream) {
stream.async_read(
buffer_,
[this](beast::error_code error, std::size_t) {
if (error) {
@@ -235,17 +419,20 @@ private:
}
read();
});
});
}
void write()
{
socket_.async_write(
withStream([this](auto& stream) {
stream.async_write(
asio::buffer(outbound_.front()),
[this](beast::error_code error, std::size_t) {
if (error) return fail("write", error);
outbound_.pop_front();
if (!outbound_.empty()) write();
});
});
}
void fail(const char* operation, beast::error_code error)
@@ -257,13 +444,20 @@ private:
}
asio::io_context io_;
#ifdef NETWORK_TLS
asio::ssl::context sslContext_;
#endif
tcp::resolver resolver_;
#ifdef NETWORK_TLS
websocket::stream<beast::ssl_stream<tcp::socket>> secureSocket_;
#endif
websocket::stream<tcp::socket> socket_;
beast::flat_buffer buffer_;
std::thread thread_;
std::string host_;
std::string port_;
std::string path_;
bool useTls_ = false;
std::atomic_bool connected_{false};
std::atomic_bool connectionFailed_{false};
std::mutex messagesMutex_;
@@ -273,9 +467,15 @@ private:
};
WebsocketGameClient::WebsocketGameClient(
std::string host, std::string port, std::string path)
std::string host, std::string port, std::string path, bool useTls)
: impl_(std::make_unique<Impl>(
std::move(host), std::move(port), std::move(path)))
std::move(host), std::move(port), std::move(path), useTls))
{
}
WebsocketGameClient::WebsocketGameClient(const WebsocketEndpoint& endpoint)
: WebsocketGameClient(
endpoint.host, endpoint.port, endpoint.path, endpoint.useTls)
{
}
+15 -1
View File
@@ -11,12 +11,26 @@ namespace FRG {
class RemoteGameServerHost;
// Parsed "ws://host[:port][/path]" or "wss://host[:port][/path]".
struct WebsocketEndpoint {
std::string host = "127.0.0.1";
std::string port = "8080";
std::string path = "/";
bool useTls = false;
// Returns false (and leaves *this untouched) for an unsupported/malformed URL.
// Default ports: 80 for ws://, 443 for wss://.
bool parse(const std::string& url);
};
class WebsocketGameClient final : public IGameServer {
public:
WebsocketGameClient(
std::string host = "127.0.0.1",
std::string port = "8080",
std::string path = "/");
std::string path = "/",
bool useTls = false);
explicit WebsocketGameClient(const WebsocketEndpoint& endpoint);
~WebsocketGameClient();
WebsocketGameClient(const WebsocketGameClient&) = delete;