From 28c6e83eda73871f6b5867baee48afbbe7273c28 Mon Sep 17 00:00:00 2001 From: Vladislav Khorev Date: Thu, 5 Nov 2026 20:12:30 +0600 Subject: [PATCH] Working on the server --- GAME_STATE_ARCHITECTURE.md | 35 +- WEBSOCKET_NETWORKING.md | 36 +- resources/config/campaigns.json | 15 +- resources/{ => config}/start_state.json | 0 resources/config/start_state_battle_zone.json | 473 +++++++++--------- server/README.md | 6 +- server/src/main.cpp | 30 +- server/tests/LocationGameStateTests.cpp | 65 ++- server/tests/NetworkProtocolTests.cpp | 15 +- src/Campaign.cpp | 1 - src/CharacterState.cpp | 4 +- src/CharacterState.h | 6 +- src/Game.cpp | 62 ++- src/GameState.h | 2 +- src/Location.cpp | 42 +- src/Location.h | 12 +- src/LocationGameState.cpp | 4 +- src/LocationGameState.h | 2 +- src/LocationState.cpp | 94 +++- src/LocationState.h | 36 +- src/MenuManager.h | 4 +- src/game_server/IGameServer.h | 8 +- src/game_server/InternalGameServer.cpp | 12 +- src/game_server/LocalGameServerHost.cpp | 16 +- src/game_server/LocalGameServerHost.h | 2 + src/game_server/NetworkProtocol.cpp | 1 + src/game_server/RemoteGameServerHost.cpp | 14 +- src/game_server/RemoteGameServerHost.h | 2 + src/game_server/WebsocketGameClient.cpp | 8 + src/game_server/WebsocketGameClient.h | 1 + 30 files changed, 646 insertions(+), 362 deletions(-) rename resources/{ => config}/start_state.json (100%) diff --git a/GAME_STATE_ARCHITECTURE.md b/GAME_STATE_ARCHITECTURE.md index 16a0b05..d370901 100644 --- a/GAME_STATE_ARCHITECTURE.md +++ b/GAME_STATE_ARCHITECTURE.md @@ -8,12 +8,13 @@ This refactor does **not** add multiplayer networking. It establishes the state ## Architecture Overview -The architecture has four layers: +The architecture has five layers: 1. `GameState` is the aggregate root for persistent, authoritative state. 2. `InternalGameServer` owns `GameState`, advances the local simulation, handles commands, and produces immutable snapshots. -3. Runtime objects hold rendering resources. `Character` and `Location` read immutable server snapshots by stable ID, while `InteractiveObject` still uses a transitional non-owning binding to server-owned state. `Location` also translates local input, scripts, and collision results into semantic server commands. -4. Client-facing systems such as `MenuManager` read immutable snapshots and submit typed commands through `IGameServer`. +3. `ClientLocationState` owns per-client `LocationLocalState` records, including camera orientation. Graphical hosts own this state; it is never replicated. +4. Runtime objects hold rendering resources. `Character` and `Location` read immutable server snapshots by stable ID, while `InteractiveObject` still uses a transitional non-owning binding to server-owned state. `Location` also translates local input, scripts, and collision results into semantic server commands. +5. Client-facing systems such as `MenuManager` read immutable snapshots and submit typed commands through `IGameServer`. ```text Input / UI @@ -69,7 +70,7 @@ lookup and simulation to treat every campaign player by stable ID. Each location record owns: -- One `LocationState`. +- One `LocationNetworkState`. - NPC `CharacterState` objects keyed by configured ID. - `InteractiveObjectState` objects keyed by configured ID. - Trigger-zone enabled/inside flags. @@ -193,7 +194,7 @@ Gameplay operations mutate the authoritative `CharacterState`, not the runtime `Character`: - `setTarget()` normalizes the destination, avoids redundant requests, stores the - optional Lua arrival function name, and asks the authoritative `LocationState` to + optional Lua arrival function name, and asks the authoritative `LocationNetworkState` to call `planPathExceptMyself()`. - `forceReplan()` recalculates the current requested destination using the same location navigation state. @@ -210,7 +211,10 @@ collision handling, and presentation-event processing. Runtime visual objects do not own their authoritative state objects. -- `Location` keeps only its `IGameServer` reference and stable location ID. It resolves its immutable `LocationState` view from `IGameServer::snapshot()`. +- `Location` keeps its `IGameServer` reference, stable location ID, and a shared + `LocationLocalState` supplied by the graphical host. It resolves its immutable + `LocationNetworkState` view from `IGameServer::snapshot()`, while rendering and + camera input access the local state directly. - `Character` keeps only its `IGameServer` reference plus stable entity and location IDs. It resolves the player-first character view from `IGameServer::snapshot()`. - `InteractiveObject` keeps a private non-owning reference to its `InteractiveObjectState`; its configured object name is its stable ID. @@ -224,9 +228,10 @@ and presentation or input work is skipped. The authoritative state objects are owned by `GameState` or `LocationGameState`. The internal server advances them before publishing a new snapshot revision. Runtime input, scripts, and collision handling submit semantic commands that invoke -authoritative character and location operations. Location commands cover camera -orientation, pending interaction targets, navigation switching and area changes, -editor obstacles, and taxi reset. No whole-location commit command is exposed. +authoritative character and location operations. Location commands cover pending +interaction targets, navigation switching and area changes, editor obstacles, and +taxi reset. Camera orientation is deliberately not a server command. No +whole-location commit command is exposed. Navigation target selection, replanning, stopping, facing, death reset, health assignment, and attack-combo clock changes are implemented by `CharacterState`. @@ -268,7 +273,7 @@ The normal frame flow is: 4. Attack impacts are queued, then damage is applied after all characters advance. 5. The runtime location consumes value-only events. Navigation requests are sent back as semantic destination commands, whose server handler calls - `CharacterState::setTarget()` with the authoritative `LocationState`; arrival + `CharacterState::setTarget()` with the authoritative `LocationNetworkState`; arrival function names are resolved through `ScriptEngine`. 6. The server invalidates its cached snapshot and advances the revision so runtime presentation can observe the completed simulation tick. @@ -326,8 +331,11 @@ The player state remains campaign-level and survives location changes. ## Save and Load Schema -Persistence uses one strict, versioned schema. `schemaVersion` is required and -unsupported versions are rejected rather than migrated. +Authoritative persistence uses one strict, versioned schema. `schemaVersion` is +required and unsupported versions are rejected rather than migrated. A +single-player save is a separate versioned envelope containing `networkState` +(the authoritative `GameState` JSON) and `localState` (the client-local location +map). WebSocket replication sends only the authoritative document. - Campaign players are stored once in an ID-keyed `players` object. - Locations are keyed by location ID. @@ -453,7 +461,8 @@ cmake --build proj-windows/build --config Debug --target ShadowOverBishkekDemo - ## Main Files - `src/GameState.h` / `src/GameState.cpp` — aggregate state and top-level persistence. -- `src/LocationGameState.h` / `src/LocationGameState.cpp` — typed location state, simulation, and ID-keyed persistence. +- `src/LocationState.h` / `src/LocationState.cpp` — separate local camera and authoritative network location records, plus single-player persistence composition. +- `src/LocationGameState.h` / `src/LocationGameState.cpp` — authoritative typed location state, simulation, and ID-keyed persistence. - `src/content/LocationDefinition.h` / `.cpp` — renderer-free content definitions, parsing, and animation metadata loading. - `src/game_server/IGameServer.h` — public snapshot and command contract. diff --git a/WEBSOCKET_NETWORKING.md b/WEBSOCKET_NETWORKING.md index 06df3ee..4325ac8 100644 --- a/WEBSOCKET_NETWORKING.md +++ b/WEBSOCKET_NETWORKING.md @@ -35,6 +35,8 @@ not enable the battle-zone test bot. thread from `update()`. - It maintains an internal `InternalGameServer` replica so existing snapshot consumers and transitional runtime-state bindings continue to work. +- Camera orientation lives in host-owned `LocationLocalState`, so incoming + authoritative snapshots cannot overwrite camera movement. - `submit()` intentionally discards all commands. - `drainEvents()` returns no events. - Client-side save and load calls are ignored because persistence remains @@ -51,11 +53,21 @@ The graphical game uses stable router objects for `IGameServer` and When `NETWORK` is defined: -- `battle_zone` uses `WebsocketGameClient` and `RemoteGameServerHost`. -- Every other campaign uses `InternalGameServer` and `LocalGameServerHost`. +- `battle_zone_multiplayer` uses `WebsocketGameClient` and `RemoteGameServerHost`. +- `main`, `battle_zone`, and every other campaign use `InternalGameServer` and + `LocalGameServerHost`. -The WebSocket client is created only when `battle_zone` is selected and is -destroyed when that campaign is unloaded. +The WebSocket client is created only when `battle_zone_multiplayer` is selected +and is destroyed when that campaign is unloaded. The single-player and +multiplayer Battle Zone entries share location content and the start-state path, +so the state can be regenerated through the single-player entry. At startup, the +standalone server loads the authoritative half of that shared save and rewrites +its campaign ID to `battle_zone_multiplayer`; each remote client restores only +the local presentation half. + +If DNS resolution, TCP connection, the WebSocket handshake, or the active socket +fails, the client unloads the remote campaign and returns to the campaign +selection screen. ## JSON Protocol @@ -66,20 +78,22 @@ Messages use a versioned JSON envelope: "protocolVersion": 1, "type": "state", "payload": { - "schemaVersion": 1 + "schemaVersion": 2 } } ``` -The payload temporarily uses the existing `GameState::save()` representation. +The payload uses the authoritative `GameState::save()` representation and excludes +the client-local location map, including camera orientation. The client applies it through `InternalGameServer::load()`. Malformed JSON, unsupported protocol versions, unsupported message types, and invalid state payloads are logged and ignored. -This temporary full-state format is expected to be replaced or separated from -the persistence schema as command handling and production replication are -implemented. +Single-player persistence uses a separate envelope that combines this authoritative +document with client-local location state. The temporary full-state network format +is still expected to be replaced by a dedicated replication schema or deltas as +command handling and production replication are implemented. ## Build Configuration @@ -106,7 +120,7 @@ cmake --build server/build --config Debug --target GameServer Run it from the repository root: ```powershell -.\server\build\Debug\GameServer.exe --campaign battle_zone --location uni_exterior +.\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: @@ -116,7 +130,7 @@ The listener defaults to `0.0.0.0:8080`. It can be changed with: --port N ``` -Launch the Windows game and select `battle_zone`. The client connects to +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. ## Verification diff --git a/resources/config/campaigns.json b/resources/config/campaigns.json index d312f1f..566d0a7 100644 --- a/resources/config/campaigns.json +++ b/resources/config/campaigns.json @@ -17,7 +17,20 @@ }, { "id": "battle_zone", - "title": { "ru": "Зона боя", "en": "Battle Zone" }, + "title": { "ru": "Зона боя (одиночная)", "en": "Battle Zone (Single Player)" }, + "locations": [ + "resources/config/locations/battle_zone_uni_exterior.json" + ], + "startLocation": "uni_exterior", + "startMusic": "music/bishkek univer day.ogg", + "startState": "resources/config/start_state_battle_zone.json", + "quests": "resources/config/quests_battle_zone.json", + "tutorial": false, + "loadingScreen": "resources/w/ui/screen_loading.json" + }, + { + "id": "battle_zone_multiplayer", + "title": { "ru": "Зона боя (сетевая)", "en": "Battle Zone (Multiplayer)" }, "locations": [ "resources/config/locations/battle_zone_uni_exterior.json" ], diff --git a/resources/start_state.json b/resources/config/start_state.json similarity index 100% rename from resources/start_state.json rename to resources/config/start_state.json diff --git a/resources/config/start_state_battle_zone.json b/resources/config/start_state_battle_zone.json index 46f49cb..cb20780 100644 --- a/resources/config/start_state_battle_zone.json +++ b/resources/config/start_state_battle_zone.json @@ -1,243 +1,252 @@ { - "campaignId": "battle_zone", - "chatHistory": [ - [], - [], - [] - ], - "chatPreviewMsg": [ - "", - "", - "" - ], - "chatUnread": [ - true, - true, - true - ], - "currentLocationId": "", - "globalFloats": { - "player_hp": 200.0 - }, - "globalInts": {}, - "inventory": { - "items": [] - }, - "isDarklands": false, - "isDawn": false, - "isNight": false, - "locations": { - "uni_exterior": { - "dialogue": { - "active": false - }, - "interactiveObjects": { - "intBin_Cans": { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 0.6000000238418579, - "positionY": 0.45736101269721985, - "positionZ": 3.8499999046325684, - "rotationY": 0.0, - "scale": 1.0 - }, - "intBin_Plastic": { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 1.399999976158142, - "positionY": 0.45736101269721985, - "positionZ": 3.8499999046325684, - "rotationY": 0.0, - "scale": 1.0 - }, - "intBin_Waste_A": { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 2.200000047683716, - "positionY": 0.45736101269721985, - "positionZ": 3.8499999046325684, - "rotationY": 0.0, - "scale": 1.0 - }, - "intBin_Waste_B": { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 3.0, - "positionY": 0.45736101269721985, - "positionZ": 3.8499999046325684, - "rotationY": 0.0, - "scale": 1.0 - }, - "intRoof": { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 0.0, - "positionY": 3.200000047683716, - "positionZ": 0.0, - "rotationY": 0.0, - "scale": 1.0 - }, - "intVending.00": { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": -1.0, - "positionY": 0.9150000214576721, - "positionZ": -3.9000000953674316, - "rotationY": 0.0, - "scale": 1.0 - }, - "intVending.01": { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 0.0, - "positionY": 0.9150000214576721, - "positionZ": -3.9000000953674316, - "rotationY": 0.0, - "scale": 1.0 - }, - "intVending.02": { - "alpha": 1.0, - "isActive": true, - "isAnimating": false, - "positionX": 1.0, - "positionY": 0.9150000214576721, - "positionZ": -3.9000000953674316, - "rotationY": 0.0, - "scale": 1.0 - } - }, - "npcs": {}, - "scriptGlobals": {}, - "state": { - "activeNavigationIndex": 0, + "localState": { + "locations": { + "uni_exterior": { "cameraAzimuth": -2.3499999046325684, - "cameraInclination": 1.103600025177002, - "targetInteractNpcId": "", - "targetInteractiveObjectId": "", - "targetTeleportZoneId": "", - "taxi": { - "arrivedCallbackCalled": false, - "car": { - "currentWaypoint": 0, - "mode": 1, - "position": [ - -100.0, - -100.0, - -100.0 - ], - "rotation": 0.0, - "steeringAngle": 0.0, - "velocity": 0.0, - "waypoints": [], - "wheelAngle": 0.0 - } - } - }, - "teleportZones": {}, - "triggerZones": {} + "cameraInclination": 1.103600025177002 + } } }, - "money": 5500, - "playerHp": 200.0, - "playerMaxHp": 200.0, - "players": { - "battle_zone_test_bot": { - "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": [ - 0.0, - 0.0, - 0.0 - ], - "hp": 200.0, - "pathWaypoints": [], - "position": [ - 0.0, - 0.0, - 0.0 - ], - "requestedWalkTarget": [ - 0.0, - 0.0, - 0.0 - ], - "showWeapon": true, - "targetFacingAngle": 0.0, - "visibleRange": 6.0, - "walkTarget": [ - 0.0, - 0.0, - 0.0 - ] + "networkState": { + "campaignId": "battle_zone", + "chatHistory": [ + [], + [], + [] + ], + "chatPreviewMsg": [ + "", + "", + "" + ], + "chatUnread": [ + true, + true, + true + ], + "currentLocationId": "", + "globalFloats": { + "player_hp": 200.0 }, - "player": { - "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": [ - 0.0, - 0.0, - 0.0 - ], - "hp": 200.0, - "pathWaypoints": [ - [ + "globalInts": {}, + "inventory": { + "items": [] + }, + "isDarklands": false, + "isDawn": false, + "isNight": false, + "locations": { + "uni_exterior": { + "dialogue": { + "active": false + }, + "interactiveObjects": { + "intBin_Cans": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": 0.6000000238418579, + "positionY": 0.45736101269721985, + "positionZ": 3.8499999046325684, + "rotationY": 0.0, + "scale": 1.0 + }, + "intBin_Plastic": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": 1.399999976158142, + "positionY": 0.45736101269721985, + "positionZ": 3.8499999046325684, + "rotationY": 0.0, + "scale": 1.0 + }, + "intBin_Waste_A": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": 2.200000047683716, + "positionY": 0.45736101269721985, + "positionZ": 3.8499999046325684, + "rotationY": 0.0, + "scale": 1.0 + }, + "intBin_Waste_B": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": 3.0, + "positionY": 0.45736101269721985, + "positionZ": 3.8499999046325684, + "rotationY": 0.0, + "scale": 1.0 + }, + "intRoof": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": 0.0, + "positionY": 3.200000047683716, + "positionZ": 0.0, + "rotationY": 0.0, + "scale": 1.0 + }, + "intVending.00": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": -1.0, + "positionY": 0.9150000214576721, + "positionZ": -3.9000000953674316, + "rotationY": 0.0, + "scale": 1.0 + }, + "intVending.01": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": 0.0, + "positionY": 0.9150000214576721, + "positionZ": -3.9000000953674316, + "rotationY": 0.0, + "scale": 1.0 + }, + "intVending.02": { + "alpha": 1.0, + "isActive": true, + "isAnimating": false, + "positionX": 1.0, + "positionY": 0.9150000214576721, + "positionZ": -3.9000000953674316, + "rotationY": 0.0, + "scale": 1.0 + } + }, + "npcs": {}, + "scriptGlobals": {}, + "state": { + "activeNavigationIndex": 0, + "targetInteractNpcId": "", + "targetInteractiveObjectId": "", + "targetTeleportZoneId": "", + "taxi": { + "arrivedCallbackCalled": false, + "car": { + "currentWaypoint": 0, + "mode": 1, + "position": [ + -100.0, + -100.0, + -100.0 + ], + "rotation": 0.0, + "steeringAngle": 0.0, + "velocity": 0.0, + "waypoints": [], + "wheelAngle": 0.0 + } + } + }, + "teleportZones": {}, + "triggerZones": {} + } + }, + "money": 5500, + "playerHp": 200.0, + "playerMaxHp": 200.0, + "players": { + "battle_zone_test_bot": { + "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": [ + 0.0, + 0.0, + 0.0 + ], + "hp": 200.0, + "pathWaypoints": [], + "position": [ + 0.0, + 0.0, + 0.0 + ], + "requestedWalkTarget": [ + 0.0, + 0.0, + 0.0 + ], + "showWeapon": true, + "targetFacingAngle": 0.0, + "visibleRange": 6.0, + "walkTarget": [ + 0.0, + 0.0, + 0.0 + ] + }, + "player": { + "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": [ + 0.0, + 0.0, + 0.0 + ], + "hp": 200.0, + "pathWaypoints": [ + [ + -6.025001525878906, + 0.0, + 12.775001525878906 + ] + ], + "position": [ + -6.0, + 0.0, + 12.800000190734863 + ], + "requestedWalkTarget": [ + -6.0, + 0.0, + 12.800000190734863 + ], + "showWeapon": true, + "targetFacingAngle": 0.0, + "visibleRange": 6.0, + "walkTarget": [ -6.025001525878906, 0.0, 12.775001525878906 ] - ], - "position": [ - -6.0, - 0.0, - 12.800000190734863 - ], - "requestedWalkTarget": [ - -6.0, - 0.0, - 12.800000190734863 - ], - "showWeapon": true, - "targetFacingAngle": 0.0, - "visibleRange": 6.0, - "walkTarget": [ - -6.025001525878906, - 0.0, - 12.775001525878906 - ] - } + } + }, + "questJournal": { + "quests": [] + }, + "savedAt": "2026-11-05 17:43", + "schemaVersion": 2, + "taxiIsCalled": false, + "tutorialJournalScreenOpened": false, + "tutorialNeedOpenTaxiScreen": false, + "tutorialPhoneChatScreenOpened": false, + "tutorialStep": 0, + "tutorialTaxiHintDone": false }, - "questJournal": { - "quests": [] - }, - "savedAt": "2026-10-30 14:50", - "schemaVersion": 1, - "taxiIsCalled": false, - "tutorialJournalScreenOpened": false, - "tutorialNeedOpenTaxiScreen": false, - "tutorialPhoneChatScreenOpened": false, - "tutorialStep": 0, - "tutorialTaxiHintDone": false + "persistenceVersion": 1 } \ No newline at end of file diff --git a/server/README.md b/server/README.md index 84c36a4..028240f 100644 --- a/server/README.md +++ b/server/README.md @@ -42,11 +42,11 @@ real-time delay. Start the first multiplayer transport iteration: ```powershell -.\server\build\Release\GameServer.exe --campaign battle_zone --location uni_exterior +.\server\build\Release\GameServer.exe --campaign battle_zone_multiplayer --location uni_exterior ``` The listener defaults to `ws://0.0.0.0:8080`. Launch the Windows graphical game -afterward and select `battle_zone`; that campaign connects to +afterward and select `battle_zone_multiplayer`; that campaign connects to `ws://127.0.0.1:8080`. Other campaigns continue to use the in-process server. The server broadcasts an initial full state and then one every five seconds. Client commands are intentionally discarded in this iteration. @@ -83,4 +83,4 @@ CTest covers: - repeated `LocationGameState::setup()` preserving mutable runtime state; - ticking a prepared location with zero players; - idle server startup; -- headless loading of `battle_zone/uni_exterior`. +- headless loading of `battle_zone_multiplayer/uni_exterior`. diff --git a/server/src/main.cpp b/server/src/main.cpp index 3dd3903..b6fff9a 100644 --- a/server/src/main.cpp +++ b/server/src/main.cpp @@ -3,6 +3,8 @@ #include "game_server/WebsocketGameServer.h" #endif #include "Campaign.h" +#include "LocationState.h" +#include "utils/Utils.h" #include #include #include @@ -88,7 +90,7 @@ int main(int argc, char** argv) } //Temporary override: - campaignId = "battle_zone"; + campaignId = "battle_zone_multiplayer"; locationId = "uni_exterior"; std::signal(SIGINT, requestStop); @@ -98,6 +100,7 @@ int main(int argc, char** argv) if (!campaignId.empty()) { try { FRG::CampaignRegistry::instance().loadFromJson(campaignsPath); + std::cout << "[server] Loading...\n" << std::flush; const FRG::CampaignConfig* campaign = FRG::CampaignRegistry::instance().find(campaignId); if (!campaign) @@ -113,7 +116,32 @@ int main(int argc, char** argv) "' has no location '" + selectedLocation + "'"); server.startCampaign(*campaign, std::string(), true, false); server.prepareLocation(*location); + const std::string startState = + FRG::readTextFile(campaign->startStatePath); + if (!startState.empty()) { + try { + nlohmann::json persisted = + nlohmann::json::parse(startState); + nlohmann::json networkState; + FRG::ClientLocationState ignoredLocalState; + FRG::loadSinglePlayerPersistence( + persisted, networkState, ignoredLocalState); + networkState["campaignId"] = campaignId; + networkState["currentLocationId"] = selectedLocation; + server.load(networkState); + std::cout << "Loaded initial state '" + << campaign->startStatePath << "'\n"; + } catch (const std::exception& error) { + std::cerr << "Ignoring invalid initial state '" + << campaign->startStatePath << "': " + << error.what() << '\n'; + } + } server.activateLocation(selectedLocation); + if (!campaign->tutorial) { + server.submit(FRG::SetTutorialStepCommand{ + FRG::TutorialStep::Completed}); + } std::cout << "Loaded campaign '" << campaignId << "', location '" << selectedLocation << "' with shared player '" << server.localPlayerId() << "'\n"; diff --git a/server/tests/LocationGameStateTests.cpp b/server/tests/LocationGameStateTests.cpp index ca3a670..348ed3a 100644 --- a/server/tests/LocationGameStateTests.cpp +++ b/server/tests/LocationGameStateTests.cpp @@ -16,10 +16,69 @@ int fail(const char* message) int main() { + FRG::ClientLocationState localState; + auto camera = localState.getOrCreate("test_location"); + camera->cameraAzimuth = 0.75f; + camera->cameraInclination = 1.25f; + nlohmann::json localJson; + localState.save(localJson); + FRG::ClientLocationState restoredLocalState; + restoredLocalState.load(localJson); + const auto restoredCamera = + restoredLocalState.getOrCreate("test_location"); + if (restoredCamera->cameraAzimuth != 0.75f || + restoredCamera->cameraInclination != 1.25f) { + return fail("Local camera state did not round trip"); + } + + FRG::LocationNetworkState networkLocationState; + nlohmann::json networkLocationJson; + networkLocationState.save(networkLocationJson); + if (networkLocationJson.contains("cameraAzimuth") || + networkLocationJson.contains("cameraInclination")) { + return fail("Network location state contains local camera fields"); + } + + const nlohmann::json authoritativeJson{ + {"schemaVersion", FRG::GameState::kSchemaVersion}, + {"campaignId", "test"} + }; + nlohmann::json persistenceJson; + FRG::saveSinglePlayerPersistence( + authoritativeJson, localState, persistenceJson); + nlohmann::json restoredAuthoritativeJson; + FRG::ClientLocationState restoredPersistenceLocalState; + FRG::loadSinglePlayerPersistence( + persistenceJson, restoredAuthoritativeJson, + restoredPersistenceLocalState); + if (restoredAuthoritativeJson != authoritativeJson || + restoredPersistenceLocalState.getOrCreate("test_location") + ->cameraAzimuth != 0.75f) { + return fail("Composed single-player state did not round trip"); + } + if (persistenceJson.at("networkState").contains("localState")) { + return fail("Local state leaked into authoritative state"); + } + const std::filesystem::path repositoryRoot = std::filesystem::path(__FILE__).parent_path().parent_path().parent_path(); std::filesystem::current_path(repositoryRoot); + FRG::CampaignRegistry::instance().loadFromJson( + "resources/config/campaigns.json"); + const FRG::CampaignConfig* singlePlayerBattleZone = + FRG::CampaignRegistry::instance().find("battle_zone"); + const FRG::CampaignConfig* multiplayerBattleZoneConfig = + FRG::CampaignRegistry::instance().find("battle_zone_multiplayer"); + if (FRG::CampaignRegistry::instance().all().size() != 3 || + !singlePlayerBattleZone || !multiplayerBattleZoneConfig) { + return fail("Expected three campaigns including both Battle Zone modes"); + } + if (singlePlayerBattleZone->startStatePath != + multiplayerBattleZoneConfig->startStatePath) { + return fail("Battle Zone modes do not share their initial state"); + } + FRG::LocationDefinition definition; definition.config.id = "test_location"; @@ -87,8 +146,12 @@ int main() } FRG::InternalGameServer networkServer; - networkServer.startCampaign(battleZone, {}, true, false); + FRG::CampaignConfig multiplayerBattleZone = battleZone; + multiplayerBattleZone.id = "battle_zone_multiplayer"; + networkServer.startCampaign(multiplayerBattleZone, {}, true, false); const auto networkSnapshot = networkServer.snapshot(); + if (networkSnapshot->campaignId != "battle_zone_multiplayer") + return fail("Network campaign ID was not retained"); if (networkSnapshot->players.count("player") != 1) return fail("Network campaign shared player was not created"); if (networkSnapshot->players.count("battle_zone_test_bot") != 0) diff --git a/server/tests/NetworkProtocolTests.cpp b/server/tests/NetworkProtocolTests.cpp index 3a0632c..8802f5c 100644 --- a/server/tests/NetworkProtocolTests.cpp +++ b/server/tests/NetworkProtocolTests.cpp @@ -1,4 +1,5 @@ #include "game_server/NetworkProtocol.h" +#include "LocationState.h" #include #include #include @@ -16,10 +17,14 @@ void require(bool condition, const char* message) int main() { + FRG::LocationNetworkState locationState; + nlohmann::json savedLocationState; + locationState.save(savedLocationState); const nlohmann::json original{ - {"schemaVersion", 1}, - {"campaignId", "battle_zone"}, - {"players", {{"player", {{"hp", 200.0}}}}} + {"schemaVersion", 2}, + {"campaignId", "battle_zone_multiplayer"}, + {"players", {{"player", {{"hp", 200.0}}}}}, + {"locations", {{"uni_exterior", {{"state", savedLocationState}}}}} }; const std::string message = FRG::makeStateMessage(original); nlohmann::json parsed; @@ -28,6 +33,10 @@ int main() FRG::parseStateMessage(message, parsed, error), "valid state envelope was rejected"); require(parsed == original, "state payload did not round trip"); + require( + !parsed["locations"]["uni_exterior"]["state"].contains("cameraAzimuth") && + !parsed["locations"]["uni_exterior"]["state"].contains("cameraInclination"), + "local camera state leaked into the network payload"); require( !FRG::parseStateMessage( diff --git a/src/Campaign.cpp b/src/Campaign.cpp index 5531160..811a564 100644 --- a/src/Campaign.cpp +++ b/src/Campaign.cpp @@ -3,7 +3,6 @@ #include "external/nlohmann/json.hpp" #include - namespace FRG { using json = nlohmann::json; diff --git a/src/CharacterState.cpp b/src/CharacterState.cpp index a32050b..2a05403 100644 --- a/src/CharacterState.cpp +++ b/src/CharacterState.cpp @@ -47,7 +47,7 @@ void CharacterState::stopInPlace() { void CharacterState::setTarget( const Eigen::Vector3f& target, std::string callbackName, - const LocationState& location, + const LocationNetworkState& location, const std::string& entityId) { const Eigen::Vector3f normalizedTarget(target.x(), 0.f, target.z()); @@ -79,7 +79,7 @@ void CharacterState::setTarget( } void CharacterState::forceReplan( - const LocationState& location, const std::string& entityId) + const LocationNetworkState& location, const std::string& entityId) { const Eigen::Vector3f normalizedTarget( requestedWalkTarget.x(), 0.f, requestedWalkTarget.z()); diff --git a/src/CharacterState.h b/src/CharacterState.h index b45ceed..7e2d367 100644 --- a/src/CharacterState.h +++ b/src/CharacterState.h @@ -16,7 +16,7 @@ namespace FRG { struct CharacterUpdateContext; struct CharacterUpdateEvents; -struct LocationState; +struct LocationNetworkState; enum class AnimationState { STAND = 0, @@ -142,9 +142,9 @@ public: void stopInPlace(); void setTarget(const Eigen::Vector3f& target, std::string callbackName, - const LocationState& location, + const LocationNetworkState& location, const std::string& entityId); - void forceReplan(const LocationState& location, + void forceReplan(const LocationNetworkState& location, const std::string& entityId); void stopAndRotateToTarget(std::string characterId); void resetPlayerAfterDeath(); diff --git a/src/Game.cpp b/src/Game.cpp index 0985ad6..fd9376c 100644 --- a/src/Game.cpp +++ b/src/Game.cpp @@ -552,7 +552,7 @@ namespace FRG CampaignRegistry::instance().find(campaignId); if (!campaign) return; #if defined(NETWORK) - remoteCampaignActive = campaignId == "battle_zone"; + remoteCampaignActive = campaignId == "battle_zone_multiplayer"; if (remoteCampaignActive) { websocketGameClient = std::make_unique(); remoteGameServerHost = @@ -644,12 +644,7 @@ namespace FRG }); loc->stopCharacter(*loc->player); } - auto locationState = loc->stateSnapshot(); - const float inclination = locationState - ? locationState->cameraInclination - : 1.1036f; - server().submit(SetLocationCameraCommand{ - destName, destRotY, inclination}); + loc->localState().cameraAzimuth = destRotY; loc->scriptEngine.callLocationEnterCallback(); //Reset taxi @@ -1163,6 +1158,17 @@ namespace FRG // The authoritative implementation advances simulation; a future // network client can use the same call to poll/apply server updates. gameServerHost.update(delta); +#if defined(NETWORK) + if (remoteCampaignActive && websocketGameClient && + websocketGameClient->connectionFailed()) { + logger() << "[network] Connection failed; returning to campaign selection" + << std::endl; + unloadCampaign(); + menuManager.showMainMenu(); + menuManager.showCampaignScreen(); + return; + } +#endif processServerEvents(); } @@ -1524,11 +1530,9 @@ namespace FRG currentLocation()->lastMouseY = eventY; // Snapshot current angles so we can measure how far the user rotates. - auto state = currentLocation()->stateSnapshot(); - if (state) { - dragStartAzimuth = state->cameraAzimuth; - dragStartInclination = state->cameraInclination; - } + const LocationLocalState& state = currentLocation()->localState(); + dragStartAzimuth = state.cameraAzimuth; + dragStartInclination = state.cameraInclination; } } @@ -1736,10 +1740,9 @@ namespace FRG if (cameraDragging && game && game->tutorialStep == TutorialStep::RotateCamera) { - auto state = currentLocation()->stateSnapshot(); - if (!state) return; - float deltaAz = std::abs(state->cameraAzimuth - dragStartAzimuth); - float deltaInc = std::abs(state->cameraInclination - dragStartInclination); + const LocationLocalState& state = currentLocation()->localState(); + float deltaAz = std::abs(state.cameraAzimuth - dragStartAzimuth); + float deltaInc = std::abs(state.cameraInclination - dragStartInclination); if (deltaAz >= TUTORIAL_ROTATION_THRESHOLD && deltaInc >= TUTORIAL_ROTATION_THRESHOLD) { server().submit( CompleteTutorialStepCommand{TutorialStep::RotateCamera}); @@ -1894,12 +1897,18 @@ namespace FRG if (content.empty()) return {}; try { nlohmann::json root = nlohmann::json::parse(content); - if (root.value("schemaVersion", -1) != GameState::kSchemaVersion) return {}; + if (root.value("persistenceVersion", -1) != + kSinglePlayerPersistenceVersion) return {}; + const auto& networkState = root.at("networkState"); + if (networkState.value("schemaVersion", -1) != + GameState::kSchemaVersion) return {}; SaveSlotInfo info; info.empty = false; - info.campaignId = root.at("campaignId").get(); - info.locationName = root.at("currentLocationId").get(); - info.savedAt = root.value("savedAt", ""); + info.campaignId = + networkState.at("campaignId").get(); + info.locationName = + networkState.at("currentLocationId").get(); + info.savedAt = networkState.value("savedAt", ""); return info; } catch (...) { return {}; @@ -1937,12 +1946,19 @@ namespace FRG return; } - if (root.value("schemaVersion", -1) != GameState::kSchemaVersion) { + if (root.value("persistenceVersion", -1) != + kSinglePlayerPersistenceVersion || + !root.contains("networkState") || + root["networkState"].value("schemaVersion", -1) != + GameState::kSchemaVersion) { std::cerr << "[save] Unsupported save schema in " << path << std::endl; return; } - const std::string campaignId = root.value("campaignId", std::string()); - const std::string locationId = root.value("currentLocationId", std::string()); + const auto& networkState = root["networkState"]; + const std::string campaignId = + networkState.value("campaignId", std::string()); + const std::string locationId = + networkState.value("currentLocationId", std::string()); const CampaignConfig* campaign = CampaignRegistry::instance().find(campaignId); if (!campaign || !campaign->findLocation(locationId)) { std::cerr << "[save] Unknown campaign/location in " << path << std::endl; diff --git a/src/GameState.h b/src/GameState.h index d874866..fd7eadd 100644 --- a/src/GameState.h +++ b/src/GameState.h @@ -33,7 +33,7 @@ struct StoredChatMessage { }; struct GameState : public ISaveable { - static constexpr int kSchemaVersion = 1; + static constexpr int kSchemaVersion = 2; // --- World --- // Campaign being played (see CampaignRegistry). diff --git a/src/Location.cpp b/src/Location.cpp index a25ad6d..eb9f01c 100644 --- a/src/Location.cpp +++ b/src/Location.cpp @@ -1,4 +1,4 @@ -#include "Location.h" +#include "Location.h" #include "utils/Utils.h" #include "render/OpenGlExtensions.h" #include @@ -62,13 +62,13 @@ namespace FRG { } - std::shared_ptr Location::stateSnapshot() const + std::shared_ptr Location::stateSnapshot() const { auto snapshot = gameServer_.snapshot(); if (!snapshot) return {}; auto location = snapshot->locations.find(locationId_); if (location == snapshot->locations.end()) return {}; - return std::shared_ptr( + return std::shared_ptr( std::move(snapshot), &location->second.state); } @@ -307,7 +307,7 @@ namespace FRG // ---- Pending-target lookup ---- - TeleportZone* Location::getTargetTeleportZone(const LocationState& state) + TeleportZone* Location::getTargetTeleportZone(const LocationNetworkState& state) { if (state.targetTeleportZoneId.empty()) return nullptr; for (auto& zone : teleportZones) @@ -321,6 +321,8 @@ namespace FRG { if (!params.gameState) throw std::runtime_error("Location requires authoritative state binding"); if (!params.definition) throw std::runtime_error("Location requires prepared content definition"); + if (!params.localState) throw std::runtime_error("Location requires local state binding"); + localState_ = params.localState; const LocationDefinition& definition = *params.definition; this->audioPlayer = audioPlayer; pathVisualization.initialize(); @@ -1006,8 +1008,8 @@ namespace FRG renderer.TranslateMatrix({ 0,0, -1.0f * Environment::zoom }); //renderer.TranslateMatrix({ 0, -6.f, 0 }); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix()); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix()); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); const Eigen::Vector3f camTarget = playerState ? playerState->position : Eigen::Vector3f::Zero(); renderer.TranslateMatrix({ -camTarget.x(), -camTarget.y(), -camTarget.z() }); @@ -1200,8 +1202,8 @@ namespace FRG renderer.LoadIdentity(); renderer.TranslateMatrix({ 0,0, -1.0f * Environment::zoom }); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix()); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix()); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); auto playerState = player ? player->stateSnapshot() : nullptr; const Eigen::Vector3f camTarget = playerState ? playerState->position : Eigen::Vector3f::Zero(); @@ -1340,8 +1342,8 @@ namespace FRG renderer.LoadIdentity(); renderer.TranslateMatrix({ 0, 0, -1.0f * Environment::zoom }); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix()); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix()); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); const Eigen::Vector3f camTarget = playerState ? playerState->position : Eigen::Vector3f::Zero(); renderer.TranslateMatrix({ -camTarget.x(), -camTarget.y(), -camTarget.z() }); @@ -1586,8 +1588,8 @@ namespace FRG renderer.LoadIdentity(); renderer.TranslateMatrix({ 0, 0, -1.0f * Environment::zoom }); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix()); - renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(locationState->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix()); + renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix()); const Eigen::Vector3f camTarget = playerState ? playerState->position : Eigen::Vector3f::Zero(); renderer.TranslateMatrix({ -camTarget.x(), -camTarget.y(), -camTarget.z() }); @@ -1963,8 +1965,8 @@ namespace FRG float aspect = (float)Environment::width / (float)Environment::height; float tanHalfFov = tan(CAMERA_FOV_Y * 0.5f); - float cosAzim = cos(locationState->cameraAzimuth), sinAzim = sin(locationState->cameraAzimuth); - float cosIncl = cos(locationState->cameraInclination), sinIncl = sin(locationState->cameraInclination); + float cosAzim = cos(localState_->cameraAzimuth), sinAzim = sin(localState_->cameraAzimuth); + float cosIncl = cos(localState_->cameraInclination), sinIncl = sin(localState_->cameraInclination); Eigen::Vector3f camRight(cosAzim, 0.f, sinAzim); Eigen::Vector3f camForward(sinAzim * cosIncl, -sinIncl, -cosAzim * cosIncl); @@ -2168,16 +2170,14 @@ namespace FRG lastMouseY = eventY; const float sensitivity = 0.005f; - auto state = stateSnapshot(); - if (!state) return; - const float azimuth = state->cameraAzimuth + dx * sensitivity; - float inclination = state->cameraInclination + dy * sensitivity; + localState_->cameraAzimuth += dx * sensitivity; + float inclination = + localState_->cameraInclination + dy * sensitivity; const float minInclination = M_PI * 30.f / 180.f; const float maxInclination = M_PI * 0.5f; - inclination = max(minInclination, min(maxInclination, inclination)); - gameServer_.submit(SetLocationCameraCommand{ - locationId_, azimuth, inclination}); + localState_->cameraInclination = + max(minInclination, min(maxInclination, inclination)); } } diff --git a/src/Location.h b/src/Location.h index dfae730..2dbfe7f 100644 --- a/src/Location.h +++ b/src/Location.h @@ -1,4 +1,4 @@ -#pragma once +#pragma once #include "render/Renderer.h" #include "Environment.h" @@ -48,7 +48,7 @@ namespace FRG float radius = 1.5f; float hysteresis = 0.3f; // exit fires at radius + hysteresis to prevent flickering bool enabled = true; - bool playerInside = false; // runtime tracking state, not serialized (mirrored in LocationState) + bool playerInside = false; // runtime tracking state, not serialized (mirrored in LocationNetworkState) }; struct LocationSetup @@ -68,6 +68,7 @@ namespace FRG // setup() creates a private one when this is null. std::shared_ptr player; LocationGameState* gameState = nullptr; + std::shared_ptr localState; }; class Location @@ -78,7 +79,9 @@ namespace FRG // Detaches the (shared, longer-lived) player from this location. ~Location(); const std::string& locationId() const { return locationId_; } - std::shared_ptr stateSnapshot() const; + std::shared_ptr stateSnapshot() const; + LocationLocalState& localState() { return *localState_; } + const LocationLocalState& localState() const { return *localState_; } bool isDarklands() const; bool isNight() const; bool isDawn() const; @@ -234,13 +237,14 @@ namespace FRG private: IGameServer& gameServer_; std::string locationId_; + std::shared_ptr localState_; std::unordered_map> remotePlayers_; void synchronizeRemotePlayers(); void drawRemotePlayerNames( const Eigen::Matrix4f& cameraViewMatrix, const Eigen::Matrix4f& projectionMatrix); // ---- Pointer-to-index helpers ---- - TeleportZone* getTargetTeleportZone(const LocationState& state); + TeleportZone* getTargetTeleportZone(const LocationNetworkState& state); enum class InstancedTexture { None, Color, Darklands }; diff --git a/src/LocationGameState.cpp b/src/LocationGameState.cpp index 35a8156..6bde6bd 100644 --- a/src/LocationGameState.cpp +++ b/src/LocationGameState.cpp @@ -14,7 +14,7 @@ bool LocationGameState::setup( LocationDefinition content, const std::string& zipFile) { const bool initializeRuntimeFields = !hasPersistentState; - if (!location) location = std::make_shared(); + if (!location) location = std::make_shared(); if (location->navigationMaps.empty()) location->initNavigation(content.config.navigationJsonPaths, zipFile); lockInteractiveObjectsDuringTutorial = @@ -377,7 +377,7 @@ void LocationGameState::save(nlohmann::json& out) const void LocationGameState::load(const nlohmann::json& in) { hasPersistentState = true; - if (!location) location = std::make_shared(); + if (!location) location = std::make_shared(); location->load(in.at("state")); const auto& npcValues = in.at("npcs"); diff --git a/src/LocationGameState.h b/src/LocationGameState.h index e64e54b..3a10a4d 100644 --- a/src/LocationGameState.h +++ b/src/LocationGameState.h @@ -49,7 +49,7 @@ struct LocationSimulationEvents { // simulation objects stable addresses; runtime renderers and Location pointers // are deliberately absent, and public clients receive value-only snapshots. struct LocationGameState { - std::shared_ptr location = std::make_shared(); + std::shared_ptr location = std::make_shared(); std::unordered_map> npcs; std::unordered_map> interactiveObjects; bool lockInteractiveObjectsDuringTutorial = false; diff --git a/src/LocationState.cpp b/src/LocationState.cpp index dda79a7..a337f34 100644 --- a/src/LocationState.cpp +++ b/src/LocationState.cpp @@ -1,7 +1,85 @@ #include "LocationState.h" +#include namespace FRG { +std::shared_ptr ClientLocationState::getOrCreate( + const std::string& locationId) +{ + auto& state = locations[locationId]; + if (!state) state = std::make_shared(); + return state; +} + +void ClientLocationState::clear() +{ + locations.clear(); +} + +void LocationLocalState::save(nlohmann::json& out) const +{ + out = { + {"cameraAzimuth", cameraAzimuth}, + {"cameraInclination", cameraInclination} + }; +} + +void LocationLocalState::load(const nlohmann::json& in) +{ + cameraAzimuth = in.at("cameraAzimuth").get(); + cameraInclination = in.at("cameraInclination").get(); +} + +void ClientLocationState::save(nlohmann::json& out) const +{ + out["locations"] = nlohmann::json::object(); + for (const auto& [id, state] : locations) { + if (!state) continue; + nlohmann::json value; + state->save(value); + out["locations"][id] = std::move(value); + } +} + +void ClientLocationState::load(const nlohmann::json& in) +{ + const auto& values = in.at("locations"); + if (!values.is_object()) + throw std::runtime_error("Client location state locations must be an object"); + for (auto& [id, state] : locations) + if (state) *state = LocationLocalState{}; + for (const auto& [id, value] : values.items()) + getOrCreate(id)->load(value); +} + +void saveSinglePlayerPersistence( + const nlohmann::json& networkState, + const ClientLocationState& localState, + nlohmann::json& out) +{ + nlohmann::json savedLocalState; + localState.save(savedLocalState); + out = { + {"persistenceVersion", kSinglePlayerPersistenceVersion}, + {"networkState", networkState}, + {"localState", std::move(savedLocalState)} + }; +} + +void loadSinglePlayerPersistence( + const nlohmann::json& in, + nlohmann::json& networkState, + ClientLocationState& localState) +{ + const int version = in.value("persistenceVersion", -1); + if (version != kSinglePlayerPersistenceVersion) + throw std::runtime_error( + "Unsupported single-player persistence version: " + + std::to_string(version)); + networkState = in.at("networkState"); + localState.load(in.at("localState")); +} + namespace { nlohmann::json saveCar(const NpcCar& car) { @@ -44,12 +122,12 @@ namespace FRG { } - std::vector LocationState::planPathExceptMyself(const Eigen::Vector3f& start, const Eigen::Vector3f& end, const std::string& entityId) const + std::vector LocationNetworkState::planPathExceptMyself(const Eigen::Vector3f& start, const Eigen::Vector3f& end, const std::string& entityId) const { return navigationMaps[activeNavigationIndex].findPathToNearest(start, end, {}); } - void LocationState::initNavigation( + void LocationNetworkState::initNavigation( const std::vector& paths, const std::string& zipFile) { const int savedActiveNavigationIndex = activeNavigationIndex; @@ -68,11 +146,8 @@ namespace FRG { : 0; } -void LocationState::save(nlohmann::json& out) const +void LocationNetworkState::save(nlohmann::json& out) const { - out["cameraAzimuth"] = cameraAzimuth; - out["cameraInclination"] = cameraInclination; - out["activeNavigationIndex"] = activeNavigationIndex; out["targetInteractiveObjectId"] = targetInteractiveObjectId; @@ -85,13 +160,8 @@ void LocationState::save(nlohmann::json& out) const }; } -void LocationState::load(const nlohmann::json& in) +void LocationNetworkState::load(const nlohmann::json& in) { - //logger() << "LocationState::load step 1" << std::endl; - - cameraAzimuth = in.value("cameraAzimuth", cameraAzimuth); - cameraInclination = in.value("cameraInclination", cameraInclination); - activeNavigationIndex = in.value("activeNavigationIndex", activeNavigationIndex); targetInteractiveObjectId = in.value("targetInteractiveObjectId", std::string()); diff --git a/src/LocationState.h b/src/LocationState.h index fb529af..a5e573b 100644 --- a/src/LocationState.h +++ b/src/LocationState.h @@ -3,11 +3,41 @@ #include "ISaveable.h" #include "NpcCar.h" #include "navigation/PathFinder.h" +#include #include +#include namespace FRG { -struct LocationState : public ISaveable { +inline constexpr int kSinglePlayerPersistenceVersion = 1; + +struct LocationLocalState : public ISaveable { + float cameraAzimuth = -2.35f; + float cameraInclination = 1.1036f; + + void save(nlohmann::json& out) const override; + void load(const nlohmann::json& in) override; +}; + +struct ClientLocationState : public ISaveable { + std::unordered_map> locations; + + std::shared_ptr getOrCreate(const std::string& locationId); + void clear(); + void save(nlohmann::json& out) const override; + void load(const nlohmann::json& in) override; +}; + +void saveSinglePlayerPersistence( + const nlohmann::json& networkState, + const ClientLocationState& localState, + nlohmann::json& out); +void loadSinglePlayerPersistence( + const nlohmann::json& in, + nlohmann::json& networkState, + ClientLocationState& localState); + +struct LocationNetworkState : public ISaveable { //This value is populated from the JSON configs and should not be changed during the regular gameplay. //It might be changed only in the editor mode during development. std::vector navigationMaps; @@ -16,10 +46,6 @@ struct LocationState : public ISaveable { const std::vector& paths, const std::string& zipFile = std::string()); - // ---- Camera ---- - float cameraAzimuth = -2.35f; - float cameraInclination = 1.1036f; - // ---- Navigation ---- int activeNavigationIndex = 0; diff --git a/src/MenuManager.h b/src/MenuManager.h index 8d9b69b..f5bb8ff 100644 --- a/src/MenuManager.h +++ b/src/MenuManager.h @@ -57,6 +57,8 @@ namespace FRG { void showMainMenu(); void showMenuAfterGameCompleted(); + // Returns to the main menu with the campaign list already open. + void showCampaignScreen(); bool isMainMenuOpen() const { return uiState_ == GameUiState::MainMenu; } void openPhoneScreen(); @@ -223,8 +225,6 @@ namespace FRG { void showLanguageScreen(); void showLoadGameScreen(); void showSaveGameScreen(); - // Campaign list opened by "Start game". - void showCampaignScreen(); // Text of a load/save slot button. std::string slotLabel(const SaveSlotInfo& info); diff --git a/src/game_server/IGameServer.h b/src/game_server/IGameServer.h index b32438a..17da003 100644 --- a/src/game_server/IGameServer.h +++ b/src/game_server/IGameServer.h @@ -30,7 +30,7 @@ struct InteractiveObjectSnapshot { struct LocationSnapshot { std::string id; - LocationState state; + LocationNetworkState state; std::unordered_map npcs; std::unordered_map interactiveObjects; std::unordered_map triggerZones; @@ -95,11 +95,6 @@ struct AppendChatMessageCommand { struct SetTaxiCalledCommand { bool called; }; struct StartTaxiCommand { std::string locationId; }; struct ResetTaxiCommand { std::string locationId; }; -struct SetLocationCameraCommand { - std::string locationId; - float azimuth; - float inclination; -}; struct SetLocationTargetsCommand { std::string locationId; std::string interactiveObjectId; @@ -232,7 +227,6 @@ using GameCommand = std::variant< SetTaxiCalledCommand, StartTaxiCommand, ResetTaxiCommand, - SetLocationCameraCommand, SetLocationTargetsCommand, SwitchLocationNavigationCommand, SetNavigationAreaAvailableCommand, diff --git a/src/game_server/InternalGameServer.cpp b/src/game_server/InternalGameServer.cpp index 4e7b60e..2603a89 100644 --- a/src/game_server/InternalGameServer.cpp +++ b/src/game_server/InternalGameServer.cpp @@ -305,12 +305,6 @@ void InternalGameServer::submit(const GameCommand& command) auto location = state_.locations.find(value.locationId); if (location != state_.locations.end()) location->second.resetTaxi(); - } else if constexpr (std::is_same_v) { - auto location = state_.locations.find(value.locationId); - if (location != state_.locations.end() && location->second.location) { - location->second.location->cameraAzimuth = value.azimuth; - location->second.location->cameraInclination = value.inclination; - } } else if constexpr (std::is_same_v) { auto location = state_.locations.find(value.locationId); if (location != state_.locations.end() && location->second.location) { @@ -326,7 +320,7 @@ void InternalGameServer::submit(const GameCommand& command) value.index >= 0 && value.index < static_cast( location->second.location->navigationMaps.size())) { - LocationState& locationState = *location->second.location; + LocationNetworkState& locationState = *location->second.location; locationState.activeNavigationIndex = value.index; for (const auto& [id, player] : state_.players) if (player) player->forceReplan(locationState, id); @@ -336,7 +330,7 @@ void InternalGameServer::submit(const GameCommand& command) } else if constexpr (std::is_same_v) { auto location = state_.locations.find(value.locationId); if (location != state_.locations.end() && location->second.location) { - LocationState& locationState = *location->second.location; + LocationNetworkState& locationState = *location->second.location; const int index = locationState.activeNavigationIndex; if (index >= 0 && index < static_cast(locationState.navigationMaps.size())) { @@ -347,7 +341,7 @@ void InternalGameServer::submit(const GameCommand& command) } else if constexpr (std::is_same_v) { auto location = state_.locations.find(value.locationId); if (location != state_.locations.end() && location->second.location) { - LocationState& locationState = *location->second.location; + LocationNetworkState& locationState = *location->second.location; const int index = locationState.activeNavigationIndex; if (index >= 0 && index < static_cast(locationState.navigationMaps.size())) { diff --git a/src/game_server/LocalGameServerHost.cpp b/src/game_server/LocalGameServerHost.cpp index 11220f9..bbef8b7 100644 --- a/src/game_server/LocalGameServerHost.cpp +++ b/src/game_server/LocalGameServerHost.cpp @@ -15,18 +15,21 @@ void LocalGameServerHost::initializeCampaign( const std::string& campaignId, const std::string& questsPath, const std::string& zipFile) { + localState_.clear(); server_.initializeCampaign(campaignId, questsPath, zipFile); } void LocalGameServerHost::clearCampaign() { runtimeLocations_.clear(); + localState_.clear(); server_.clearCampaign(); } void LocalGameServerHost::resetCampaign( const std::string& questsPath, const std::string& zipFile) { + localState_.clear(); server_.resetCampaign(questsPath, zipFile); } @@ -44,6 +47,7 @@ void LocalGameServerHost::setupRuntimeLocation( throw std::runtime_error("Location state was not prepared"); params.definition = stored->second.definition; params.gameState = &stored->second; + params.localState = localState_.getOrCreate(id); location->setup(params, audioPlayer); server_.publishMutation(); } @@ -122,12 +126,20 @@ void LocalGameServerHost::update(std::int64_t deltaMs) void LocalGameServerHost::save(nlohmann::json& out) { synchronizeRuntimeAuxiliaryState(); - server_.save(out); + nlohmann::json networkState; + server_.save(networkState); + saveSinglePlayerPersistence(networkState, localState_, out); } void LocalGameServerHost::load(const nlohmann::json& in) { - server_.load(in); + nlohmann::json networkState; + ClientLocationState loadedLocalState; + loadSinglePlayerPersistence(in, networkState, loadedLocalState); + server_.load(networkState); + nlohmann::json localStateJson; + loadedLocalState.save(localStateJson); + localState_.load(localStateJson); applyStateToRuntime(); } diff --git a/src/game_server/LocalGameServerHost.h b/src/game_server/LocalGameServerHost.h index 7e5a5f7..d814e52 100644 --- a/src/game_server/LocalGameServerHost.h +++ b/src/game_server/LocalGameServerHost.h @@ -1,6 +1,7 @@ #pragma once #include "IGameServerHost.h" +#include "../LocationState.h" #include namespace FRG { @@ -39,6 +40,7 @@ private: void applyStateToRuntime(); InternalGameServer& server_; + ClientLocationState localState_; std::unordered_map> runtimeLocations_; }; diff --git a/src/game_server/NetworkProtocol.cpp b/src/game_server/NetworkProtocol.cpp index 1440dee..82728ac 100644 --- a/src/game_server/NetworkProtocol.cpp +++ b/src/game_server/NetworkProtocol.cpp @@ -1,6 +1,7 @@ #include "NetworkProtocol.h" #include + namespace FRG { std::string makeStateMessage(const nlohmann::json& state) diff --git a/src/game_server/RemoteGameServerHost.cpp b/src/game_server/RemoteGameServerHost.cpp index c8d43a4..7bc251f 100644 --- a/src/game_server/RemoteGameServerHost.cpp +++ b/src/game_server/RemoteGameServerHost.cpp @@ -15,6 +15,7 @@ void RemoteGameServerHost::initializeCampaign( const std::string& campaignId, const std::string&, const std::string& zipFile) { + localState_.clear(); const CampaignConfig* campaign = CampaignRegistry::instance().find(campaignId); if (!campaign) @@ -25,6 +26,7 @@ void RemoteGameServerHost::initializeCampaign( void RemoteGameServerHost::clearCampaign() { runtimeLocations_.clear(); + localState_.clear(); client_.replica_.clearCampaign(); } @@ -48,6 +50,7 @@ void RemoteGameServerHost::setupRuntimeLocation( throw std::runtime_error("Remote location state was not prepared"); params.definition = stored->second.definition; params.gameState = &stored->second; + params.localState = localState_.getOrCreate(id); location->setup(params, audioPlayer); } @@ -101,9 +104,16 @@ void RemoteGameServerHost::save(nlohmann::json&) // Persistence remains server-side. } -void RemoteGameServerHost::load(const nlohmann::json&) +void RemoteGameServerHost::load(const nlohmann::json& in) { - // Persistence remains server-side. + // The authoritative half is loaded by the standalone server. Each client + // restores only its local presentation state from the shared start state. + nlohmann::json ignoredNetworkState; + ClientLocationState loadedLocalState; + loadSinglePlayerPersistence(in, ignoredNetworkState, loadedLocalState); + nlohmann::json localStateJson; + loadedLocalState.save(localStateJson); + localState_.load(localStateJson); } } // namespace FRG diff --git a/src/game_server/RemoteGameServerHost.h b/src/game_server/RemoteGameServerHost.h index 5b97c0b..60e2928 100644 --- a/src/game_server/RemoteGameServerHost.h +++ b/src/game_server/RemoteGameServerHost.h @@ -1,6 +1,7 @@ #pragma once #include "IGameServerHost.h" +#include "../LocationState.h" #include namespace FRG { @@ -33,6 +34,7 @@ public: private: WebsocketGameClient& client_; + ClientLocationState localState_; std::unordered_map> runtimeLocations_; }; diff --git a/src/game_server/WebsocketGameClient.cpp b/src/game_server/WebsocketGameClient.cpp index 863c2cd..9732b05 100644 --- a/src/game_server/WebsocketGameClient.cpp +++ b/src/game_server/WebsocketGameClient.cpp @@ -47,6 +47,7 @@ public: } bool connected() const { return connected_.load(); } + bool connectionFailed() const { return connectionFailed_.load(); } private: void resolve() @@ -101,6 +102,7 @@ private: void fail(const char* operation, beast::error_code error) { connected_.store(false); + connectionFailed_.store(true); std::cerr << "[network] WebSocket " << operation << " failed: " << error.message() << '\n'; } @@ -114,6 +116,7 @@ private: std::string port_; std::string path_; std::atomic_bool connected_{false}; + std::atomic_bool connectionFailed_{false}; std::mutex messagesMutex_; std::vector messages_; }; @@ -186,4 +189,9 @@ bool WebsocketGameClient::connected() const return impl_->connected(); } +bool WebsocketGameClient::connectionFailed() const +{ + return impl_->connectionFailed(); +} + } // namespace FRG diff --git a/src/game_server/WebsocketGameClient.h b/src/game_server/WebsocketGameClient.h index 95bf9e0..c656e95 100644 --- a/src/game_server/WebsocketGameClient.h +++ b/src/game_server/WebsocketGameClient.h @@ -29,6 +29,7 @@ public: void load(const nlohmann::json& in) override; bool connected() const; + bool connectionFailed() const; private: friend class RemoteGameServerHost;