From 618d5759bf44268cc9262af59e204117829e53a7 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 17:20:37 +0100 Subject: [PATCH 01/47] Move level restoration into the owning game instance --- .../java/jake2/server/GameImportsImpl.java | 16 +++++++++++++ .../src/main/java/jake2/server/SV_CCMDS.java | 23 ------------------- .../src/main/java/jake2/server/SV_GAME.java | 2 +- 3 files changed, 17 insertions(+), 24 deletions(-) diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index 788d1330..0d759071 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -643,6 +643,22 @@ void SV_BroadcastCommand(String s) { } } + /** Restore this game's configstrings, portals and gameplay state from the current save. */ + void readLevelFile() { + Com.DPrintf("SV_ReadLevelFile()\n"); + + try { + ServerLevelJsonStore store = ServerLevelJsonStore.forWriteDir(EngineWriteRoot.pathString()); + store.applyLevelState(store.readLevelState("current", sv.name), sv.configstrings, cm); + } catch (Exception e) { + Com.Printf("Failed to open current level state for " + sv.name + "\n"); + e.printStackTrace(); + } + + String name = EngineWriteRoot.resolve("save/current/" + sv.name + ".sav.json").toString(); + gameExports.ReadLevel(name); + } + void SV_WriteLevelFile() { Com.DPrintf("SV_WriteLevelFile()\n"); diff --git a/server/src/main/java/jake2/server/SV_CCMDS.java b/server/src/main/java/jake2/server/SV_CCMDS.java index c77e1506..ad95a177 100644 --- a/server/src/main/java/jake2/server/SV_CCMDS.java +++ b/server/src/main/java/jake2/server/SV_CCMDS.java @@ -27,7 +27,6 @@ of the License, or (at your option) any later version. import jake2.qcommon.Defines; import jake2.qcommon.sys.Sys; import jake2.qcommon.vfs.EngineWriteRoot; -import jake2.server.save.ServerLevelJsonStore; import jake2.server.save.ServerSaveJsonStore; import java.io.File; @@ -205,28 +204,6 @@ static void SV_CopySaveGame(String src, String dst) { Sys.FindClose(); } - /** - * SV_ReadLevelFile - * Read: config strings, portal state, read level_locals and entities - */ - static void SV_ReadLevelFile(String saveName, GameImportsImpl gameImports) { - - Com.DPrintf("SV_ReadLevelFile()\n"); - - try { - ServerLevelJsonStore store = ServerLevelJsonStore.forWriteDir(EngineWriteRoot.pathString()); - store.applyLevelState(store.readLevelState("current", saveName), gameImports.sv.configstrings, gameImports.cm); - } - catch (Exception e1) { - Com.Printf("Failed to open current level state for " + saveName + "\n"); - e1.printStackTrace(); - } - - String name = EngineWriteRoot.resolve("save/current/" + saveName + ".sav.json").toString(); - gameImports.gameExports.ReadLevel(name); - } - - //========================================================= /** Print the memory used by the java vm. */ diff --git a/server/src/main/java/jake2/server/SV_GAME.java b/server/src/main/java/jake2/server/SV_GAME.java index df9f92e2..9eeea72b 100644 --- a/server/src/main/java/jake2/server/SV_GAME.java +++ b/server/src/main/java/jake2/server/SV_GAME.java @@ -266,7 +266,7 @@ void restorePreviousGame(server_t sv) { // get configstrings and areaportals // then read game enitites - SV_CCMDS.SV_ReadLevelFile(sv.name, gameImports); + gameImports.readLevelFile(); if (!sv.loadgame) { // coming back to a level after being in a different From 0296074555493b7cea6ea25f10febb2e11245612 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 17:20:58 +0100 Subject: [PATCH 02/47] Keep JVM diagnostics with host command registration --- server/src/main/java/jake2/server/SV_CCMDS.java | 13 ------------- server/src/main/java/jake2/server/SV_MAIN.java | 5 ++++- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/server/src/main/java/jake2/server/SV_CCMDS.java b/server/src/main/java/jake2/server/SV_CCMDS.java index ad95a177..fa6cb6b1 100644 --- a/server/src/main/java/jake2/server/SV_CCMDS.java +++ b/server/src/main/java/jake2/server/SV_CCMDS.java @@ -24,7 +24,6 @@ of the License, or (at your option) any later version. package jake2.server; import jake2.qcommon.Com; -import jake2.qcommon.Defines; import jake2.qcommon.sys.Sys; import jake2.qcommon.vfs.EngineWriteRoot; import jake2.server.save.ServerSaveJsonStore; @@ -33,7 +32,6 @@ of the License, or (at your option) any later version. import java.io.IOException; import java.io.RandomAccessFile; import java.nio.file.Path; -import java.util.List; public class SV_CCMDS { /* @@ -204,15 +202,4 @@ static void SV_CopySaveGame(String src, String dst) { Sys.FindClose(); } - //========================================================= - - /** Print the memory used by the java vm. */ - static void VM_Mem_f(List args) - { - Com.Printf("vm memory:" + - (Runtime.getRuntime().totalMemory() - - Runtime.getRuntime().freeMemory()) + "\n" ); - } - - } diff --git a/server/src/main/java/jake2/server/SV_MAIN.java b/server/src/main/java/jake2/server/SV_MAIN.java index 6c78779d..a64fb9a7 100644 --- a/server/src/main/java/jake2/server/SV_MAIN.java +++ b/server/src/main/java/jake2/server/SV_MAIN.java @@ -1085,7 +1085,10 @@ public SV_MAIN() { Com.Printf(PRINT_ALL, line.trim() + "\n"); } }); - commands.add("jvm_memory", SV_CCMDS::VM_Mem_f); + commands.add("jvm_memory", args -> { + Runtime runtime = Runtime.getRuntime(); + Com.Printf("vm memory:" + (runtime.totalMemory() - runtime.freeMemory()) + "\n"); + }); commands.add("games", args -> { for (GameImportsImpl game : games.values()) { Com.Printf(game.name + " - " + game.mapcmd + "\n"); From 4eb84e7e4951c1692a88aff9fd8666099b1658b1 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 17:26:39 +0100 Subject: [PATCH 03/47] Scope save file operations to an explicit root and remove SV_CCMDS --- .../jake2/server/EmbeddedCakeHostTest.kt | 10 +- .../java/jake2/server/GameImportsImpl.java | 4 +- .../src/main/java/jake2/server/SV_CCMDS.java | 205 ------------------ .../src/main/java/jake2/server/SV_MAIN.java | 11 +- .../jake2/server/save/ServerSaveFiles.java | 105 +++++++++ .../server/save/ServerSaveFilesTest.java | 97 +++++++++ 6 files changed, 215 insertions(+), 217 deletions(-) delete mode 100644 server/src/main/java/jake2/server/SV_CCMDS.java create mode 100644 server/src/main/java/jake2/server/save/ServerSaveFiles.java create mode 100644 server/src/test/java/jake2/server/save/ServerSaveFilesTest.java diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt index b53e418f..d05c0e11 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt @@ -11,6 +11,7 @@ import jake2.qcommon.network.Netchan import jake2.qcommon.network.messages.server.DownloadMessage import jake2.qcommon.network.messages.server.ServerMessage import jake2.qcommon.vfs.EngineWriteRoot +import jake2.server.save.ServerSaveFiles import org.demoth.cake.download.CakeDownloadCache import org.demoth.cake.download.CakeDownloadManager import org.demoth.cake.download.CakeDownloadSession @@ -114,6 +115,7 @@ class EmbeddedCakeHostTest { assertEquals(HostStartResult.Started, host.start(request())) val current = temp.resolve("session/save/current/server_mapcmd.ssv.json") val snapshot = Files.readString(current) + val saveFiles = ServerSaveFiles(temp.resolve("session")) val sentinel = temp.resolve("sentinel/server_mapcmd.ssv.json") Files.createDirectories(sentinel.parent) Files.writeString(sentinel, "outside profile") @@ -121,13 +123,13 @@ class EmbeddedCakeHostTest { // Invalid names must be rejected before the not-yet-spawned player check or any writes. Cmd.ExecuteString("save \"$slot\"") Cmd.ExecuteString("load \"$slot\"") - assertThrows(IllegalArgumentException::class.java) { SV_CCMDS.SV_CopySaveGame("current", slot) } - assertThrows(IllegalArgumentException::class.java) { SV_CCMDS.SV_CopySaveGame(slot, "current") } - assertThrows(IllegalArgumentException::class.java) { SV_CCMDS.SV_WipeSavegame(slot) } + assertThrows(IllegalArgumentException::class.java) { saveFiles.copy("current", slot) } + assertThrows(IllegalArgumentException::class.java) { saveFiles.copy(slot, "current") } + assertThrows(IllegalArgumentException::class.java) { saveFiles.wipe(slot) } assertEquals(snapshot, Files.readString(current)) assertEquals("outside profile", Files.readString(sentinel)) } - SV_CCMDS.SV_CopySaveGame("current", "current") + saveFiles.copy("current", "current") Cmd.ExecuteString("load current") Cmd.ExecuteString("save current") Cmd.ExecuteString("save before-spawn") diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index 0d759071..aa171363 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -39,6 +39,7 @@ import jake2.qcommon.util.Vargs; import jake2.server.save.ServerLevelJsonStore; import jake2.server.save.ServerSaveJsonStore; +import jake2.server.save.ServerSaveFiles; import java.io.IOException; import java.util.ArrayList; @@ -48,7 +49,6 @@ import static jake2.qcommon.Defines.CS_MAPCHECKSUM; import static jake2.qcommon.Defines.CS_MODELS; import static jake2.qcommon.exec.Cmd.getArguments; -import static jake2.server.SV_CCMDS.SV_CopySaveGame; /* Collection of functions provided by the main engine. @@ -416,7 +416,7 @@ private void SV_Savegame_f(List args) { } // copy it off - SV_CopySaveGame("current", saveGame); + new ServerSaveFiles(EngineWriteRoot.path()).copy("current", saveGame); Com.Printf("Done.\n"); } diff --git a/server/src/main/java/jake2/server/SV_CCMDS.java b/server/src/main/java/jake2/server/SV_CCMDS.java deleted file mode 100644 index fa6cb6b1..00000000 --- a/server/src/main/java/jake2/server/SV_CCMDS.java +++ /dev/null @@ -1,205 +0,0 @@ -/* -Copyright (C) 1997-2001 Id Software, Inc. - -This program is free software; you can redistribute it and/or -modify it under the terms of the GNU General Public License -as published by the Free Software Foundation; either version 2 -of the License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - -See the GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; if not, write to the Free Software -Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. - -*/ - -// Created on 18.01.2004 by RST. -// $Id: SV_CCMDS.java,v 1.17 2011-07-07 21:07:09 salomo Exp $ - -package jake2.server; - -import jake2.qcommon.Com; -import jake2.qcommon.sys.Sys; -import jake2.qcommon.vfs.EngineWriteRoot; -import jake2.server.save.ServerSaveJsonStore; - -import java.io.File; -import java.io.IOException; -import java.io.RandomAccessFile; -import java.nio.file.Path; - -public class SV_CCMDS { - /* - =============================================================================== - - OPERATOR CONSOLE ONLY COMMANDS - - These commands can only be entered from stdin or by a remote operator datagram - =============================================================================== - */ - - public static void remove(String name) { - try { - new File(name).delete(); - } - catch (Exception e) { - } - } - - /** Delete save files save/(number)/. */ - static void SV_WipeSavegame(String savename) { - savename = ServerSaveJsonStore.normalizeSlot(savename); - - Com.DPrintf("SV_WipeSaveGame(" + savename + ")\n"); - - String name = EngineWriteRoot.resolve("save/" + savename + "/server_latched_cvars.ssv.json").toString(); - remove(name); - - name = EngineWriteRoot.resolve("save/" + savename + "/server_mapcmd.ssv.json").toString(); - remove(name); - - name = EngineWriteRoot.resolve("save/" + savename + "/game.ssv.json").toString(); - remove(name); - - name = EngineWriteRoot.resolve("save/" + savename).toString() + "/*.sav.json"; - - File f = Sys.FindFirst(name, 0, 0); - while (f != null) { - f.delete(); - f = Sys.FindNext(); - } - Sys.FindClose(); - - name = EngineWriteRoot.resolve("save/" + savename).toString() + "/*.sv2.json"; - - f = Sys.FindFirst(name, 0, 0); - - while (f != null) { - f.delete(); - f = Sys.FindNext(); - } - Sys.FindClose(); - } - /* - ================ - CopyFile - ================ - */ - private static void CopyFile(String src, String dst) { - RandomAccessFile f1, f2; - int l = -1; - byte buffer[] = new byte[65536]; - - //Com.DPrintf("CopyFile (" + src + ", " + dst + ")\n"); - try { - f1 = new RandomAccessFile(src, "r"); - } - catch (Exception e) { - return; - } - try { - f2 = new RandomAccessFile(dst, "rw"); - } - catch (Exception e) { - try { - f1.close(); - } - catch (IOException e1) { - e1.printStackTrace(); - } - return; - } - - while (true) { - - try { - l = f1.read(buffer, 0, 65536); - } - catch (IOException e1) { - - e1.printStackTrace(); - } - if (l == -1) - break; - try { - f2.write(buffer, 0, l); - } - catch (IOException e2) { - - e2.printStackTrace(); - } - } - - try { - f1.close(); - } - catch (IOException e1) { - - e1.printStackTrace(); - } - try { - f2.close(); - } - catch (IOException e2) { - - e2.printStackTrace(); - } - } - /* - ================ - SV_CopySaveGame - ================ - */ - static void SV_CopySaveGame(String src, String dst) { - src = ServerSaveJsonStore.normalizeSlot(src); - dst = ServerSaveJsonStore.normalizeSlot(dst); - // Never wipe the source when asked to copy a slot onto itself. - if (src.equals(dst)) return; - - Com.DPrintf("SV_CopySaveGame(" + src + "," + dst + ")\n"); - - SV_WipeSavegame(dst); - - // copy the savegame over - String name = EngineWriteRoot.resolve("save/" + src + "/server_latched_cvars.ssv.json").toString(); - String name2 = EngineWriteRoot.resolve("save/" + dst + "/server_latched_cvars.ssv.json").toString(); - EngineWriteRoot.ensureParentDirectories(Path.of(name2)); - CopyFile(name, name2); - - // copy the savegame over - name = EngineWriteRoot.resolve("save/" + src + "/server_mapcmd.ssv.json").toString(); - name2 = EngineWriteRoot.resolve("save/" + dst + "/server_mapcmd.ssv.json").toString(); - EngineWriteRoot.ensureParentDirectories(Path.of(name2)); - CopyFile(name, name2); - - name = EngineWriteRoot.resolve("save/" + src + "/game.ssv.json").toString(); - name2 = EngineWriteRoot.resolve("save/" + dst + "/game.ssv.json").toString(); - CopyFile(name, name2); - - String name1 = EngineWriteRoot.resolve("save/" + src).toString() + "/"; - name = EngineWriteRoot.resolve("save/" + src).toString() + "/*.sav.json"; - - File found = Sys.FindFirst(name, 0, 0); - - while (found != null) { - name = name1 + found.getName(); - name2 = EngineWriteRoot.resolve("save/" + dst + "/" + found.getName()).toString(); - - CopyFile(name, name2); - - name = name.replace(".sav.json", ".sv2.json"); - name2 = name2.replace(".sav.json", ".sv2.json"); - - CopyFile(name, name2); - - found = Sys.FindNext(); - } - Sys.FindClose(); - } - -} diff --git a/server/src/main/java/jake2/server/SV_MAIN.java b/server/src/main/java/jake2/server/SV_MAIN.java index a64fb9a7..9a71c999 100644 --- a/server/src/main/java/jake2/server/SV_MAIN.java +++ b/server/src/main/java/jake2/server/SV_MAIN.java @@ -44,14 +44,13 @@ import jake2.qcommon.network.netadr_t; import jake2.qcommon.util.Lib; import jake2.server.save.ServerSaveJsonStore; +import jake2.server.save.ServerSaveFiles; import java.lang.reflect.Constructor; import java.util.*; import java.util.stream.Collectors; import static jake2.qcommon.Defines.*; -import static jake2.server.SV_CCMDS.SV_CopySaveGame; -import static jake2.server.SV_CCMDS.SV_WipeSavegame; import static jake2.server.SV_SEND.SV_SendClientDatagram; import static jake2.server.SV_USER.userCommands; @@ -1202,7 +1201,7 @@ void SV_Loadgame_f(List args) { return; } - SV_CopySaveGame(saveGame, "current"); + new ServerSaveFiles(EngineWriteRoot.path()).copy(saveGame, "current"); final String mapCommand = SV_ReadMapCommand(); @@ -1282,7 +1281,7 @@ void SV_GameMap_f(List args) { // check for clearing the current savegame if (mapName.charAt(0) == '*') { // wipe all the *.sav files - SV_WipeSavegame("current"); + new ServerSaveFiles(EngineWriteRoot.path()).wipe("current"); } else { // save the map just exited // note: only applicable to the default instance (subject to change in future) var gameImports = games.get("default"); @@ -1337,7 +1336,7 @@ void SV_GameMap_f(List args) { // copy off the level to the autosave slot if (!Globals.dedicated.getTypedValue()) { game.SV_WriteServerFile(true); - SV_CopySaveGame("current", "save0"); + new ServerSaveFiles(EngineWriteRoot.path()).copy("current", "save0"); } // multi instance preview: @@ -1481,7 +1480,7 @@ void SV_Map_f(List args) { if (game != null) game.sv.state = ServerStates.SS_DEAD; // don't save current level when changing - SV_WipeSavegame("current"); + new ServerSaveFiles(EngineWriteRoot.path()).wipe("current"); // A running server needs its clients reset for map. For a fresh server, // gamemap performs this initialization itself. diff --git a/server/src/main/java/jake2/server/save/ServerSaveFiles.java b/server/src/main/java/jake2/server/save/ServerSaveFiles.java new file mode 100644 index 00000000..8b814e0f --- /dev/null +++ b/server/src/main/java/jake2/server/save/ServerSaveFiles.java @@ -0,0 +1,105 @@ +/* +Copyright (C) 1997-2001 Id Software, Inc. + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + +See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +*/ + +package jake2.server.save; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.List; +import java.util.Objects; + +/** Save-slot file operations scoped to an explicit engine write root. */ +public final class ServerSaveFiles { + private static final List SERVER_FILES = List.of( + "server_latched_cvars.ssv.json", "server_mapcmd.ssv.json", "game.ssv.json"); + private final Path saveRoot; + + public ServerSaveFiles(Path writeRoot) { + saveRoot = Objects.requireNonNull(writeRoot, "writeRoot").resolve("save"); + } + + /** Remove known save files, leaving unrelated files and the slot directory alone. */ + public void wipe(String slot) { + Path directory = slotPath(slot); + try { + wipeDirectory(directory); + } catch (IOException e) { + throw new UncheckedIOException("Could not wipe save slot: " + slot, e); + } + } + + /** + * Replace a slot's save files. Level portal files are copied with their gameplay files. + * Missing files remain optional, as for the initial autosave before a level is archived. + * This operation is not transactional and does not validate save completeness. + */ + public void copy(String source, String destination) { + // Validate both slots before deleting anything. + Path sourceDirectory = slotPath(source); + Path destinationDirectory = slotPath(destination); + if (sourceDirectory.equals(destinationDirectory)) return; + + try { + wipeDirectory(destinationDirectory); + Files.createDirectories(destinationDirectory); + for (String name : SERVER_FILES) { + copyIfPresent(sourceDirectory.resolve(name), destinationDirectory.resolve(name)); + } + for (Path level : levelFiles(sourceDirectory, "*.sav.json")) { + String name = level.getFileName().toString(); + copyIfPresent(level, destinationDirectory.resolve(name)); + String portals = name.substring(0, name.length() - ".sav.json".length()) + ".sv2.json"; + copyIfPresent(sourceDirectory.resolve(portals), destinationDirectory.resolve(portals)); + } + } catch (IOException e) { + throw new UncheckedIOException("Could not copy save slot " + source + " to " + destination, e); + } + } + + private Path slotPath(String slot) { + return saveRoot.resolve(ServerSaveJsonStore.normalizeSlot(slot)); + } + + private static void wipeDirectory(Path directory) throws IOException { + for (String name : SERVER_FILES) Files.deleteIfExists(directory.resolve(name)); + for (Path file : levelFiles(directory, "*.sav.json")) Files.deleteIfExists(file); + for (Path file : levelFiles(directory, "*.sv2.json")) Files.deleteIfExists(file); + } + + private static List levelFiles(Path directory, String glob) throws IOException { + try (var files = Files.newDirectoryStream(directory, glob)) { + var result = new java.util.ArrayList(); + for (Path file : files) result.add(file); + return result; + } catch (NoSuchFileException e) { + return List.of(); + } + } + + private static void copyIfPresent(Path source, Path destination) throws IOException { + // Only a missing source is optional; destination and other IO errors must propagate. + if (Files.notExists(source)) return; + Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING); + } +} diff --git a/server/src/test/java/jake2/server/save/ServerSaveFilesTest.java b/server/src/test/java/jake2/server/save/ServerSaveFilesTest.java new file mode 100644 index 00000000..e1627833 --- /dev/null +++ b/server/src/test/java/jake2/server/save/ServerSaveFilesTest.java @@ -0,0 +1,97 @@ +package jake2.server.save; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +class ServerSaveFilesTest { + @TempDir Path temp; + + @Test + void copiesSaveFilesAndLevelPairsReplacingStaleFiles() throws Exception { + Path root = temp.resolve("profile"); + write(root, "current", "server_mapcmd.ssv.json", "map"); + write(root, "current", "server_latched_cvars.ssv.json", "settings"); + write(root, "current", "game.ssv.json", "game"); + write(root, "current", "base1.sav.json", "level"); + write(root, "current", "base1.sv2.json", "portals"); + write(root, "current", "orphan.sv2.json", "no matching level"); + write(root, "current", "notes.txt", "not a save file"); + write(root, "manual", "game.ssv.json", "longer old game contents"); + write(root, "manual", "old.sav.json", "old level"); + write(root, "manual", "old.sv2.json", "old portals"); + write(root, "manual", "notes.txt", "keep me"); + + new ServerSaveFiles(root).copy("current", "manual"); + + for (String file : new String[]{"server_mapcmd.ssv.json", "server_latched_cvars.ssv.json", + "game.ssv.json", "base1.sav.json", "base1.sv2.json"}) { + assertEquals(Files.readString(root.resolve("save/current/" + file)), + Files.readString(root.resolve("save/manual/" + file))); + } + assertFalse(Files.exists(root.resolve("save/manual/old.sav.json"))); + assertFalse(Files.exists(root.resolve("save/manual/old.sv2.json"))); + assertFalse(Files.exists(root.resolve("save/manual/orphan.sv2.json"))); + assertEquals("keep me", Files.readString(root.resolve("save/manual/notes.txt"))); + } + + @Test + void storesKeepIndependentRootsWhenOperationsAreInterleaved() throws Exception { + Path first = temp.resolve("first"); + Path second = temp.resolve("second"); + ServerSaveFiles firstFiles = new ServerSaveFiles(first); + ServerSaveFiles secondFiles = new ServerSaveFiles(second); + write(first, "current", "game.ssv.json", "first game"); + write(second, "current", "game.ssv.json", "second game"); + + firstFiles.copy("current", "shared"); + secondFiles.copy("current", "shared"); + firstFiles.wipe("shared"); + + assertFalse(Files.exists(first.resolve("save/shared/game.ssv.json"))); + assertEquals("second game", Files.readString(second.resolve("save/shared/game.ssv.json"))); + assertEquals("first game", Files.readString(first.resolve("save/current/game.ssv.json"))); + } + + @Test + void initialAutosaveDoesNotRequireArchivedLevels() throws Exception { + write(temp, "current", "server_mapcmd.ssv.json", "map"); + ServerSaveFiles files = new ServerSaveFiles(temp); + files.copy("current", "save0"); + files.wipe("missing"); + assertEquals("map", Files.readString(temp.resolve("save/save0/server_mapcmd.ssv.json"))); + } + + @Test + void rejectsInvalidSlotsBeforeChangingDestinationAndSelfCopyIsHarmless() throws Exception { + Path saved = write(temp, "manual", "game.ssv.json", "keep"); + ServerSaveFiles files = new ServerSaveFiles(temp); + assertThrows(IllegalArgumentException.class, () -> files.copy("../outside", "manual")); + assertThrows(IllegalArgumentException.class, () -> files.copy("manual", "../outside")); + assertThrows(IllegalArgumentException.class, () -> files.wipe("../outside")); + files.copy(" manual ", "manual"); + assertEquals("keep", Files.readString(saved)); + } + + @Test + void filesystemFailuresAreReportedInsteadOfSilentlyContinuing() throws Exception { + write(temp, "current", "game.ssv.json", "game"); + Path blocked = temp.resolve("save/manual/game.ssv.json"); + Files.createDirectories(blocked); + Files.writeString(blocked.resolve("child"), "prevents deletion"); + ServerSaveFiles files = new ServerSaveFiles(temp); + assertThrows(UncheckedIOException.class, () -> files.wipe("manual")); + assertThrows(UncheckedIOException.class, () -> files.copy("current", "manual")); + } + + private Path write(Path root, String slot, String name, String content) throws Exception { + Path file = root.resolve("save").resolve(slot).resolve(name); + Files.createDirectories(file.getParent()); + return Files.writeString(file, content); + } +} From ea2095b3bd28a5bfda331fce7b3512d6fd0e3523 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 17:29:09 +0100 Subject: [PATCH 04/47] Consolidate per-map server state into the game instance --- .../java/jake2/server/GameImportsTest.java | 28 +++++++- .../java/jake2/server/GameImportsImpl.java | 66 ++++++++++-------- .../src/main/java/jake2/server/SV_ENTS.java | 10 +-- .../src/main/java/jake2/server/SV_GAME.java | 20 +++--- .../src/main/java/jake2/server/SV_MAIN.java | 32 ++++----- .../src/main/java/jake2/server/SV_SEND.java | 2 +- .../src/main/java/jake2/server/SV_USER.java | 18 ++--- .../src/main/java/jake2/server/SV_WORLD.java | 8 +-- .../src/main/java/jake2/server/server_t.java | 69 ------------------- .../jake2/server/AirAccelerationTest.java | 2 +- 10 files changed, 110 insertions(+), 145 deletions(-) delete mode 100644 server/src/main/java/jake2/server/server_t.java diff --git a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java index 90133366..b142ad14 100644 --- a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java +++ b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java @@ -55,7 +55,33 @@ public void runEmptyInstance() { testInstance.SV_RunGameFrame(100); - Assertions.assertEquals(1, testInstance.sv.framenum); + Assertions.assertEquals(1, testInstance.frameNum); + } + + @Test + public void gamesKeepIndependentMapStateAndFrameCounters() { + manager = new SV_MAIN(); + GameImportsImpl first = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); + GameImportsImpl second = manager.createGameInstance(new ChangeMapInfo("testbox2", false, false), null); + + Assertions.assertEquals("testbox", first.mapName); + Assertions.assertEquals("testbox2", second.mapName); + Assertions.assertNotEquals(first.name, second.name); + Assertions.assertEquals(ServerStates.SS_LOADING, second.state); + Assertions.assertNotSame(first.models, second.models); + Assertions.assertNotSame(first.baselines[1], second.baselines[1]); + String secondMapLabel = second.configstrings[jake2.qcommon.Defines.CS_NAME]; + + first.configstrings[jake2.qcommon.Defines.CS_NAME] = "changed first map"; + first.baselines[1].modelindex = 7; + first.state = ServerStates.SS_GAME; + first.SV_RunGameFrame(100); + + Assertions.assertEquals(1, first.frameNum); + Assertions.assertEquals(0, second.frameNum); + Assertions.assertEquals(secondMapLabel, second.configstrings[jake2.qcommon.Defines.CS_NAME]); + Assertions.assertEquals(0, second.baselines[1].modelindex); + Assertions.assertEquals(ServerStates.SS_LOADING, second.state); } @Test diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index aa171363..fb12c44a 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -50,12 +50,7 @@ import static jake2.qcommon.Defines.CS_MODELS; import static jake2.qcommon.exec.Cmd.getArguments; -/* - Collection of functions provided by the main engine. - Also serves as the holder of game state from the server side - - todo make singleton (same as game exports) -*/ +/** Owns one server-side game instance and exposes engine services to its gameplay module. */ @SuppressWarnings("deprecation") public class GameImportsImpl implements GameImports { @@ -63,8 +58,15 @@ public class GameImportsImpl implements GameImports { final JakeServer serverMain; public GameExports gameExports; - // local (instance) server state - public server_t sv; + // Per-game map state. The routing name above is independent of the loaded map name. + final String mapName; + ServerStates state; + final boolean isDemo; + final boolean loadgame; + int frameNum; + final cmodel_t[] models = new cmodel_t[Defines.MAX_MODELS]; + final String[] configstrings = new String[Defines.MAX_CONFIGSTRINGS]; + final entity_state_t[] baselines = new entity_state_t[Defines.MAX_EDICTS]; public int realtime; // always increasing, no clamping, etc // todo: remove @@ -111,23 +113,29 @@ public GameImportsImpl(JakeServer serverMain, ChangeMapInfo changeMapInfo) { // heartbeats will always be sent to the id master - // create local server state - sv = new server_t(changeMapInfo); + mapName = changeMapInfo.mapName; + state = ServerStates.SS_LOADING; + isDemo = changeMapInfo.isDemo; + loadgame = changeMapInfo.isLoadgame; + for (int i = 0; i < models.length; i++) models[i] = new cmodel_t(); + for (int i = 0; i < baselines.length; i++) baselines[i] = new entity_state_t(); + // Fallback for maps that do not supply a display name. + configstrings[Defines.CS_NAME] = mapName; int[] iw = { 0 }; if (changeMapInfo.state == ServerStates.SS_GAME) { - sv.configstrings[CS_MODELS + 1] = "maps/" + changeMapInfo.mapName + ".bsp"; - sv.models[1] = cm.CM_LoadMap(sv.configstrings[CS_MODELS + 1], false, iw); + configstrings[CS_MODELS + 1] = "maps/" + changeMapInfo.mapName + ".bsp"; + models[1] = cm.CM_LoadMap(configstrings[CS_MODELS + 1], false, iw); } else { - sv.models[1] = cm.CM_LoadMap("", false, iw); // no real map + models[1] = cm.CM_LoadMap("", false, iw); // no real map } - sv.configstrings[CS_MAPCHECKSUM] = "" + iw[0]; + configstrings[CS_MAPCHECKSUM] = "" + iw[0]; for (int i = 1; i < cm.CM_NumInlineModels(); i++) { - sv.configstrings[CS_MODELS + 1 + i] = "*" + i; + configstrings[CS_MODELS + 1 + i] = "*" + i; // copy references - sv.models[i + 1] = cm.InlineModel(sv.configstrings[CS_MODELS + 1 + i]); + models[i + 1] = cm.InlineModel(configstrings[CS_MODELS + 1 + i]); } @@ -144,7 +152,7 @@ public GameImportsImpl(JakeServer serverMain, ChangeMapInfo changeMapInfo) { configuredAirAcceleration = Cvar.getInstance().find("sv_airaccelerate", Float.class).getTypedValue(); airAcceleration = Cvar.getInstance().find("deathmatch", Boolean.class).getTypedValue() ? configuredAirAcceleration : 0f; - sv.configstrings[Defines.CS_AIRACCEL] = Float.toString(airAcceleration); + configstrings[Defines.CS_AIRACCEL] = Float.toString(airAcceleration); // clear physics interaction links // Q: is it required? the instance was just created @@ -361,7 +369,7 @@ public int getPointContents(float[] p) { private void SV_Savegame_f(List args) { - if (sv.state != ServerStates.SS_GAME) { + if (state != ServerStates.SS_GAME) { Com.Printf("You must be in a game to save.\n"); return; } @@ -454,7 +462,7 @@ private void SV_Status_f(List args) { Com.Printf("No server running.\n"); return; } - Com.Printf("map : " + sv.name + "\n"); + Com.Printf("map : " + mapName + "\n"); Com.Printf("num score ping name lastmsg address qport \n"); Com.Printf("--- ----- ---- --------------- ------- --------------------- ------\n"); @@ -612,7 +620,7 @@ void SV_RunGameFrame(long fixedtime) { // don't run the world, otherwise the delta // compression can get confused when a client // has the "current" frame - sv.framenum++; + frameNum++; if (SV_MAIN.sv_paused != null && SV_MAIN.sv_paused.getTypedValue() && serverMain.getClients().size() <= 1) { return; @@ -633,7 +641,7 @@ void SV_RunGameFrame(long fixedtime) { void SV_BroadcastCommand(String s) { //fixme: check if server is running - if (sv.state == ServerStates.SS_DEAD) + if (state == ServerStates.SS_DEAD) return; for (ClientNetworkInfo client : serverMain.getClients()) { @@ -649,13 +657,13 @@ void readLevelFile() { try { ServerLevelJsonStore store = ServerLevelJsonStore.forWriteDir(EngineWriteRoot.pathString()); - store.applyLevelState(store.readLevelState("current", sv.name), sv.configstrings, cm); + store.applyLevelState(store.readLevelState("current", mapName), configstrings, cm); } catch (Exception e) { - Com.Printf("Failed to open current level state for " + sv.name + "\n"); + Com.Printf("Failed to open current level state for " + mapName + "\n"); e.printStackTrace(); } - String name = EngineWriteRoot.resolve("save/current/" + sv.name + ".sav.json").toString(); + String name = EngineWriteRoot.resolve("save/current/" + mapName + ".sav.json").toString(); gameExports.ReadLevel(name); } @@ -665,14 +673,14 @@ void SV_WriteLevelFile() { try { ServerLevelJsonStore store = ServerLevelJsonStore.forWriteDir(EngineWriteRoot.pathString()); - store.writeLevelState("current", sv.name, sv.configstrings, cm.portalopen); + store.writeLevelState("current", mapName, configstrings, cm.portalopen); } catch (Exception e) { - Com.Printf("Failed to write current level state for " + sv.name + "\n"); + Com.Printf("Failed to write current level state for " + mapName + "\n"); e.printStackTrace(); } - String name = EngineWriteRoot.resolve("save/current/" + sv.name + ".sav.json").toString(); + String name = EngineWriteRoot.resolve("save/current/" + mapName + ".sav.json").toString(); gameExports.WriteLevel(name); } @@ -695,9 +703,9 @@ void SV_WriteServerFile(boolean autosave) { final String comment; if (autosave) { - comment = "Autosave in " + sv.configstrings[Defines.CS_NAME]; + comment = "Autosave in " + configstrings[Defines.CS_NAME]; } else { - comment = new Date() + " " + sv.configstrings[Defines.CS_NAME]; + comment = new Date() + " " + configstrings[Defines.CS_NAME]; } store.writeMapCommand("current", comment, mapcmd); diff --git a/server/src/main/java/jake2/server/SV_ENTS.java b/server/src/main/java/jake2/server/SV_ENTS.java index ed7bfe02..a017b9e4 100644 --- a/server/src/main/java/jake2/server/SV_ENTS.java +++ b/server/src/main/java/jake2/server/SV_ENTS.java @@ -89,14 +89,14 @@ public Collection buildClientFrameMessages(ClientNetworkInfo clie // now, build the messages List result = new ArrayList<>(); // this is the frame we are creating - client_frame_t currentFrame = client.frames[gameImports.sv.framenum & Defines.UPDATE_MASK]; + client_frame_t currentFrame = client.frames[gameImports.frameNum & Defines.UPDATE_MASK]; client_frame_t lastReceivedFrame; int lastReceivedFrameNum; // client is asking for a retransmit if (client.lastReceivedFrame <= 0) { lastReceivedFrame = null; lastReceivedFrameNum = -1; - } else if (gameImports.sv.framenum - client.lastReceivedFrame >= (Defines.UPDATE_BACKUP - 3)) { + } else if (gameImports.frameNum - client.lastReceivedFrame >= (Defines.UPDATE_BACKUP - 3)) { // client hasn't gotten a good message through in a long time // Com_Printf ("%s: Delta request from out-of-date packet.\n", // client.name); @@ -109,7 +109,7 @@ public Collection buildClientFrameMessages(ClientNetworkInfo clie } result.add(new FrameHeaderMessage( - gameImports.sv.framenum, + gameImports.frameNum, lastReceivedFrameNum, client.surpressCount, currentFrame.areabytes, @@ -127,7 +127,7 @@ public Collection buildClientFrameMessages(ClientNetworkInfo clie lastReceivedFrame, currentFrame, clientEntityStates, - gameImports.sv.baselines, + gameImports.baselines, gameImports.serverMain.getClients().size()); result.add(packetEntities); return result; @@ -266,7 +266,7 @@ public void SV_BuildClientFrame(ClientNetworkInfo client) { return; // not in game yet // this is the frame we are creating - client_frame_t frame = client.frames[gameImports.sv.framenum & Defines.UPDATE_MASK]; + client_frame_t frame = client.frames[gameImports.frameNum & Defines.UPDATE_MASK]; frame.senttime = gameImports.realtime; // save it for ping calc later diff --git a/server/src/main/java/jake2/server/SV_GAME.java b/server/src/main/java/jake2/server/SV_GAME.java index 9eeea72b..0d229d2d 100644 --- a/server/src/main/java/jake2/server/SV_GAME.java +++ b/server/src/main/java/jake2/server/SV_GAME.java @@ -130,9 +130,9 @@ void PF_Configstring(int index, String val) { val = ""; // change the string in sv - gameImports.sv.configstrings[index] = val; + gameImports.configstrings[index] = val; - if (gameImports.sv.state != ServerStates.SS_LOADING) { + if (gameImports.state != ServerStates.SS_LOADING) { // send the update to everyone gameImports.multicastMessage(null, new ConfigStringMessage(index, val), MulticastTypes.MULTICAST_ALL_R); } @@ -220,8 +220,8 @@ int SV_FindIndex(String val, int start, int max, boolean create) { if (val == null || val.length() == 0) return 0; - for (i = 1; i < max && gameImports.sv.configstrings[start + i] != null; i++) - if (val.equals(gameImports.sv.configstrings[start + i])) + for (i = 1; i < max && gameImports.configstrings[start + i] != null; i++) + if (val.equals(gameImports.configstrings[start + i])) return i; if (!create) @@ -255,38 +255,38 @@ void SV_CreateBaseline() { // take current state as baseline Math3D.VectorCopy(svent.s.origin, svent.s.old_origin); - gameImports.sv.baselines[entnum].set(svent.s); + gameImports.baselines[entnum].set(svent.s); } } /** * SV_CheckForSavegame. */ - void restorePreviousGame(server_t sv) { + void restorePreviousGame() { // get configstrings and areaportals // then read game enitites gameImports.readLevelFile(); - if (!sv.loadgame) { + if (!gameImports.loadgame) { // coming back to a level after being in a different // level, so run it for 100 frames - // assert sv.state == LOADING, otherwise, updates from 100 frames may overflow the reliable channel + // assert gameImports.state == LOADING, otherwise, updates from 100 frames may overflow the reliable channel for (int i = 0; i < 100; i++) { gameImports.gameExports.G_RunFrame(); } } } - boolean isPreviousLevel(server_t sv) { + boolean isPreviousLevel() { if (Cvar.getInstance().find("sv_noreload", Boolean.class).getTypedValue()) return false; if (Cvar.getInstance().find("deathmatch", Boolean.class).getTypedValue()) return false; - String name = EngineWriteRoot.resolve("save/current/" + sv.name + ".sav.json").toString(); + String name = EngineWriteRoot.resolve("save/current/" + gameImports.mapName + ".sav.json").toString(); return new File(name).exists(); diff --git a/server/src/main/java/jake2/server/SV_MAIN.java b/server/src/main/java/jake2/server/SV_MAIN.java index 9a71c999..e9183522 100644 --- a/server/src/main/java/jake2/server/SV_MAIN.java +++ b/server/src/main/java/jake2/server/SV_MAIN.java @@ -209,7 +209,7 @@ private String SVC_Info(List args, netadr_t from) { if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) players++; - info = SV_MAIN.hostname.getTypedValue() + " " + games.get("default").sv.name + " " + players + "/" + clients.size() + "\n"; + info = SV_MAIN.hostname.getTypedValue() + " " + games.get("default").mapName + " " + players + "/" + clients.size() + "\n"; } return info; @@ -273,7 +273,7 @@ private void SVC_DirectConnect(List args, netadr_t adr) { /* - if (gameImports.sv.isDemo) { + if (gameImports.isDemo) { if (!adr.IsLocalAddress()) { Com.Printf("Remote connect in attract loop. Ignored.\n"); Netchan.sendConnectionlessPacket(Defines.NS_SERVER, adr, ConnectionlessCommand.print, "\nConnection refused.\n"); @@ -567,7 +567,7 @@ void SV_SendClientMessages() { SV_DropClient(c); } - if (game.sv.state == ServerStates.SS_CINEMATIC || game.sv.state == ServerStates.SS_DEMO || game.sv.state == ServerStates.SS_PIC) { + if (game.state == ServerStates.SS_CINEMATIC || game.state == ServerStates.SS_DEMO || game.state == ServerStates.SS_PIC) { // leftover from demo code c.netchan.transmit(null); } else if (c.state == ClientStates.CS_SPAWNED) { @@ -852,7 +852,7 @@ private static void SV_ExecuteUserCommand(ClientNetworkInfo cl, String command) return; } - if (game.sv.state == ServerStates.SS_GAME) + if (game.state == ServerStates.SS_GAME) game.gameExports.ClientCommand(cl.edict, args); } @@ -931,7 +931,7 @@ void SV_GiveMsec() { if (cl.state == ClientStates.CS_FREE) continue; - if ((games.get(cl.gameName).sv.framenum & 15) != 0) + if ((games.get(cl.gameName).frameNum & 15) != 0) continue; cl.commandMsec = 1800; // 1600 + some slop @@ -1285,7 +1285,7 @@ void SV_GameMap_f(List args) { } else { // save the map just exited // note: only applicable to the default instance (subject to change in future) var gameImports = games.get("default"); - if (gameImports != null && gameImports.sv.state == ServerStates.SS_GAME) { + if (gameImports != null && gameImports.state == ServerStates.SS_GAME) { // clear all the client inuse flags before saving so that // when the level is re-entered, the clients will spawn // at spawn points instead of occupying body shells @@ -1396,7 +1396,7 @@ GameImportsImpl spawnServerInstance(ChangeMapInfo changeMapInfo, int clientIndex // precache and static commands can be issued during // map initialization - Globals.server_state = gameImports.sv.state; // LOADING + Globals.server_state = gameImports.state; // LOADING if (oldGame != null && !changeMapInfo.isLoadgame) { // copy persistent data between instances. @@ -1406,15 +1406,15 @@ GameImportsImpl spawnServerInstance(ChangeMapInfo changeMapInfo, int clientIndex // spawn fresh entities - gameImports.gameExports.SpawnEntities(gameImports.sv.name, gameImports.cm.CM_EntityString(), changeMapInfo.spawnPoint); + gameImports.gameExports.SpawnEntities(gameImports.mapName, gameImports.cm.CM_EntityString(), changeMapInfo.spawnPoint); - if (gameImports.sv_game.isPreviousLevel(gameImports.sv)) { + if (gameImports.sv_game.isPreviousLevel()) { // fixme: need to spawn all entities and then remove them, // because otherwise precaching will not work properly - configstrings will not match. SV_WORLD.SV_ClearWorld(gameImports); // loads a stored game, runs 100 frames afterwards - gameImports.sv_game.restorePreviousGame(gameImports.sv); + gameImports.sv_game.restorePreviousGame(); } else { // run two frames to allow everything to settle gameImports.gameExports.G_RunFrame(); @@ -1426,16 +1426,16 @@ GameImportsImpl spawnServerInstance(ChangeMapInfo changeMapInfo, int clientIndex // Level restoration may replace configstrings, including saves from before air movement was published. - gameImports.sv.configstrings[CS_AIRACCEL] = Float.toString(gameImports.airAcceleration); + gameImports.configstrings[CS_AIRACCEL] = Float.toString(gameImports.airAcceleration); // all precaches are complete - gameImports.sv.state = changeMapInfo.state; - Globals.server_state = gameImports.sv.state; + gameImports.state = changeMapInfo.state; + Globals.server_state = gameImports.state; // set serverinfo variable - Cvar.getInstance().find("mapname", String.class).forceSet(gameImports.sv.name); - Com.Println("mapname: " + gameImports.sv.name); + Cvar.getInstance().find("mapname", String.class).forceSet(gameImports.mapName); + Com.Println("mapname: " + gameImports.mapName); if (changeMapInfo.state == ServerStates.SS_GAME) Cbuf.CopyToDefer(); @@ -1478,7 +1478,7 @@ void SV_Map_f(List args) { var game = games.get("default"); if (game != null) - game.sv.state = ServerStates.SS_DEAD; // don't save current level when changing + game.state = ServerStates.SS_DEAD; // don't save current level when changing new ServerSaveFiles(EngineWriteRoot.path()).wipe("current"); diff --git a/server/src/main/java/jake2/server/SV_SEND.java b/server/src/main/java/jake2/server/SV_SEND.java index 78bb47b3..c4461cbb 100644 --- a/server/src/main/java/jake2/server/SV_SEND.java +++ b/server/src/main/java/jake2/server/SV_SEND.java @@ -197,7 +197,7 @@ public static void SV_SendClientDatagram(ClientNetworkInfo client, GameImportsIm client.netchan.transmit(unreliable); // record the size for rate estimation - client.message_size[gameImports.sv.framenum % Defines.RATE_MESSAGES] = unreliable.stream().mapToInt(NetworkMessage::getSize).sum(); + client.message_size[gameImports.frameNum % Defines.RATE_MESSAGES] = unreliable.stream().mapToInt(NetworkMessage::getSize).sum(); } } diff --git a/server/src/main/java/jake2/server/SV_USER.java b/server/src/main/java/jake2/server/SV_USER.java index 4e83071d..d47d2107 100644 --- a/server/src/main/java/jake2/server/SV_USER.java +++ b/server/src/main/java/jake2/server/SV_USER.java @@ -91,7 +91,7 @@ private static void SV_New_f(List args, GameImportsImpl gameImports, Cli // send full levelname final int clientIndex; - if (gameImports.sv.state == ServerStates.SS_CINEMATIC || gameImports.sv.state == ServerStates.SS_PIC) + if (gameImports.state == ServerStates.SS_CINEMATIC || gameImports.state == ServerStates.SS_PIC) clientIndex = -1; else clientIndex = client.edict.index - 1; @@ -101,15 +101,15 @@ private static void SV_New_f(List args, GameImportsImpl gameImports, Cli new ServerDataMessage( Defines.PROTOCOL_VERSION, gameImports.spawncount, - gameImports.sv.isDemo, + gameImports.isDemo, gamedir, clientIndex, - gameImports.sv.configstrings[Defines.CS_NAME] + gameImports.configstrings[Defines.CS_NAME] )); // // game server // - if (gameImports.sv.state == ServerStates.SS_GAME) { + if (gameImports.state == ServerStates.SS_GAME) { // set up the entity for the client ServerEntity ent = gameImports.gameExports.getEdict(clientIndex + 1); ent.s.index = clientIndex + 1; @@ -146,8 +146,8 @@ private static void SV_Configstrings_f(List args, GameImportsImpl gameIm // write a packet full of data int currentReliableSize = client.netchan.reliablePending.stream().mapToInt(NetworkMessage::getSize).sum(); while (currentReliableSize < Defines.MAX_MSGLEN / 2 && start < Defines.MAX_CONFIGSTRINGS) { - if (gameImports.sv.configstrings[start] != null && gameImports.sv.configstrings[start].length() != 0) { - final ConfigStringMessage config = new ConfigStringMessage(start, gameImports.sv.configstrings[start]); + if (gameImports.configstrings[start] != null && gameImports.configstrings[start].length() != 0) { + final ConfigStringMessage config = new ConfigStringMessage(start, gameImports.configstrings[start]); currentReliableSize += config.getSize(); client.netchan.reliablePending.add(config); } @@ -191,7 +191,7 @@ private static void SV_Baselines_f(List args, GameImportsImpl gameImport // write a packet full of data int currentReliableSize = client.netchan.reliablePending.stream().mapToInt(NetworkMessage::getSize).sum(); while (currentReliableSize < Defines.MAX_MSGLEN / 2 && start < Defines.MAX_EDICTS) { - entity_state_t base = gameImports.sv.baselines[start]; + entity_state_t base = gameImports.baselines[start]; if (base.modelindex != 0 || base.sound != 0 || base.effects != 0) { final SpawnBaselineMessage spawn = new SpawnBaselineMessage(base); currentReliableSize += spawn.getSize(); @@ -359,8 +359,8 @@ private static void SV_ShowServerinfo_f(List args, GameImportsImpl gameI static void SV_Nextserver(GameImportsImpl gameImports) { //ZOID, ss_pic can be nextserver'd in coop mode - if (gameImports.sv.state == ServerStates.SS_GAME - || (gameImports.sv.state == ServerStates.SS_PIC && !Cvar.getInstance().find("coop", Boolean.class).getTypedValue())) + if (gameImports.state == ServerStates.SS_GAME + || (gameImports.state == ServerStates.SS_PIC && !Cvar.getInstance().find("coop", Boolean.class).getTypedValue())) return; // can't nextserver while playing a normal game gameImports.spawncount++; // make sure another doesn't sneak in diff --git a/server/src/main/java/jake2/server/SV_WORLD.java b/server/src/main/java/jake2/server/SV_WORLD.java index 87ed2ac6..313fb318 100644 --- a/server/src/main/java/jake2/server/SV_WORLD.java +++ b/server/src/main/java/jake2/server/SV_WORLD.java @@ -117,7 +117,7 @@ private static areanode_t SV_CreateAreaNode(int depth, float[] mins, */ static void SV_ClearWorld(GameImportsImpl gameImports) { gameImports.world.clearAreaNodes(); - SV_CreateAreaNode(0, gameImports.sv.models[1].mins, gameImports.sv.models[1].maxs, gameImports); + SV_CreateAreaNode(0, gameImports.models[1].mins, gameImports.models[1].maxs, gameImports); /* * Com.p("areanodes:" + sv_numareanodes + " (sollten 32 sein)."); for * (int n = 0; n < sv_numareanodes; n++) { Com.Printf( "|%3i|%2i|%8.2f @@ -227,7 +227,7 @@ static void SV_LinkEdict(ServerEntity ent, GameImportsImpl gameImports) { // but nothing should ever need more than that if (ent.areanum != 0 && ent.areanum != area) { if (ent.areanum2 != 0 && ent.areanum2 != area - && gameImports.sv.state == ServerStates.SS_LOADING) + && gameImports.state == ServerStates.SS_LOADING) Com.DPrintf("Object touching 3 areas at " + ent.absmin[0] + " " + ent.absmin[1] + " " + ent.absmin[2] + "\n"); @@ -350,7 +350,7 @@ static int SV_PointContents(float[] p, GameImportsImpl gameImports) { int contents, c2; int headnode; // get base contents from world - contents = gameImports.cm.PointContents(p, gameImports.sv.models[1].headnode); + contents = gameImports.cm.PointContents(p, gameImports.models[1].headnode); // or in contents from all the other entities num = SV_AreaEdicts(p, p, gameImports.world.touch, Defines.MAX_EDICTS, Defines.AREA_SOLID, gameImports); for (i = 0; i < num; i++) { @@ -379,7 +379,7 @@ private static int SV_HullForEntity(ServerEntity ent, GameImportsImpl gameImport // decide which clipping hull to use, based on the size if (ent.solid == Defines.SOLID_BSP) { // explicit hulls in the BSP model - model = gameImports.sv.models[ent.s.modelindex]; + model = gameImports.models[ent.s.modelindex]; if (null == model) Com.Error(Defines.ERR_FATAL, "MOVETYPE_PUSH with a non bsp model"); diff --git a/server/src/main/java/jake2/server/server_t.java b/server/src/main/java/jake2/server/server_t.java deleted file mode 100644 index cd26f3de..00000000 --- a/server/src/main/java/jake2/server/server_t.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright (C) 1997-2001 Id Software, Inc. - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License as published by the Free Software - * Foundation; either version 2 of the License, or (at your option) any later - * version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. - * - * See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * this program; if not, write to the Free Software Foundation, Inc., 59 Temple - * Place - Suite 330, Boston, MA 02111-1307, USA. - * - */ - -// Created on 14.01.2004 by RST. -// $Id: server_t.java,v 1.2 2004-09-22 19:22:12 salomo Exp $ -package jake2.server; - -import jake2.qcommon.Defines; -import jake2.qcommon.ServerStates; -import jake2.qcommon.cmodel_t; -import jake2.qcommon.entity_state_t; - -import static jake2.qcommon.Defines.CS_NAME; - -class server_t { - - server_t(ChangeMapInfo changeMapInfo) { - models = new cmodel_t[Defines.MAX_MODELS]; - for (int n = 0; n < Defines.MAX_MODELS; n++) { - models[n] = new cmodel_t(); - } - - for (int n = 0; n < Defines.MAX_EDICTS; n++) { - baselines[n] = new entity_state_t(); - } - - loadgame = changeMapInfo.isLoadgame; - isDemo = changeMapInfo.isDemo; - name = changeMapInfo.mapName; - state = ServerStates.SS_LOADING; - // save name for levels that don't set message - configstrings[CS_NAME] = changeMapInfo.mapName; - - } - - ServerStates state; - - boolean isDemo; // running cinematics and demos for the local system - // only - - boolean loadgame; // client begins should reuse existing entity - - int framenum; - - String name = ""; // map name, or cinematic name - - cmodel_t[] models; - - String[] configstrings = new String[Defines.MAX_CONFIGSTRINGS]; - - entity_state_t[] baselines = new entity_state_t[Defines.MAX_EDICTS]; -} diff --git a/server/src/test/java/jake2/server/AirAccelerationTest.java b/server/src/test/java/jake2/server/AirAccelerationTest.java index f5b08906..a2ea6a58 100644 --- a/server/src/test/java/jake2/server/AirAccelerationTest.java +++ b/server/src/test/java/jake2/server/AirAccelerationTest.java @@ -153,6 +153,6 @@ private static pmove_t frozenMovement() { private static void assertMovementSetting(GameImportsImpl game, pmove_t movement, float expected) { game.Pmove(movement); assertEquals(expected, movement.airAccelerate); - assertEquals(Float.toString(expected), game.sv.configstrings[CS_AIRACCEL]); + assertEquals(Float.toString(expected), game.configstrings[CS_AIRACCEL]); } } From b2039681c52fcd32acb16b592c7c2055bd1bb88b Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 17:34:16 +0100 Subject: [PATCH 05/47] Consolidate game import behavior and remove SV_GAME --- .../java/jake2/server/GameImportsTest.java | 40 +++ .../main/java/jake2/game/GameExportsImpl.java | 1 - .../java/jake2/server/GameImportsImpl.java | 169 +++++++++- .../src/main/java/jake2/server/SV_GAME.java | 294 ------------------ .../src/main/java/jake2/server/SV_MAIN.java | 6 +- 5 files changed, 197 insertions(+), 313 deletions(-) delete mode 100644 server/src/main/java/jake2/server/SV_GAME.java diff --git a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java index b142ad14..253aa431 100644 --- a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java +++ b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java @@ -7,6 +7,8 @@ import jake2.qcommon.MainCommon; import jake2.qcommon.Globals; import jake2.qcommon.ServerStates; +import jake2.qcommon.Defines; +import jake2.qcommon.network.messages.server.ConfigStringMessage; import jake2.qcommon.vfs.EngineVfs; import jake2.qcommon.vfs.EngineWriteRoot; import jake2.qcommon.vfs.VfsConfig; @@ -84,6 +86,44 @@ public void gamesKeepIndependentMapStateAndFrameCounters() { Assertions.assertEquals(ServerStates.SS_LOADING, second.state); } + @Test + public void resourceUpdatesStayWithTheirGameAndOnlySendAfterLoading() { + manager = new SV_MAIN(); + Cvar.getInstance().find("maxclients", Integer.class).forceSet(2); + manager.resetClients(); + GameImportsImpl first = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); + GameImportsImpl second = manager.createGameInstance(new ChangeMapInfo("testbox2", false, false), null); + SV_MAIN.games.put(first.name, first); + SV_MAIN.games.put(second.name, second); + ClientNetworkInfo firstClient = manager.getClients().get(0); + ClientNetworkInfo secondClient = manager.getClients().get(1); + firstClient.gameName = first.name; + secondClient.gameName = second.name; + firstClient.state = secondClient.state = ClientStates.CS_CONNECTED; + + try { + int firstIndex = first.modelindex("models/first.md2"); + int secondIndex = second.modelindex("models/second.md2"); + Assertions.assertEquals(firstIndex, first.modelindex("models/first.md2")); + Assertions.assertEquals("models/first.md2", first.configstrings[Defines.CS_MODELS + firstIndex]); + Assertions.assertEquals("models/second.md2", second.configstrings[Defines.CS_MODELS + secondIndex]); + Assertions.assertTrue(firstClient.netchan.reliablePending.isEmpty()); + Assertions.assertTrue(secondClient.netchan.reliablePending.isEmpty()); + + first.state = ServerStates.SS_GAME; + int soundIndex = first.soundindex("sound/first.wav"); + // Reusing an index should not publish another resource update. + Assertions.assertEquals(soundIndex, first.soundindex("sound/first.wav")); + Assertions.assertEquals(1, firstClient.netchan.reliablePending.size()); + Assertions.assertEquals(new ConfigStringMessage(Defines.CS_SOUNDS + soundIndex, "sound/first.wav"), + firstClient.netchan.reliablePending.iterator().next()); + Assertions.assertTrue(secondClient.netchan.reliablePending.isEmpty()); + } finally { + // These clients exercise message queues without establishing network channels. + firstClient.state = secondClient.state = ClientStates.CS_FREE; + } + } + @Test public void runGame() { diff --git a/game/src/main/java/jake2/game/GameExportsImpl.java b/game/src/main/java/jake2/game/GameExportsImpl.java index 93182916..f794654a 100644 --- a/game/src/main/java/jake2/game/GameExportsImpl.java +++ b/game/src/main/java/jake2/game/GameExportsImpl.java @@ -30,7 +30,6 @@ /** * Collection of functions provided by the game module. * Also serves as the holder of game state from the game side - * See jake2.server.SV_GAME#SV_InitGameProgs() * */ public class GameExportsImpl implements GameExports { diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index fb12c44a..ed89a400 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -32,15 +32,18 @@ import jake2.qcommon.network.MulticastTypes; import jake2.qcommon.network.NET; import jake2.qcommon.network.messages.NetworkMessage; +import jake2.qcommon.network.messages.server.ConfigStringMessage; import jake2.qcommon.network.messages.server.PrintCenterMessage; import jake2.qcommon.network.messages.server.ServerMessage; import jake2.qcommon.network.messages.server.StuffTextMessage; import jake2.qcommon.util.Lib; +import jake2.qcommon.util.Math3D; import jake2.qcommon.util.Vargs; import jake2.server.save.ServerLevelJsonStore; import jake2.server.save.ServerSaveJsonStore; import jake2.server.save.ServerSaveFiles; +import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Date; @@ -85,8 +88,6 @@ public class GameImportsImpl implements GameImports { SV_ENTS sv_ents; - SV_GAME sv_game; - final float[] origin_v = { 0, 0, 0 }; private final float configuredAirAcceleration; final float airAcceleration; @@ -109,8 +110,6 @@ public GameImportsImpl(JakeServer serverMain, ChangeMapInfo changeMapInfo) { cm = new CM(); sv_ents = new SV_ENTS(this, serverMain.getClients().size() * Defines.UPDATE_BACKUP * 64); - sv_game = new SV_GAME(this); - // heartbeats will always be sent to the id master mapName = changeMapInfo.mapName; @@ -220,20 +219,32 @@ public void dprintf(String s) { @Override public void cprintf(ServerEntity ent, int printlevel, String s) { - sv_game.PF_cprintf(ent, printlevel, s); + + int n = 0; + + if (ent != null) { + n = ent.index; + if (n < 1 || n > serverMain.getClients().size()) + Com.Error(Defines.ERR_DROP, "cprintf to a non-client"); + } + + if (ent != null) + SV_SEND.SV_ClientPrintf(serverMain.getClients().get(n - 1), printlevel, s); + else + Com.Printf(s); } @Override public void centerprintf(ServerEntity ent, String s) { // todo: check if s is not empty - sv_game.PF_Unicast(ent.index, true, new PrintCenterMessage(s)); + unicastMessage(ent.index, new PrintCenterMessage(s), true); } @Override public void sound(ServerEntity ent, int channel, int soundindex, float volume, float attenuation, float timeofs) { - sv_game.PF_StartSound(ent, channel, soundindex, volume, attenuation, - timeofs); + if (ent == null) return; + positioned_sound(null, ent, channel, soundindex, volume, attenuation, timeofs); } @Override @@ -252,7 +263,20 @@ public void positioned_sound(float[] origin, ServerEntity ent, int channel, */ @Override public void configstring(int num, String string) { - sv_game.PF_Configstring(num, string); + if (num < 0 || num >= Defines.MAX_CONFIGSTRINGS) + Com.Error(Defines.ERR_DROP, "configstring: bad index " + num + + "\n"); + + if (string == null) + string = ""; + + // Update this Game's resource table. + configstrings[num] = string; + + if (state != ServerStates.SS_LOADING) { + // send the update to everyone + multicastMessage(null, new ConfigStringMessage(num, string), MulticastTypes.MULTICAST_ALL_R); + } } @Override @@ -268,22 +292,36 @@ public void error(int level, String err) { /* the *index functions create configstrings and some internal server state */ @Override public int modelindex(String name) { - return sv_game.SV_FindIndex(name, Defines.CS_MODELS, Defines.MAX_MODELS, true); + return findConfigstringIndex(name, Defines.CS_MODELS, Defines.MAX_MODELS); } @Override public int soundindex(String name) { - return sv_game.SV_FindIndex(name, Defines.CS_SOUNDS, Defines.MAX_SOUNDS, true); + return findConfigstringIndex(name, Defines.CS_SOUNDS, Defines.MAX_SOUNDS); } @Override public int imageindex(String name) { - return sv_game.SV_FindIndex(name, Defines.CS_IMAGES, Defines.MAX_IMAGES, true); + return findConfigstringIndex(name, Defines.CS_IMAGES, Defines.MAX_IMAGES); } @Override public void setmodel(ServerEntity ent, String name) { - sv_game.PF_setmodel(ent, name); + + if (name == null) { + Com.DPrintf( "Error: GameImportsImpl.setmodel: name is null"); + return; + } + + ent.s.modelindex = findConfigstringIndex(name, Defines.CS_MODELS, Defines.MAX_MODELS); + + // if it is an inline model, get the size information for it + if (name.startsWith("*")) { + cmodel_t mod = cm.InlineModel(name); + Math3D.VectorCopy(mod.mins, ent.mins); + Math3D.VectorCopy(mod.maxs, ent.maxs); + SV_WORLD.SV_LinkEdict(ent, this); + } } /* collision detection */ @@ -295,7 +333,29 @@ public trace_t trace(float[] start, float[] mins, float[] maxs, @Override public boolean inPHS(float[] p1, float[] p2) { - return sv_game.PF_inPHS(p1, p2); + int leafnum; + int cluster; + int area1, area2; + byte mask[]; + + leafnum = cm.CM_PointLeafnum(p1); + cluster = cm.CM_LeafCluster(leafnum); + area1 = cm.CM_LeafArea(leafnum); + mask = cm.CM_ClusterPHS(cluster); + + leafnum = cm.CM_PointLeafnum(p2); + cluster = cm.CM_LeafCluster(leafnum); + area2 = cm.CM_LeafArea(leafnum); + + // quake2 bugfix + if (cluster == -1) + return false; + if (mask != null && (0 == (mask[cluster >> 3] & (1 << (cluster & 7))))) + return false; // more than one bounce away + if (!cm.CM_AreasConnected(area1, area2)) + return false; // a door blocks hearing + + return true; } @Override @@ -651,6 +711,75 @@ void SV_BroadcastCommand(String s) { } } + /** Find or allocate a configstring index within this Game's resource table. */ + private int findConfigstringIndex(String val, int start, int max) { + int i; + + if (val == null || val.length() == 0) + return 0; + + for (i = 1; i < max && configstrings[start + i] != null; i++) + if (val.equals(configstrings[start + i])) + return i; + + if (i == max) + Com.Error(Defines.ERR_DROP, "*Index: overflow"); + + configstring(start + i, val); + + return i; + } + + /** Build the entity baselines used to compress client updates. */ + void createBaselines() { + for (int entnum = 1; entnum < gameExports.getNumEdicts(); entnum++) { + ServerEntity svent = gameExports.getEdict(entnum); + + if (!svent.inuse) + continue; + if (0 == svent.s.modelindex && 0 == svent.s.sound + && 0 == svent.s.effects) + continue; + + svent.s.index = entnum; + + // take current state as baseline + Math3D.VectorCopy(svent.s.origin, svent.s.old_origin); + baselines[entnum].set(svent.s); + } + } + + /** Restore archived level state, settling revisited levels before clients reconnect. */ + void restorePreviousGame() { + + // get configstrings and areaportals + // then read game entities + readLevelFile(); + + if (!loadgame) { + // coming back to a level after being in a different + // level, so run it for 100 frames + + // assert state == LOADING, otherwise, updates from 100 frames may overflow the reliable channel + for (int i = 0; i < 100; i++) { + gameExports.G_RunFrame(); + } + } + } + + boolean isPreviousLevel() { + if (Cvar.getInstance().find("sv_noreload", Boolean.class).getTypedValue()) + return false; + + if (Cvar.getInstance().find("deathmatch", Boolean.class).getTypedValue()) + return false; + + String name = EngineWriteRoot.resolve("save/current/" + mapName + ".sav.json").toString(); + + return new File(name).exists(); + + } + /** Restore this game's configstrings, portals and gameplay state from the current save. */ void readLevelFile() { Com.DPrintf("SV_ReadLevelFile()\n"); @@ -830,6 +959,16 @@ public void multicastMessage(float[] origin, ServerMessage msg, MulticastTypes t @Override public void unicastMessage(int index, ServerMessage msg, boolean reliable) { - sv_game.PF_Unicast(index, reliable, msg); + + if (index < 1 || index > serverMain.getClients().size()) + return; + + ClientNetworkInfo client = serverMain.getClients().get(index - 1); + + if (reliable) { + client.netchan.reliablePending.add(msg); + } else + client.unreliable.add(msg); + } } diff --git a/server/src/main/java/jake2/server/SV_GAME.java b/server/src/main/java/jake2/server/SV_GAME.java deleted file mode 100644 index 0d229d2d..00000000 --- a/server/src/main/java/jake2/server/SV_GAME.java +++ /dev/null @@ -1,294 +0,0 @@ -/* - * Copyright (C) 1997-2001 Id Software, Inc. - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License as published by the Free Software - * Foundation; either version 2 of the License, or (at your option) any later - * version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. - * - * See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * this program; if not, write to the Free Software Foundation, Inc., 59 Temple - * Place - Suite 330, Boston, MA 02111-1307, USA. - * - */ - -// Created on 14.01.2004 by RST. -// $Id: SV_GAME.java,v 1.10 2006-01-21 21:53:32 salomo Exp $ -package jake2.server; - -import jake2.qcommon.*; -import jake2.qcommon.exec.Cvar; -import jake2.qcommon.network.MulticastTypes; -import jake2.qcommon.network.messages.NetworkMessage; -import jake2.qcommon.network.messages.server.ConfigStringMessage; -import jake2.qcommon.util.Math3D; -import jake2.qcommon.vfs.EngineWriteRoot; - -import java.io.File; - -public class SV_GAME { - final GameImportsImpl gameImports; - - public SV_GAME(GameImportsImpl gameImports) { - this.gameImports = gameImports; - } - - /** - * PF_Unicast - * - * Sends the contents of the mutlicast buffer to a single client. - */ - public void PF_Unicast(int index, boolean reliable, NetworkMessage msg) { - - if (index < 1 || index > gameImports.serverMain.getClients().size()) - return; - - ClientNetworkInfo client = gameImports.serverMain.getClients().get(index - 1); - - if (reliable) { - client.netchan.reliablePending.add(msg); - } else - client.unreliable.add(msg); - - } - - /** - * Centerprintf for critical messages. - */ - public void PF_cprintfhigh(ServerEntity ent, String fmt) { - PF_cprintf(ent, Defines.PRINT_HIGH, fmt); - } - - /** - * PF_cprintf - * - * Print to a single client. - */ - public void PF_cprintf(ServerEntity ent, int level, String fmt) { - - int n = 0; - - if (ent != null) { - n = ent.index; - if (n < 1 || n > gameImports.serverMain.getClients().size()) - Com.Error(Defines.ERR_DROP, "cprintf to a non-client"); - } - - if (ent != null) - SV_SEND.SV_ClientPrintf(gameImports.serverMain.getClients().get(n - 1), level, fmt); - else - Com.Printf(fmt); - } - - /** - * PF_error - * - * Abort the server with a game error. - */ - public void PF_error(String fmt) { - Com.Error(Defines.ERR_DROP, "Game Error: " + fmt); - } - - /** - * PF_setmodel - * - * Also sets mins and maxs for inline bmodels. - */ - public void PF_setmodel(ServerEntity ent, String name) { - - if (name == null) { - Com.DPrintf( "Error: SV_GAME.PF_setmodel: name is null"); - return; - } - - ent.s.modelindex = SV_FindIndex(name, Defines.CS_MODELS, Defines.MAX_MODELS, true); - - // if it is an inline model, get the size information for it - if (name.startsWith("*")) { - cmodel_t mod = gameImports.cm.InlineModel(name); - Math3D.VectorCopy(mod.mins, ent.mins); - Math3D.VectorCopy(mod.maxs, ent.maxs); - SV_WORLD.SV_LinkEdict(ent, gameImports); - } - } - - /** - * Change i-th configstring to 'val' and multicast it to everyone reliably - */ - void PF_Configstring(int index, String val) { - if (index < 0 || index >= Defines.MAX_CONFIGSTRINGS) - Com.Error(Defines.ERR_DROP, "configstring: bad index " + index - + "\n"); - - if (val == null) - val = ""; - - // change the string in sv - gameImports.configstrings[index] = val; - - if (gameImports.state != ServerStates.SS_LOADING) { - // send the update to everyone - gameImports.multicastMessage(null, new ConfigStringMessage(index, val), MulticastTypes.MULTICAST_ALL_R); - } - } - - /** - * PF_inPVS - * - * Also checks portalareas so that doors block sight. - */ - public boolean PF_inPVS(float[] p1, float[] p2) { - int leafnum; - int cluster; - int area1, area2; - byte mask[]; - - leafnum = gameImports.cm.CM_PointLeafnum(p1); - cluster = gameImports.cm.CM_LeafCluster(leafnum); - area1 = gameImports.cm.CM_LeafArea(leafnum); - mask = gameImports.cm.CM_ClusterPVS(cluster); - - leafnum = gameImports.cm.CM_PointLeafnum(p2); - cluster = gameImports.cm.CM_LeafCluster(leafnum); - area2 = gameImports.cm.CM_LeafArea(leafnum); - - // quake2 bugfix - if (cluster == -1) - return false; - if (mask != null && (0 == (mask[cluster >>> 3] & (1 << (cluster & 7))))) - return false; - - if (!gameImports.cm.CM_AreasConnected(area1, area2)) - return false; // a door blocks sight - - return true; - } - - /** - * PF_inPHS. - * - * Also checks portalareas so that doors block sound. - */ - public boolean PF_inPHS(float[] p1, float[] p2) { - int leafnum; - int cluster; - int area1, area2; - byte mask[]; - - leafnum = gameImports.cm.CM_PointLeafnum(p1); - cluster = gameImports.cm.CM_LeafCluster(leafnum); - area1 = gameImports.cm.CM_LeafArea(leafnum); - mask = gameImports.cm.CM_ClusterPHS(cluster); - - leafnum = gameImports.cm.CM_PointLeafnum(p2); - cluster = gameImports.cm.CM_LeafCluster(leafnum); - area2 = gameImports.cm.CM_LeafArea(leafnum); - - // quake2 bugfix - if (cluster == -1) - return false; - if (mask != null && (0 == (mask[cluster >> 3] & (1 << (cluster & 7))))) - return false; // more than one bounce away - if (!gameImports.cm.CM_AreasConnected(area1, area2)) - return false; // a door blocks hearing - - return true; - } - - public void PF_StartSound(ServerEntity entity, int channel, - int sound_num, float volume, float attenuation, float timeofs) { - - if (null == entity) - return; - SV_SEND.SV_StartSound(null, entity, channel, sound_num, volume, - attenuation, timeofs, gameImports); - - } - - /** - * Find an index of a configstring with `val` value, or create a new one. - */ - int SV_FindIndex(String val, int start, int max, boolean create) { - int i; - - if (val == null || val.length() == 0) - return 0; - - for (i = 1; i < max && gameImports.configstrings[start + i] != null; i++) - if (val.equals(gameImports.configstrings[start + i])) - return i; - - if (!create) - return 0; - - if (i == max) - Com.Error(Defines.ERR_DROP, "*Index: overflow"); - - PF_Configstring(start + i, val); - - return i; - } - - /** - * SV_CreateBaseline - * - * Entity baselines are used to compress the update messages to the clients -- - * only the fields that differ from the baseline will be transmitted. - */ - void SV_CreateBaseline() { - for (int entnum = 1; entnum < gameImports.gameExports.getNumEdicts(); entnum++) { - ServerEntity svent = gameImports.gameExports.getEdict(entnum); - - if (!svent.inuse) - continue; - if (0 == svent.s.modelindex && 0 == svent.s.sound - && 0 == svent.s.effects) - continue; - - svent.s.index = entnum; - - // take current state as baseline - Math3D.VectorCopy(svent.s.origin, svent.s.old_origin); - gameImports.baselines[entnum].set(svent.s); - } - } - - /** - * SV_CheckForSavegame. - */ - void restorePreviousGame() { - - // get configstrings and areaportals - // then read game enitites - gameImports.readLevelFile(); - - if (!gameImports.loadgame) { - // coming back to a level after being in a different - // level, so run it for 100 frames - - // assert gameImports.state == LOADING, otherwise, updates from 100 frames may overflow the reliable channel - for (int i = 0; i < 100; i++) { - gameImports.gameExports.G_RunFrame(); - } - } - } - - boolean isPreviousLevel() { - if (Cvar.getInstance().find("sv_noreload", Boolean.class).getTypedValue()) - return false; - - if (Cvar.getInstance().find("deathmatch", Boolean.class).getTypedValue()) - return false; - - String name = EngineWriteRoot.resolve("save/current/" + gameImports.mapName + ".sav.json").toString(); - - return new File(name).exists(); - - } -} diff --git a/server/src/main/java/jake2/server/SV_MAIN.java b/server/src/main/java/jake2/server/SV_MAIN.java index e9183522..54aeff0f 100644 --- a/server/src/main/java/jake2/server/SV_MAIN.java +++ b/server/src/main/java/jake2/server/SV_MAIN.java @@ -1408,13 +1408,13 @@ GameImportsImpl spawnServerInstance(ChangeMapInfo changeMapInfo, int clientIndex // spawn fresh entities gameImports.gameExports.SpawnEntities(gameImports.mapName, gameImports.cm.CM_EntityString(), changeMapInfo.spawnPoint); - if (gameImports.sv_game.isPreviousLevel()) { + if (gameImports.isPreviousLevel()) { // fixme: need to spawn all entities and then remove them, // because otherwise precaching will not work properly - configstrings will not match. SV_WORLD.SV_ClearWorld(gameImports); // loads a stored game, runs 100 frames afterwards - gameImports.sv_game.restorePreviousGame(); + gameImports.restorePreviousGame(); } else { // run two frames to allow everything to settle gameImports.gameExports.G_RunFrame(); @@ -1422,7 +1422,7 @@ GameImportsImpl spawnServerInstance(ChangeMapInfo changeMapInfo, int clientIndex } // create a baseline for more efficient communications - gameImports.sv_game.SV_CreateBaseline(); + gameImports.createBaselines(); // Level restoration may replace configstrings, including saves from before air movement was published. From 7abcc6b8e50330bff6fde92f483a848b53bc76e4 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 17:40:59 +0100 Subject: [PATCH 06/47] Consolidate spatial state and operations into the game instance --- .../java/jake2/server/GameImportsTest.java | 39 ++ .../java/jake2/server/GameImportsImpl.java | 400 +++++++++++++- .../src/main/java/jake2/server/SV_MAIN.java | 2 +- .../src/main/java/jake2/server/SV_WORLD.java | 497 ------------------ 4 files changed, 429 insertions(+), 509 deletions(-) delete mode 100644 server/src/main/java/jake2/server/SV_WORLD.java diff --git a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java index 253aa431..53ffec8f 100644 --- a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java +++ b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java @@ -3,6 +3,7 @@ import jake2.game.GameEntity; import jake2.game.monsters.M_Gunner; import jake2.qcommon.GameExports; +import jake2.qcommon.ServerEntity; import jake2.qcommon.exec.Cvar; import jake2.qcommon.MainCommon; import jake2.qcommon.Globals; @@ -124,6 +125,44 @@ public void resourceUpdatesStayWithTheirGameAndOnlySendAfterLoading() { } } + @Test + public void spatialQueriesAndClearingStayWithTheirGame() { + manager = new SV_MAIN(); + GameImportsImpl first = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); + GameImportsImpl second = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); + ServerEntity firstEntity = first.gameExports.getEdict(1); + ServerEntity secondEntity = second.gameExports.getEdict(1); + for (ServerEntity entity : new ServerEntity[]{firstEntity, secondEntity}) { + entity.inuse = true; + entity.solid = Defines.SOLID_BBOX; + entity.s.origin[2] = 64; + java.util.Arrays.fill(entity.mins, -8); + java.util.Arrays.fill(entity.maxs, 8); + } + first.linkentity(firstEntity); + second.linkentity(secondEntity); + float[] mins = {-16, -16, 48}; + float[] maxs = {16, 16, 80}; + ServerEntity[] found = new ServerEntity[8]; + Assertions.assertEquals(1, first.BoxEdicts(mins, maxs, found, found.length, Defines.AREA_SOLID)); + Assertions.assertSame(firstEntity, found[0]); + Assertions.assertEquals(1, second.BoxEdicts(mins, maxs, found, found.length, Defines.AREA_SOLID)); + Assertions.assertSame(secondEntity, found[0]); + + float[] start = {-64, 0, 64}; + float[] end = {64, 0, 64}; + Assertions.assertSame(firstEntity, first.trace(start, null, null, end, null, Defines.MASK_PLAYERSOLID).ent); + Assertions.assertSame(secondEntity, second.trace(start, null, null, end, null, Defines.MASK_PLAYERSOLID).ent); + first.unlinkentity(firstEntity); + Assertions.assertEquals(0, first.BoxEdicts(mins, maxs, found, found.length, Defines.AREA_SOLID)); + first.linkentity(firstEntity); + first.clearWorld(); + Assertions.assertEquals(0, first.BoxEdicts(mins, maxs, found, found.length, Defines.AREA_SOLID)); + Assertions.assertEquals(1, second.BoxEdicts(mins, maxs, found, found.length, Defines.AREA_SOLID)); + Assertions.assertSame(secondEntity, found[0]); + Assertions.assertSame(secondEntity, second.trace(start, null, null, end, null, Defines.MASK_PLAYERSOLID).ent); + } + @Test public void runGame() { diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index ed89a400..a2b65057 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -80,7 +80,20 @@ public class GameImportsImpl implements GameImports { String mapcmd = ""; // ie: *intro.cin+base - SV_WORLD world; + // Spatial indexing and query scratch state belong to this Game. + private static final int MAX_TOTAL_ENT_LEAFS = 128; + private areanode_t[] areaNodes; + private int areaNodeCount; + private float[] areaMins; + private float[] areaMaxs; + private ServerEntity[] areaEntities; + private int areaCount; + private int areaMaxCount; + private int areaType; + private final ServerEntity[] pointContentsTouches = new ServerEntity[Defines.MAX_EDICTS]; + private final ServerEntity[] traceTouches = new ServerEntity[Defines.MAX_EDICTS]; + private final int[] entityLeafs = new int[MAX_TOTAL_ENT_LEAFS]; + private final int[] entityClusters = new int[MAX_TOTAL_ENT_LEAFS]; private final Cvar localCvars = new Cvar(); @@ -105,8 +118,6 @@ public GameImportsImpl(JakeServer serverMain, ChangeMapInfo changeMapInfo) { spawncount = Lib.rand(); - world = new SV_WORLD(); - cm = new CM(); sv_ents = new SV_ENTS(this, serverMain.getClients().size() * Defines.UPDATE_BACKUP * 64); @@ -155,7 +166,7 @@ public GameImportsImpl(JakeServer serverMain, ChangeMapInfo changeMapInfo) { // clear physics interaction links // Q: is it required? the instance was just created - SV_WORLD.SV_ClearWorld(this); + clearWorld(); SV_InitOperatorCommands(); @@ -320,7 +331,7 @@ public void setmodel(ServerEntity ent, String name) { cmodel_t mod = cm.InlineModel(name); Math3D.VectorCopy(mod.mins, ent.mins); Math3D.VectorCopy(mod.maxs, ent.maxs); - SV_WORLD.SV_LinkEdict(ent, this); + linkentity(ent); } } @@ -328,7 +339,31 @@ public void setmodel(ServerEntity ent, String name) { @Override public trace_t trace(float[] start, float[] mins, float[] maxs, float[] end, ServerEntity passent, int contentmask) { - return SV_WORLD.SV_Trace(start, mins, maxs, end, passent, contentmask, this); + moveclip_t clip = new moveclip_t(); + if (mins == null) + mins = Globals.vec3_origin; + if (maxs == null) + maxs = Globals.vec3_origin; + + // clip to world + clip.trace = cm.BoxTrace(start, end, mins, maxs, 0, contentmask); + clip.trace.ent = gameExports.getEdict(0); + if (clip.trace.fraction == 0) + return clip.trace; // blocked by the world + clip.contentmask = contentmask; + clip.start = start; + clip.end = end; + clip.mins = mins; + clip.maxs = maxs; + clip.passedict = passent; + Math3D.VectorCopy(mins, clip.mins2); + Math3D.VectorCopy(maxs, clip.maxs2); + // create the bounding box of the entire move + traceBounds(start, clip.mins2, clip.maxs2, end, clip.boxmins, + clip.boxmaxs); + // clip to other solid entities + clipMoveToEntities(clip); + return clip.trace; } @Override @@ -375,19 +410,171 @@ public boolean AreasConnected(int area1, int area2) { */ @Override public void linkentity(ServerEntity ent) { - SV_WORLD.SV_LinkEdict(ent, this); + areanode_t node; + int num_leafs; + int j, k; + int area; + int topnode = 0; + if (ent.area.prev != null) + unlinkentity(ent); // unlink from old position + if (ent == gameExports.getEdict(0)) + return; // don't add the world + if (!ent.inuse) + return; + // set the size + Math3D.VectorSubtract(ent.maxs, ent.mins, ent.size); + // encode the size into the entity_state for client prediction + if (ent.solid == Defines.SOLID_BBOX + && 0 == (ent.svflags & Defines.SVF_DEADMONSTER)) { + // assume that x/y are equal and symetric + int i = (int) (ent.maxs[0] / 8); + if (i < 1) + i = 1; + if (i > 31) + i = 31; + // z is not symetric + j = (int) ((-ent.mins[2]) / 8); + if (j < 1) + j = 1; + if (j > 31) + j = 31; + // and z maxs can be negative... + k = (int) ((ent.maxs[2] + 32) / 8); + if (k < 1) + k = 1; + if (k > 63) + k = 63; + ent.s.solid = (k << 10) | (j << 5) | i; + } else if (ent.solid == Defines.SOLID_BSP) { + ent.s.solid = 31; // a solid_bbox will never create this value + } else + ent.s.solid = 0; + // set the abs box + if (ent.solid == Defines.SOLID_BSP + && (ent.s.angles[0] != 0 || ent.s.angles[1] != 0 || ent.s.angles[2] != 0)) { + // expand for rotation + float max, v; + max = 0; + for (int i = 0; i < 3; i++) { + v = Math.abs(ent.mins[i]); + if (v > max) + max = v; + v = Math.abs(ent.maxs[i]); + if (v > max) + max = v; + } + for (int i = 0; i < 3; i++) { + ent.absmin[i] = ent.s.origin[i] - max; + ent.absmax[i] = ent.s.origin[i] + max; + } + } else { + // normal + Math3D.VectorAdd(ent.s.origin, ent.mins, ent.absmin); + Math3D.VectorAdd(ent.s.origin, ent.maxs, ent.absmax); + } + // because movement is clipped an epsilon away from an actual edge, + // we must fully check even when bounding boxes don't quite touch + ent.absmin[0]--; + ent.absmin[1]--; + ent.absmin[2]--; + ent.absmax[0]++; + ent.absmax[1]++; + ent.absmax[2]++; + // link to PVS leafs + ent.num_clusters = 0; + ent.areanum = 0; + ent.areanum2 = 0; + // get all leafs, including solids + int iw[] = { topnode }; + num_leafs = cm.CM_BoxLeafnums(ent.absmin, ent.absmax, this.entityLeafs, MAX_TOTAL_ENT_LEAFS, iw); + topnode = iw[0]; + // set areas + for (int i = 0; i < num_leafs; i++) { + this.entityClusters[i] = cm.CM_LeafCluster(this.entityLeafs[i]); + area = cm.CM_LeafArea(this.entityLeafs[i]); + if (area != 0) { + // doors may legally straggle two areas, + // but nothing should ever need more than that + if (ent.areanum != 0 && ent.areanum != area) { + if (ent.areanum2 != 0 && ent.areanum2 != area + && state == ServerStates.SS_LOADING) + Com.DPrintf("Object touching 3 areas at " + + ent.absmin[0] + " " + ent.absmin[1] + " " + + ent.absmin[2] + "\n"); + ent.areanum2 = area; + } else + ent.areanum = area; + } + } + if (num_leafs >= MAX_TOTAL_ENT_LEAFS) { + // assume we missed some leafs, and mark by headnode + ent.num_clusters = -1; + ent.headnode = topnode; + } else { + ent.num_clusters = 0; + for (int i = 0; i < num_leafs; i++) { + if (this.entityClusters[i] == -1) + continue; // not a visible leaf + for (j = 0; j < i; j++) + if (this.entityClusters[j] == this.entityClusters[i]) + break; + if (j == i) { + if (ent.num_clusters == Defines.MAX_ENT_CLUSTERS) { + // assume we missed some leafs, and mark by headnode + ent.num_clusters = -1; + ent.headnode = topnode; + break; + } + ent.clusternums[ent.num_clusters++] = this.entityClusters[i]; + } + } + } + // if first time, make sure old_origin is valid + if (0 == ent.linkcount) { + Math3D.VectorCopy(ent.s.origin, ent.s.old_origin); + } + ent.linkcount++; + if (ent.solid == Defines.SOLID_NOT) + return; + // find the first node that the ent's box crosses + node = this.areaNodes[0]; + while (true) { + if (node.axis == -1) + break; + if (ent.absmin[node.axis] > node.dist) + node = node.children[0]; + else if (ent.absmax[node.axis] < node.dist) + node = node.children[1]; + else + break; // crosses the node + } + // link it in + if (ent.solid == Defines.SOLID_TRIGGER) + insertLinkBefore(ent.area, node.trigger_edicts); + else + insertLinkBefore(ent.area, node.solid_edicts); } @Override public void unlinkentity(ServerEntity ent) { - SV_WORLD.SV_UnlinkEdict(ent); + if (null == ent.area.prev) + return; // not linked in anywhere + removeLink(ent.area); + ent.area.prev = ent.area.next = null; } - /* call before removing an interactive edict */ + /** Collect linked entities overlapping the supplied bounds in this Game. */ @Override public int BoxEdicts(float[] mins, float[] maxs, ServerEntity list[], int maxcount, int areatype) { - return SV_WORLD.SV_AreaEdicts(mins, maxs, list, maxcount, areatype, this); + this.areaMins = mins; + this.areaMaxs = maxs; + this.areaEntities = list; + this.areaCount = 0; + this.areaMaxCount = maxcount; + this.areaType = areatype; + collectAreaEntities(this.areaNodes[0]); + return this.areaCount; } @Override @@ -424,7 +611,25 @@ public void AddCommandString(String text) { @Override public int getPointContents(float[] p) { - return SV_WORLD.SV_PointContents(p, this); + ServerEntity hit; + int i, num; + int contents, c2; + int headnode; + // get base contents from world + contents = cm.PointContents(p, models[1].headnode); + // or in contents from all the other entities + num = BoxEdicts(p, p, this.pointContentsTouches, Defines.MAX_EDICTS, Defines.AREA_SOLID); + for (i = 0; i < num; i++) { + hit = this.pointContentsTouches[i]; + // might intersect, so do an exact clip + headnode = hullForEntity(hit); + if (hit.solid != Defines.SOLID_BSP) { + } + c2 = cm.TransformedPointContents(p, headnode, hit.s.origin, + hit.s.angles); + contents |= c2; + } + return contents; } private void SV_Savegame_f(List args) { @@ -711,6 +916,179 @@ void SV_BroadcastCommand(String s) { } } + /** Reset this Game's spatial index before spawning or restoring entities. */ + void clearWorld() { + areaNodeCount = 0; + areaNodes = new areanode_t[Defines.AREA_NODES]; + for (int i = 0; i < areaNodes.length; i++) areaNodes[i] = new areanode_t(); + createAreaNode(0, models[1].mins, models[1].maxs); + } + + private static void clearLink(link_t l) { + l.prev = l.next = l; + } + + private static void removeLink(link_t l) { + l.next.prev = l.prev; + l.prev.next = l.next; + } + + private static void insertLinkBefore(link_t l, link_t before) { + l.next = before; + l.prev = before.prev; + l.prev.next = l; + l.next.prev = l; + } + + private areanode_t createAreaNode(int depth, float[] mins, float[] maxs) { + areanode_t anode; + float[] size = { 0, 0, 0 }; + float[] mins1 = { 0, 0, 0 }, maxs1 = { 0, 0, 0 }, mins2 = { 0, 0, 0 }, maxs2 = { + 0, 0, 0 }; + anode = this.areaNodes[this.areaNodeCount]; + // just for debugging (rst) +// Math3D.VectorCopy(mins, anode.mins_rst); +// Math3D.VectorCopy(maxs, anode.maxs_rst); + this.areaNodeCount++; + clearLink(anode.trigger_edicts); + clearLink(anode.solid_edicts); + if (depth == Defines.AREA_DEPTH) { + anode.axis = -1; + anode.children[0] = anode.children[1] = null; + return anode; + } + Math3D.VectorSubtract(maxs, mins, size); + if (size[0] > size[1]) + anode.axis = 0; + else + anode.axis = 1; + anode.dist = 0.5f * (maxs[anode.axis] + mins[anode.axis]); + Math3D.VectorCopy(mins, mins1); + Math3D.VectorCopy(mins, mins2); + Math3D.VectorCopy(maxs, maxs1); + Math3D.VectorCopy(maxs, maxs2); + maxs1[anode.axis] = mins2[anode.axis] = anode.dist; + anode.children[0] = createAreaNode(depth + 1, mins2, maxs2); + anode.children[1] = createAreaNode(depth + 1, mins1, maxs1); + return anode; + } + + private void collectAreaEntities(areanode_t node) { + link_t l, next, start; + ServerEntity check; + // touch linked edicts + if (this.areaType == Defines.AREA_SOLID) + start = node.solid_edicts; + else + start = node.trigger_edicts; + for (l = start.next; l != start; l = next) { + next = l.next; + check = (ServerEntity) l.o; + if (check.solid == Defines.SOLID_NOT) + continue; // deactivated + if (check.absmin[0] > this.areaMaxs[0] + || check.absmin[1] > this.areaMaxs[1] + || check.absmin[2] > this.areaMaxs[2] + || check.absmax[0] < this.areaMins[0] + || check.absmax[1] < this.areaMins[1] + || check.absmax[2] < this.areaMins[2]) + continue; // not touching + if (this.areaCount == this.areaMaxCount) { + Com.Printf("BoxEdicts: MAXCOUNT\n"); + return; + } + this.areaEntities[this.areaCount] = check; + this.areaCount++; + } + if (node.axis == -1) + return; // terminal node + // recurse down both sides + if (this.areaMaxs[node.axis] > node.dist) + collectAreaEntities(node.children[0]); + if (this.areaMins[node.axis] < node.dist) + collectAreaEntities(node.children[1]); + } + + private int hullForEntity(ServerEntity ent) { + cmodel_t model; + // decide which clipping hull to use, based on the size + if (ent.solid == Defines.SOLID_BSP) { + // explicit hulls in the BSP model + model = models[ent.s.modelindex]; + if (null == model) + Com.Error(Defines.ERR_FATAL, + "MOVETYPE_PUSH with a non bsp model"); + return model.headnode; + } + // create a temp hull from bounding box sizes + return cm.HeadnodeForBox(ent.mins, ent.maxs); + } + + private void clipMoveToEntities(moveclip_t clip) { + int i, num; + ServerEntity touch; + trace_t trace; + int headnode; + float angles[]; + num = BoxEdicts(clip.boxmins, clip.boxmaxs, this.traceTouches, Defines.MAX_EDICTS, Defines.AREA_SOLID); + // be careful, it is possible to have an entity in this + // list removed before we get to it (killtriggered) + for (i = 0; i < num; i++) { + touch = this.traceTouches[i]; + if (touch.solid == Defines.SOLID_NOT) + continue; + if (touch == clip.passedict) + continue; + if (clip.trace.allsolid) + return; + if (clip.passedict != null) { + if (touch.getOwner() == clip.passedict) + continue; // don't clip against own missiles + if (clip.passedict.getOwner() == touch) + continue; // don't clip against owner + } + if (0 == (clip.contentmask & Defines.CONTENTS_DEADMONSTER) + && 0 != (touch.svflags & Defines.SVF_DEADMONSTER)) + continue; + // might intersect, so do an exact clip + headnode = hullForEntity(touch); + angles = touch.s.angles; + if (touch.solid != Defines.SOLID_BSP) + angles = Globals.vec3_origin; // boxes don't rotate + if ((touch.svflags & Defines.SVF_MONSTER) != 0) + trace = cm.TransformedBoxTrace(clip.start, clip.end, + clip.mins2, clip.maxs2, headnode, clip.contentmask, + touch.s.origin, angles); + else + trace = cm.TransformedBoxTrace(clip.start, clip.end, clip.mins, + clip.maxs, headnode, clip.contentmask, touch.s.origin, + angles); + if (trace.allsolid || trace.startsolid + || trace.fraction < clip.trace.fraction) { + trace.ent = touch; + if (clip.trace.startsolid) { + clip.trace = trace; + clip.trace.startsolid = true; + } else + clip.trace.set(trace); + } else if (trace.startsolid) + clip.trace.startsolid = true; + } + } + + private static void traceBounds(float[] start, float[] mins, float[] maxs, float[] end, float[] boxmins, float[] boxmaxs) { + int i; + for (i = 0; i < 3; i++) { + if (end[i] > start[i]) { + boxmins[i] = start[i] + mins[i] - 1; + boxmaxs[i] = end[i] + maxs[i] + 1; + } else { + boxmins[i] = end[i] + mins[i] - 1; + boxmaxs[i] = start[i] + maxs[i] + 1; + } + } + } + /** Find or allocate a configstring index within this Game's resource table. */ private int findConfigstringIndex(String val, int start, int max) { int i; diff --git a/server/src/main/java/jake2/server/SV_MAIN.java b/server/src/main/java/jake2/server/SV_MAIN.java index 54aeff0f..f9bffd1b 100644 --- a/server/src/main/java/jake2/server/SV_MAIN.java +++ b/server/src/main/java/jake2/server/SV_MAIN.java @@ -1411,7 +1411,7 @@ GameImportsImpl spawnServerInstance(ChangeMapInfo changeMapInfo, int clientIndex if (gameImports.isPreviousLevel()) { // fixme: need to spawn all entities and then remove them, // because otherwise precaching will not work properly - configstrings will not match. - SV_WORLD.SV_ClearWorld(gameImports); + gameImports.clearWorld(); // loads a stored game, runs 100 frames afterwards gameImports.restorePreviousGame(); diff --git a/server/src/main/java/jake2/server/SV_WORLD.java b/server/src/main/java/jake2/server/SV_WORLD.java deleted file mode 100644 index 313fb318..00000000 --- a/server/src/main/java/jake2/server/SV_WORLD.java +++ /dev/null @@ -1,497 +0,0 @@ -/* - * Copyright (C) 1997-2001 Id Software, Inc. - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License as published by the Free Software - * Foundation; either version 2 of the License, or (at your option) any later - * version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. - * - * See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * this program; if not, write to the Free Software Foundation, Inc., 59 Temple - * Place - Suite 330, Boston, MA 02111-1307, USA. - * - */ -// Created on 07.01.2000 by RST. - -package jake2.server; - -import jake2.qcommon.*; -import jake2.qcommon.util.Math3D; - -class SV_WORLD { - - //ENTITY AREA CHECKING - // World related - //FIXME: this use of "area" is different from the bsp file use - areanode_t[] sv_areanodes; - int sv_numareanodes; - float[] area_mins; - float[] area_maxs; - ServerEntity[] area_list; - int area_count; - int area_maxcount; - int area_type; - ServerEntity[] touch = new ServerEntity[Defines.MAX_EDICTS]; - ServerEntity[] touchlist = new ServerEntity[Defines.MAX_EDICTS]; - public final int MAX_TOTAL_ENT_LEAFS = 128; - int[] leafs = new int[MAX_TOTAL_ENT_LEAFS]; - int[] clusters = new int[MAX_TOTAL_ENT_LEAFS]; - - /** - * SV_WORLD.initNodes() - */ - void clearAreaNodes() { - sv_numareanodes = 0; - sv_areanodes = new areanode_t[Defines.AREA_NODES]; - for (int n = 0; n < Defines.AREA_NODES; n++) - sv_areanodes[n] = new areanode_t(); - } - - // ClearLink is used for new headnodes - private static void ClearLink(link_t l) { - l.prev = l.next = l; - } - - private static void RemoveLink(link_t l) { - l.next.prev = l.prev; - l.prev.next = l.next; - } - - private static void InsertLinkBefore(link_t l, link_t before) { - l.next = before; - l.prev = before.prev; - l.prev.next = l; - l.next.prev = l; - } - - /* - * =============== SV_CreateAreaNode - * - * Builds a uniformly subdivided tree for the given world size - * =============== - */ - private static areanode_t SV_CreateAreaNode(int depth, float[] mins, - float[] maxs, GameImportsImpl gameImports) { - areanode_t anode; - float[] size = { 0, 0, 0 }; - float[] mins1 = { 0, 0, 0 }, maxs1 = { 0, 0, 0 }, mins2 = { 0, 0, 0 }, maxs2 = { - 0, 0, 0 }; - anode = gameImports.world.sv_areanodes[gameImports.world.sv_numareanodes]; - // just for debugging (rst) -// Math3D.VectorCopy(mins, anode.mins_rst); -// Math3D.VectorCopy(maxs, anode.maxs_rst); - gameImports.world.sv_numareanodes++; - ClearLink(anode.trigger_edicts); - ClearLink(anode.solid_edicts); - if (depth == Defines.AREA_DEPTH) { - anode.axis = -1; - anode.children[0] = anode.children[1] = null; - return anode; - } - Math3D.VectorSubtract(maxs, mins, size); - if (size[0] > size[1]) - anode.axis = 0; - else - anode.axis = 1; - anode.dist = 0.5f * (maxs[anode.axis] + mins[anode.axis]); - Math3D.VectorCopy(mins, mins1); - Math3D.VectorCopy(mins, mins2); - Math3D.VectorCopy(maxs, maxs1); - Math3D.VectorCopy(maxs, maxs2); - maxs1[anode.axis] = mins2[anode.axis] = anode.dist; - anode.children[0] = SV_CreateAreaNode(depth + 1, mins2, maxs2, gameImports); - anode.children[1] = SV_CreateAreaNode(depth + 1, mins1, maxs1, gameImports); - return anode; - } - - /* - * =============== SV_ClearWorld - * - * =============== - */ - static void SV_ClearWorld(GameImportsImpl gameImports) { - gameImports.world.clearAreaNodes(); - SV_CreateAreaNode(0, gameImports.models[1].mins, gameImports.models[1].maxs, gameImports); - /* - * Com.p("areanodes:" + sv_numareanodes + " (sollten 32 sein)."); for - * (int n = 0; n < sv_numareanodes; n++) { Com.Printf( "|%3i|%2i|%8.2f - * |%8.2f|%8.2f|%8.2f| %8.2f|%8.2f|%8.2f|\n", new Vargs() .add(n) - * .add(sv_areanodes[n].axis) .add(sv_areanodes[n].dist) - * .add(sv_areanodes[n].mins_rst[0]) .add(sv_areanodes[n].mins_rst[1]) - * .add(sv_areanodes[n].mins_rst[2]) .add(sv_areanodes[n].maxs_rst[0]) - * .add(sv_areanodes[n].maxs_rst[1]) .add(sv_areanodes[n].maxs_rst[2])); } - */ - } - - /* - * =============== SV_UnlinkEdict =============== - */ - static void SV_UnlinkEdict(ServerEntity ent) { - if (null == ent.area.prev) - return; // not linked in anywhere - RemoveLink(ent.area); - ent.area.prev = ent.area.next = null; - } - - static void SV_LinkEdict(ServerEntity ent, GameImportsImpl gameImports) { - areanode_t node; - int num_leafs; - int j, k; - int area; - int topnode = 0; - if (ent.area.prev != null) - SV_UnlinkEdict(ent); // unlink from old position - if (ent == gameImports.gameExports.getEdict(0)) - return; // don't add the world - if (!ent.inuse) - return; - // set the size - Math3D.VectorSubtract(ent.maxs, ent.mins, ent.size); - // encode the size into the entity_state for client prediction - if (ent.solid == Defines.SOLID_BBOX - && 0 == (ent.svflags & Defines.SVF_DEADMONSTER)) { - // assume that x/y are equal and symetric - int i = (int) (ent.maxs[0] / 8); - if (i < 1) - i = 1; - if (i > 31) - i = 31; - // z is not symetric - j = (int) ((-ent.mins[2]) / 8); - if (j < 1) - j = 1; - if (j > 31) - j = 31; - // and z maxs can be negative... - k = (int) ((ent.maxs[2] + 32) / 8); - if (k < 1) - k = 1; - if (k > 63) - k = 63; - ent.s.solid = (k << 10) | (j << 5) | i; - } else if (ent.solid == Defines.SOLID_BSP) { - ent.s.solid = 31; // a solid_bbox will never create this value - } else - ent.s.solid = 0; - // set the abs box - if (ent.solid == Defines.SOLID_BSP - && (ent.s.angles[0] != 0 || ent.s.angles[1] != 0 || ent.s.angles[2] != 0)) { - // expand for rotation - float max, v; - max = 0; - for (int i = 0; i < 3; i++) { - v = Math.abs(ent.mins[i]); - if (v > max) - max = v; - v = Math.abs(ent.maxs[i]); - if (v > max) - max = v; - } - for (int i = 0; i < 3; i++) { - ent.absmin[i] = ent.s.origin[i] - max; - ent.absmax[i] = ent.s.origin[i] + max; - } - } else { - // normal - Math3D.VectorAdd(ent.s.origin, ent.mins, ent.absmin); - Math3D.VectorAdd(ent.s.origin, ent.maxs, ent.absmax); - } - // because movement is clipped an epsilon away from an actual edge, - // we must fully check even when bounding boxes don't quite touch - ent.absmin[0]--; - ent.absmin[1]--; - ent.absmin[2]--; - ent.absmax[0]++; - ent.absmax[1]++; - ent.absmax[2]++; - // link to PVS leafs - ent.num_clusters = 0; - ent.areanum = 0; - ent.areanum2 = 0; - // get all leafs, including solids - int iw[] = { topnode }; - num_leafs = gameImports.cm.CM_BoxLeafnums(ent.absmin, ent.absmax, gameImports.world.leafs, gameImports.world.MAX_TOTAL_ENT_LEAFS, iw); - topnode = iw[0]; - // set areas - for (int i = 0; i < num_leafs; i++) { - gameImports.world.clusters[i] = gameImports.cm.CM_LeafCluster(gameImports.world.leafs[i]); - area = gameImports.cm.CM_LeafArea(gameImports.world.leafs[i]); - if (area != 0) { - // doors may legally straggle two areas, - // but nothing should ever need more than that - if (ent.areanum != 0 && ent.areanum != area) { - if (ent.areanum2 != 0 && ent.areanum2 != area - && gameImports.state == ServerStates.SS_LOADING) - Com.DPrintf("Object touching 3 areas at " - + ent.absmin[0] + " " + ent.absmin[1] + " " - + ent.absmin[2] + "\n"); - ent.areanum2 = area; - } else - ent.areanum = area; - } - } - if (num_leafs >= gameImports.world.MAX_TOTAL_ENT_LEAFS) { - // assume we missed some leafs, and mark by headnode - ent.num_clusters = -1; - ent.headnode = topnode; - } else { - ent.num_clusters = 0; - for (int i = 0; i < num_leafs; i++) { - if (gameImports.world.clusters[i] == -1) - continue; // not a visible leaf - for (j = 0; j < i; j++) - if (gameImports.world.clusters[j] == gameImports.world.clusters[i]) - break; - if (j == i) { - if (ent.num_clusters == Defines.MAX_ENT_CLUSTERS) { - // assume we missed some leafs, and mark by headnode - ent.num_clusters = -1; - ent.headnode = topnode; - break; - } - ent.clusternums[ent.num_clusters++] = gameImports.world.clusters[i]; - } - } - } - // if first time, make sure old_origin is valid - if (0 == ent.linkcount) { - Math3D.VectorCopy(ent.s.origin, ent.s.old_origin); - } - ent.linkcount++; - if (ent.solid == Defines.SOLID_NOT) - return; - // find the first node that the ent's box crosses - node = gameImports.world.sv_areanodes[0]; - while (true) { - if (node.axis == -1) - break; - if (ent.absmin[node.axis] > node.dist) - node = node.children[0]; - else if (ent.absmax[node.axis] < node.dist) - node = node.children[1]; - else - break; // crosses the node - } - // link it in - if (ent.solid == Defines.SOLID_TRIGGER) - InsertLinkBefore(ent.area, node.trigger_edicts); - else - InsertLinkBefore(ent.area, node.solid_edicts); - } - - /* - * ==================== SV_AreaEdicts_r - * - * ==================== - */ - private static void SV_AreaEdicts_r(areanode_t node, GameImportsImpl gameImports) { - link_t l, next, start; - ServerEntity check; - // touch linked edicts - if (gameImports.world.area_type == Defines.AREA_SOLID) - start = node.solid_edicts; - else - start = node.trigger_edicts; - for (l = start.next; l != start; l = next) { - next = l.next; - check = (ServerEntity) l.o; - if (check.solid == Defines.SOLID_NOT) - continue; // deactivated - if (check.absmin[0] > gameImports.world.area_maxs[0] - || check.absmin[1] > gameImports.world.area_maxs[1] - || check.absmin[2] > gameImports.world.area_maxs[2] - || check.absmax[0] < gameImports.world.area_mins[0] - || check.absmax[1] < gameImports.world.area_mins[1] - || check.absmax[2] < gameImports.world.area_mins[2]) - continue; // not touching - if (gameImports.world.area_count == gameImports.world.area_maxcount) { - Com.Printf("SV_AreaEdicts: MAXCOUNT\n"); - return; - } - gameImports.world.area_list[gameImports.world.area_count] = check; - gameImports.world.area_count++; - } - if (node.axis == -1) - return; // terminal node - // recurse down both sides - if (gameImports.world.area_maxs[node.axis] > node.dist) - SV_AreaEdicts_r(node.children[0], gameImports); - if (gameImports.world.area_mins[node.axis] < node.dist) - SV_AreaEdicts_r(node.children[1], gameImports); - } - - /* - * ================ SV_AreaEdicts ================ - * TODO: return a collection of edicts rather than modifying param - */ - static int SV_AreaEdicts(float[] mins, float[] maxs, ServerEntity[] list, int maxcount, int areatype, GameImportsImpl gameImports) { - gameImports.world.area_mins = mins; - gameImports.world.area_maxs = maxs; - gameImports.world.area_list = list; - gameImports.world.area_count = 0; - gameImports.world.area_maxcount = maxcount; - gameImports.world.area_type = areatype; - SV_AreaEdicts_r(gameImports.world.sv_areanodes[0], gameImports); - return gameImports.world.area_count; - } - - /* - * ============= SV_PointContents ============= - */ - static int SV_PointContents(float[] p, GameImportsImpl gameImports) { - ServerEntity hit; - int i, num; - int contents, c2; - int headnode; - // get base contents from world - contents = gameImports.cm.PointContents(p, gameImports.models[1].headnode); - // or in contents from all the other entities - num = SV_AreaEdicts(p, p, gameImports.world.touch, Defines.MAX_EDICTS, Defines.AREA_SOLID, gameImports); - for (i = 0; i < num; i++) { - hit = gameImports.world.touch[i]; - // might intersect, so do an exact clip - headnode = SV_HullForEntity(hit, gameImports); - if (hit.solid != Defines.SOLID_BSP) { - } - c2 = gameImports.cm.TransformedPointContents(p, headnode, hit.s.origin, - hit.s.angles); - contents |= c2; - } - return contents; - } - - /* - * ================ SV_HullForEntity - * - * Returns a headnode that can be used for testing or clipping an object of - * mins/maxs size. Offset is filled in to contain the adjustment that must - * be added to the testing object's origin to get a point to use with the - * returned hull. ================ - */ - private static int SV_HullForEntity(ServerEntity ent, GameImportsImpl gameImports) { - cmodel_t model; - // decide which clipping hull to use, based on the size - if (ent.solid == Defines.SOLID_BSP) { - // explicit hulls in the BSP model - model = gameImports.models[ent.s.modelindex]; - if (null == model) - Com.Error(Defines.ERR_FATAL, - "MOVETYPE_PUSH with a non bsp model"); - return model.headnode; - } - // create a temp hull from bounding box sizes - return gameImports.cm.HeadnodeForBox(ent.mins, ent.maxs); - } - - private static void SV_ClipMoveToEntities(moveclip_t clip, GameImportsImpl gameImports) { - int i, num; - ServerEntity touch; - trace_t trace; - int headnode; - float angles[]; - num = SV_AreaEdicts(clip.boxmins, clip.boxmaxs, gameImports.world.touchlist, Defines.MAX_EDICTS, Defines.AREA_SOLID, gameImports); - // be careful, it is possible to have an entity in this - // list removed before we get to it (killtriggered) - for (i = 0; i < num; i++) { - touch = gameImports.world.touchlist[i]; - if (touch.solid == Defines.SOLID_NOT) - continue; - if (touch == clip.passedict) - continue; - if (clip.trace.allsolid) - return; - if (clip.passedict != null) { - if (touch.getOwner() == clip.passedict) - continue; // don't clip against own missiles - if (clip.passedict.getOwner() == touch) - continue; // don't clip against owner - } - if (0 == (clip.contentmask & Defines.CONTENTS_DEADMONSTER) - && 0 != (touch.svflags & Defines.SVF_DEADMONSTER)) - continue; - // might intersect, so do an exact clip - headnode = SV_HullForEntity(touch, gameImports); - angles = touch.s.angles; - if (touch.solid != Defines.SOLID_BSP) - angles = Globals.vec3_origin; // boxes don't rotate - if ((touch.svflags & Defines.SVF_MONSTER) != 0) - trace = gameImports.cm.TransformedBoxTrace(clip.start, clip.end, - clip.mins2, clip.maxs2, headnode, clip.contentmask, - touch.s.origin, angles); - else - trace = gameImports.cm.TransformedBoxTrace(clip.start, clip.end, clip.mins, - clip.maxs, headnode, clip.contentmask, touch.s.origin, - angles); - if (trace.allsolid || trace.startsolid - || trace.fraction < clip.trace.fraction) { - trace.ent = touch; - if (clip.trace.startsolid) { - clip.trace = trace; - clip.trace.startsolid = true; - } else - clip.trace.set(trace); - } else if (trace.startsolid) - clip.trace.startsolid = true; - } - } - - /* - * ================== SV_TraceBounds ================== - */ - private static void SV_TraceBounds(float[] start, float[] mins, - float[] maxs, float[] end, float[] boxmins, float[] boxmaxs) { - int i; - for (i = 0; i < 3; i++) { - if (end[i] > start[i]) { - boxmins[i] = start[i] + mins[i] - 1; - boxmaxs[i] = end[i] + maxs[i] + 1; - } else { - boxmins[i] = end[i] + mins[i] - 1; - boxmaxs[i] = start[i] + maxs[i] + 1; - } - } - } - - /* - * ================== SV_Trace - * - * Moves the given mins/maxs volume through the world from start to end. - * - * Passedict and edicts owned by passedict are explicitly not checked. - * - * ================== - */ - static trace_t SV_Trace(float[] start, float[] mins, float[] maxs, float[] end, ServerEntity passedict, int contentmask, GameImportsImpl gameImports) { - moveclip_t clip = new moveclip_t(); - if (mins == null) - mins = Globals.vec3_origin; - if (maxs == null) - maxs = Globals.vec3_origin; - - // clip to world - clip.trace = gameImports.cm.BoxTrace(start, end, mins, maxs, 0, contentmask); - clip.trace.ent = gameImports.gameExports.getEdict(0); - if (clip.trace.fraction == 0) - return clip.trace; // blocked by the world - clip.contentmask = contentmask; - clip.start = start; - clip.end = end; - clip.mins = mins; - clip.maxs = maxs; - clip.passedict = passedict; - Math3D.VectorCopy(mins, clip.mins2); - Math3D.VectorCopy(maxs, clip.maxs2); - // create the bounding box of the entire move - SV_TraceBounds(start, clip.mins2, clip.maxs2, end, clip.boxmins, - clip.boxmaxs); - // clip to other solid entities - SV_ClipMoveToEntities(clip, gameImports); - return clip.trace; - } -} \ No newline at end of file From 06bccc748a6463622a35411ed572eccc77e45e7c Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 17:46:37 +0100 Subject: [PATCH 07/47] Consolidate client snapshot construction into the game instance --- .../java/jake2/server/GameImportsTest.java | 65 ++- .../java/jake2/server/GameImportsImpl.java | 310 +++++++++++++- .../src/main/java/jake2/server/SV_ENTS.java | 380 ------------------ .../src/main/java/jake2/server/SV_SEND.java | 2 +- 4 files changed, 373 insertions(+), 384 deletions(-) delete mode 100644 server/src/main/java/jake2/server/SV_ENTS.java diff --git a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java index 53ffec8f..6be20f6c 100644 --- a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java +++ b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java @@ -1,6 +1,7 @@ package jake2.server; import jake2.game.GameEntity; +import jake2.game.GamePlayerInfo; import jake2.game.monsters.M_Gunner; import jake2.qcommon.GameExports; import jake2.qcommon.ServerEntity; @@ -9,7 +10,7 @@ import jake2.qcommon.Globals; import jake2.qcommon.ServerStates; import jake2.qcommon.Defines; -import jake2.qcommon.network.messages.server.ConfigStringMessage; +import jake2.qcommon.network.messages.server.*; import jake2.qcommon.vfs.EngineVfs; import jake2.qcommon.vfs.EngineWriteRoot; import jake2.qcommon.vfs.VfsConfig; @@ -163,6 +164,68 @@ public void spatialQueriesAndClearingStayWithTheirGame() { Assertions.assertSame(secondEntity, second.trace(start, null, null, end, null, Defines.MASK_PLAYERSOLID).ent); } + @Test + public void clientSnapshotsKeepIndependentGameHistory() { + manager = new SV_MAIN(); + GameImportsImpl first = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); + GameImportsImpl second = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); + ClientNetworkInfo firstClient = snapshotClient(first, 11); + ClientNetworkInfo secondClient = snapshotClient(second, 22); + first.frameNum = second.frameNum = 1; + + var initial = List.copyOf(first.buildClientFrameMessages(firstClient)); + Assertions.assertEquals(3, initial.size()); + Assertions.assertEquals(-1, ((FrameHeaderMessage) initial.get(0)).lastFrame); + EntityUpdate initialUpdate = ((PacketEntitiesMessage) initial.get(2)).updates.get(0); + Assertions.assertTrue(initialUpdate.force); + Assertions.assertEquals(11, initialUpdate.newState.modelindex); + second.buildClientFrameMessages(secondClient); + + // Each delta must refer to its own saved snapshot, not the live entity or the other Game. + firstClient.edict.s.modelindex = 12; + secondClient.edict.s.modelindex = 23; + first.frameNum = second.frameNum = 2; + firstClient.lastReceivedFrame = secondClient.lastReceivedFrame = 1; + for (GameImportsImpl game : List.of(first, second)) { + ClientNetworkInfo client = game == first ? firstClient : secondClient; + int oldModel = game == first ? 11 : 22; + var messages = List.copyOf(game.buildClientFrameMessages(client)); + FrameHeaderMessage header = (FrameHeaderMessage) messages.get(0); + Assertions.assertEquals(2, header.frameNumber); + Assertions.assertEquals(1, header.lastFrame); + var updates = ((PacketEntitiesMessage) messages.get(2)).updates; + Assertions.assertEquals(1, updates.size()); + Assertions.assertEquals(oldModel, updates.get(0).oldState.modelindex); + Assertions.assertEquals(oldModel + 1, updates.get(0).newState.modelindex); + Assertions.assertFalse(updates.get(0).force); + } + + firstClient.lastReceivedFrame = 2; + first.frameNum = 3; + firstClient.edict.s.modelindex = 0; + var removed = List.copyOf(first.buildClientFrameMessages(firstClient)); + EntityUpdate removal = ((PacketEntitiesMessage) removed.get(2)).updates.get(0); + Assertions.assertEquals(1, removal.header.number); + Assertions.assertNotEquals(0, removal.header.flags & Defines.U_REMOVE); + + // An acknowledgement outside the retained frame window falls back to a baseline update. + second.frameNum += Defines.UPDATE_BACKUP; + var stale = List.copyOf(second.buildClientFrameMessages(secondClient)); + Assertions.assertEquals(-1, ((FrameHeaderMessage) stale.get(0)).lastFrame); + Assertions.assertTrue(((PacketEntitiesMessage) stale.get(2)).updates.get(0).force); + } + + private ClientNetworkInfo snapshotClient(GameImportsImpl game, int modelIndex) { + GameEntity entity = (GameEntity) game.gameExports.getEdict(1); + entity.setClient(new GamePlayerInfo(0)); + entity.getClient().getPlayerState().pmove.origin[2] = 64 * 8; + entity.s.modelindex = modelIndex; + ClientNetworkInfo client = new ClientNetworkInfo(); + client.gameName = game.name; + client.edict = entity; + return client; + } + @Test public void runGame() { diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index a2b65057..ed8a3759 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -34,6 +34,11 @@ import jake2.qcommon.network.messages.NetworkMessage; import jake2.qcommon.network.messages.server.ConfigStringMessage; import jake2.qcommon.network.messages.server.PrintCenterMessage; +import jake2.qcommon.network.messages.server.FrameHeaderMessage; +import jake2.qcommon.network.messages.server.PlayerInfoMessage; +import jake2.qcommon.network.messages.server.PacketEntitiesMessage; +import jake2.qcommon.network.messages.server.EntityUpdate; +import jake2.qcommon.network.messages.server.DeltaEntityHeader; import jake2.qcommon.network.messages.server.ServerMessage; import jake2.qcommon.network.messages.server.StuffTextMessage; import jake2.qcommon.util.Lib; @@ -47,6 +52,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Date; +import java.util.Collection; import java.util.List; import static jake2.qcommon.Defines.CS_MAPCHECKSUM; @@ -99,7 +105,10 @@ public class GameImportsImpl implements GameImports { CM cm; - SV_ENTS sv_ents; + // Entity snapshots are shared by this Game's clients; frame acknowledgements stay on each client. + private final byte[] fatPvs = new byte[65536 / 8]; + private final entity_state_t[] clientEntityStates; + private int nextClientEntity; final float[] origin_v = { 0, 0, 0 }; private final float configuredAirAcceleration; @@ -119,7 +128,8 @@ public GameImportsImpl(JakeServer serverMain, ChangeMapInfo changeMapInfo) { spawncount = Lib.rand(); cm = new CM(); - sv_ents = new SV_ENTS(this, serverMain.getClients().size() * Defines.UPDATE_BACKUP * 64); + clientEntityStates = new entity_state_t[serverMain.getClients().size() * Defines.UPDATE_BACKUP * 64]; + for (int i = 0; i < clientEntityStates.length; i++) clientEntityStates[i] = new entity_state_t(); // heartbeats will always be sent to the id master @@ -1349,4 +1359,300 @@ public void unicastMessage(int index, ServerMessage msg, boolean reliable) { client.unreliable.add(msg); } + + /** + * Build the frame and prepare a list of messages to a client system. + * @return - listOf(FrameHeaderMessage, PlayerInfoMessage, PacketEntitiesMessage) + * + * SV_WriteFrameToClient + */ + Collection buildClientFrameMessages(ClientNetworkInfo client) { + // first, build the "frame" (previously it was invoked outside of this method) + buildClientFrame(client); + + // now, build the messages + List result = new ArrayList<>(); + // this is the frame we are creating + client_frame_t currentFrame = client.frames[frameNum & Defines.UPDATE_MASK]; + client_frame_t lastReceivedFrame; + int lastReceivedFrameNum; + // client is asking for a retransmit + if (client.lastReceivedFrame <= 0) { + lastReceivedFrame = null; + lastReceivedFrameNum = -1; + } else if (frameNum - client.lastReceivedFrame >= (Defines.UPDATE_BACKUP - 3)) { + // client hasn't gotten a good message through in a long time + // Com_Printf ("%s: Delta request from out-of-date packet.\n", + // client.name); + lastReceivedFrame = null; + lastReceivedFrameNum = -1; + } else { + // we have a valid message to delta from + lastReceivedFrame = client.frames[client.lastReceivedFrame & Defines.UPDATE_MASK]; + lastReceivedFrameNum = client.lastReceivedFrame; + } + + result.add(new FrameHeaderMessage( + frameNum, + lastReceivedFrameNum, + client.surpressCount, + currentFrame.areabytes, + currentFrame.areabits + )); + + client.surpressCount = 0; + + // delta encode the playerstate + PlayerInfoMessage playerInfoMsg = new PlayerInfoMessage(lastReceivedFrame != null ? lastReceivedFrame.ps : new player_state_t(), currentFrame.ps); + result.add(playerInfoMsg); + + // delta encode the entities + PacketEntitiesMessage packetEntities = buildPacketEntities( + lastReceivedFrame, + currentFrame); + result.add(packetEntities); + return result; + } + + // SV_EmitPacketEntities + /** + * Encode recent entity updates into a message using the previously sent updates and the baseline (shared with the client in the beginning of the match). + */ + private PacketEntitiesMessage buildPacketEntities(client_frame_t lastReceivedFrame, + client_frame_t currentFrame) { + int maxClients = serverMain.getClients().size(); + assert baselines.length == Defines.MAX_EDICTS; + int maxEntityStates = clientEntityStates.length; + PacketEntitiesMessage result = new PacketEntitiesMessage(); + + // Write delta entity + int from_num_entities; + if (lastReceivedFrame == null) + from_num_entities = 0; + else + from_num_entities = lastReceivedFrame.num_entities; + + int newindex = 0; + int oldindex = 0; + entity_state_t oldState = null; + entity_state_t newState = null; + while (newindex < currentFrame.num_entities || oldindex < from_num_entities) { + final int newnum; + if (newindex >= currentFrame.num_entities) { + // does not exist in the frame + newnum = 9999; + } else { + newState = clientEntityStates[(currentFrame.first_entity + newindex) % maxEntityStates]; + newnum = newState.index; + } + final int oldnum; + if (oldindex >= from_num_entities) + // does not exist in the frame + oldnum = 9999; + else { + oldState = clientEntityStates[(lastReceivedFrame.first_entity + oldindex) % maxEntityStates]; + oldnum = oldState.index; + } + + if (newnum == oldnum) { + // delta update from old position. + // because the force parm is false, this will not result + // in any bytes being emited if the entity has not changed at all. + // Note: players are always 'newentities', this updates + // their oldorigin always and prevents warping + final boolean isPlayer = newState.index <= maxClients; + result.updates.add(new EntityUpdate(oldState, newState, false, isPlayer)); + oldindex++; + newindex++; + } else if (newnum < oldnum) { + // this is a new entity, send it from the baseline + result.updates.add(new EntityUpdate(baselines[newnum], newState, true, true)); + newindex++; + } else { + // if (newnum > oldnum) { + // the old entity isn't present in the new message + + int flags = Defines.U_REMOVE; + if (oldnum >= 256) + flags |= Defines.U_NUMBER16 | Defines.U_MOREBITS1; + + result.updates.add(new EntityUpdate(new DeltaEntityHeader(flags, oldnum))); + oldindex++; + } + } + return result; + } + + /** + * The client will interpolate the view position, so we can't use a single + * PVS point. + */ + private void buildFatPvs(float[] org) { + int leafs[] = new int[64]; + int i, j, count; + int longs; + byte src[]; + float[] mins = { 0, 0, 0 }, maxs = { 0, 0, 0 }; + + for (i = 0; i < 3; i++) { + mins[i] = org[i] - 8; + maxs[i] = org[i] + 8; + } + count = cm.CM_BoxLeafnums(mins, maxs, leafs, 64, null); + + if (count < 1) + Com.Error(Defines.ERR_FATAL, "SV_FatPVS: count < 1"); + + longs = (cm.CM_NumClusters() + 31) >> 5; + + // convert leafs to clusters + for (i = 0; i < count; i++) + leafs[i] = cm.CM_LeafCluster(leafs[i]); + + System.arraycopy(cm.CM_ClusterPVS(leafs[0]), 0, fatPvs, 0, + longs << 2); + // or in all the other leaf bits + for (i = 1; i < count; i++) { + for (j = 0; j < i; j++) + if (leafs[i] == leafs[j]) + break; + if (j != i) + continue; // already have the cluster we want + + src = cm.CM_ClusterPVS(leafs[i]); + + //for (j=0 ; j> 3] & (1 << (l & 7)))) + continue; + } else { + // FIXME: if an ent has a model and a sound, but isn't + // in the PVS, only the PHS, clear the model + byte[] bitvector; + if (ent.s.sound == 0) { + bitvector = fatPvs; //clientphs; + } else + bitvector = fatPvs; + + if (ent.num_clusters == -1) { // too many leafs for + // individual check, go by + // headnode + if (!cm.CM_HeadnodeVisible(ent.headnode, bitvector)) + continue; + } else { // check individual leafs + for (i = 0; i < ent.num_clusters; i++) { + l = ent.clusternums[i]; + if ((bitvector[l >> 3] & (1 << (l & 7))) != 0) + break; + } + if (i == ent.num_clusters) + continue; // not visible + } + + if (ent.s.modelindex == 0) { // don't send sounds if they + // will be attenuated away + float[] delta = { 0, 0, 0 }; + float len; + + Math3D.VectorSubtract(org, ent.s.origin, delta); + len = Math3D.VectorLength(delta); + if (len > 400) + continue; + } + } + } + + // add it to the circular client_entities array + int ix = nextClientEntity % clientEntityStates.length; + entity_state_t state = clientEntityStates[ix]; + if (ent.s.index != e) { + Com.DPrintf("FIXING ENT.S.NUMBER!!!\n"); + ent.s.index = e; + } + + //*state = ent.s; + clientEntityStates[ix].set(ent.s); + + // don't mark players missiles as solid + if (ent.getOwner() == client.edict) + state.solid = 0; + + nextClientEntity++; + frame.num_entities++; + } + } } diff --git a/server/src/main/java/jake2/server/SV_ENTS.java b/server/src/main/java/jake2/server/SV_ENTS.java deleted file mode 100644 index a017b9e4..00000000 --- a/server/src/main/java/jake2/server/SV_ENTS.java +++ /dev/null @@ -1,380 +0,0 @@ -/* - * Copyright (C) 1997-2001 Id Software, Inc. - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License as published by the Free Software - * Foundation; either version 2 of the License, or (at your option) any later - * version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. - * - * See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * this program; if not, write to the Free Software Foundation, Inc., 59 Temple - * Place - Suite 330, Boston, MA 02111-1307, USA. - * - */ - -// Created on 17.01.2004 by RST. - -package jake2.server; - -import jake2.qcommon.*; -import jake2.qcommon.network.messages.server.*; -import jake2.qcommon.util.Math3D; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; - -public class SV_ENTS { - - final GameImportsImpl gameImports; - /** - * ============================================================================= - * - * Build a client frame structure - * - * ============================================================================= - */ - - public final byte[] fatpvs; // 32767 is MAX_MAP_LEAFS - - private final int maxClientEntityStates; // maxclients->value * UPDATE_BACKUP * MAX_PACKET_ENTITIES - - // common for all clients, represents current server frame, updated in SV_BuildClientFrame - private final entity_state_t[] clientEntityStates; // ring buffer of size [maxClientEntityStates] - - private int next_client_entities; // next client_entity to use - - - public SV_ENTS(GameImportsImpl gameImports, int clientEntitiesMax) { - fatpvs = new byte[65536 / 8]; - this.gameImports = gameImports; - - maxClientEntityStates = clientEntitiesMax; - - // Clear all client entity states - clientEntityStates = new entity_state_t[maxClientEntityStates]; - for (int n = 0; n < clientEntityStates.length; n++) { - clientEntityStates[n] = new entity_state_t(); - } - - } - - /** - * SV_WritePlayerstateToClient - * Writes the status of a player to a PlayerInfoMessage. - */ - // todo: move this logic into players state message write Properties function. - // todo: PlayerInfoMessage should hold only player state - static PlayerInfoMessage buildPlayerInfoMessage(player_state_t lastFrameState, player_state_t currentPlayerState) { - player_state_t ops = lastFrameState != null ? lastFrameState : new player_state_t(); - return new PlayerInfoMessage(ops, currentPlayerState); - } - - /** - * Build the frame and prepare a list of messages to a client system. - * @return - listOf(FrameHeaderMessage, PlayerInfoMessage, PacketEntitiesMessage) - * - * SV_WriteFrameToClient - */ - public Collection buildClientFrameMessages(ClientNetworkInfo client) { - // first, build the "frame" (previously it was invoked outside of this method) - gameImports.sv_ents.SV_BuildClientFrame(client); - - // now, build the messages - List result = new ArrayList<>(); - // this is the frame we are creating - client_frame_t currentFrame = client.frames[gameImports.frameNum & Defines.UPDATE_MASK]; - client_frame_t lastReceivedFrame; - int lastReceivedFrameNum; - // client is asking for a retransmit - if (client.lastReceivedFrame <= 0) { - lastReceivedFrame = null; - lastReceivedFrameNum = -1; - } else if (gameImports.frameNum - client.lastReceivedFrame >= (Defines.UPDATE_BACKUP - 3)) { - // client hasn't gotten a good message through in a long time - // Com_Printf ("%s: Delta request from out-of-date packet.\n", - // client.name); - lastReceivedFrame = null; - lastReceivedFrameNum = -1; - } else { - // we have a valid message to delta from - lastReceivedFrame = client.frames[client.lastReceivedFrame & Defines.UPDATE_MASK]; - lastReceivedFrameNum = client.lastReceivedFrame; - } - - result.add(new FrameHeaderMessage( - gameImports.frameNum, - lastReceivedFrameNum, - client.surpressCount, - currentFrame.areabytes, - currentFrame.areabits - )); - - client.surpressCount = 0; - - // delta encode the playerstate - PlayerInfoMessage playerInfoMsg = buildPlayerInfoMessage(lastReceivedFrame != null ? lastReceivedFrame.ps : null, currentFrame.ps); - result.add(playerInfoMsg); - - // delta encode the entities - PacketEntitiesMessage packetEntities = buildPacketEntities( - lastReceivedFrame, - currentFrame, - clientEntityStates, - gameImports.baselines, - gameImports.serverMain.getClients().size()); - result.add(packetEntities); - return result; - } - - // SV_EmitPacketEntities - /** - * Encode recent entity updates into a message using the previously sent updates and the baseline (shared with the client in the beginning of the match). - */ - static PacketEntitiesMessage buildPacketEntities(client_frame_t lastReceivedFrame, - client_frame_t currentFrame, - entity_state_t[] clientEntityStates, // many states per entity, - entity_state_t[] baselines, // one state per entity, length = Defines.MAX_EDICTS - int maxClients) { - assert baselines.length == Defines.MAX_EDICTS; - int maxEntityStates = clientEntityStates.length; - PacketEntitiesMessage result = new PacketEntitiesMessage(); - - // Write delta entity - int from_num_entities; - if (lastReceivedFrame == null) - from_num_entities = 0; - else - from_num_entities = lastReceivedFrame.num_entities; - - int newindex = 0; - int oldindex = 0; - entity_state_t oldState = null; - entity_state_t newState = null; - while (newindex < currentFrame.num_entities || oldindex < from_num_entities) { - final int newnum; - if (newindex >= currentFrame.num_entities) { - // does not exist in the frame - newnum = 9999; - } else { - newState = clientEntityStates[(currentFrame.first_entity + newindex) % maxEntityStates]; - newnum = newState.index; - } - final int oldnum; - if (oldindex >= from_num_entities) - // does not exist in the frame - oldnum = 9999; - else { - oldState = clientEntityStates[(lastReceivedFrame.first_entity + oldindex) % maxEntityStates]; - oldnum = oldState.index; - } - - if (newnum == oldnum) { - // delta update from old position. - // because the force parm is false, this will not result - // in any bytes being emited if the entity has not changed at all. - // Note: players are always 'newentities', this updates - // their oldorigin always and prevents warping - final boolean isPlayer = newState.index <= maxClients; - result.updates.add(new EntityUpdate(oldState, newState, false, isPlayer)); - oldindex++; - newindex++; - } else if (newnum < oldnum) { - // this is a new entity, send it from the baseline - result.updates.add(new EntityUpdate(baselines[newnum], newState, true, true)); - newindex++; - } else { - // if (newnum > oldnum) { - // the old entity isn't present in the new message - - int flags = Defines.U_REMOVE; - if (oldnum >= 256) - flags |= Defines.U_NUMBER16 | Defines.U_MOREBITS1; - - result.updates.add(new EntityUpdate(new DeltaEntityHeader(flags, oldnum))); - oldindex++; - } - } - return result; - } - - /** - * The client will interpolate the view position, so we can't use a single - * PVS point. - */ - public void SV_FatPVS(float[] org) { - int leafs[] = new int[64]; - int i, j, count; - int longs; - byte src[]; - float[] mins = { 0, 0, 0 }, maxs = { 0, 0, 0 }; - - for (i = 0; i < 3; i++) { - mins[i] = org[i] - 8; - maxs[i] = org[i] + 8; - } - final CM cm = gameImports.cm; - count = cm.CM_BoxLeafnums(mins, maxs, leafs, 64, null); - - if (count < 1) - Com.Error(Defines.ERR_FATAL, "SV_FatPVS: count < 1"); - - longs = (cm.CM_NumClusters() + 31) >> 5; - - // convert leafs to clusters - for (i = 0; i < count; i++) - leafs[i] = cm.CM_LeafCluster(leafs[i]); - - System.arraycopy(cm.CM_ClusterPVS(leafs[0]), 0, fatpvs, 0, - longs << 2); - // or in all the other leaf bits - for (i = 1; i < count; i++) { - for (j = 0; j < i; j++) - if (leafs[i] == leafs[j]) - break; - if (j != i) - continue; // already have the cluster we want - - src = cm.CM_ClusterPVS(leafs[i]); - - //for (j=0 ; j> 3] & (1 << (l & 7)))) - continue; - } else { - // FIXME: if an ent has a model and a sound, but isn't - // in the PVS, only the PHS, clear the model - byte[] bitvector; - if (ent.s.sound == 0) { - bitvector = fatpvs; //clientphs; - } else - bitvector = fatpvs; - - if (ent.num_clusters == -1) { // too many leafs for - // individual check, go by - // headnode - if (!gameImports.cm.CM_HeadnodeVisible(ent.headnode, bitvector)) - continue; - } else { // check individual leafs - for (i = 0; i < ent.num_clusters; i++) { - l = ent.clusternums[i]; - if ((bitvector[l >> 3] & (1 << (l & 7))) != 0) - break; - } - if (i == ent.num_clusters) - continue; // not visible - } - - if (ent.s.modelindex == 0) { // don't send sounds if they - // will be attenuated away - float[] delta = { 0, 0, 0 }; - float len; - - Math3D.VectorSubtract(org, ent.s.origin, delta); - len = Math3D.VectorLength(delta); - if (len > 400) - continue; - } - } - } - - // add it to the circular client_entities array - int ix = next_client_entities % maxClientEntityStates; - entity_state_t state = clientEntityStates[ix]; - if (ent.s.index != e) { - Com.DPrintf("FIXING ENT.S.NUMBER!!!\n"); - ent.s.index = e; - } - - //*state = ent.s; - clientEntityStates[ix].set(ent.s); - - // don't mark players missiles as solid - if (ent.getOwner() == client.edict) - state.solid = 0; - - next_client_entities++; - frame.num_entities++; - } - } -} diff --git a/server/src/main/java/jake2/server/SV_SEND.java b/server/src/main/java/jake2/server/SV_SEND.java index c4461cbb..e8f9fb64 100644 --- a/server/src/main/java/jake2/server/SV_SEND.java +++ b/server/src/main/java/jake2/server/SV_SEND.java @@ -185,7 +185,7 @@ public static void SV_SendClientDatagram(ClientNetworkInfo client, GameImportsIm // send over all the relevant entity_state_t // and the player_state_t Collection unreliable = new ArrayList<>(); - unreliable.addAll(gameImports.sv_ents.buildClientFrameMessages(client)); // 3 messages + unreliable.addAll(gameImports.buildClientFrameMessages(client)); // 3 messages // copy the accumulated multicast datagram // for this client out to the message From 397449b2b478a5dbc1c99579534686a7c60b2f0a Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 17:49:28 +0100 Subject: [PATCH 08/47] Split server sending behavior by Game and Host ownership --- .../java/jake2/server/GameImportsTest.java | 54 +++++ .../java/jake2/server/ClientNetworkInfo.java | 8 + .../java/jake2/server/GameImportsImpl.java | 90 +++++++- .../src/main/java/jake2/server/SV_MAIN.java | 22 +- .../src/main/java/jake2/server/SV_SEND.java | 204 ------------------ 5 files changed, 165 insertions(+), 213 deletions(-) delete mode 100644 server/src/main/java/jake2/server/SV_SEND.java diff --git a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java index 6be20f6c..90d65c87 100644 --- a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java +++ b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java @@ -226,6 +226,60 @@ private ClientNetworkInfo snapshotClient(GameImportsImpl game, int modelIndex) { return client; } + @Test + public void queuedSoundsKeepTheirPositionsAndStayWithinTheirGame() { + manager = new SV_MAIN(); + Cvar.getInstance().find("maxclients", Integer.class).forceSet(2); + manager.resetClients(); + GameImportsImpl first = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); + GameImportsImpl second = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); + ClientNetworkInfo firstClient = manager.getClients().get(0); + ClientNetworkInfo secondClient = manager.getClients().get(1); + firstClient.gameName = first.name; + secondClient.gameName = second.name; + firstClient.state = secondClient.state = ClientStates.CS_SPAWNED; + try { + ServerEntity entity = first.gameExports.getEdict(1); + entity.solid = Defines.SOLID_BSP; + entity.s.origin[0] = 10; + entity.maxs[0] = 4; + first.sound(entity, Defines.CHAN_WEAPON, 3, 0.5f, Defines.ATTN_NONE, 0.1f); + entity.s.origin[0] = 40; + first.sound(entity, Defines.CHAN_WEAPON, 4, 1, Defines.ATTN_NONE, 0); + var sounds = List.copyOf(firstClient.unreliable); + Assertions.assertEquals(2, sounds.size()); + SoundMessage earlier = (SoundMessage) sounds.get(0); + Assertions.assertArrayEquals(new float[]{12, 0, 0}, earlier.origin); + Assertions.assertArrayEquals(new float[]{42, 0, 0}, ((SoundMessage) sounds.get(1)).origin); + Assertions.assertEquals(Defines.SND_ENT | Defines.SND_POS | Defines.SND_VOLUME + | Defines.SND_ATTENUATION | Defines.SND_OFFSET, earlier.flags); + Assertions.assertEquals((entity.index << 3) | Defines.CHAN_WEAPON, earlier.sendchan); + + float[] explicitOrigin = {1, 2, 3}; + first.positioned_sound(explicitOrigin, entity, Defines.CHAN_RELIABLE | Defines.CHAN_VOICE, + 5, 1, Defines.ATTN_NONE, 0); + explicitOrigin[0] = 99; + Assertions.assertEquals(1, firstClient.netchan.reliablePending.size()); + SoundMessage reliable = (SoundMessage) firstClient.netchan.reliablePending.iterator().next(); + Assertions.assertArrayEquals(new float[]{1, 2, 3}, reliable.origin); + Assertions.assertTrue(secondClient.unreliable.isEmpty()); + Assertions.assertTrue(secondClient.netchan.reliablePending.isEmpty()); + } finally { + firstClient.state = secondClient.state = ClientStates.CS_FREE; + } + } + + @Test + public void clientPrintQueueRespectsItsMessageLevel() { + ClientNetworkInfo client = new ClientNetworkInfo(); + client.messagelevel = Defines.PRINT_HIGH; + client.queuePrint(Defines.PRINT_LOW, "hidden"); + client.queuePrint(Defines.PRINT_HIGH, "visible"); + Assertions.assertEquals(List.of(new PrintMessage(Defines.PRINT_HIGH, "visible")), + List.copyOf(client.netchan.reliablePending)); + Assertions.assertTrue(client.unreliable.isEmpty()); + } + @Test public void runGame() { diff --git a/server/src/main/java/jake2/server/ClientNetworkInfo.java b/server/src/main/java/jake2/server/ClientNetworkInfo.java index 55c0e4fa..0b43888a 100644 --- a/server/src/main/java/jake2/server/ClientNetworkInfo.java +++ b/server/src/main/java/jake2/server/ClientNetworkInfo.java @@ -26,6 +26,7 @@ of the License, or (at your option) any later version. import jake2.qcommon.ServerEntity; import jake2.qcommon.network.messages.NetworkMessage; import jake2.qcommon.network.netchan_t; +import jake2.qcommon.network.messages.server.PrintMessage; import jake2.qcommon.usercmd_t; import java.util.ArrayList; @@ -90,4 +91,11 @@ public ClientNetworkInfo() { int challenge; // challenge of this user, randomly generated netchan_t netchan = new netchan_t(); + + /** Queue text only when it meets this connection's requested message level. */ + void queuePrint(int level, String text) { + if (level >= messagelevel) { + netchan.reliablePending.add(new PrintMessage(level, text)); + } + } } diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index ed8a3759..3b7594cd 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -41,6 +41,7 @@ import jake2.qcommon.network.messages.server.DeltaEntityHeader; import jake2.qcommon.network.messages.server.ServerMessage; import jake2.qcommon.network.messages.server.StuffTextMessage; +import jake2.qcommon.network.messages.server.SoundMessage; import jake2.qcommon.util.Lib; import jake2.qcommon.util.Math3D; import jake2.qcommon.util.Vargs; @@ -110,7 +111,6 @@ public class GameImportsImpl implements GameImports { private final entity_state_t[] clientEntityStates; private int nextClientEntity; - final float[] origin_v = { 0, 0, 0 }; private final float configuredAirAcceleration; final float airAcceleration; private final PMove.PmoveProcessor pmoveProcessor = PMove.newLegacyProcessor(); @@ -250,7 +250,7 @@ public void cprintf(ServerEntity ent, int printlevel, String s) { } if (ent != null) - SV_SEND.SV_ClientPrintf(serverMain.getClients().get(n - 1), printlevel, s); + serverMain.getClients().get(n - 1).queuePrint(printlevel, s); else Com.Printf(s); } @@ -270,10 +270,86 @@ public void sound(ServerEntity ent, int channel, int soundindex, float volume, @Override public void positioned_sound(float[] origin, ServerEntity ent, int channel, - int soundinedex, float volume, float attenuation, float timeofs) { + int soundindex, float volume, float attenuation, float timeofs) { - SV_SEND.SV_StartSound(origin, ent, channel, soundinedex, volume, - attenuation, timeofs, this); + if (volume < 0 || volume > 1.0) + Com.Error(Defines.ERR_FATAL, "SV_StartSound: volume = " + volume); + + if (attenuation < 0 || attenuation > 4) + Com.Error(Defines.ERR_FATAL, "SV_StartSound: attenuation = " + attenuation); + + // if (channel < 0 || channel > 15) + // Com_Error (ERR_FATAL, "SV_StartSound: channel = %i", channel); + + if (timeofs < 0 || timeofs > 0.255) + Com.Error(Defines.ERR_FATAL, "SV_StartSound: timeofs = " + timeofs); + + int entityIndex = ent.index; + + // no PHS flag + boolean use_phs; + if ((channel & 8) != 0) { + use_phs = false; + channel &= 7; + } + else + use_phs = true; + + int sendchan = (entityIndex << 3) | (channel & 7); + + int flags = 0; + if (volume != Defines.DEFAULT_SOUND_PACKET_VOLUME) + flags |= Defines.SND_VOLUME; + if (attenuation != Defines.DEFAULT_SOUND_PACKET_ATTENUATION) + flags |= Defines.SND_ATTENUATION; + + // the client doesn't know that bmodels have weird origins + // the origin can also be explicitly set + if ((ent.svflags & Defines.SVF_NOCLIENT) != 0 || (ent.solid == Defines.SOLID_BSP) || origin != null) + flags |= Defines.SND_POS; + + // always send the ent number for channel overrides + flags |= Defines.SND_ENT; + + if (timeofs != 0) + flags |= Defines.SND_OFFSET; + + // use the ent origin unless it is a bmodel or explicitly specified + if (origin == null) { + origin = new float[3]; + if (ent.solid == Defines.SOLID_BSP) { + for (int i = 0; i < 3; i++) + origin[i] = ent.s.origin[i] + 0.5f * (ent.mins[i] + ent.maxs[i]); + } + else { + Math3D.VectorCopy(ent.s.origin, origin); + } + } + + // Queued messages own their position until the transport serializes them. + else { + origin = origin.clone(); + } + + // if the sound doesn't attenuate,send it to everyone + // (global radio chatter, voiceovers, etc) + if (attenuation == Defines.ATTN_NONE) + use_phs = false; + + final MulticastTypes multicastType; + + if ((channel & Defines.CHAN_RELIABLE) != 0) { + if (use_phs) + multicastType = MulticastTypes.MULTICAST_PHS_R; + else + multicastType = MulticastTypes.MULTICAST_ALL_R; + } else { + if (use_phs) + multicastType = MulticastTypes.MULTICAST_PHS; + else + multicastType = MulticastTypes.MULTICAST_ALL; + } + multicastMessage(origin, new SoundMessage(flags, soundindex, volume, attenuation, timeofs, sendchan, origin), multicastType); } /* @@ -722,7 +798,7 @@ private void SV_Kick_f(List args) { serverMain.SV_BroadcastPrintf(Defines.PRINT_HIGH, client.name + " was kicked\n", client.gameName); // print directly, because the dropped client won't get the // SV_BroadcastPrintf message - SV_SEND.SV_ClientPrintf(client, Defines.PRINT_HIGH, "You were kicked from the game\n"); + client.queuePrint(Defines.PRINT_HIGH, "You were kicked from the game\n"); SV_MAIN.SV_DropClient(client); client.lastmessage = realtime; // min case there is a funny zombie } @@ -803,7 +879,7 @@ private void SV_ConSay_f(List args) { for (ClientNetworkInfo client: serverMain.getClients()) { if (client.state != ClientStates.CS_SPAWNED) continue; - SV_SEND.SV_ClientPrintf(client, Defines.PRINT_CHAT, text + "\n"); + client.queuePrint(Defines.PRINT_CHAT, text + "\n"); } } /* diff --git a/server/src/main/java/jake2/server/SV_MAIN.java b/server/src/main/java/jake2/server/SV_MAIN.java index f9bffd1b..e1c6c8c5 100644 --- a/server/src/main/java/jake2/server/SV_MAIN.java +++ b/server/src/main/java/jake2/server/SV_MAIN.java @@ -51,7 +51,6 @@ import java.util.stream.Collectors; import static jake2.qcommon.Defines.*; -import static jake2.server.SV_SEND.SV_SendClientDatagram; import static jake2.server.SV_USER.userCommands; @SuppressWarnings("deprecation") @@ -575,7 +574,7 @@ void SV_SendClientMessages() { if (SV_RateDrop(c)) continue; - SV_SendClientDatagram(c, game); + sendClientDatagram(c, game); } else { // just update reliable if needed if (c.netchan.reliablePending.size() != 0 || Globals.curtime - c.netchan.last_sent > 1000) @@ -1504,4 +1503,23 @@ private void initializeServerSession() { resetClients(); } + /** Send this client's Game snapshot followed by its queued events, recording rate usage. */ + private void sendClientDatagram(ClientNetworkInfo client, GameImportsImpl gameImports) { + // send over all the relevant entity_state_t + // and the player_state_t + Collection unreliable = new ArrayList<>(); + unreliable.addAll(gameImports.buildClientFrameMessages(client)); // 3 messages + + // copy the accumulated multicast datagram + // for this client out to the message + // it is necessary for this to be after the buildClientFrameMessages (SV_BuildClientFrame) + // so that entity references will be current + unreliable.addAll(client.unreliable); + client.unreliable.clear(); + // send the datagram + client.netchan.transmit(unreliable); + + // record the size for rate estimation + client.message_size[gameImports.frameNum % Defines.RATE_MESSAGES] = unreliable.stream().mapToInt(NetworkMessage::getSize).sum(); + } } diff --git a/server/src/main/java/jake2/server/SV_SEND.java b/server/src/main/java/jake2/server/SV_SEND.java deleted file mode 100644 index e8f9fb64..00000000 --- a/server/src/main/java/jake2/server/SV_SEND.java +++ /dev/null @@ -1,204 +0,0 @@ -/* -Copyright (C) 1997-2001 Id Software, Inc. - -This program is free software; you can redistribute it and/or -modify it under the terms of the GNU General Public License -as published by the Free Software Foundation; either version 2 -of the License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - -See the GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; if not, write to the Free Software -Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. - -*/ - -// Created on 17.01.2004 by RST. - -package jake2.server; - -import jake2.qcommon.Com; -import jake2.qcommon.Defines; -import jake2.qcommon.ServerEntity; -import jake2.qcommon.network.MulticastTypes; -import jake2.qcommon.network.messages.NetworkMessage; -import jake2.qcommon.network.messages.server.PrintMessage; -import jake2.qcommon.network.messages.server.SoundMessage; -import jake2.qcommon.util.Math3D; - -import java.util.ArrayList; -import java.util.Collection; - -public class SV_SEND { - /* - ============================================================================= - - EVENT MESSAGES - - ============================================================================= - */ - - /* - ================= - SV_ClientPrintf - - Sends text across to be displayed if the level passes - ================= - */ - public static void SV_ClientPrintf(ClientNetworkInfo cl, int level, String s) { - - if (level >= cl.messagelevel) { - cl.netchan.reliablePending.add(new PrintMessage(level, s)); - } - } - - /* - ================== - SV_StartSound - - Each entity can have eight independent sound sources, like voice, - weapon, feet, etc. - - If channel & 8, the sound will be sent to everyone, not just - things in the PHS. - - FIXME: if entity isn't in PHS, they must be forced to be sent or - have the origin explicitly sent. - - Channel 0 is an auto-allocate channel, the others override anything - already running on that entity/channel pair. - - An attenuation of 0 will play full volume everywhere in the level. - Larger attenuations will drop off. (max 4 attenuation) - - Timeofs can range from 0.0 to 0.1 to cause sounds to be started - later in the frame than they normally would. - - If origin is null, the origin is determined from the entity origin - or the midpoint of the entity box for bmodels. - ================== - */ - public static void SV_StartSound( - float[] origin, - ServerEntity entity, - int channel, - int soundindex, - float volume, - float attenuation, - float timeofs, - GameImportsImpl gameImports) { - - if (volume < 0 || volume > 1.0) - Com.Error(Defines.ERR_FATAL, "SV_StartSound: volume = " + volume); - - if (attenuation < 0 || attenuation > 4) - Com.Error(Defines.ERR_FATAL, "SV_StartSound: attenuation = " + attenuation); - - // if (channel < 0 || channel > 15) - // Com_Error (ERR_FATAL, "SV_StartSound: channel = %i", channel); - - if (timeofs < 0 || timeofs > 0.255) - Com.Error(Defines.ERR_FATAL, "SV_StartSound: timeofs = " + timeofs); - - int ent = entity.index; - - // no PHS flag - boolean use_phs; - if ((channel & 8) != 0) { - use_phs = false; - channel &= 7; - } - else - use_phs = true; - - int sendchan = (ent << 3) | (channel & 7); - - int flags = 0; - if (volume != Defines.DEFAULT_SOUND_PACKET_VOLUME) - flags |= Defines.SND_VOLUME; - if (attenuation != Defines.DEFAULT_SOUND_PACKET_ATTENUATION) - flags |= Defines.SND_ATTENUATION; - - // the client doesn't know that bmodels have weird origins - // the origin can also be explicitly set - if ((entity.svflags & Defines.SVF_NOCLIENT) != 0 || (entity.solid == Defines.SOLID_BSP) || origin != null) - flags |= Defines.SND_POS; - - // always send the entity number for channel overrides - flags |= Defines.SND_ENT; - - if (timeofs != 0) - flags |= Defines.SND_OFFSET; - - // use the entity origin unless it is a bmodel or explicitly specified - if (origin == null) { - origin = gameImports.origin_v; - if (entity.solid == Defines.SOLID_BSP) { - for (int i = 0; i < 3; i++) - gameImports.origin_v[i] = entity.s.origin[i] + 0.5f * (entity.mins[i] + entity.maxs[i]); - } - else { - Math3D.VectorCopy(entity.s.origin, gameImports.origin_v); - } - } - - // if the sound doesn't attenuate,send it to everyone - // (global radio chatter, voiceovers, etc) - if (attenuation == Defines.ATTN_NONE) - use_phs = false; - - final MulticastTypes multicastType; - - if ((channel & Defines.CHAN_RELIABLE) != 0) { - if (use_phs) - multicastType = MulticastTypes.MULTICAST_PHS_R; - else - multicastType = MulticastTypes.MULTICAST_ALL_R; - } else { - if (use_phs) - multicastType = MulticastTypes.MULTICAST_PHS; - else - multicastType = MulticastTypes.MULTICAST_ALL; - } - gameImports.multicastMessage(origin, new SoundMessage(flags, soundindex, volume, attenuation, timeofs, sendchan, origin), multicastType); - } - /* - =============================================================================== - - FRAME UPDATES - - =============================================================================== - */ - - /* - ======================= - SV_SendClientDatagram - ======================= - */ - public static void SV_SendClientDatagram(ClientNetworkInfo client, GameImportsImpl gameImports) { - - // send over all the relevant entity_state_t - // and the player_state_t - Collection unreliable = new ArrayList<>(); - unreliable.addAll(gameImports.buildClientFrameMessages(client)); // 3 messages - - // copy the accumulated multicast datagram - // for this client out to the message - // it is necessary for this to be after the buildClientFrameMessages (SV_BuildClientFrame) - // so that entity references will be current - unreliable.addAll(client.unreliable); - client.unreliable.clear(); - // send the datagram - client.netchan.transmit(unreliable); - - // record the size for rate estimation - client.message_size[gameImports.frameNum % Defines.RATE_MESSAGES] = unreliable.stream().mapToInt(NetworkMessage::getSize).sum(); - } - -} - From c9220ac0802a6b2d1325b74a7d6fb4b88e31557c Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 17:58:35 +0100 Subject: [PATCH 09/47] Move game initialization and entity event cleanup into Game --- .../java/jake2/server/GameImportsTest.java | 64 +++++++++++++ .../java/jake2/qcommon/entity_state_t.java | 2 +- .../java/jake2/server/GameImportsImpl.java | 73 +++++++++++++++ .../src/main/java/jake2/server/SV_MAIN.java | 91 ++----------------- 4 files changed, 147 insertions(+), 83 deletions(-) diff --git a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java index 90d65c87..1bf5b2ba 100644 --- a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java +++ b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java @@ -1,6 +1,7 @@ package jake2.server; import jake2.game.GameEntity; +import jake2.game.GameExportsImpl; import jake2.game.GamePlayerInfo; import jake2.game.monsters.M_Gunner; import jake2.qcommon.GameExports; @@ -280,6 +281,69 @@ public void clientPrintQueueRespectsItsMessageLevel() { Assertions.assertTrue(client.unreliable.isEmpty()); } + @Test + public void levelInitializationTransfersPersistentStateAndKeepsEventsGameLocal() { + manager = new SV_MAIN(); + GameImportsImpl first = manager.spawnServerInstance(new ChangeMapInfo("testbox", false, false), 0); + GameExportsImpl firstGameplay = (GameExportsImpl) first.gameExports; + Assertions.assertEquals(ServerStates.SS_GAME, first.state); + Assertions.assertEquals(2, firstGameplay.level.framenum); + firstGameplay.game.serverflags = 7; + SV_MAIN.games.put("default", first); + + GameImportsImpl next = manager.spawnServerInstance(new ChangeMapInfo("testbox2", false, false), 0); + GameExportsImpl nextGameplay = (GameExportsImpl) next.gameExports; + Assertions.assertEquals(7, nextGameplay.game.serverflags); + Assertions.assertNotSame(firstGameplay.game, nextGameplay.game); + Assertions.assertEquals(ServerStates.SS_GAME, next.state); + Assertions.assertEquals(2, nextGameplay.level.framenum); + + first.gameExports.getEdict(1).s.event = 1; + next.gameExports.getEdict(1).s.event = 2; + first.clearEntityStateEvents(); + Assertions.assertEquals(0, first.gameExports.getEdict(1).s.event); + Assertions.assertEquals(2, next.gameExports.getEdict(1).s.event); + } + + @Test + public void levelInitializationRestoresSavesAndOnlySettlesRevisitedLevels() { + manager = new SV_MAIN(); + GameImportsImpl original = manager.spawnServerInstance(new ChangeMapInfo("testbox", false, false), 0); + GameExportsImpl originalGameplay = (GameExportsImpl) original.gameExports; + originalGameplay.game.serverflags = 9; + int entityIndex = -1; + for (int i = 1; i < original.gameExports.getNumEdicts(); i++) { + GameEntity entity = (GameEntity) original.gameExports.getEdict(i); + if ("monster_gunner".equals(entity.classname)) { + entity.health = 73; + entityIndex = i; + break; + } + } + Assertions.assertTrue(entityIndex > 0); + int savedFrame = originalGameplay.level.framenum; + original.SV_WriteLevelFile(); + original.SV_WriteServerFile(false); + originalGameplay.game.serverflags = 99; + SV_MAIN.games.put("default", original); + + GameImportsImpl loaded = manager.spawnServerInstance(new ChangeMapInfo("testbox", false, true), 0); + GameExportsImpl loadedGameplay = (GameExportsImpl) loaded.gameExports; + Assertions.assertEquals(9, loadedGameplay.game.serverflags); + Assertions.assertEquals(savedFrame, loadedGameplay.level.framenum); + Assertions.assertEquals(73, ((GameEntity) loaded.gameExports.getEdict(entityIndex)).health); + Assertions.assertEquals(ServerStates.SS_GAME, loaded.state); + Assertions.assertEquals(loaded.gameExports.getEdict(entityIndex).s.modelindex, + loaded.baselines[entityIndex].modelindex); + Assertions.assertArrayEquals(loaded.gameExports.getEdict(entityIndex).s.origin, + loaded.baselines[entityIndex].origin); + Assertions.assertEquals(Float.toString(loaded.airAcceleration), loaded.configstrings[Defines.CS_AIRACCEL]); + + GameImportsImpl revisited = manager.spawnServerInstance(new ChangeMapInfo("testbox", false, false), 0); + Assertions.assertEquals(savedFrame + 100, ((GameExportsImpl) revisited.gameExports).level.framenum); + Assertions.assertEquals(ServerStates.SS_GAME, revisited.state); + } + @Test public void runGame() { diff --git a/qcommon/src/main/java/jake2/qcommon/entity_state_t.java b/qcommon/src/main/java/jake2/qcommon/entity_state_t.java index 89a517d6..f0914d80 100644 --- a/qcommon/src/main/java/jake2/qcommon/entity_state_t.java +++ b/qcommon/src/main/java/jake2/qcommon/entity_state_t.java @@ -57,7 +57,7 @@ public entity_state_t(int index) { // 8*(bits 5-9) is z down distance, 8(bits10-15) is z up // gi.linkentity sets this properly public int sound; // for looping sounds, to guarantee shutoff - public int event; // impulse events -- muzzle flashes, footsteps, etc + public int event; // impulse events -- muzzle flashes, footsteps, etc. cleared each frame // events only go out for a single frame, they // are automatically cleared each frame diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index 3b7594cd..0b425ded 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -50,6 +50,7 @@ import jake2.server.save.ServerSaveFiles; import java.io.File; +import java.lang.reflect.Constructor; import java.io.IOException; import java.util.ArrayList; import java.util.Date; @@ -182,6 +183,78 @@ public GameImportsImpl(JakeServer serverMain, ChangeMapInfo changeMapInfo) { } + /** Allocate the gameplay module after engine services and latched settings are ready. */ + static GameImportsImpl create(JakeServer host, ChangeMapInfo changeMapInfo, GameImportsImpl previous) { + GameImportsImpl game = new GameImportsImpl(host, changeMapInfo, previous); + game.gameExports = game.createGameModInstance(); + if (changeMapInfo.isLoadgame) + game.gameExports.readGameLocals(EngineWriteRoot.resolve("save/current/game.ssv.json").toString()); + return game; + } + + /** + * Find and create an instance of the game subsystem + */ + private GameExports createGameModInstance() { + // todo: introduce proper Dependency Injection + try { + Class game = Class.forName("jake2.game.GameExportsImpl"); + Constructor constructor = game.getConstructor(GameImports.class); + return (GameExports) constructor.newInstance(this); + } catch (Exception e) { + e.printStackTrace(); + Com.Error(Defines.ERR_FATAL, "Could not initialise game subsystem due to : " + e.getMessage() + "\n"); + return null; + } + } + + /** Capture persistent player data before allocating the replacement Game. */ + void prepareLevelTransition() { + gameExports.SaveClientData(); + } + + /** Populate and settle this level while the Host keeps clients in the loading handshake. */ + void initializeLevel(ChangeMapInfo changeMapInfo, GameImportsImpl previous) { + if (previous != null && !changeMapInfo.isLoadgame) { + // copy persistent data between instances. + // after a 'map' command the persistent state will be empty + gameExports.fromPrevious(previous.gameExports, changeMapInfo.spawnPoint); + } + + + // spawn fresh entities + gameExports.SpawnEntities(mapName, cm.CM_EntityString(), changeMapInfo.spawnPoint); + + if (isPreviousLevel()) { + // fixme: need to spawn all entities and then remove them, + // because otherwise precaching will not work properly - configstrings will not match. + clearWorld(); + + // loads a stored game, runs 100 frames afterwards + restorePreviousGame(); + } else { + // run two frames to allow everything to settle + gameExports.G_RunFrame(); + gameExports.G_RunFrame(); + + } + // create a baseline for more efficient communications + createBaselines(); + + + // Level restoration may replace configstrings, including saves from before air movement was published. + configstrings[Defines.CS_AIRACCEL] = Float.toString(airAcceleration); + + // all precaches are complete + state = changeMapInfo.state; + } + + /** Entity events last for one transmission; the Host calls this after sending snapshots. */ + void clearEntityStateEvents() { + for (int i = 0; i < gameExports.getNumEdicts(); i++) + gameExports.getEdict(i).s.event = 0; + } + /** * SV_ReadServerFile * diff --git a/server/src/main/java/jake2/server/SV_MAIN.java b/server/src/main/java/jake2/server/SV_MAIN.java index e1c6c8c5..2c52aad2 100644 --- a/server/src/main/java/jake2/server/SV_MAIN.java +++ b/server/src/main/java/jake2/server/SV_MAIN.java @@ -46,7 +46,6 @@ import jake2.server.save.ServerSaveJsonStore; import jake2.server.save.ServerSaveFiles; -import java.lang.reflect.Constructor; import java.util.*; import java.util.stream.Collectors; @@ -469,19 +468,6 @@ void SV_ConnectionlessPacket(netadr_t from, String messageLine) { } } - /** - * SV_PrepWorldFrame - * - * This has to be done before the world logic, because player processing - * happens outside RunWorldFrame. - */ - private static void clearEntityStateEvents(GameExports gameExports) { - for (int i = 0; i < gameExports.getNumEdicts(); i++) { - // events only last for a single message - gameExports.getEdict(i).s.event = 0; - } - } - /** * SV_Frame. */ @@ -543,7 +529,7 @@ public void update(long msec) { // clear teleport flags, etc for next frame for (GameImportsImpl game : games.values()) { - clearEntityStateEvents(game.gameExports); + game.clearEntityStateEvents(); } } @@ -1107,11 +1093,7 @@ public SV_MAIN() { * A brand new game has been started. */ GameImportsImpl createGameInstance(ChangeMapInfo changeMapInfo, GameImportsImpl previous) { - GameImportsImpl gameImports = new GameImportsImpl(this, changeMapInfo, previous); - gameImports.gameExports = createGameModInstance(gameImports); - if (changeMapInfo.isLoadgame) - gameImports.gameExports.readGameLocals(EngineWriteRoot.resolve("save/current/game.ssv.json").toString()); - return gameImports; + return GameImportsImpl.create(this, changeMapInfo, previous); } /** @@ -1155,22 +1137,6 @@ private boolean initializeServerCvars() { return maxclients.getTypedValue() > 1; } - /** - * Find and create an instance of the game subsystem - */ - private static GameExports createGameModInstance(GameImportsImpl gameImports) { - // todo: introduce proper Dependency Injection - try { - Class game = Class.forName("jake2.game.GameExportsImpl"); - Constructor constructor = game.getConstructor(GameImports.class); - return (GameExports) constructor.newInstance(gameImports); - } catch (Exception e) { - e.printStackTrace(); - Com.Error(ERR_FATAL, "Could not initialise game subsystem due to : " + e.getMessage() + "\n"); - return null; - } - } - void SV_Loadgame_f(List args) { if (args.size() != 2) { @@ -1352,21 +1318,13 @@ void SV_GameMap_f(List args) { GameImportsImpl spawnServerInstance(ChangeMapInfo changeMapInfo, int clientIndex) { Globals.server_state = ServerStates.SS_DEAD; //fixme: used by the client code - GameExports oldGame = null; - GameImportsImpl gameImports; - - if (clientIndex != 0) - gameImports = games.get(clients.get(clientIndex - 1).gameName); - else - gameImports = games.get("default"); - - if (gameImports != null) { //todo - // store some entity data into gclient_t struct, will be restored in the next game - oldGame = gameImports.gameExports; - oldGame.SaveClientData(); - } + GameImportsImpl previous = clientIndex != 0 + ? games.get(clients.get(clientIndex - 1).gameName) + : games.get("default"); + if (previous != null) + previous.prepareLevelTransition(); - gameImports = createGameInstance(changeMapInfo, gameImports); + GameImportsImpl gameImports = createGameInstance(changeMapInfo, previous); Cvar.getInstance().find("nextserver", String.class).set("gamemap \"" + changeMapInfo.nextServer + "\""); @@ -1397,38 +1355,7 @@ GameImportsImpl spawnServerInstance(ChangeMapInfo changeMapInfo, int clientIndex // map initialization Globals.server_state = gameImports.state; // LOADING - if (oldGame != null && !changeMapInfo.isLoadgame) { - // copy persistent data between instances. - // after a 'map' command the persistent state will be empty - gameImports.gameExports.fromPrevious(oldGame, changeMapInfo.spawnPoint); - } - - - // spawn fresh entities - gameImports.gameExports.SpawnEntities(gameImports.mapName, gameImports.cm.CM_EntityString(), changeMapInfo.spawnPoint); - - if (gameImports.isPreviousLevel()) { - // fixme: need to spawn all entities and then remove them, - // because otherwise precaching will not work properly - configstrings will not match. - gameImports.clearWorld(); - - // loads a stored game, runs 100 frames afterwards - gameImports.restorePreviousGame(); - } else { - // run two frames to allow everything to settle - gameImports.gameExports.G_RunFrame(); - gameImports.gameExports.G_RunFrame(); - - } - // create a baseline for more efficient communications - gameImports.createBaselines(); - - - // Level restoration may replace configstrings, including saves from before air movement was published. - gameImports.configstrings[CS_AIRACCEL] = Float.toString(gameImports.airAcceleration); - - // all precaches are complete - gameImports.state = changeMapInfo.state; + gameImports.initializeLevel(changeMapInfo, previous); Globals.server_state = gameImports.state; From 4b1582563c995437f8ef9811548d0d039f068933 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 18:12:30 +0100 Subject: [PATCH 10/47] Consolidate client protocol and mutable server state under Host ownership --- .../demoth/cake/fullgame/EmbeddedCakeHost.kt | 3 +- .../java/jake2/server/GameImportsTest.java | 54 ++- .../jake2/server/CakeHostApplicationTest.kt | 10 +- .../jake2/server/CakeLocalSessionGlTest.kt | 14 +- .../jake2/server/CakeProfileSaveGlTest.kt | 2 +- .../jake2/server/EmbeddedCakeHostTest.kt | 10 +- .../java/jake2/dedicated/Jake2Dedicated.java | 3 +- .../java/jake2/server/GameImportsImpl.java | 14 +- .../main/java/jake2/server/JakeServer.java | 19 - .../src/main/java/jake2/server/SV_MAIN.java | 448 ++++++++++++++++-- .../src/main/java/jake2/server/SV_USER.java | 395 --------------- .../java/jake2/server/ServerUserCommand.java | 7 - .../server/GameCvarInitializationTest.java | 2 +- 13 files changed, 476 insertions(+), 505 deletions(-) delete mode 100644 server/src/main/java/jake2/server/JakeServer.java delete mode 100644 server/src/main/java/jake2/server/SV_USER.java delete mode 100644 server/src/main/java/jake2/server/ServerUserCommand.java diff --git a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt index 503b9411..3aa1cc73 100644 --- a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt +++ b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt @@ -21,7 +21,8 @@ import java.nio.file.Path class EmbeddedCakeHost( private val profilesRoot: Path = Path.of(System.getProperty("user.home"), ".cake"), ) : CakeHost { - private var server: SV_MAIN? = null + internal var server: SV_MAIN? = null + private set private var starting = false private var ownerThread: Thread? = null diff --git a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java index 1bf5b2ba..3b07e78d 100644 --- a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java +++ b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java @@ -96,8 +96,8 @@ public void resourceUpdatesStayWithTheirGameAndOnlySendAfterLoading() { manager.resetClients(); GameImportsImpl first = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); GameImportsImpl second = manager.createGameInstance(new ChangeMapInfo("testbox2", false, false), null); - SV_MAIN.games.put(first.name, first); - SV_MAIN.games.put(second.name, second); + manager.games.put(first.name, first); + manager.games.put(second.name, second); ClientNetworkInfo firstClient = manager.getClients().get(0); ClientNetworkInfo secondClient = manager.getClients().get(1); firstClient.gameName = first.name; @@ -289,7 +289,7 @@ public void levelInitializationTransfersPersistentStateAndKeepsEventsGameLocal() Assertions.assertEquals(ServerStates.SS_GAME, first.state); Assertions.assertEquals(2, firstGameplay.level.framenum); firstGameplay.game.serverflags = 7; - SV_MAIN.games.put("default", first); + manager.games.put("default", first); GameImportsImpl next = manager.spawnServerInstance(new ChangeMapInfo("testbox2", false, false), 0); GameExportsImpl nextGameplay = (GameExportsImpl) next.gameExports; @@ -325,7 +325,7 @@ public void levelInitializationRestoresSavesAndOnlySettlesRevisitedLevels() { original.SV_WriteLevelFile(); original.SV_WriteServerFile(false); originalGameplay.game.serverflags = 99; - SV_MAIN.games.put("default", original); + manager.games.put("default", original); GameImportsImpl loaded = manager.spawnServerInstance(new ChangeMapInfo("testbox", false, true), 0); GameExportsImpl loadedGameplay = (GameExportsImpl) loaded.gameExports; @@ -344,6 +344,52 @@ public void levelInitializationRestoresSavesAndOnlySettlesRevisitedLevels() { Assertions.assertEquals(ServerStates.SS_GAME, revisited.state); } + @Test + public void hostProtocolDispatchAndShutdownUseTheirOwnGameRegistry() { + manager = new SV_MAIN(); + Cvar.getInstance().register("gamedir", jake2.qcommon.exec.CvarTypes.STRING, "", 0, "Test mod directory"); + Cvar.getInstance().find("maxclients", Integer.class).forceSet(2); + manager.resetClients(); + GameImportsImpl first = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); + GameImportsImpl second = manager.createGameInstance(new ChangeMapInfo("testbox2", false, false), null); + manager.games.put(first.name, first); + manager.games.put(second.name, second); + first.configstrings[Defines.CS_NAME] = "first game"; + second.configstrings[Defines.CS_NAME] = "second game"; + ClientNetworkInfo firstClient = manager.getClients().get(0); + ClientNetworkInfo secondClient = manager.getClients().get(1); + firstClient.gameName = first.name; + secondClient.gameName = second.name; + firstClient.edict = first.gameExports.getEdict(1); + secondClient.edict = second.gameExports.getEdict(2); + firstClient.state = secondClient.state = ClientStates.CS_CONNECTED; + try { + manager.SV_ExecuteUserCommand(firstClient, "new"); + manager.SV_ExecuteUserCommand(secondClient, "new"); + Assertions.assertEquals("first game", + ((ServerDataMessage) firstClient.netchan.reliablePending.iterator().next()).levelString); + Assertions.assertEquals("second game", + ((ServerDataMessage) secondClient.netchan.reliablePending.iterator().next()).levelString); + firstClient.netchan.reliablePending.clear(); + manager.SV_ExecuteUserCommand(firstClient, "configstrings " + first.spawncount + " 0"); + Assertions.assertTrue(firstClient.netchan.reliablePending.contains( + new ConfigStringMessage(Defines.CS_NAME, "first game"))); + } finally { + firstClient.state = secondClient.state = ClientStates.CS_FREE; + } + + // A separate idle Host must not inherit or clear the running Host's registry. + // Global console/cvar/network isolation is outside this ownership test. + try (SV_MAIN other = new SV_MAIN()) { + Assertions.assertFalse(other.isRunning()); + Assertions.assertEquals(first.name, other.allocateGameName()); + } + Assertions.assertSame(first, manager.games.get(first.name)); + Assertions.assertSame(second, manager.games.get(second.name)); + Assertions.assertEquals(2, manager.getClients().size()); + Assertions.assertTrue(manager.isRunning()); + } + @Test public void runGame() { diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt index f2a6f698..ca03815e 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt @@ -75,7 +75,7 @@ class CakeHostApplicationTest { Cbuf.AddText("host_start testbox coop") cake!!.render() assertEquals(HostState.RUNNING, host.state) - frame = gameFrame() + frame = gameFrame(host) } 1 -> { val graphics = Gdx.graphics @@ -84,7 +84,7 @@ class CakeHostApplicationTest { override fun getBackBufferHeight() = 0 } try { cake!!.render() } finally { Gdx.graphics = graphics } - assertEquals(frame + 1, gameFrame(), "Menus and an undrawable window must not stop coop") + assertEquals(frame + 1, gameFrame(host), "Menus and an undrawable window must not stop coop") } 2 -> { Cbuf.AddText("connect 127.0.0.1:9") @@ -108,11 +108,11 @@ class CakeHostApplicationTest { Cbuf.AddText("host_start testbox") cake!!.render() assertEquals(HostState.RUNNING, host.state) - frame = gameFrame() + frame = gameFrame(host) } 5 -> { cake!!.render() - assertEquals(frame, gameFrame(), "The main menu pauses singleplayer") + assertEquals(frame, gameFrame(host), "The main menu pauses singleplayer") Gdx.app.exit() } } @@ -142,7 +142,7 @@ class CakeHostApplicationTest { } } - private fun gameFrame() = (SV_MAIN.games.getValue("default").gameExports as GameExportsImpl).level.framenum + private fun gameFrame(host: EmbeddedCakeHost) = (host.server!!.games.getValue("default").gameExports as GameExportsImpl).level.framenum private fun clientState(cake: Cake) = Cake::class.java.getDeclaredField("networkState").apply { isAccessible = true }.get(cake).toString() diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt index 5b0a2a55..906e65cf 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeLocalSessionGlTest.kt @@ -85,12 +85,12 @@ class CakeLocalSessionGlTest { when (step) { 0 -> if (state(cake) == "ACTIVE") { assertTrue(NET.ip_sockets.all { it == null }) - origin = playerOrigin() + origin = playerOrigin(host) next("+forward") } 1 -> if (ticks >= 45) { Cmd.ExecuteString("-forward") - val moved = playerOrigin() + val moved = playerOrigin(host) assertTrue(kotlin.math.abs(moved[0] - origin[0]) + kotlin.math.abs(moved[1] - origin[1]) > 1f, "Local movement must reach the server") next("gamemap base2") @@ -128,9 +128,9 @@ class CakeLocalSessionGlTest { } 9 -> { assertTrue(clientPeer.isAlive, "Thin-client peer exited; see ${temp.resolve("client.log")}") - if (Files.exists(clientReady) && spawnedClients() == 2) next("gamemap base2") + if (Files.exists(clientReady) && spawnedClients(host) == 2) next("gamemap base2") } - 10 -> if (ticks > 2 && state(cake) == "ACTIVE" && mapName() == "base2" && spawnedClients() == 2) { + 10 -> if (ticks > 2 && state(cake) == "ACTIVE" && mapName() == "base2" && spawnedClients(host) == 2) { assertSame(serverSocket, NET.ip_sockets[Defines.NS_SERVER]) clientPeer.destroy() next("host_start base1 deathmatch") @@ -144,7 +144,7 @@ class CakeLocalSessionGlTest { } 12 -> { assertTrue(clientPeer.isAlive) - if (Files.exists(clientReady) && spawnedClients() == 2) { + if (Files.exists(clientReady) && spawnedClients(host) == 2) { clientPeer.destroy() serverPeer = launchPeer("server", basedir, serverReady) peers.add(serverPeer) @@ -205,8 +205,8 @@ class CakeLocalSessionGlTest { }.get(cake).toString() private fun mapName() = Cvar.getInstance().find("mapname").typedValue - private fun playerOrigin() = (SV_MAIN.games.getValue("default").gameExports as GameExportsImpl).getEdict(1).s.origin.copyOf() - private fun spawnedClients() = SV_MAIN.games.getValue("default").serverMain.clients.count { + private fun playerOrigin(host: EmbeddedCakeHost) = (host.server!!.games.getValue("default").gameExports as GameExportsImpl).getEdict(1).s.origin.copyOf() + private fun spawnedClients(host: EmbeddedCakeHost) = host.server!!.games.getValue("default").serverMain.clients.count { it.state == ClientStates.CS_SPAWNED } diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeProfileSaveGlTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeProfileSaveGlTest.kt index ecc7820a..0dc259af 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeProfileSaveGlTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeProfileSaveGlTest.kt @@ -76,7 +76,7 @@ class CakeProfileSaveGlTest { private fun state() = Cake::class.java.getDeclaredField("networkState").apply { isAccessible = true }.get(cake).toString() - private fun player() = (SV_MAIN.games.getValue("default").gameExports as GameExportsImpl).getEdict(1) as GameEntity + private fun player() = (host.server!!.games.getValue("default").gameExports as GameExportsImpl).getEdict(1) as GameEntity override fun render() { if (failure != null) return diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt index d05c0e11..29d6562a 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt @@ -54,7 +54,7 @@ class EmbeddedCakeHostTest { cvars.clear() } - private fun game() = SV_MAIN.games.getValue("default").gameExports as GameExportsImpl + private fun game() = host.server!!.games.getValue("default").gameExports as GameExportsImpl @Test fun `missing download receives failure for fresh and resumed requests`() { @@ -70,8 +70,8 @@ class EmbeddedCakeHostTest { val client = ClientNetworkInfo() val downloads = CakeDownloadSession( sendReliable = { command -> - SV_USER.userCommands.getValue("download").execute( - Cmd.TokenizeString(command, false), SV_MAIN.games.getValue("default"), client) + client.gameName = "default" + host.server!!.SV_ExecuteUserCommand(client, command) }, downloadManager = CakeDownloadManager(CakeDownloadCache(cache), cvars), ) @@ -96,7 +96,7 @@ class EmbeddedCakeHostTest { Cmd.ExecuteString("gamemap *testbox") host.update(100) assertEquals(HostState.RUNNING, host.state) - assertEquals(1, SV_MAIN.games.getValue("default").serverMain.clients.size) + assertEquals(1, host.server!!.games.getValue("default").serverMain.clients.size) assertTrue(NET.ip_sockets.all { it == null }) } @@ -154,7 +154,7 @@ class EmbeddedCakeHostTest { host.stop("done") host.stop("again") assertEquals(HostState.STOPPED, host.state) - assertTrue(SV_MAIN.games.isEmpty()) + assertNull(host.server) assertNull(Cmd.FindCommand("map")) assertNull(Cmd.FindCommand("save")) assertEquals(previousRoot, EngineWriteRoot.path()) diff --git a/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java b/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java index 8711dc01..417ad3cd 100644 --- a/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java +++ b/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java @@ -10,7 +10,6 @@ import jake2.qcommon.sys.Sys; import jake2.qcommon.sys.Timer; import jake2.qcommon.vfs.EngineFilesystemLifecycle; -import jake2.server.JakeServer; import jake2.server.SV_MAIN; import java.util.Arrays; @@ -34,7 +33,7 @@ public static void main(String[] args) { if (argc > 1) { System.arraycopy(args, 0, c_args, 1, argc - 1); } - JakeServer serverMain = null; + SV_MAIN serverMain = null; try { // prepare enough of the subsystems to handle diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index 0b425ded..27b8fb1f 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -66,7 +66,7 @@ public class GameImportsImpl implements GameImports { String name; - final JakeServer serverMain; + final SV_MAIN serverMain; public GameExports gameExports; // Per-game map state. The routing name above is independent of the loaded map name. @@ -116,13 +116,13 @@ public class GameImportsImpl implements GameImports { final float airAcceleration; private final PMove.PmoveProcessor pmoveProcessor = PMove.newLegacyProcessor(); - public GameImportsImpl(JakeServer serverMain, ChangeMapInfo changeMapInfo) { + public GameImportsImpl(SV_MAIN serverMain, ChangeMapInfo changeMapInfo) { this(serverMain, changeMapInfo, null); } - GameImportsImpl(JakeServer serverMain, ChangeMapInfo changeMapInfo, GameImportsImpl previous) { + GameImportsImpl(SV_MAIN serverMain, ChangeMapInfo changeMapInfo, GameImportsImpl previous) { this.serverMain = serverMain; - this.name = "jake2_" + SV_MAIN.gameCounter++; + this.name = serverMain.allocateGameName(); if (previous != null && !changeMapInfo.isLoadgame) previous.localCvars.copyValuesTo(localCvars); @@ -184,7 +184,7 @@ public GameImportsImpl(JakeServer serverMain, ChangeMapInfo changeMapInfo) { } /** Allocate the gameplay module after engine services and latched settings are ready. */ - static GameImportsImpl create(JakeServer host, ChangeMapInfo changeMapInfo, GameImportsImpl previous) { + static GameImportsImpl create(SV_MAIN host, ChangeMapInfo changeMapInfo, GameImportsImpl previous) { GameImportsImpl game = new GameImportsImpl(host, changeMapInfo, previous); game.gameExports = game.createGameModInstance(); if (changeMapInfo.isLoadgame) @@ -872,7 +872,7 @@ private void SV_Kick_f(List args) { // print directly, because the dropped client won't get the // SV_BroadcastPrintf message client.queuePrint(Defines.PRINT_HIGH, "You were kicked from the game\n"); - SV_MAIN.SV_DropClient(client); + serverMain.SV_DropClient(client); client.lastmessage = realtime; // min case there is a funny zombie } /* @@ -1046,7 +1046,7 @@ void SV_RunGameFrame(long fixedtime) { // has the "current" frame frameNum++; - if (SV_MAIN.sv_paused != null && SV_MAIN.sv_paused.getTypedValue() && serverMain.getClients().size() <= 1) { + if (serverMain.isPaused() && serverMain.getClients().size() <= 1) { return; } diff --git a/server/src/main/java/jake2/server/JakeServer.java b/server/src/main/java/jake2/server/JakeServer.java deleted file mode 100644 index 1b24987a..00000000 --- a/server/src/main/java/jake2/server/JakeServer.java +++ /dev/null @@ -1,19 +0,0 @@ -package jake2.server; - -import java.util.List; -import jake2.qcommon.exec.Command; - -public interface JakeServer { - void update(long time); - - List getClientsForInstance(String gameName); - - List getClients(); - - void SV_BroadcastPrintf(int level, String s, String name); - - void SV_Shutdown(String message, boolean reconnect); - - /** Operator commands belong to the current session and are removed on shutdown. */ - void registerGameCommand(String name, Command command); -} diff --git a/server/src/main/java/jake2/server/SV_MAIN.java b/server/src/main/java/jake2/server/SV_MAIN.java index 2c52aad2..6a7e2fbd 100644 --- a/server/src/main/java/jake2/server/SV_MAIN.java +++ b/server/src/main/java/jake2/server/SV_MAIN.java @@ -37,10 +37,7 @@ import jake2.qcommon.network.messages.NetworkMessage; import jake2.qcommon.network.messages.NetworkPacket; import jake2.qcommon.network.messages.client.*; -import jake2.qcommon.network.messages.server.DisconnectMessage; -import jake2.qcommon.network.messages.server.PrintMessage; -import jake2.qcommon.network.messages.server.ReconnectMessage; -import jake2.qcommon.network.messages.server.StuffTextMessage; +import jake2.qcommon.network.messages.server.*; import jake2.qcommon.network.netadr_t; import jake2.qcommon.util.Lib; import jake2.server.save.ServerSaveJsonStore; @@ -50,10 +47,9 @@ import java.util.stream.Collectors; import static jake2.qcommon.Defines.*; -import static jake2.server.SV_USER.userCommands; @SuppressWarnings("deprecation") -public class SV_MAIN implements JakeServer, AutoCloseable { +public class SV_MAIN implements AutoCloseable { // Recreating an embedded manager must reuse the same choice declaration. private static final CvarTypes.ChoiceType NEEDPASS_TYPE = @@ -62,7 +58,6 @@ public class SV_MAIN implements JakeServer, AutoCloseable { private final ServerCommands commands = new ServerCommands(); private final ServerCommands gameCommands = new ServerCommands(); - @Override public void registerGameCommand(String name, jake2.qcommon.exec.Command command) { gameCommands.add(name, true, command); } @@ -88,16 +83,14 @@ public void close() { } } - @Override public List getClients() { return clients; } private static final int MAX_STRINGCMDS = 8; - public static Map games = new HashMap<>(); + final Map games = new HashMap<>(); - @Override public List getClientsForInstance(final String gameName) { if (!games.containsKey(gameName)) { // happens during the game instance creation, while the instance has random name @@ -108,11 +101,6 @@ public List getClientsForInstance(final String gameName) { } - /** - * Addess of group servers. - */ - static netadr_t master_adr[] = new netadr_t[Defines.MAX_MASTERS]; - // prevent invalid IPs from connecting private final challenge_t[] challenges = new challenge_t[Defines.MAX_CHALLENGES]; // to @@ -121,45 +109,46 @@ public List getClientsForInstance(final String gameName) { private long realtime = 0; private int frameNum = 0; private int fixedtime = 1000; - public static int gameCounter = 0; + private int gameCounter; - static { - for (int i = 0; i < Defines.MAX_MASTERS; i++) { - master_adr[i] = new netadr_t(); - } + String allocateGameName() { + return "jake2_" + gameCounter++; + } + boolean isPaused() { + return sv_paused.getTypedValue(); } - static cvar_t sv_paused; + final cvar_t sv_paused; - static cvar_t sv_timedemo; + final cvar_t sv_timedemo; - static cvar_t sv_enforcetime; + final cvar_t sv_enforcetime; - static cvar_t timeout; // seconds without any message + final cvar_t timeout; // seconds without any message - static cvar_t zombietime; // seconds to sink messages after + final cvar_t zombietime; // seconds to sink messages after // disconnect - static cvar_t allow_download; + final cvar_t allow_download; - static cvar_t allow_download_players; + final cvar_t allow_download_players; - static cvar_t allow_download_models; + final cvar_t allow_download_models; - static cvar_t allow_download_sounds; + final cvar_t allow_download_sounds; - static cvar_t allow_download_maps; + final cvar_t allow_download_maps; - static cvar_t sv_airaccelerate; + final cvar_t sv_airaccelerate; - static cvar_t sv_showclamp; + final cvar_t sv_showclamp; - static cvar_t hostname; + final cvar_t hostname; - static cvar_t public_server; // should heartbeats be sent + final cvar_t public_server; // should heartbeats be sent - static cvar_t sv_reconnect_limit; // minimum seconds between connect + final cvar_t sv_reconnect_limit; // minimum seconds between connect // messages @@ -200,14 +189,14 @@ private String SVC_Info(List args, netadr_t from) { String info; if (version != Defines.PROTOCOL_VERSION) - info = SV_MAIN.hostname.getTypedValue() + ": wrong version\n"; + info = hostname.getTypedValue() + ": wrong version\n"; else { int players = 0; for (ClientNetworkInfo cl: clients) if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) players++; - info = SV_MAIN.hostname.getTypedValue() + " " + games.get("default").mapName + " " + players + "/" + clients.size() + "\n"; + info = hostname.getTypedValue() + " " + games.get("default").mapName + " " + players + "/" + clients.size() + "\n"; } return info; @@ -305,7 +294,7 @@ private void SVC_DirectConnect(List args, netadr_t adr) { continue; if (adr.CompareBaseAdr(cl.netchan.remote_address) && (cl.netchan.qport == qport || adr.port == cl.netchan.remote_address.port)) { - if (!adr.IsLocalAddress() && (realtime - cl.lastconnect) < (SV_MAIN.sv_reconnect_limit.getTypedValue() * 1000)) { + if (!adr.IsLocalAddress() && (realtime - cl.lastconnect) < (sv_reconnect_limit.getTypedValue() * 1000)) { Com.DPrintf(adr.toString() + ":reconnect rejected : too soon\n"); return; } @@ -574,7 +563,7 @@ void SV_SendClientMessages() { * unwillingly. This is NOT called if the entire server is quiting or * crashing. */ - static void SV_DropClient(ClientNetworkInfo client) { + void SV_DropClient(ClientNetworkInfo client) { // add the disconnect client.netchan.reliablePending.add(new DisconnectMessage()); @@ -694,7 +683,7 @@ void SV_ReadPackets() { * The current net_message is parsed for the given client * =================== */ - static void SV_ExecuteClientMessage(ClientNetworkInfo cl, Collection clientMessages) { + void SV_ExecuteClientMessage(ClientNetworkInfo cl, Collection clientMessages) { // only allow one move command boolean move_issued = false; @@ -746,7 +735,7 @@ static void SV_ExecuteClientMessage(ClientNetworkInfo cl, Collection game.realtime) @@ -955,7 +944,6 @@ private void SV_FinalMessage(String message, boolean reconnect) { * Called when each game quits, before Sys_Quit or Sys_Error. * todo: shutdown a particular instance, not the whole server */ - @Override public void SV_Shutdown(String finalmsg, boolean reconnect) { try { if (!games.isEmpty()) { @@ -995,9 +983,6 @@ void resetClients() { */ public SV_MAIN() { - // long gone - SV_MAIN.master_adr[0] = netadr_t.fromString("192.246.40.37", Defines.PORT_MASTER); - for (int n = 0; n < Defines.MAX_CHALLENGES; n++) { challenges[n] = new challenge_t(); } @@ -1449,4 +1434,365 @@ private void sendClientDatagram(ClientNetworkInfo client, GameImportsImpl gameIm // record the size for rate estimation client.message_size[gameImports.frameNum % Defines.RATE_MESSAGES] = unreliable.stream().mapToInt(NetworkMessage::getSize).sum(); } + private final Map userCommands; + + { + Map handlers = new HashMap<>(); + // auto issued + handlers.put(StringCmdMessage.NEW, this::SV_New_f); + handlers.put(StringCmdMessage.CONFIG_STRINGS, this::SV_Configstrings_f); + handlers.put(StringCmdMessage.BASELINES, this::SV_Baselines_f); + handlers.put(StringCmdMessage.BEGIN, this::SV_Begin_f); + handlers.put(StringCmdMessage.NEXT_SERVER, this::SV_Nextserver_f); + handlers.put(StringCmdMessage.DISCONNECT, this::SV_Disconnect_f); + + // issued by hand at client consoles + handlers.put(StringCmdMessage.INFO, this::SV_ShowServerinfo_f); + handlers.put(StringCmdMessage.DOWNLOAD, this::SV_BeginDownload_f); + handlers.put(StringCmdMessage.NEXT_DOWNLOAD, this::SV_NextDownload_f); + + userCommands = Map.copyOf(handlers); + } + + @FunctionalInterface + private interface ServerUserCommand { + void execute(List args, GameImportsImpl game, ClientNetworkInfo client); + } + + /* + * ============================================================ + * + * USER STRINGCMD EXECUTION + * + * sv_client and sv_player will be valid. + * ============================================================ + */ + + /* + * ================ SV_New_f + * + * Sends the first message from the server to a connected client. This will + * be sent on the initial connection and upon each server load. + * ================ + */ + private void SV_New_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { + + Com.DPrintf("New() from " + client.name + "\n"); + + if (client.state != ClientStates.CS_CONNECTED) { + Com.Printf("New not valid -- already spawned\n"); + return; + } + + // + // serverdata needs to go over for all types of servers + // to make sure the protocol is right, and to set the gamedir + // + String gamedir = Cvar.getInstance().find("gamedir", String.class).getTypedValue(); + + // send the serverdata + // send full levelname + + final int clientIndex; + if (gameImports.state == ServerStates.SS_CINEMATIC || gameImports.state == ServerStates.SS_PIC) + clientIndex = -1; + else + clientIndex = client.edict.index - 1; + + + client.netchan.reliablePending.add( + new ServerDataMessage( + Defines.PROTOCOL_VERSION, + gameImports.spawncount, + gameImports.isDemo, + gamedir, + clientIndex, + gameImports.configstrings[Defines.CS_NAME] + )); + // + // game server + // + if (gameImports.state == ServerStates.SS_GAME) { + // set up the entity for the client + ServerEntity ent = gameImports.gameExports.getEdict(clientIndex + 1); + ent.s.index = clientIndex + 1; + client.edict = ent; + client.lastcmd = new usercmd_t(); + + // begin fetching configstrings + client.netchan.reliablePending.add(new StuffTextMessage(String.format("cmd %s %d 0", StringCmdMessage.CONFIG_STRINGS, gameImports.spawncount))); + } + } + + /* + * ================== SV_Configstrings_f ================== + */ + private void SV_Configstrings_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { + + Com.DPrintf("Configstrings() from " + client.name + "\n"); + + if (client.state != ClientStates.CS_CONNECTED) { + Com.Printf("configstrings not valid -- already spawned\n"); + return; + } + + // handle the case of a level changing while a client was connecting + int spawnCount = args.size() >= 2 ? Lib.atoi(args.get(1)) : 0; + if (spawnCount != gameImports.spawncount) { + Com.Printf("SV_Configstrings_f from different level\n"); + SV_New_f(args, gameImports, client); + return; + } + + int start = args.size() >= 3 ? Lib.atoi(args.get(2)) : 0; + + // write a packet full of data + int currentReliableSize = client.netchan.reliablePending.stream().mapToInt(NetworkMessage::getSize).sum(); + while (currentReliableSize < Defines.MAX_MSGLEN / 2 && start < Defines.MAX_CONFIGSTRINGS) { + if (gameImports.configstrings[start] != null && gameImports.configstrings[start].length() != 0) { + final ConfigStringMessage config = new ConfigStringMessage(start, gameImports.configstrings[start]); + currentReliableSize += config.getSize(); + client.netchan.reliablePending.add(config); + } + start++; + } + + // send next command + final String nextCmd; + + if (start == Defines.MAX_CONFIGSTRINGS) { + nextCmd = String.format("cmd %s %d 0", StringCmdMessage.BASELINES, gameImports.spawncount); + } else { + nextCmd = String.format("cmd %s %d %d", StringCmdMessage.CONFIG_STRINGS, gameImports.spawncount, start); + } + client.netchan.reliablePending.add(new StuffTextMessage(nextCmd)); + } + + /* + * ================== SV_Baselines_f ================== + */ + private void SV_Baselines_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { + + Com.DPrintf("Baselines() from " + client.name + "\n"); + + if (client.state != ClientStates.CS_CONNECTED) { + Com.Printf("baselines not valid -- already spawned\n"); + return; + } + + // handle the case of a level changing while a client was connecting + int spawnCount = args.size() >= 2 ? Lib.atoi(args.get(1)) : 0; + if (spawnCount != gameImports.spawncount) { + Com.Printf("SV_Baselines_f from different level\n"); + SV_New_f(args, gameImports, client); + return; + } + + // todo: validate argument + int start = args.size() >= 3 ? Lib.atoi(args.get(2)) : 0; + + // write a packet full of data + int currentReliableSize = client.netchan.reliablePending.stream().mapToInt(NetworkMessage::getSize).sum(); + while (currentReliableSize < Defines.MAX_MSGLEN / 2 && start < Defines.MAX_EDICTS) { + entity_state_t base = gameImports.baselines[start]; + if (base.modelindex != 0 || base.sound != 0 || base.effects != 0) { + final SpawnBaselineMessage spawn = new SpawnBaselineMessage(base); + currentReliableSize += spawn.getSize(); + client.netchan.reliablePending.add(spawn); + } + start++; + } + + // send next command + final String nextCmd; + if (start == Defines.MAX_EDICTS) { + // finished sending baselines + nextCmd = String.format("%s %d", StringCmdMessage.PRECACHE,gameImports.spawncount); + } else { + // continue from where we finished + nextCmd = String.format("cmd %s %d %d", StringCmdMessage.BASELINES, gameImports.spawncount, start); + } + client.netchan.reliablePending.add(new StuffTextMessage(nextCmd)); + } + + /* + * ================== SV_Begin_f ================== + */ + private void SV_Begin_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { + Com.DPrintf("Begin() from " + client.name + "\n"); + + // handle the case of a level changing while a client was connecting + int spawnCount = args.size() >= 2 ? Lib.atoi(args.get(1)) : 0; + if (spawnCount != gameImports.spawncount) { + Com.Printf("SV_Begin_f from different level\n"); + SV_New_f(args, gameImports, client); + return; + } + + client.state = ClientStates.CS_SPAWNED; + + // call the jake2.game begin function + gameImports.gameExports.ClientBegin(client.edict); + + Cbuf.InsertFromDefer(); + } + + //============================================================================= + + /* + * ================== SV_NextDownload_f ================== + */ + private void SV_NextDownload_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { + + if (client.download == null) + return; + + int packet = client.downloadsize - client.downloadcount; + if (packet > 1024) + packet = 1024; + + client.downloadcount += packet; + int size = client.downloadsize; + if (size == 0) + size = 1; + byte percent = (byte) (client.downloadcount * 100 / size); + + byte[] data = new byte[packet]; + System.arraycopy(client.download, client.downloadcount - packet, data, 0, packet); + client.netchan.reliablePending.add(new DownloadMessage(data, percent)); + + if (client.downloadcount == client.downloadsize) { + client.download = null; + } + } + + /* + * ================== SV_BeginDownload_f ================== + */ + private void SV_BeginDownload_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { + int offset = 0; + + if (args.size() < 2) + return; + + String name = args.get(1); + + if (args.size() > 2) + offset = Lib.atoi(args.get(2)); // downloaded offset + + // hacked by zoid to allow more conrol over download + // first off, no .. or global allow check + + if (name.contains("..") + || !allow_download.getTypedValue() // leading dot is no good + || name.charAt(0) == '.' // leading slash bad as well, must be + // in subdir + || name.charAt(0) == '/' // next up, skin check + || (name.startsWith("players/") && !allow_download_players.getTypedValue()) // now + // models + || (name.startsWith("models/") && !allow_download_models.getTypedValue()) // now + // sounds + || (name.startsWith("sound/") && !allow_download_sounds.getTypedValue()) + // now maps (note special case for maps, must not be in pak) + || (name.startsWith("maps/") && !allow_download_maps.getTypedValue()) // MUST + // be + // in a + // subdirectory + || name.indexOf('/') == -1) { // don't allow anything with .. + // path + + // refuse + client.netchan.reliablePending.add(new DownloadMessage()); + return; + } + + client.download = EngineVfs.loadBytes(name); + + // The client waits for either data or an explicit refusal. Silently + // returning here leaves precache stuck waiting for a download forever. + if (client.download == null) { + client.netchan.reliablePending.add(new DownloadMessage()); + return; + } + + client.downloadsize = client.download.length; + client.downloadcount = offset; + + if (offset > client.downloadsize) + client.downloadcount = client.downloadsize; + + // special check for maps, if it came from a pak file, don't allow download ZOID + if (name.startsWith("maps/") && EngineVfs.isFromPack(name)) { + Com.DPrintf("Couldn't download " + name + " to " + client.name + "\n"); + if (client.download != null) { + client.download = null; + } + + // refuse + client.netchan.reliablePending.add(new DownloadMessage()); + return; + } + + SV_NextDownload_f(args, gameImports, client); + Com.DPrintf("Downloading " + name + " to " + client.name + "\n"); + } + + //============================================================================ + + /* + * ================= SV_Disconnect_f + * + * The client is going to disconnect, so remove the connection immediately + * ================= + */ + private void SV_Disconnect_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { + // SV_EndRedirect (); + SV_DropClient(client); + } + + /* + * ================== SV_ShowServerinfo_f + * + * Dumps the serverinfo info string ================== + */ + private void SV_ShowServerinfo_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { + Info.Print(Cvar.getInstance().Serverinfo()); + } + + private void SV_Nextserver(GameImportsImpl gameImports) { + + //ZOID, ss_pic can be nextserver'd in coop mode + if (gameImports.state == ServerStates.SS_GAME + || (gameImports.state == ServerStates.SS_PIC && !Cvar.getInstance().find("coop", Boolean.class).getTypedValue())) + return; // can't nextserver while playing a normal game + + gameImports.spawncount++; // make sure another doesn't sneak in + String v = Cvar.getInstance().find("nextserver", String.class).getTypedValue(); + //if (!v[0]) + if (v.length() == 0) + Cbuf.AddText("killserver\n"); + else { + Cbuf.AddText(v); + Cbuf.AddText("\n"); + } + Cvar.getInstance().find("nextserver", String.class).set(""); + } + + /* + * ================== SV_Nextserver_f + * + * A cinematic has completed or been aborted by a client, so move to the + * next server, ================== + */ + private void SV_Nextserver_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { + int spawnCount = args.size() >= 2 ? Lib.atoi(args.get(1)) : 0; + if (spawnCount != gameImports.spawncount) { + Com.DPrintf("Nextserver() from wrong level, from " + client.name + "\n"); + return; // leftover from last server + } + + Com.DPrintf("Nextserver() from " + client.name + "\n"); + SV_Nextserver(gameImports); + } + } diff --git a/server/src/main/java/jake2/server/SV_USER.java b/server/src/main/java/jake2/server/SV_USER.java deleted file mode 100644 index d47d2107..00000000 --- a/server/src/main/java/jake2/server/SV_USER.java +++ /dev/null @@ -1,395 +0,0 @@ -/* - * Copyright (C) 1997-2001 Id Software, Inc. - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License as published by the Free Software - * Foundation; either version 2 of the License, or (at your option) any later - * version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. - * - * See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * this program; if not, write to the Free Software Foundation, Inc., 59 Temple - * Place - Suite 330, Boston, MA 02111-1307, USA. - * - */ - -// Created on 17.01.2004 by RST. - -package jake2.server; - -import jake2.qcommon.*; -import jake2.qcommon.exec.Cbuf; -import jake2.qcommon.exec.Cvar; -import jake2.qcommon.network.messages.NetworkMessage; -import jake2.qcommon.network.messages.client.StringCmdMessage; -import jake2.qcommon.network.messages.server.*; -import jake2.qcommon.vfs.EngineVfs; -import jake2.qcommon.util.Lib; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -class SV_USER { - - static final Map userCommands; - - static { - userCommands = new HashMap<>(); - // auto issued - userCommands.put(StringCmdMessage.NEW, SV_USER::SV_New_f); - userCommands.put(StringCmdMessage.CONFIG_STRINGS, SV_USER::SV_Configstrings_f); - userCommands.put(StringCmdMessage.BASELINES, SV_USER::SV_Baselines_f); - userCommands.put(StringCmdMessage.BEGIN, SV_USER::SV_Begin_f); - userCommands.put(StringCmdMessage.NEXT_SERVER, SV_USER::SV_Nextserver_f); - userCommands.put(StringCmdMessage.DISCONNECT, SV_USER::SV_Disconnect_f); - - // issued by hand at client consoles - userCommands.put(StringCmdMessage.INFO, SV_USER::SV_ShowServerinfo_f); - userCommands.put(StringCmdMessage.DOWNLOAD, SV_USER::SV_BeginDownload_f); - userCommands.put(StringCmdMessage.NEXT_DOWNLOAD, SV_USER::SV_NextDownload_f); - - } - - /* - * ============================================================ - * - * USER STRINGCMD EXECUTION - * - * sv_client and sv_player will be valid. - * ============================================================ - */ - - /* - * ================ SV_New_f - * - * Sends the first message from the server to a connected client. This will - * be sent on the initial connection and upon each server load. - * ================ - */ - private static void SV_New_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { - - Com.DPrintf("New() from " + client.name + "\n"); - - if (client.state != ClientStates.CS_CONNECTED) { - Com.Printf("New not valid -- already spawned\n"); - return; - } - - // - // serverdata needs to go over for all types of servers - // to make sure the protocol is right, and to set the gamedir - // - String gamedir = Cvar.getInstance().find("gamedir", String.class).getTypedValue(); - - // send the serverdata - // send full levelname - - final int clientIndex; - if (gameImports.state == ServerStates.SS_CINEMATIC || gameImports.state == ServerStates.SS_PIC) - clientIndex = -1; - else - clientIndex = client.edict.index - 1; - - - client.netchan.reliablePending.add( - new ServerDataMessage( - Defines.PROTOCOL_VERSION, - gameImports.spawncount, - gameImports.isDemo, - gamedir, - clientIndex, - gameImports.configstrings[Defines.CS_NAME] - )); - // - // game server - // - if (gameImports.state == ServerStates.SS_GAME) { - // set up the entity for the client - ServerEntity ent = gameImports.gameExports.getEdict(clientIndex + 1); - ent.s.index = clientIndex + 1; - client.edict = ent; - client.lastcmd = new usercmd_t(); - - // begin fetching configstrings - client.netchan.reliablePending.add(new StuffTextMessage(String.format("cmd %s %d 0", StringCmdMessage.CONFIG_STRINGS, gameImports.spawncount))); - } - } - - /* - * ================== SV_Configstrings_f ================== - */ - private static void SV_Configstrings_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { - - Com.DPrintf("Configstrings() from " + client.name + "\n"); - - if (client.state != ClientStates.CS_CONNECTED) { - Com.Printf("configstrings not valid -- already spawned\n"); - return; - } - - // handle the case of a level changing while a client was connecting - int spawnCount = args.size() >= 2 ? Lib.atoi(args.get(1)) : 0; - if (spawnCount != gameImports.spawncount) { - Com.Printf("SV_Configstrings_f from different level\n"); - SV_New_f(args, gameImports, client); - return; - } - - int start = args.size() >= 3 ? Lib.atoi(args.get(2)) : 0; - - // write a packet full of data - int currentReliableSize = client.netchan.reliablePending.stream().mapToInt(NetworkMessage::getSize).sum(); - while (currentReliableSize < Defines.MAX_MSGLEN / 2 && start < Defines.MAX_CONFIGSTRINGS) { - if (gameImports.configstrings[start] != null && gameImports.configstrings[start].length() != 0) { - final ConfigStringMessage config = new ConfigStringMessage(start, gameImports.configstrings[start]); - currentReliableSize += config.getSize(); - client.netchan.reliablePending.add(config); - } - start++; - } - - // send next command - final String nextCmd; - - if (start == Defines.MAX_CONFIGSTRINGS) { - nextCmd = String.format("cmd %s %d 0", StringCmdMessage.BASELINES, gameImports.spawncount); - } else { - nextCmd = String.format("cmd %s %d %d", StringCmdMessage.CONFIG_STRINGS, gameImports.spawncount, start); - } - client.netchan.reliablePending.add(new StuffTextMessage(nextCmd)); - } - - /* - * ================== SV_Baselines_f ================== - */ - private static void SV_Baselines_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { - - Com.DPrintf("Baselines() from " + client.name + "\n"); - - if (client.state != ClientStates.CS_CONNECTED) { - Com.Printf("baselines not valid -- already spawned\n"); - return; - } - - // handle the case of a level changing while a client was connecting - int spawnCount = args.size() >= 2 ? Lib.atoi(args.get(1)) : 0; - if (spawnCount != gameImports.spawncount) { - Com.Printf("SV_Baselines_f from different level\n"); - SV_New_f(args, gameImports, client); - return; - } - - // todo: validate argument - int start = args.size() >= 3 ? Lib.atoi(args.get(2)) : 0; - - // write a packet full of data - int currentReliableSize = client.netchan.reliablePending.stream().mapToInt(NetworkMessage::getSize).sum(); - while (currentReliableSize < Defines.MAX_MSGLEN / 2 && start < Defines.MAX_EDICTS) { - entity_state_t base = gameImports.baselines[start]; - if (base.modelindex != 0 || base.sound != 0 || base.effects != 0) { - final SpawnBaselineMessage spawn = new SpawnBaselineMessage(base); - currentReliableSize += spawn.getSize(); - client.netchan.reliablePending.add(spawn); - } - start++; - } - - // send next command - final String nextCmd; - if (start == Defines.MAX_EDICTS) { - // finished sending baselines - nextCmd = String.format("%s %d", StringCmdMessage.PRECACHE,gameImports.spawncount); - } else { - // continue from where we finished - nextCmd = String.format("cmd %s %d %d", StringCmdMessage.BASELINES, gameImports.spawncount, start); - } - client.netchan.reliablePending.add(new StuffTextMessage(nextCmd)); - } - - /* - * ================== SV_Begin_f ================== - */ - private static void SV_Begin_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { - Com.DPrintf("Begin() from " + client.name + "\n"); - - // handle the case of a level changing while a client was connecting - int spawnCount = args.size() >= 2 ? Lib.atoi(args.get(1)) : 0; - if (spawnCount != gameImports.spawncount) { - Com.Printf("SV_Begin_f from different level\n"); - SV_New_f(args, gameImports, client); - return; - } - - client.state = ClientStates.CS_SPAWNED; - - // call the jake2.game begin function - gameImports.gameExports.ClientBegin(client.edict); - - Cbuf.InsertFromDefer(); - } - - //============================================================================= - - /* - * ================== SV_NextDownload_f ================== - */ - private static void SV_NextDownload_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { - - if (client.download == null) - return; - - int packet = client.downloadsize - client.downloadcount; - if (packet > 1024) - packet = 1024; - - client.downloadcount += packet; - int size = client.downloadsize; - if (size == 0) - size = 1; - byte percent = (byte) (client.downloadcount * 100 / size); - - byte[] data = new byte[packet]; - System.arraycopy(client.download, client.downloadcount - packet, data, 0, packet); - client.netchan.reliablePending.add(new DownloadMessage(data, percent)); - - if (client.downloadcount == client.downloadsize) { - client.download = null; - } - } - - /* - * ================== SV_BeginDownload_f ================== - */ - private static void SV_BeginDownload_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { - int offset = 0; - - if (args.size() < 2) - return; - - String name = args.get(1); - - if (args.size() > 2) - offset = Lib.atoi(args.get(2)); // downloaded offset - - // hacked by zoid to allow more conrol over download - // first off, no .. or global allow check - - if (name.contains("..") - || !SV_MAIN.allow_download.getTypedValue() // leading dot is no good - || name.charAt(0) == '.' // leading slash bad as well, must be - // in subdir - || name.charAt(0) == '/' // next up, skin check - || (name.startsWith("players/") && !SV_MAIN.allow_download_players.getTypedValue()) // now - // models - || (name.startsWith("models/") && !SV_MAIN.allow_download_models.getTypedValue()) // now - // sounds - || (name.startsWith("sound/") && !SV_MAIN.allow_download_sounds.getTypedValue()) - // now maps (note special case for maps, must not be in pak) - || (name.startsWith("maps/") && !SV_MAIN.allow_download_maps.getTypedValue()) // MUST - // be - // in a - // subdirectory - || name.indexOf('/') == -1) { // don't allow anything with .. - // path - - // refuse - client.netchan.reliablePending.add(new DownloadMessage()); - return; - } - - client.download = EngineVfs.loadBytes(name); - - // The client waits for either data or an explicit refusal. Silently - // returning here leaves precache stuck waiting for a download forever. - if (client.download == null) { - client.netchan.reliablePending.add(new DownloadMessage()); - return; - } - - client.downloadsize = client.download.length; - client.downloadcount = offset; - - if (offset > client.downloadsize) - client.downloadcount = client.downloadsize; - - // special check for maps, if it came from a pak file, don't allow download ZOID - if (name.startsWith("maps/") && EngineVfs.isFromPack(name)) { - Com.DPrintf("Couldn't download " + name + " to " + client.name + "\n"); - if (client.download != null) { - client.download = null; - } - - // refuse - client.netchan.reliablePending.add(new DownloadMessage()); - return; - } - - SV_NextDownload_f(args, gameImports, client); - Com.DPrintf("Downloading " + name + " to " + client.name + "\n"); - } - - //============================================================================ - - /* - * ================= SV_Disconnect_f - * - * The client is going to disconnect, so remove the connection immediately - * ================= - */ - private static void SV_Disconnect_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { - // SV_EndRedirect (); - SV_MAIN.SV_DropClient(client); - } - - /* - * ================== SV_ShowServerinfo_f - * - * Dumps the serverinfo info string ================== - */ - private static void SV_ShowServerinfo_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { - Info.Print(Cvar.getInstance().Serverinfo()); - } - - static void SV_Nextserver(GameImportsImpl gameImports) { - - //ZOID, ss_pic can be nextserver'd in coop mode - if (gameImports.state == ServerStates.SS_GAME - || (gameImports.state == ServerStates.SS_PIC && !Cvar.getInstance().find("coop", Boolean.class).getTypedValue())) - return; // can't nextserver while playing a normal game - - gameImports.spawncount++; // make sure another doesn't sneak in - String v = Cvar.getInstance().find("nextserver", String.class).getTypedValue(); - //if (!v[0]) - if (v.length() == 0) - Cbuf.AddText("killserver\n"); - else { - Cbuf.AddText(v); - Cbuf.AddText("\n"); - } - Cvar.getInstance().find("nextserver", String.class).set(""); - } - - /* - * ================== SV_Nextserver_f - * - * A cinematic has completed or been aborted by a client, so move to the - * next server, ================== - */ - private static void SV_Nextserver_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { - int spawnCount = args.size() >= 2 ? Lib.atoi(args.get(1)) : 0; - if (spawnCount != gameImports.spawncount) { - Com.DPrintf("Nextserver() from wrong level, from " + client.name + "\n"); - return; // leftover from last server - } - - Com.DPrintf("Nextserver() from " + client.name + "\n"); - SV_Nextserver(gameImports); - } - -} diff --git a/server/src/main/java/jake2/server/ServerUserCommand.java b/server/src/main/java/jake2/server/ServerUserCommand.java deleted file mode 100644 index 7aecb317..00000000 --- a/server/src/main/java/jake2/server/ServerUserCommand.java +++ /dev/null @@ -1,7 +0,0 @@ -package jake2.server; - -import java.util.List; - -public interface ServerUserCommand { - void execute(List args, GameImportsImpl gameImports, ClientNetworkInfo client); -} diff --git a/server/src/test/java/jake2/server/GameCvarInitializationTest.java b/server/src/test/java/jake2/server/GameCvarInitializationTest.java index eb0e87fb..f87667a2 100644 --- a/server/src/test/java/jake2/server/GameCvarInitializationTest.java +++ b/server/src/test/java/jake2/server/GameCvarInitializationTest.java @@ -33,7 +33,7 @@ void gameWritesValidateSharedTypesAndRetainLatchSemantics() { cvar_t maxclients = cvars.find("maxclients", Integer.class); cvar_t deathmatch = cvars.find("deathmatch", Boolean.class); assertEquals(1, maxclients.getTypedValue()); - assertEquals(0.5f, SV_MAIN.timeout.getTypedValue()); + assertEquals(0.5f, server.timeout.getTypedValue()); assertSame(deathmatch, imports.findCvar("deathmatch", Boolean.class)); Globals.server_state = ServerStates.SS_GAME; From a5a8551444e60a6af3e14cf7a7c58f4dd4cb8571 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 18:24:03 +0100 Subject: [PATCH 11/47] Consolidate Host operator commands and remove legacy sv commands --- .../java/jake2/server/GameImportsTest.java | 28 ++ game/src/main/java/jake2/game/GameCvars.java | 2 - .../main/java/jake2/game/GameExportsImpl.java | 7 - game/src/main/java/jake2/game/GameSVCmds.java | 294 ------------------ .../main/java/jake2/game/PlayerClient.java | 7 - .../main/java/jake2/qcommon/GameExports.java | 5 - .../java/jake2/server/GameImportsImpl.java | 208 +------------ .../src/main/java/jake2/server/SV_MAIN.java | 225 +++++++++++++- .../java/jake2/server/ServerCommands.java | 6 +- 9 files changed, 248 insertions(+), 534 deletions(-) delete mode 100644 game/src/main/java/jake2/game/GameSVCmds.java diff --git a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java index 3b07e78d..067e5da1 100644 --- a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java +++ b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java @@ -390,6 +390,34 @@ public void hostProtocolDispatchAndShutdownUseTheirOwnGameRegistry() { Assertions.assertTrue(manager.isRunning()); } + @Test + public void saveCommandResolvesDefaultAndExplicitGamesWithoutReregistration() throws Exception { + manager = new SV_MAIN(); + Cvar.getInstance().find("maxclients", Integer.class).forceSet(2); + manager.resetClients(); + var saveRegistration = jake2.qcommon.exec.Cmd.FindCommand("save"); + Assertions.assertNull(jake2.qcommon.exec.Cmd.FindCommand("sv")); + GameImportsImpl first = manager.spawnServerInstance(new ChangeMapInfo("testbox", false, false), 0); + manager.games.put("default", first); + GameImportsImpl second = manager.spawnServerInstance(new ChangeMapInfo("testbox2", false, false), 0); + manager.games.put(second.name, second); + Assertions.assertSame(saveRegistration, jake2.qcommon.exec.Cmd.FindCommand("save")); + Assertions.assertFalse(Cvar.getInstance().isRegistered("filterban")); + + jake2.qcommon.exec.Cmd.ExecuteString("save first"); + jake2.qcommon.exec.Cmd.ExecuteString("save --game " + second.name + " second"); + var store = jake2.server.save.ServerSaveJsonStore.forWriteDir(writeRoot.toString()); + Assertions.assertEquals("testbox", store.readMapCommand("first").mapCommand()); + Assertions.assertEquals("testbox2", store.readMapCommand("second").mapCommand()); + + jake2.qcommon.exec.Cmd.ExecuteString("save --game missing absent"); + Assertions.assertFalse(store.hasMapCommand("absent")); + manager.SV_Shutdown("test", false); + Assertions.assertSame(saveRegistration, jake2.qcommon.exec.Cmd.FindCommand("save")); + jake2.qcommon.exec.Cmd.ExecuteString("save idle"); + Assertions.assertFalse(store.hasMapCommand("idle")); + } + @Test public void runGame() { diff --git a/game/src/main/java/jake2/game/GameCvars.java b/game/src/main/java/jake2/game/GameCvars.java index 64110233..dfb1f9cf 100644 --- a/game/src/main/java/jake2/game/GameCvars.java +++ b/game/src/main/java/jake2/game/GameCvars.java @@ -23,7 +23,6 @@ public class GameCvars { public final cvar_t password; public final cvar_t spectator_password; public final cvar_t needpass; - public final cvar_t filterban; public final cvar_t g_select_empty; public final cvar_t flood_msgs; public final cvar_t flood_persecond; @@ -60,7 +59,6 @@ public GameCvars(GameImports gameImports) { password = gameImports.findCvar("password", String.class); spectator_password = gameImports.findCvar("spectator_password", String.class); needpass = gameImports.findCvar("needpass", Integer.class); - filterban = gameImports.registerCvar("filterban", CvarTypes.BOOLEAN, true, 0, "True blocks listed IP addresses; false allows only listed addresses"); g_select_empty = gameImports.registerCvar("g_select_empty", CvarTypes.BOOLEAN, false, Defines.CVAR_ARCHIVE, "Allow selecting weapons with no ammunition"); diff --git a/game/src/main/java/jake2/game/GameExportsImpl.java b/game/src/main/java/jake2/game/GameExportsImpl.java index f794654a..93d89b24 100644 --- a/game/src/main/java/jake2/game/GameExportsImpl.java +++ b/game/src/main/java/jake2/game/GameExportsImpl.java @@ -56,7 +56,6 @@ public class GameExportsImpl implements GameExports { "jake2.game.GameChase", "jake2.game.GameCombat", "jake2.game.GameMisc", - "jake2.game.GameSVCmds", "jake2.game.GameSpawn", "jake2.game.GameUtil", "jake2.game.GameWeapon", @@ -1688,12 +1687,6 @@ public void SpawnEntities(String mapname, String entities, String spawnpoint) { GameSpawn.SpawnEntities(mapname, entities, spawnpoint, this); } - @Override - @SuppressWarnings("deprecation") - public void ServerCommand(List args) { - GameSVCmds.ServerCommand(args, this); - } - @Override public void ClientBegin(ServerEntity e) { PlayerClient.ClientBegin(g_edicts[e.index], this); diff --git a/game/src/main/java/jake2/game/GameSVCmds.java b/game/src/main/java/jake2/game/GameSVCmds.java deleted file mode 100644 index fbcce861..00000000 --- a/game/src/main/java/jake2/game/GameSVCmds.java +++ /dev/null @@ -1,294 +0,0 @@ -/* - * Copyright (C) 1997-2001 Id Software, Inc. - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License as published by the Free Software - * Foundation; either version 2 of the License, or (at your option) any later - * version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. - * - * See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * this program; if not, write to the Free Software Foundation, Inc., 59 Temple - * Place - Suite 330, Boston, MA 02111-1307, USA. - * - */ - -// Created on 01.02.2004 by RST. -// $Id: GameSVCmds.java,v 1.4 2006-01-21 21:53:32 salomo Exp $ -package jake2.game; - -import jake2.qcommon.Defines; -import jake2.qcommon.exec.cvar_t; -import jake2.qcommon.util.Lib; - -import java.io.IOException; -import java.io.RandomAccessFile; -import java.nio.ByteBuffer; -import java.util.List; -import java.util.StringTokenizer; - -// todo refactor completely -@Deprecated -public class GameSVCmds { - - /** - * - * PACKET FILTERING - * - * - * You can add or remove addresses from the filter list with: - * - * addip removeip - * - * The ip address is specified in dot format, and any unspecified digits - * will match any value, so you can specify an entire class C network with - * "addip 192.246.40". - * - * Removeip will only remove an address specified exactly the same way. You - * cannot addip a subnet, then removeip a single host. - * - * listip Prints the current list of filters. - * - * writeip Dumps "addip " commands to listip.cfg so it can be execed at - * a later date. The filter lists are not saved and restored by default, - * because I beleive it would cause too much confusion. - * - * filterban <0 or 1> - * - * If 1 (the default), then ip addresses matching the current list will be - * prohibited from entering the game. This is the default setting. - * - * If 0, then only addresses matching the list will be allowed. This lets - * you easily set up a private game, or a game that only allows players from - * your local network. - * - */ - - public static class ipfilter_t { - int mask; - - int compare; - }; - - public static final int MAX_IPFILTERS = 1024; - - static GameSVCmds.ipfilter_t ipfilters[] = new GameSVCmds.ipfilter_t[MAX_IPFILTERS]; - - static int numipfilters; - static { - for (int n = 0; n < GameSVCmds.MAX_IPFILTERS; n++) - GameSVCmds.ipfilters[n] = new ipfilter_t(); - } - - /** - * StringToFilter. - */ - static boolean StringToFilter(String s, GameSVCmds.ipfilter_t f, GameExportsImpl gameExports) { - - byte b[] = { 0, 0, 0, 0 }; - byte m[] = { 0, 0, 0, 0 }; - - try { - StringTokenizer tk = new StringTokenizer(s, ". "); - - for (int n = 0; n < 4; n++) { - b[n] = (byte) Lib.atoi(tk.nextToken()); - if (b[n] != 0) - m[n] = -1; - } - - f.mask = ByteBuffer.wrap(m).getInt(); - f.compare = ByteBuffer.wrap(b).getInt(); - } catch (Exception e) { - gameExports.gameImports.cprintf(null, Defines.PRINT_HIGH, "Bad filter address: " + s + "\n"); - return false; - } - - return true; - } - - /** - * SV_FilterPacket. - */ - static boolean SV_FilterPacket(String from, boolean filterBan) { - int i; - int in; - int m[] = { 0, 0, 0, 0 }; - - int p = 0; - char c; - - i = 0; - - while (p < from.length() && i < 4) { - m[i] = 0; - - c = from.charAt(p); - while (c >= '0' && c <= '9') { - m[i] = m[i] * 10 + (c - '0'); - c = from.charAt(p++); - } - if (p == from.length() || c == ':') - break; - - i++; - p++; - } - - in = (m[0] & 0xff) | ((m[1] & 0xff) << 8) | ((m[2] & 0xff) << 16) - | ((m[3] & 0xff) << 24); - - for (i = 0; i < numipfilters; i++) - if ((in & ipfilters[i].mask) == ipfilters[i].compare) - return filterBan; - - return !filterBan; - } - - /** - * SV_AddIP_f. - */ - private static void SVCmd_AddIP_f(List args, GameExportsImpl gameExports) { - int i; - - if (args.size() < 3) { - gameExports.gameImports.cprintf(null, Defines.PRINT_HIGH, - "Usage: addip \n"); - return; - } - - for (i = 0; i < numipfilters; i++) - if (ipfilters[i].compare == 0xffffffff) - break; // free spot - if (i == numipfilters) { - if (numipfilters == MAX_IPFILTERS) { - gameExports.gameImports.cprintf(null, Defines.PRINT_HIGH, - "IP filter list is full\n"); - return; - } - numipfilters++; - } - - if (!StringToFilter(args.get(2), ipfilters[i], gameExports)) - ipfilters[i].compare = 0xffffffff; - } - - /** - * SV_RemoveIP_f. - */ - private static void SVCmd_RemoveIP_f(List args, GameExportsImpl gameExports) { - GameSVCmds.ipfilter_t f = new GameSVCmds.ipfilter_t(); - - if (args.size() < 3) { - gameExports.gameImports.cprintf(null, Defines.PRINT_HIGH, - "Usage: sv removeip \n"); - return; - } - - if (!StringToFilter(args.get(2), f, gameExports)) - return; - - for (int i = 0; i < numipfilters; i++) - if (ipfilters[i].mask == f.mask - && ipfilters[i].compare == f.compare) { - for (int j = i + 1; j < numipfilters; j++) - ipfilters[j - 1] = ipfilters[j]; - numipfilters--; - gameExports.gameImports.cprintf(null, Defines.PRINT_HIGH, "Removed.\n"); - return; - } - gameExports.gameImports.cprintf(null, Defines.PRINT_HIGH, "Didn't find " + args.get(2) + ".\n"); - } - - /** - * SV_ListIP_f. - * @param gameExports - */ - static void SVCmd_ListIP_f(GameExportsImpl gameExports) { - int i; - byte b[]; - - gameExports.gameImports.cprintf(null, Defines.PRINT_HIGH, "Filter list:\n"); - for (i = 0; i < numipfilters; i++) { - b = Lib.getIntBytes(ipfilters[i].compare); - gameExports.gameImports - .cprintf(null, Defines.PRINT_HIGH, (b[0] & 0xff) + "." - + (b[1] & 0xff) + "." + (b[2] & 0xff) + "." - + (b[3] & 0xff)); - } - } - - /** - * SV_WriteIP_f. - * @param gameExports - */ - static void SVCmd_WriteIP_f(GameExportsImpl gameExports) { - RandomAccessFile f; - byte b[]; - - int i; - cvar_t game; - - game = gameExports.gameImports.findCvar("game", String.class); - - String name; - if (game.getTypedValue().isEmpty()) - name = Defines.GAMEVERSION + "/listip.cfg"; - else - name = game.getTypedValue() + "/listip.cfg"; - - gameExports.gameImports.cprintf(null, Defines.PRINT_HIGH, "Writing " + name + ".\n"); - - f = Lib.fopen(name, "rw"); - if (f == null) { - gameExports.gameImports.cprintf(null, Defines.PRINT_HIGH, "Couldn't open " - + name + "\n"); - return; - } - - try { - f.writeChars("set filterban " + (gameExports.gameCvars.filterban.getTypedValue() ? 1 : 0) - + "\n"); - - for (i = 0; i < numipfilters; i++) { - b = Lib.getIntBytes(ipfilters[i].compare); - f.writeChars("sv addip " + (b[0] & 0xff) + "." + (b[1] & 0xff) - + "." + (b[2] & 0xff) + "." + (b[3] & 0xff) + "\n"); - } - - } catch (IOException e) { - gameExports.gameImports.dprintf("IOError in SVCmd_WriteIP_f:" + e); - } - - Lib.fclose(f); - } - - // todo move to game exports - public static void ServerCommand(List args, GameExportsImpl gameExports) { - - if (args.size() < 2) { - gameExports.gameImports.dprintf("usage: sv test|addip|removeip|listip|writeip "); - return; - } - - String cmd = args.get(1); - - if (Lib.Q_stricmp(cmd, "test") == 0) - gameExports.gameImports.cprintf(null, Defines.PRINT_HIGH, "Svcmd_Test_f()\n"); - else if (Lib.Q_stricmp(cmd, "addip") == 0) - SVCmd_AddIP_f(args, gameExports); - else if (Lib.Q_stricmp(cmd, "removeip") == 0) - SVCmd_RemoveIP_f(args, gameExports); - else if (Lib.Q_stricmp(cmd, "listip") == 0) - SVCmd_ListIP_f(gameExports); - else if (Lib.Q_stricmp(cmd, "writeip") == 0) - SVCmd_WriteIP_f(gameExports); - else - gameExports.gameImports.cprintf(null, Defines.PRINT_HIGH, "Unknown server command \"" + cmd + "\"\n"); - } -} \ No newline at end of file diff --git a/game/src/main/java/jake2/game/PlayerClient.java b/game/src/main/java/jake2/game/PlayerClient.java index 5fbd9bd7..969eec40 100644 --- a/game/src/main/java/jake2/game/PlayerClient.java +++ b/game/src/main/java/jake2/game/PlayerClient.java @@ -987,13 +987,6 @@ else if (client.getPlayerState().fov > 160) */ @SuppressWarnings("deprecation") static boolean ClientConnect(GameEntity ent, String userinfo, GameExportsImpl gameExports) { - // check to see if they are on the banned IP list - String ip = Info.Info_ValueForKey(userinfo, "ip"); - if (GameSVCmds.SV_FilterPacket(ip, gameExports.gameCvars.filterban.getTypedValue())) { - Info.Info_SetValueForKey(userinfo, "rejmsg", "Banned."); - return false; - } - // check for a spectator String spectator = Info.Info_ValueForKey(userinfo, "spectator"); if (gameExports.gameCvars.deathmatch.getTypedValue() && spectator.length() != 0 && !"0".equals(spectator)) { diff --git a/qcommon/src/main/java/jake2/qcommon/GameExports.java b/qcommon/src/main/java/jake2/qcommon/GameExports.java index 166bde41..91c07a4d 100644 --- a/qcommon/src/main/java/jake2/qcommon/GameExports.java +++ b/qcommon/src/main/java/jake2/qcommon/GameExports.java @@ -51,11 +51,6 @@ public interface GameExports { */ void SpawnEntities(String mapname, String entities, String spawnpoint); - /** - * ServerCommand will be called when an "sv" command is issued. - */ - void ServerCommand(List args); - /** * Called when a client has finished connecting, and is ready to be placed * into the game. This will happen every level load. diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index 27b8fb1f..73e0bf0a 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -24,7 +24,6 @@ import jake2.qcommon.*; import jake2.qcommon.exec.Cbuf; -import jake2.qcommon.exec.Cmd; import jake2.qcommon.exec.Cvar; import jake2.qcommon.exec.CvarType; import jake2.qcommon.exec.cvar_t; @@ -44,7 +43,6 @@ import jake2.qcommon.network.messages.server.SoundMessage; import jake2.qcommon.util.Lib; import jake2.qcommon.util.Math3D; -import jake2.qcommon.util.Vargs; import jake2.server.save.ServerLevelJsonStore; import jake2.server.save.ServerSaveJsonStore; import jake2.server.save.ServerSaveFiles; @@ -59,7 +57,6 @@ import static jake2.qcommon.Defines.CS_MAPCHECKSUM; import static jake2.qcommon.Defines.CS_MODELS; -import static jake2.qcommon.exec.Cmd.getArguments; /** Owns one server-side game instance and exposes engine services to its gameplay module. */ @SuppressWarnings("deprecation") @@ -179,7 +176,6 @@ public GameImportsImpl(SV_MAIN serverMain, ChangeMapInfo changeMapInfo) { // Q: is it required? the instance was just created clearWorld(); - SV_InitOperatorCommands(); } @@ -281,25 +277,6 @@ void SV_Read_Latched_Vars() { } } - /** - * bind operator commands to this server instance - */ - private void SV_InitOperatorCommands() { - serverMain.registerGameCommand("heartbeat", this::SV_Heartbeat_f); - serverMain.registerGameCommand("kick", this::SV_Kick_f); - serverMain.registerGameCommand("status", this::SV_Status_f); - serverMain.registerGameCommand("serverinfo", this::SV_Serverinfo_f); - serverMain.registerGameCommand("dumpuser", this::SV_DumpUser_f); - serverMain.registerGameCommand("say", this::SV_ConSay_f); - serverMain.registerGameCommand("save", this::SV_Savegame_f); - - // ip filtering and stuff - serverMain.registerGameCommand("sv", args -> { - if (gameExports != null) - gameExports.ServerCommand(args); - }); - } - // special messages @Override public void bprintf(int printlevel, String s) { @@ -791,7 +768,7 @@ public int getPointContents(float[] p) { return contents; } - private void SV_Savegame_f(List args) { + void saveGame(List args) { if (state != ServerStates.SS_GAME) { Com.Printf("You must be in a game to save.\n"); @@ -852,189 +829,6 @@ private void SV_Savegame_f(List args) { Com.Printf("Done.\n"); } - /* - ================== - SV_Kick_f - - Kick a user off of the server - ================== - */ - private void SV_Kick_f(List args) { - if (args.size() != 2) { - Com.Printf("Usage: kick \n"); - return; - } - ClientNetworkInfo client = SV_SetPlayer(args); - if (client == null) - return; - - serverMain.SV_BroadcastPrintf(Defines.PRINT_HIGH, client.name + " was kicked\n", client.gameName); - // print directly, because the dropped client won't get the - // SV_BroadcastPrintf message - client.queuePrint(Defines.PRINT_HIGH, "You were kicked from the game\n"); - serverMain.SV_DropClient(client); - client.lastmessage = realtime; // min case there is a funny zombie - } - /* - ================ - SV_Status_f - ================ - */ - private void SV_Status_f(List args) { - // todo: check - if (serverMain.getClients().isEmpty()) { - Com.Printf("No server running.\n"); - return; - } - Com.Printf("map : " + mapName + "\n"); - - Com.Printf("num score ping name lastmsg address qport \n"); - Com.Printf("--- ----- ---- --------------- ------- --------------------- ------\n"); - - int i = 0; - for (ClientNetworkInfo cl : serverMain.getClients()) { - if (ClientStates.CS_FREE == cl.state) - continue; - - Com.Printf("%3i ", new Vargs().add(i)); - Com.Printf("%5i ", new Vargs().add(cl.edict.getClient().getPlayerState().stats[Defines.STAT_FRAGS])); - - if (cl.state == ClientStates.CS_CONNECTED) - Com.Printf("CNCT "); - else if (cl.state == ClientStates.CS_ZOMBIE) - Com.Printf("ZMBI "); - else { - int ping = cl.ping < 9999 ? cl.ping : 9999; - Com.Printf("%4i ", new Vargs().add(ping)); - } - - Com.Printf("%s", new Vargs().add(cl.name)); - int l = 16 - cl.name.length(); - int j; - for (j = 0; j < l; j++) - Com.Printf(" "); - - Com.Printf("%7i ", new Vargs().add(realtime - cl.lastmessage)); - - String s = cl.netchan.remote_address.toString(); - Com.Printf(s); - l = 22 - s.length(); - for (j = 0; j < l; j++) - Com.Printf(" "); - - Com.Printf("%5i", new Vargs().add(cl.netchan.qport)); - - Com.Printf("\n"); - i++; - } - Com.Printf("\n"); - } - /* - ================== - SV_ConSay_f - ================== - */ - private void SV_ConSay_f(List args) { - - if (args.size() < 2) - return; - - // char[1024]; - String text = "console: "; - String p = getArguments(args); - - if (p.charAt(0) == '"') { - p = p.substring(1, p.length() - 1); - } - - text += p; - - for (ClientNetworkInfo client: serverMain.getClients()) { - if (client.state != ClientStates.CS_SPAWNED) - continue; - client.queuePrint(Defines.PRINT_CHAT, text + "\n"); - } - } - /* - ================== - SV_Heartbeat_f - ================== - */ - private void SV_Heartbeat_f(List agrs) { - } - /* - =========== - SV_Serverinfo_f - - Examine or change the serverinfo string - =========== - */ - private void SV_Serverinfo_f(List args) { - Com.Printf("Server info settings:\n"); - Info.Print(Cvar.getInstance().Serverinfo()); - } - /* - =========== - SV_DumpUser_f - - Examine all a users info strings - =========== - */ - private void SV_DumpUser_f(List args) { - if (args.size() != 2) { - Com.Printf("Usage: info \n"); - return; - } - ClientNetworkInfo client = SV_SetPlayer(args); - if (client == null) - return; - - Com.Printf("userinfo\n"); - Com.Printf("--------\n"); - Info.Print(client.userinfo); - - } - - /** - * Sets sv_client and sv_player to the player with idnum Cmd.Argv(1) - */ - private ClientNetworkInfo SV_SetPlayer(List args) { - - if (args.size() < 2) - return null; - - String idOrName = args.get(1); - - // numeric values are just slot numbers - // fixme: player name cannot start with a number? - if (idOrName.charAt(0) >= '0' && idOrName.charAt(0) <= '9') { - int id = Lib.atoi(idOrName); - if (id < 0 || id >= serverMain.getClients().size()) { - Com.Printf("Bad client slot: " + id + "\n"); - return null; - } - - final ClientNetworkInfo result = serverMain.getClients().get(id); - if (ClientStates.CS_FREE == result.state) { - Com.Printf("Client " + id + " is not active\n"); - return null; - } - return result; - } - - // check for a name match - for (ClientNetworkInfo cl: serverMain.getClients()) { - if (ClientStates.CS_FREE == cl.state) - continue; - if (idOrName.equals(cl.name)) { - return cl; - } - } - - Com.Printf("Userid " + idOrName + " is not on the server\n"); - return null; - } - /** * SV_RunGameFrame. */ diff --git a/server/src/main/java/jake2/server/SV_MAIN.java b/server/src/main/java/jake2/server/SV_MAIN.java index 6a7e2fbd..69f554f2 100644 --- a/server/src/main/java/jake2/server/SV_MAIN.java +++ b/server/src/main/java/jake2/server/SV_MAIN.java @@ -40,6 +40,7 @@ import jake2.qcommon.network.messages.server.*; import jake2.qcommon.network.netadr_t; import jake2.qcommon.util.Lib; +import jake2.qcommon.util.Vargs; import jake2.server.save.ServerSaveJsonStore; import jake2.server.save.ServerSaveFiles; @@ -56,12 +57,6 @@ public class SV_MAIN implements AutoCloseable { new CvarTypes.ChoiceType<>(List.of(0, 1, 2, 3)); private final ServerCommands commands = new ServerCommands(); - private final ServerCommands gameCommands = new ServerCommands(); - - public void registerGameCommand(String name, jake2.qcommon.exec.Command command) { - gameCommands.add(name, true, command); - } - public boolean isRunning() { return !games.isEmpty(); } @@ -951,8 +946,6 @@ public void SV_Shutdown(String finalmsg, boolean reconnect) { Com.Printf("==== ShutdownGame ====\n"); } } finally { - // Also clean commands installed by a partially constructed game. - gameCommands.close(); games.clear(); clients.clear(); Globals.server_state = ServerStates.SS_DEAD; @@ -1024,8 +1017,15 @@ public SV_MAIN() { sv_reconnect_limit = cvars.register("sv_reconnect_limit", new CvarTypes.FloatType(0f, null), 3f, Defines.CVAR_ARCHIVE, "Minimum seconds between reconnect attempts from the same remote client"); resetClients(); - // Publish callbacks only after initialization succeeds. Game operator commands - // are registered separately when a map starts. + // Publish Host callbacks only after initialization succeeds. They survive Game replacement. + commands.add("heartbeat", true, this::SV_Heartbeat_f); + commands.add("kick", true, this::SV_Kick_f); + commands.add("status", true, this::SV_Status_f); + commands.add("serverinfo", true, this::SV_Serverinfo_f); + commands.add("dumpuser", true, this::SV_DumpUser_f); + commands.add("say", true, this::SV_ConSay_f); + commands.add("save", true, this::saveGame); + commands.add("killserver", args -> { SV_Shutdown("Server was killed.\n", false); NET.configureServer(false); @@ -1795,4 +1795,209 @@ private void SV_Nextserver_f(List args, GameImportsImpl gameImports, Cli SV_Nextserver(gameImports); } + /** Resolve a Game at execution time; creating another Game never replaces these callbacks. */ + private void saveGame(List args) { + String target = "default"; + List command = args; + if (args.size() > 1 && args.get(1).equals("--game")) { + if (args.size() < 4) { + Com.Printf("Usage: " + args.get(0) + " --game \n"); + return; + } + target = args.get(2); + command = new ArrayList<>(); + command.add(args.get(0)); + command.addAll(args.subList(3, args.size())); + } + GameImportsImpl game = games.get(target); + if (game == null) { + Com.Printf("No running game: " + target + "\n"); + return; + } + game.saveGame(command); + } + + /* + ================== + SV_Kick_f + + Kick a user off of the server + ================== + */ + private void SV_Kick_f(List args) { + if (args.size() != 2) { + Com.Printf("Usage: kick \n"); + return; + } + ClientNetworkInfo client = SV_SetPlayer(args); + if (client == null) + return; + + SV_BroadcastPrintf(Defines.PRINT_HIGH, client.name + " was kicked\n", client.gameName); + // print directly, because the dropped client won't get the + // SV_BroadcastPrintf message + client.queuePrint(Defines.PRINT_HIGH, "You were kicked from the game\n"); + SV_DropClient(client); + client.lastmessage = games.get(client.gameName).realtime; // min case there is a funny zombie + } + /* + ================ + SV_Status_f + ================ + */ + private void SV_Status_f(List args) { + // todo: check + if (games.isEmpty()) { + Com.Printf("No server running.\n"); + return; + } + for (GameImportsImpl game : games.values()) + Com.Printf("game " + game.name + " : " + game.mapName + "\n"); + + Com.Printf("num score ping name lastmsg address qport \n"); + Com.Printf("--- ----- ---- --------------- ------- --------------------- ------\n"); + + for (int i = 0; i < clients.size(); i++) { + ClientNetworkInfo cl = clients.get(i); + if (ClientStates.CS_FREE == cl.state) + continue; + + Com.Printf("%3i ", new Vargs().add(i)); + Com.Printf("%5i ", new Vargs().add(cl.edict.getClient().getPlayerState().stats[Defines.STAT_FRAGS])); + + if (cl.state == ClientStates.CS_CONNECTED) + Com.Printf("CNCT "); + else if (cl.state == ClientStates.CS_ZOMBIE) + Com.Printf("ZMBI "); + else { + int ping = cl.ping < 9999 ? cl.ping : 9999; + Com.Printf("%4i ", new Vargs().add(ping)); + } + + Com.Printf("%s", new Vargs().add(cl.name)); + int l = 16 - cl.name.length(); + int j; + for (j = 0; j < l; j++) + Com.Printf(" "); + + Com.Printf("%7i ", new Vargs().add(games.get(cl.gameName).realtime - cl.lastmessage)); + + String s = cl.netchan.remote_address.toString(); + Com.Printf(s); + l = 22 - s.length(); + for (j = 0; j < l; j++) + Com.Printf(" "); + + Com.Printf("%5i", new Vargs().add(cl.netchan.qport)); + + Com.Printf("\n"); + } + Com.Printf("\n"); + } + /* + ================== + SV_ConSay_f + ================== + */ + private void SV_ConSay_f(List args) { + + if (args.size() < 2) + return; + + // char[1024]; + String text = "console: "; + String p = Cmd.getArguments(args); + + if (!p.isEmpty() && p.charAt(0) == '"') { + p = p.substring(1, p.length() - 1); + } + + text += p; + + for (ClientNetworkInfo client: clients) { + if (client.state != ClientStates.CS_SPAWNED) + continue; + client.queuePrint(Defines.PRINT_CHAT, text + "\n"); + } + } + /* + ================== + SV_Heartbeat_f + ================== + */ + private void SV_Heartbeat_f(List agrs) { + } + /* + =========== + SV_Serverinfo_f + + Examine or change the serverinfo string + =========== + */ + private void SV_Serverinfo_f(List args) { + Com.Printf("Server info settings:\n"); + Info.Print(Cvar.getInstance().Serverinfo()); + } + /* + =========== + SV_DumpUser_f + + Examine all a users info strings + =========== + */ + private void SV_DumpUser_f(List args) { + if (args.size() != 2) { + Com.Printf("Usage: info \n"); + return; + } + ClientNetworkInfo client = SV_SetPlayer(args); + if (client == null) + return; + + Com.Printf("userinfo\n"); + Com.Printf("--------\n"); + Info.Print(client.userinfo); + + } + + /** + * Sets sv_client and sv_player to the player with idnum Cmd.Argv(1) + */ + private ClientNetworkInfo SV_SetPlayer(List args) { + + if (args.size() < 2) + return null; + + String idOrName = args.get(1); + + // numeric values are just slot numbers + // fixme: player name cannot start with a number? + if (!idOrName.isEmpty() && idOrName.charAt(0) >= '0' && idOrName.charAt(0) <= '9') { + int id = Lib.atoi(idOrName); + if (id < 0 || id >= clients.size()) { + Com.Printf("Bad client slot: " + id + "\n"); + return null; + } + + final ClientNetworkInfo result = clients.get(id); + if (ClientStates.CS_FREE == result.state) { + Com.Printf("Client " + id + " is not active\n"); + return null; + } + return result; + } + + // check for a name match + for (ClientNetworkInfo cl: clients) { + if (ClientStates.CS_FREE == cl.state) + continue; + if (idOrName.equals(cl.name)) { + return cl; + } + } + + Com.Printf("Userid " + idOrName + " is not on the server\n"); + return null; + } + } diff --git a/server/src/main/java/jake2/server/ServerCommands.java b/server/src/main/java/jake2/server/ServerCommands.java index 1b0e3e1d..f405d967 100644 --- a/server/src/main/java/jake2/server/ServerCommands.java +++ b/server/src/main/java/jake2/server/ServerCommands.java @@ -28,8 +28,10 @@ void add(String name, boolean replace, Command command) { @Override public void close() { installed.forEach((name, registration) -> { - // A later owner may already have replaced our command. - if (Cmd.FindCommand(name) == registration) { + // Restoring an earlier owner creates a fresh registration around its same callback. + // A genuinely different callback still belongs to the later owner. + Cmd.cmd_function_t current = Cmd.FindCommand(name); + if (current != null && current.function == registration.function) { Cmd.RemoveCommand(name); Cmd.cmd_function_t original = previous.get(name); if (original != null) From 12e5a08e508aacd43f08865d38e9a4841459a8ff Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 18:39:07 +0100 Subject: [PATCH 12/47] Replace SV_MAIN with Kotlin ServerHost --- .../demoth/cake/fullgame/EmbeddedCakeHost.kt | 8 +- .../java/jake2/server/GameImportsTest.java | 28 +- .../src/main/kotlin/org/demoth/cake/Cake.kt | 2 +- client/src/main/java/jake2/client/CL.java | 2 +- .../java/jake2/dedicated/Jake2Dedicated.java | 6 +- .../java/jake2/server/GameImportsImpl.java | 9 +- .../src/main/java/jake2/server/SV_MAIN.java | 2003 ---------------- .../main/kotlin/jake2/server/ServerHost.kt | 2071 +++++++++++++++++ .../jake2/server/AirAccelerationTest.java | 8 +- .../server/GameCvarInitializationTest.java | 12 +- 10 files changed, 2108 insertions(+), 2041 deletions(-) delete mode 100644 server/src/main/java/jake2/server/SV_MAIN.java create mode 100644 server/src/main/kotlin/jake2/server/ServerHost.kt diff --git a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt index 3aa1cc73..b9227c46 100644 --- a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt +++ b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt @@ -10,7 +10,7 @@ import jake2.qcommon.network.NET import jake2.qcommon.vfs.EngineVfs import jake2.qcommon.vfs.EngineWriteRoot import jake2.qcommon.vfs.VfsConfig -import jake2.server.SV_MAIN +import jake2.server.ServerHost import org.demoth.cake.hosting.* import org.demoth.cake.profile.CakeGameProfile import java.io.IOException @@ -21,7 +21,7 @@ import java.nio.file.Path class EmbeddedCakeHost( private val profilesRoot: Path = Path.of(System.getProperty("user.home"), ".cake"), ) : CakeHost { - internal var server: SV_MAIN? = null + internal var server: ServerHost? = null private set private var starting = false private var ownerThread: Thread? = null @@ -63,7 +63,7 @@ class EmbeddedCakeHost( return HostStartResult.Rejected("Map not found: ${request.map}") } val cvars = Cvar.getInstance() - val manager = SV_MAIN() + val manager = ServerHost() server = manager cvars.find("deathmatch").forceSet(request.mode == HostMode.DEATHMATCH) cvars.find("coop").forceSet(request.mode == HostMode.COOP) @@ -129,7 +129,7 @@ class EmbeddedCakeHost( server = null starting = false try { - manager?.SV_Shutdown("$reason\n", false) + manager?.stop("$reason\n", false) } finally { try { manager?.close() diff --git a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java index 067e5da1..12d6921d 100644 --- a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java +++ b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java @@ -30,7 +30,7 @@ public class GameImportsTest { @TempDir Path writeRoot; private Path previousWriteRoot; - private SV_MAIN manager; + private ServerHost manager; private ServerStates previousServerState; @BeforeEach @@ -55,7 +55,7 @@ public void cleanup() { @Test public void runEmptyInstance() { - manager = new SV_MAIN(); + manager = new ServerHost(); GameImportsImpl testInstance = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); testInstance.SV_RunGameFrame(100); @@ -65,7 +65,7 @@ public void runEmptyInstance() { @Test public void gamesKeepIndependentMapStateAndFrameCounters() { - manager = new SV_MAIN(); + manager = new ServerHost(); GameImportsImpl first = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); GameImportsImpl second = manager.createGameInstance(new ChangeMapInfo("testbox2", false, false), null); @@ -91,7 +91,7 @@ public void gamesKeepIndependentMapStateAndFrameCounters() { @Test public void resourceUpdatesStayWithTheirGameAndOnlySendAfterLoading() { - manager = new SV_MAIN(); + manager = new ServerHost(); Cvar.getInstance().find("maxclients", Integer.class).forceSet(2); manager.resetClients(); GameImportsImpl first = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); @@ -129,7 +129,7 @@ public void resourceUpdatesStayWithTheirGameAndOnlySendAfterLoading() { @Test public void spatialQueriesAndClearingStayWithTheirGame() { - manager = new SV_MAIN(); + manager = new ServerHost(); GameImportsImpl first = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); GameImportsImpl second = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); ServerEntity firstEntity = first.gameExports.getEdict(1); @@ -167,7 +167,7 @@ public void spatialQueriesAndClearingStayWithTheirGame() { @Test public void clientSnapshotsKeepIndependentGameHistory() { - manager = new SV_MAIN(); + manager = new ServerHost(); GameImportsImpl first = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); GameImportsImpl second = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); ClientNetworkInfo firstClient = snapshotClient(first, 11); @@ -229,7 +229,7 @@ private ClientNetworkInfo snapshotClient(GameImportsImpl game, int modelIndex) { @Test public void queuedSoundsKeepTheirPositionsAndStayWithinTheirGame() { - manager = new SV_MAIN(); + manager = new ServerHost(); Cvar.getInstance().find("maxclients", Integer.class).forceSet(2); manager.resetClients(); GameImportsImpl first = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); @@ -283,7 +283,7 @@ public void clientPrintQueueRespectsItsMessageLevel() { @Test public void levelInitializationTransfersPersistentStateAndKeepsEventsGameLocal() { - manager = new SV_MAIN(); + manager = new ServerHost(); GameImportsImpl first = manager.spawnServerInstance(new ChangeMapInfo("testbox", false, false), 0); GameExportsImpl firstGameplay = (GameExportsImpl) first.gameExports; Assertions.assertEquals(ServerStates.SS_GAME, first.state); @@ -307,7 +307,7 @@ public void levelInitializationTransfersPersistentStateAndKeepsEventsGameLocal() @Test public void levelInitializationRestoresSavesAndOnlySettlesRevisitedLevels() { - manager = new SV_MAIN(); + manager = new ServerHost(); GameImportsImpl original = manager.spawnServerInstance(new ChangeMapInfo("testbox", false, false), 0); GameExportsImpl originalGameplay = (GameExportsImpl) original.gameExports; originalGameplay.game.serverflags = 9; @@ -346,7 +346,7 @@ public void levelInitializationRestoresSavesAndOnlySettlesRevisitedLevels() { @Test public void hostProtocolDispatchAndShutdownUseTheirOwnGameRegistry() { - manager = new SV_MAIN(); + manager = new ServerHost(); Cvar.getInstance().register("gamedir", jake2.qcommon.exec.CvarTypes.STRING, "", 0, "Test mod directory"); Cvar.getInstance().find("maxclients", Integer.class).forceSet(2); manager.resetClients(); @@ -380,7 +380,7 @@ public void hostProtocolDispatchAndShutdownUseTheirOwnGameRegistry() { // A separate idle Host must not inherit or clear the running Host's registry. // Global console/cvar/network isolation is outside this ownership test. - try (SV_MAIN other = new SV_MAIN()) { + try (ServerHost other = new ServerHost()) { Assertions.assertFalse(other.isRunning()); Assertions.assertEquals(first.name, other.allocateGameName()); } @@ -392,7 +392,7 @@ public void hostProtocolDispatchAndShutdownUseTheirOwnGameRegistry() { @Test public void saveCommandResolvesDefaultAndExplicitGamesWithoutReregistration() throws Exception { - manager = new SV_MAIN(); + manager = new ServerHost(); Cvar.getInstance().find("maxclients", Integer.class).forceSet(2); manager.resetClients(); var saveRegistration = jake2.qcommon.exec.Cmd.FindCommand("save"); @@ -412,7 +412,7 @@ public void saveCommandResolvesDefaultAndExplicitGamesWithoutReregistration() th jake2.qcommon.exec.Cmd.ExecuteString("save --game missing absent"); Assertions.assertFalse(store.hasMapCommand("absent")); - manager.SV_Shutdown("test", false); + manager.stop("test", false); Assertions.assertSame(saveRegistration, jake2.qcommon.exec.Cmd.FindCommand("save")); jake2.qcommon.exec.Cmd.ExecuteString("save idle"); Assertions.assertFalse(store.hasMapCommand("idle")); @@ -421,7 +421,7 @@ public void saveCommandResolvesDefaultAndExplicitGamesWithoutReregistration() th @Test public void runGame() { - final SV_MAIN sv_main = manager = new SV_MAIN(); + final ServerHost sv_main = manager = new ServerHost(); GameImportsImpl game = sv_main.spawnServerInstance(new ChangeMapInfo("testbox", false, false), 0); final GameExports gameExports = game.gameExports; GameEntity gunner = null; diff --git a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt index d8cf2afa..d4394d27 100644 --- a/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt +++ b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt @@ -436,7 +436,7 @@ class Cake( * things like godmode, noclip, etc, are commands directed to the server, so * when they are typed in at the console, they will need to be forwarded. * - * see jake2.server.SV_MAIN#SV_ExecuteUserCommand(jake2.server.client_t, java.lang.String) + * see jake2.server.ServerHost#SV_ExecuteUserCommand */ Cmd.AddCommand("cmd", "(internal) Forward a command to the connected server") { if (networkState != CONNECTED && networkState != ACTIVE) { diff --git a/client/src/main/java/jake2/client/CL.java b/client/src/main/java/jake2/client/CL.java index 7e4eb03d..fee8a7c5 100644 --- a/client/src/main/java/jake2/client/CL.java +++ b/client/src/main/java/jake2/client/CL.java @@ -264,7 +264,7 @@ public static class cheatvar_t { * things like godmode, noclip, etc, are commands directed to the server, so * when they are typed in at the console, they will need to be forwarded. * - * see jake2.server.SV_MAIN#SV_ExecuteUserCommand(jake2.server.client_t, java.lang.String) + * see jake2.server.ServerHost#SV_ExecuteUserCommand */ private static Command ForwardToServer_f = (List args) -> { if (ClientGlobals.cls.state != Defines.ca_connected diff --git a/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java b/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java index 417ad3cd..442e4f02 100644 --- a/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java +++ b/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java @@ -10,7 +10,7 @@ import jake2.qcommon.sys.Sys; import jake2.qcommon.sys.Timer; import jake2.qcommon.vfs.EngineFilesystemLifecycle; -import jake2.server.SV_MAIN; +import jake2.server.ServerHost; import java.util.Arrays; import java.util.List; @@ -33,7 +33,7 @@ public static void main(String[] args) { if (argc > 1) { System.arraycopy(args, 0, c_args, 1, argc - 1); } - SV_MAIN serverMain = null; + ServerHost serverMain = null; try { // prepare enough of the subsystems to handle @@ -59,7 +59,7 @@ public static void main(String[] args) { // init commands and vars Netchan.Netchan_Init(); - serverMain = new SV_MAIN(); //ok + serverMain = new ServerHost(); //ok // add + commands from command line if (Cbuf.AddLateCommands(args1)) { diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index 73e0bf0a..dd0ee332 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -29,7 +29,6 @@ import jake2.qcommon.exec.cvar_t; import jake2.qcommon.vfs.EngineWriteRoot; import jake2.qcommon.network.MulticastTypes; -import jake2.qcommon.network.NET; import jake2.qcommon.network.messages.NetworkMessage; import jake2.qcommon.network.messages.server.ConfigStringMessage; import jake2.qcommon.network.messages.server.PrintCenterMessage; @@ -63,7 +62,7 @@ public class GameImportsImpl implements GameImports { String name; - final SV_MAIN serverMain; + final ServerHost serverMain; public GameExports gameExports; // Per-game map state. The routing name above is independent of the loaded map name. @@ -113,11 +112,11 @@ public class GameImportsImpl implements GameImports { final float airAcceleration; private final PMove.PmoveProcessor pmoveProcessor = PMove.newLegacyProcessor(); - public GameImportsImpl(SV_MAIN serverMain, ChangeMapInfo changeMapInfo) { + public GameImportsImpl(ServerHost serverMain, ChangeMapInfo changeMapInfo) { this(serverMain, changeMapInfo, null); } - GameImportsImpl(SV_MAIN serverMain, ChangeMapInfo changeMapInfo, GameImportsImpl previous) { + GameImportsImpl(ServerHost serverMain, ChangeMapInfo changeMapInfo, GameImportsImpl previous) { this.serverMain = serverMain; this.name = serverMain.allocateGameName(); if (previous != null && !changeMapInfo.isLoadgame) @@ -180,7 +179,7 @@ public GameImportsImpl(SV_MAIN serverMain, ChangeMapInfo changeMapInfo) { } /** Allocate the gameplay module after engine services and latched settings are ready. */ - static GameImportsImpl create(SV_MAIN host, ChangeMapInfo changeMapInfo, GameImportsImpl previous) { + static GameImportsImpl create(ServerHost host, ChangeMapInfo changeMapInfo, GameImportsImpl previous) { GameImportsImpl game = new GameImportsImpl(host, changeMapInfo, previous); game.gameExports = game.createGameModInstance(); if (changeMapInfo.isLoadgame) diff --git a/server/src/main/java/jake2/server/SV_MAIN.java b/server/src/main/java/jake2/server/SV_MAIN.java deleted file mode 100644 index 69f554f2..00000000 --- a/server/src/main/java/jake2/server/SV_MAIN.java +++ /dev/null @@ -1,2003 +0,0 @@ -/* - * Copyright (C) 1997-2001 Id Software, Inc. - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License as published by the Free Software - * Foundation; either version 2 of the License, or (at your option) any later - * version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. - * - * See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * this program; if not, write to the Free Software Foundation, Inc., 59 Temple - * Place - Suite 330, Boston, MA 02111-1307, USA. - * - */ - -// Created on 13.01.2004 by RST. -// $Id: SV_MAIN.java,v 1.16 2006-01-20 22:44:07 salomo Exp $ -package jake2.server; - -import jake2.qcommon.*; -import jake2.qcommon.exec.Cbuf; -import jake2.qcommon.exec.Cmd; -import jake2.qcommon.exec.Cvar; -import jake2.qcommon.exec.CvarTypes; -import jake2.qcommon.exec.cvar_t; -import jake2.qcommon.vfs.EngineVfs; -import jake2.qcommon.vfs.EngineWriteRoot; -import jake2.qcommon.network.NET; -import jake2.qcommon.network.NetAddrType; -import jake2.qcommon.network.Netchan; -import jake2.qcommon.network.messages.ConnectionlessCommand; -import jake2.qcommon.network.messages.NetworkMessage; -import jake2.qcommon.network.messages.NetworkPacket; -import jake2.qcommon.network.messages.client.*; -import jake2.qcommon.network.messages.server.*; -import jake2.qcommon.network.netadr_t; -import jake2.qcommon.util.Lib; -import jake2.qcommon.util.Vargs; -import jake2.server.save.ServerSaveJsonStore; -import jake2.server.save.ServerSaveFiles; - -import java.util.*; -import java.util.stream.Collectors; - -import static jake2.qcommon.Defines.*; - -@SuppressWarnings("deprecation") -public class SV_MAIN implements AutoCloseable { - - // Recreating an embedded manager must reuse the same choice declaration. - private static final CvarTypes.ChoiceType NEEDPASS_TYPE = - new CvarTypes.ChoiceType<>(List.of(0, 1, 2, 3)); - - private final ServerCommands commands = new ServerCommands(); - public boolean isRunning() { - return !games.isEmpty(); - } - - /** Typed entrypoint for an embedded host; uses the same map path as the console. */ - public boolean startMap(String mapName) { - GameImportsImpl previous = games.get("default"); - SV_Map_f(List.of("map", mapName)); - return games.get("default") != null && games.get("default") != previous; - } - - /** Release this manager's console hooks after ending its last session. */ - @Override - public void close() { - try { - SV_Shutdown("Server stopped\n", false); - } finally { - commands.close(); - } - } - - public List getClients() { - return clients; - } - - private static final int MAX_STRINGCMDS = 8; - - final Map games = new HashMap<>(); - - public List getClientsForInstance(final String gameName) { - if (!games.containsKey(gameName)) { - // happens during the game instance creation, while the instance has random name - Com.Printf("Game " + gameName + " is not found!\n"); - } - // nullify unrelated clients - return clients.stream().map(c -> c.gameName != null && c.gameName.equals(gameName) ? c : null).collect(Collectors.toList()); - } - - - // prevent invalid IPs from connecting - private final challenge_t[] challenges = new challenge_t[Defines.MAX_CHALLENGES]; // to - - private List clients = new ArrayList<>(); - - private long realtime = 0; - private int frameNum = 0; - private int fixedtime = 1000; - private int gameCounter; - - String allocateGameName() { - return "jake2_" + gameCounter++; - } - - boolean isPaused() { - return sv_paused.getTypedValue(); - } - - final cvar_t sv_paused; - - final cvar_t sv_timedemo; - - final cvar_t sv_enforcetime; - - final cvar_t timeout; // seconds without any message - - final cvar_t zombietime; // seconds to sink messages after - // disconnect - - final cvar_t allow_download; - - final cvar_t allow_download_players; - - final cvar_t allow_download_models; - - final cvar_t allow_download_sounds; - - final cvar_t allow_download_maps; - - final cvar_t sv_airaccelerate; - - final cvar_t sv_showclamp; - - final cvar_t hostname; - - final cvar_t public_server; // should heartbeats be sent - - final cvar_t sv_reconnect_limit; // minimum seconds between connect - // messages - - - /** - * Builds the string that is sent as heartbeats and status replies. - */ - private String SV_StatusString() { - - String status = Cvar.getInstance().Serverinfo() + "\n"; - - for (ClientNetworkInfo cl: clients) { - if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) { - String player = "" + cl.edict.getClient().getPlayerState().stats[Defines.STAT_FRAGS] + " " + cl.ping + "\"" + cl.name + "\"\n"; - - int playerLength = player.length(); - int statusLength = status.length(); - - if (statusLength + playerLength >= 1024) - break; // can't hold any more - - status += player; - } - } - - return status; - } - - /** - * SVC_Info, responds with short info for broadcast scans The second parameter should - * be the current protocol version number. - */ - private String SVC_Info(List args, netadr_t from) { - - if (clients.size() == 1) - return null; // ignore in single player - - int version = args.size() < 2 ? 0 : Lib.atoi(args.get(1)); - - String info; - if (version != Defines.PROTOCOL_VERSION) - info = hostname.getTypedValue() + ": wrong version\n"; - else { - int players = 0; - for (ClientNetworkInfo cl: clients) - if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) - players++; - - info = hostname.getTypedValue() + " " + games.get("default").mapName + " " + players + "/" + clients.size() + "\n"; - } - - return info; - } - - /** - * Returns a challenge number that can be used in a subsequent - * client_connect command. We do this to prevent denial of service attacks - * that flood the server with invalid connection IPs. With a challenge, they - * must give a valid IP address. - */ - private int SVC_GetChallenge(netadr_t from) { - - int oldest = 0; - int oldestTime = 0x7fffffff; - - int i; - for (i = 0; i < Defines.MAX_CHALLENGES; i++) { - if (from.CompareBaseAdr(challenges[i].adr)) - break; - if (challenges[i].time < oldestTime) { - oldestTime = challenges[i].time; - oldest = i; - } - } - - if (i == Defines.MAX_CHALLENGES) { - // overwrite the oldest - challenges[oldest].challenge = Lib.rand() & 0x7fff; - challenges[oldest].adr = from; - challenges[oldest].time = (int) Globals.curtime; - i = oldest; - } - - // send it back - return challenges[i].challenge; - } - - /** - * A connection request that did not come from the master. - */ - private void SVC_DirectConnect(List args, netadr_t adr) { - - Com.DPrintf("SVC_DirectConnect ()\n"); - - int version = args.size() >= 2 ? Lib.atoi(args.get(1)) : 0; - - if (version != Defines.PROTOCOL_VERSION) { - Netchan.sendConnectionlessPacket(Defines.NS_SERVER, adr, ConnectionlessCommand.print, - "\nServer is version " + Globals.VERSION + "\n"); - Com.DPrintf(" rejected connect from version " + version + "\n"); - return; - } - - int qport = args.size() >= 3 ? Lib.atoi(args.get(2)) : 0; - int challenge = args.size() >= 4 ? Lib.atoi(args.get(3)) : 0; - String userinfo = args.size() >= 5 ? args.get(4) : ""; - - // force the IP key/value pair so the game can filter based on ip - userinfo = Info.Info_SetValueForKey(userinfo, "ip", adr.toString()); - - -/* - if (gameImports.isDemo) { - if (!adr.IsLocalAddress()) { - Com.Printf("Remote connect in attract loop. Ignored.\n"); - Netchan.sendConnectionlessPacket(Defines.NS_SERVER, adr, ConnectionlessCommand.print, "\nConnection refused.\n"); - return; - } - } -*/ - - - // see if the challenge is valid - int j; - if (!adr.IsLocalAddress()) { - for (j = 0; j < Defines.MAX_CHALLENGES; j++) { - if (adr.CompareBaseAdr(challenges[j].adr)) { - if (challenge == challenges[j].challenge) - break; // good - Netchan.sendConnectionlessPacket(Defines.NS_SERVER, adr, ConnectionlessCommand.print, "\nBad challenge.\n"); - return; - } - } - if (j == Defines.MAX_CHALLENGES) { - Netchan.sendConnectionlessPacket(Defines.NS_SERVER, adr, ConnectionlessCommand.print, "\nNo challenge for address.\n"); - return; - } - } - - // if there is already a slot for this ip, reuse it - int i = 0; - for (ClientNetworkInfo cl: clients) { - if (cl.state == ClientStates.CS_FREE) - continue; - if (adr.CompareBaseAdr(cl.netchan.remote_address) - && (cl.netchan.qport == qport || adr.port == cl.netchan.remote_address.port)) { - if (!adr.IsLocalAddress() && (realtime - cl.lastconnect) < (sv_reconnect_limit.getTypedValue() * 1000)) { - Com.DPrintf(adr.toString() + ":reconnect rejected : too soon\n"); - return; - } - Com.Printf(adr + ":reconnect\n"); - - gotnewcl(i, challenge, userinfo, adr, qport); - return; - } - i++; - } - - // find a client slot - int index = -1; - i = 0; - for (ClientNetworkInfo cl: clients) { - if (cl.state == ClientStates.CS_FREE) { - index = i; - break; - } - i++; - } - if (index == -1) { - Netchan.sendConnectionlessPacket(Defines.NS_SERVER, adr, ConnectionlessCommand.print, "\nServer is full.\n"); - Com.DPrintf("Rejected a connection.\n"); - return; - } - gotnewcl(index, challenge, userinfo, adr, qport); - } - - /** - * Initializes player structures after successfull connection. - */ - private void gotnewcl(int i, int challenge, String userinfo, netadr_t adr, int qport) { - // build a new connection - // accept the new client - // this is the only place a ClientNetworkInfo is ever initialized - - final ClientNetworkInfo client = clients.get(i); - GameImportsImpl gameImports = games.get("default"); - client.gameName = "default"; - - ServerEntity ent = gameImports.gameExports.getEdict(i + 1); - client.edict = ent; // fixme: why? isn't it already set? - - // save challenge for checksumming - client.challenge = challenge; - - // get the game a chance to reject this connection or modify the - // userinfo - if (!(gameImports.gameExports.ClientConnect(ent, userinfo))) { - if (Info.Info_ValueForKey(userinfo, "rejmsg") != null) { - Netchan.sendConnectionlessPacket(Defines.NS_SERVER, adr, ConnectionlessCommand.print, "\n" + Info.Info_ValueForKey(userinfo, "rejmsg") + "\nConnection refused.\n"); - } else { - Netchan.sendConnectionlessPacket(Defines.NS_SERVER, adr, ConnectionlessCommand.print, "\nConnection refused.\n"); - } - Com.DPrintf("Game rejected a connection.\n"); - return; - } - - // parse some info from the info strings - client.userinfo = userinfo; - SV_UserinfoChanged(client); - - Netchan.sendConnectionlessPacket(Defines.NS_SERVER, adr, ConnectionlessCommand.client_connect, ""); - - client.netchan.setup(Defines.NS_SERVER, adr, qport); - - client.state = ClientStates.CS_CONNECTED; - - client.lastmessage = gameImports.realtime; // don't timeout - client.lastconnect = gameImports.realtime; - Com.DPrintf("new client added.\n"); - } - - /** - * Checks if the rcon password is corect. - */ - private static boolean Rcon_Validate(List args) { - String rconPassword = Cvar.getInstance().find("rcon_password", String.class).getTypedValue(); - if (rconPassword.isEmpty()) - return false; - - return args.size() >= 2 && args.get(1).equals(rconPassword); - } - - /** - * A client issued an rcon command. Shift down the remaining args Redirect - * all printfs from the server to the client. - */ - private static void SVC_RemoteCommand(List args, final netadr_t from) { - - boolean rconIsValid = Rcon_Validate(args); - - if (rconIsValid) { - Com.Printf("Rcon from " + from + ":\n" + args + "\n"); - } else { - Com.Printf("Bad rcon from " + from + ":\n" + args + "\n"); - } - - - Com.BeginRedirect((buffer) -> Netchan.sendConnectionlessPacket(NS_SERVER, from, ConnectionlessCommand.print, "\n" + buffer.toString())); - if (rconIsValid) { - Cmd.ExecuteString(Cmd.getArguments(args, 2)); - } else { - // redirected to client - Com.Printf("Bad rcon_password.\n"); - } - - Com.EndRedirect(); - } - - /** - * A connectionless packet has four leading 0xff characters to distinguish - * it from a game channel. Clients that are in the game can still send - * connectionless packets. It is used also by rcon commands. - */ - void SV_ConnectionlessPacket(netadr_t from, String messageLine) { - - List args = Cmd.TokenizeString(messageLine, false); - - if (args.isEmpty()) { - Com.Printf("empty connectionless packet: " + messageLine); - return; - } - - String c = args.get(0); - ConnectionlessCommand cmd = ConnectionlessCommand.fromString(c); - - switch (cmd) { - case ping: - // SVC_Ping, Just responds with an acknowledgement. - Netchan.sendConnectionlessPacket(Defines.NS_SERVER, from, ConnectionlessCommand.ack, ""); - break; - case ack: - //SVC_Ack - Com.Printf("Ping acknowledge from " + from + "\n"); - break; - case status: - // SVC_Status, Responds with all the info that qplug or qspy can see - Netchan.sendConnectionlessPacket(Defines.NS_SERVER, from, ConnectionlessCommand.print, "\n" + SV_StatusString()); - break; - case info: - String info = SVC_Info(args, from); - if (info != null) - Netchan.sendConnectionlessPacket(Defines.NS_SERVER, from, ConnectionlessCommand.info, "\n" + info); - break; - case getchallenge: - int challenge = SVC_GetChallenge(from); - Netchan.sendConnectionlessPacket(Defines.NS_SERVER, from, ConnectionlessCommand.challenge, " " + challenge); - break; - case connect: - SVC_DirectConnect(args, from); - break; - case rcon: - SVC_RemoteCommand(args, from); - break; - default: - Com.Printf("Unknown ClientConnectionlessCommand: " + c + '\n'); - break; - } - } - - /** - * SV_Frame. - */ - public void update(long msec) { - // if server is not active, do nothing - // like when connected to another server - if (games.isEmpty()) - return; - - realtime += msec; - for (GameImportsImpl game: games.values()) { - game.realtime += msec; - } - - // keep the random time dependent - Lib.rand(); - - // check timeouts - SV_CheckTimeouts(); - - // get packets from clients - SV_ReadPackets(); - - // identify if we need to update the game or it's too early (sleep or skip this update). - // fixedtime - the time of the next scheduled game update. - long delay = fixedtime - realtime; - if (delay > 0) { - // never let the time get too far off - if (delay > 100) { // how is it even possible? - for (GameImportsImpl game: games.values()) - game.realtime = (int) (fixedtime - 100); - delay = 100; - } - NET.Sleep((int) delay); - return; - } - // move autonomous things around if enough time has passed - - frameNum++; - fixedtime = 100 * frameNum; - - // update ping based on the last known frame from all clients - SV_CalcPings(); - - // give the clients some timeslices - SV_GiveMsec(); - - // let everything in the world think and move - for (GameImportsImpl game : games.values()) { - game.SV_RunGameFrame(fixedtime); - } - - // send messages back to the clients that had packets read this frame - SV_SendClientMessages(); - - - // send a heartbeat to the master if needed - //Master_Heartbeat(); - - // clear teleport flags, etc for next frame - for (GameImportsImpl game : games.values()) { - game.clearEntityStateEvents(); - } - } - - void SV_SendClientMessages() { - - // send a message to each connected client - for (ClientNetworkInfo c : clients) { - - if (c.state == ClientStates.CS_FREE) - continue; - - GameImportsImpl game = games.get(c.gameName); - // if the reliable message overflowed, - // drop the client - int pendingReliableSize = c.netchan.reliablePending.stream().mapToInt(NetworkMessage::getSize).sum(); - if (pendingReliableSize > Defines.MAX_MSGLEN - 16) { - c.netchan.reliablePending.clear(); - c.unreliable.clear(); - SV_BroadcastPrintf(Defines.PRINT_HIGH, c.name + " overflowed\n", c.gameName); - SV_DropClient(c); - } - - if (game.state == ServerStates.SS_CINEMATIC || game.state == ServerStates.SS_DEMO || game.state == ServerStates.SS_PIC) { - // leftover from demo code - c.netchan.transmit(null); - } else if (c.state == ClientStates.CS_SPAWNED) { - // don't overrun bandwidth - if (SV_RateDrop(c)) - continue; - - sendClientDatagram(c, game); - } else { - // just update reliable if needed - if (c.netchan.reliablePending.size() != 0 || Globals.curtime - c.netchan.last_sent > 1000) - c.netchan.transmit(null); - } - } - } - - /** - * Called when the player is totally leaving the server, either willingly or - * unwillingly. This is NOT called if the entire server is quiting or - * crashing. - */ - void SV_DropClient(ClientNetworkInfo client) { - // add the disconnect - client.netchan.reliablePending.add(new DisconnectMessage()); - - if (client.state == ClientStates.CS_SPAWNED) { - // call the prog function for removing a client - // this will remove the body, among other things - games.get(client.gameName).gameExports.ClientDisconnect(client.edict); - } - - if (client.download != null) { - client.download = null; - } - - client.state = ClientStates.CS_ZOMBIE; // become free in a few seconds - client.name = ""; - } - - /** - * Sends text to all active clients - */ - public void SV_BroadcastPrintf(int level, String message, String gameName) { - - // echo to console - if (Globals.dedicated.getTypedValue()) { - Com.Printf(message); - } - - for (ClientNetworkInfo cl: getClientsForInstance(gameName)) { - if (cl == null || level < cl.messagelevel) - continue; - if (cl.state != ClientStates.CS_SPAWNED) - continue; - cl.netchan.reliablePending.add(new PrintMessage(level, message)); - } - } - - /** - * SV_RateDrop - *

- * Returns true if the client is over its current - * bandwidth estimation and should not be sent another packet - */ - boolean SV_RateDrop(ClientNetworkInfo c) { - - // never drop over the loopback - if (c.netchan.remote_address.type == NetAddrType.NA_LOOPBACK) - return false; - - int total = 0; - - for (int i = 0; i < Defines.RATE_MESSAGES; i++) { - total += c.message_size[i]; - } - - if (total > c.rate) { - c.surpressCount++; - c.message_size[frameNum % Defines.RATE_MESSAGES] = 0; - return true; - } - - return false; - } - - /** - * Reads packets from the network or loopback. - */ - void SV_ReadPackets() { - while (true) { - NetworkPacket networkPacket = NET.receiveNetworkPacket( - NET.ip_sockets[NS_SERVER], - NET.ip_channels[NS_SERVER], - NET.loopbacks[NS_SERVER], - true); - - if (networkPacket == null) - break; - - if (networkPacket.isConnectionless()) { - SV_ConnectionlessPacket(networkPacket.from, networkPacket.connectionlessMessage); - continue; - } - - // Match incoming packet with the existing client. - // todo: get client by address (hashmap?) - for (ClientNetworkInfo cl: clients) { - if (cl.state == ClientStates.CS_FREE) - continue; - if (!networkPacket.from.CompareBaseAdr(cl.netchan.remote_address)) - continue; - if (cl.netchan.qport != networkPacket.qport) - continue; - if (cl.netchan.remote_address.port != networkPacket.from.port) { - Com.Printf("SV_ReadPackets: fixing up a translated port\n"); - cl.netchan.remote_address.port = networkPacket.from.port; - } - - if (cl.netchan.accept(networkPacket)) { - // this is a valid, sequenced packet, so process it - if (cl.state != ClientStates.CS_ZOMBIE) { - cl.lastmessage = games.get(cl.gameName).realtime; // don't timeout - Collection body = networkPacket.parseBodyFromClient(cl.netchan.incoming_sequence); - if (body == null) - SV_DropClient(cl); - else - SV_ExecuteClientMessage(cl, body); - } - } - break; - } - - } - } - - /* - * =================== SV_ExecuteClientMessage - * - * The current net_message is parsed for the given client - * =================== - */ - void SV_ExecuteClientMessage(ClientNetworkInfo cl, Collection clientMessages) { - - // only allow one move command - boolean move_issued = false; - int stringCmdCount = 0; - - - for (ClientMessage msg : clientMessages) { - if (msg instanceof EndOfClientPacketMessage) { - break; - } else if (msg instanceof StringCmdMessage) { - StringCmdMessage m = (StringCmdMessage) msg; - // malicious users may try using too many string commands - if (++stringCmdCount < MAX_STRINGCMDS) { - SV_ExecuteUserCommand(cl, m.command); - } - - if (cl.state == ClientStates.CS_ZOMBIE) { - return; // disconnect command - } - } else if (msg instanceof UserInfoMessage) { - UserInfoMessage m = (UserInfoMessage) msg; - cl.userinfo = m.userInfo; - SV_UserinfoChanged(cl); - } else if (msg instanceof MoveMessage) { - MoveMessage m = (MoveMessage) msg; - if (move_issued) - return; // someone is trying to cheat... - - move_issued = true; - int lastReceivedFrame = m.lastReceivedFrame; - - if (lastReceivedFrame != cl.lastReceivedFrame) { - cl.lastReceivedFrame = lastReceivedFrame; - if (cl.lastReceivedFrame > 0) { - final int framaLatency = games.get(cl.gameName).realtime - cl.frames[cl.lastReceivedFrame & UPDATE_MASK].senttime; - cl.frame_latency[cl.lastReceivedFrame & (Defines.LATENCY_COUNTS - 1)] = framaLatency; - } - } - - if (cl.state != ClientStates.CS_SPAWNED) { - cl.lastReceivedFrame = -1; - continue; - } - - // if the checksum fails, ignore the rest of the packet - if (!m.valid) { - Com.Printf("Invalid crc\n"); - return; - } - - - if (!sv_paused.getTypedValue()) { - int net_drop = cl.netchan.dropped; - if (net_drop < 20) { - - //if (net_drop > 2) - - // Com.Printf ("drop %i\n", net_drop); - while (net_drop > 2) { - SV_ClientThink(cl, cl.lastcmd); - - net_drop--; - } - if (net_drop > 1) - SV_ClientThink(cl, m.oldestCmd); - - if (net_drop > 0) - SV_ClientThink(cl, m.oldCmd); - - } - SV_ClientThink(cl, m.newCmd); - } - - // copy. - cl.lastcmd.set(m.newCmd); - } else { - Com.Printf("SV_ReadClientMessage: unknown command: " + msg.type + "\n"); - SV_DropClient(cl); - } - } - } - - void SV_ClientThink(ClientNetworkInfo cl, usercmd_t cmd) { - cl.commandMsec -= cmd.msec & 0xFF; - - if (cl.commandMsec < 0 && sv_enforcetime.getTypedValue()) { - Com.DPrintf("commandMsec underflow from " + cl.name + "\n"); - return; - } - - games.get(cl.gameName).gameExports.ClientThink(cl.edict, cmd); - } - - /** - * Pull specific info from a newly changed userinfo string into a more C - * freindly form. - */ - void SV_UserinfoChanged(ClientNetworkInfo cl) { - // call prog code to allow overrides - games.get(cl.gameName).gameExports.ClientUserinfoChanged(cl.edict, cl.userinfo); - - // name for C code - cl.name = Info.Info_ValueForKey(cl.userinfo, "name"); - - // mask off high bit - //TODO: masking for german umlaute - //for (i=0 ; i 0) { - cl.rate = Lib.atoi(val); - if (cl.rate < 100) - cl.rate = 100; - if (cl.rate > 15000) - cl.rate = 15000; - } else - cl.rate = 5000; - - // msg command - val = Info.Info_ValueForKey(cl.userinfo, "msg"); - if (val.length() > 0) { - cl.messagelevel = Lib.atoi(val); - } - - } - - void SV_ExecuteUserCommand(ClientNetworkInfo cl, String command) { - - Com.dprintln("SV_ExecuteUserCommand:" + command ); - - List args = Cmd.TokenizeString(command, true); - if (args.isEmpty()) - return; - - final GameImportsImpl game = games.get(cl.gameName); - if (userCommands.containsKey(args.get(0))) { - userCommands.get(args.get(0)).execute(args, game, cl); - return; - } - - if (game.state == ServerStates.SS_GAME) - game.gameExports.ClientCommand(cl.edict, args); - } - - /** - * If a packet has not been received from a client for the configured timeout - * in seconds, drop the connection. Server frames are used instead of realtime - * to avoid dropping the local client while debugging. - * - * When a client is normally dropped, the ClientNetworkInfo goes into a zombie state - * for a few seconds to make sure any final reliable message gets resent if - * necessary. - */ - void SV_CheckTimeouts() { - - for (ClientNetworkInfo cl : clients) { - if (cl.state == ClientStates.CS_FREE) - continue; - - final GameImportsImpl game = games.get(cl.gameName); - int droppoint = (int) (game.realtime - 1000 * timeout.getTypedValue()); - int zombiepoint = (int) (game.realtime - 1000 * zombietime.getTypedValue()); - - // message times may be wrong across a changelevel - if (cl.lastmessage > game.realtime) - cl.lastmessage = game.realtime; - - if (cl.state == ClientStates.CS_ZOMBIE && cl.lastmessage < zombiepoint) { - cl.state = ClientStates.CS_FREE; // can now be reused - continue; - } - if ((cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) && cl.lastmessage < droppoint) { - SV_BroadcastPrintf(Defines.PRINT_HIGH, cl.name + " timed out\n", cl.gameName); - SV_DropClient(cl); - cl.state = ClientStates.CS_FREE; // don't bother with zombie state - } - } - } - - /** - * Updates the cl.ping variables. - */ - void SV_CalcPings() { - - for (ClientNetworkInfo cl: clients) { - if (cl.state != ClientStates.CS_SPAWNED) - continue; - - int total = 0; - int count = 0; - for (int j = 0; j < Defines.LATENCY_COUNTS; j++) { - if (cl.frame_latency[j] > 0) { - count++; - total += cl.frame_latency[j]; - } - } - if (0 == count) - cl.ping = 0; - else - cl.ping = total / count; - - // let the jake2.game dll know about the ping - cl.edict.getClient().setPing(cl.ping); - } - } - - /** - * Every few frames, gives all clients an allotment of milliseconds for - * their command moves. If they exceed it, assume cheating. - */ - void SV_GiveMsec() { - // why *1111 - if ((frameNum & 15) != 0) - return; - - for (ClientNetworkInfo cl: clients) { - if (cl.state == ClientStates.CS_FREE) - continue; - - if ((games.get(cl.gameName).frameNum & 15) != 0) - continue; - - cl.commandMsec = 1800; // 1600 + some slop - } - } - - /** - * Used by SV_Shutdown to send a final message to all connected clients - * before the server goes down. The messages are sent immediately, not just - * stuck on the outgoing message list, because the server is going to - * totally exit after returning from this function. - */ - private void SV_FinalMessage(String message, boolean reconnect) { - - Collection msgs = new ArrayList<>(); - msgs.add(new PrintMessage(Defines.PRINT_HIGH, message)); - - if (reconnect) - msgs.add(new ReconnectMessage()); - else - msgs.add(new DisconnectMessage()); - - // send it twice - // stagger the packets to crutch operating system limited buffers - for (ClientNetworkInfo cl : clients) { - if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) - cl.netchan.transmit(msgs); - } - for (ClientNetworkInfo cl : clients) { - if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) - cl.netchan.transmit(msgs); - } - } - - /** - * Called when each game quits, before Sys_Quit or Sys_Error. - * todo: shutdown a particular instance, not the whole server - */ - public void SV_Shutdown(String finalmsg, boolean reconnect) { - try { - if (!games.isEmpty()) { - if (!clients.isEmpty()) SV_FinalMessage(finalmsg, reconnect); - Com.Printf("==== ShutdownGame ====\n"); - } - } finally { - games.clear(); - clients.clear(); - Globals.server_state = ServerStates.SS_DEAD; - realtime = 0; - frameNum = 0; - fixedtime = 100; - } - } - - /** - * Resets the clients when maxclients changes - */ - void resetClients() { - SV_FinalMessage("Server restarted", true); - final int max = Cvar.getInstance().find("maxclients", Integer.class).getTypedValue(); - - // Clear all clients - clients = new ArrayList<>(max); - for (int i = 0; i < max; i++) { - ClientNetworkInfo cl = new ClientNetworkInfo(); - cl.lastcmd = new usercmd_t(); - clients.add(cl); - } - } - - /** - * One manager per dedicated process or embedded session; map changes reuse it. - */ - public SV_MAIN() { - - for (int n = 0; n < Defines.MAX_CHALLENGES; n++) { - challenges[n] = new challenge_t(); - } - - Cvar cvars = Cvar.getInstance(); - cvars.register("spectator_password", CvarTypes.STRING, "", Defines.CVAR_USERINFO, - "Spectator admission password; empty or none allows access"); - cvars.register("needpass", NEEDPASS_TYPE, 0, Defines.CVAR_SERVERINFO, - "Required passwords: 0 none, 1 player, 2 spectator, 3 both"); - cvars.register("deathmatch", CvarTypes.BOOLEAN, false, Defines.CVAR_SERVERINFO | Defines.CVAR_LATCH); - cvars.register("coop", CvarTypes.BOOLEAN, false, Defines.CVAR_SERVERINFO | Defines.CVAR_LATCH); - cvars.register("dmflags", CvarTypes.INTEGER, Defines.DF_INSTANT_ITEMS, Defines.CVAR_SERVERINFO, "Deathmatch rule bitmask; combine DF_* flags"); - cvars.register("fraglimit", new CvarTypes.IntegerType(0, null), 0, Defines.CVAR_SERVERINFO, "End a deathmatch level at this score; 0 disables the limit"); - cvars.register("timelimit", new CvarTypes.FloatType(0f, null), 0f, Defines.CVAR_SERVERINFO, "Deathmatch level time limit in minutes; 0 disables the limit"); - cvars.register("cheats", CvarTypes.BOOLEAN, false, Defines.CVAR_SERVERINFO | Defines.CVAR_LATCH); - cvars.register("protocol", CvarTypes.INTEGER, Defines.PROTOCOL_VERSION, Defines.CVAR_SERVERINFO | Defines.CVAR_NOSET); - cvars.register("mapname", CvarTypes.STRING, "", Defines.CVAR_SERVERINFO | Defines.CVAR_NOSET); - cvars.register("nextserver", CvarTypes.STRING, "", 0, "Console command to execute after a cinematic or picture"); - cvars.register("maxclients", new CvarTypes.IntegerType(1, Defines.MAX_CLIENTS), 1, Defines.CVAR_SERVERINFO | Defines.CVAR_LATCH, "Client slot limit; single-player and coop mode also restrict the count"); - - hostname = cvars.register("hostname", CvarTypes.STRING, "noname", Defines.CVAR_SERVERINFO | Defines.CVAR_ARCHIVE); - timeout = cvars.register("timeout", new CvarTypes.FloatType(0f, null), 125f, 0, "Seconds without a client message before disconnection"); - zombietime = cvars.register("zombietime", new CvarTypes.FloatType(0f, null), 2f, 0, "Seconds to retain a disconnected client slot for final reliable messages"); - sv_showclamp = cvars.register("showclamp", CvarTypes.BOOLEAN, false, 0, "Legacy frame-time clamp diagnostics; unused by the current server and Cake"); - sv_paused = cvars.register("paused", CvarTypes.BOOLEAN, false, 0); - sv_timedemo = cvars.register("timedemo", CvarTypes.BOOLEAN, false, 0, "Legacy demo benchmarking switch; unused by the current server and Cake"); - sv_enforcetime = cvars.register("sv_enforcetime", CvarTypes.BOOLEAN, false, 0, "Ignore movement commands that exceed the client time budget"); - - // come from MainCommon - allow_download = cvars.find("allow_download", Boolean.class); - allow_download_players = cvars.find("allow_download_players", Boolean.class); - allow_download_models = cvars.find("allow_download_models", Boolean.class); - allow_download_sounds = cvars.find("allow_download_sounds", Boolean.class); - allow_download_maps = cvars.find("allow_download_maps", Boolean.class); - - cvars.register("sv_noreload", CvarTypes.BOOLEAN, false, 0, "Start revisited levels fresh instead of restoring their saved state"); - sv_airaccelerate = cvars.register("sv_airaccelerate", CvarTypes.FLOAT, 0f, Defines.CVAR_LATCH, "Controls air acceleration (currently only for deathmatch)"); - public_server = cvars.register("public", CvarTypes.BOOLEAN, false, 0, "Legacy master-server advertising switch; currently unused"); - sv_reconnect_limit = cvars.register("sv_reconnect_limit", new CvarTypes.FloatType(0f, null), 3f, Defines.CVAR_ARCHIVE, "Minimum seconds between reconnect attempts from the same remote client"); - resetClients(); - - // Publish Host callbacks only after initialization succeeds. They survive Game replacement. - commands.add("heartbeat", true, this::SV_Heartbeat_f); - commands.add("kick", true, this::SV_Kick_f); - commands.add("status", true, this::SV_Status_f); - commands.add("serverinfo", true, this::SV_Serverinfo_f); - commands.add("dumpuser", true, this::SV_DumpUser_f); - commands.add("say", true, this::SV_ConSay_f); - commands.add("save", true, this::saveGame); - - commands.add("killserver", args -> { - SV_Shutdown("Server was killed.\n", false); - NET.configureServer(false); - }); - commands.add("map", this::SV_Map_f); - commands.add("gamemap", this::SV_GameMap_f); - commands.add("load", this::SV_Loadgame_f); - commands.add("sv_shutdown", args -> { - String reason; - if (args.size() > 1) { - reason = args.get(1); - } else { - reason = "Server is shut down"; - } - - SV_Shutdown(reason + "\n", args.size() > 2 && Boolean.parseBoolean(args.get(2))); - }); - - commands.add("listmaps", (List args) -> { - byte[] bytes = EngineVfs.loadBytes("maps.lst"); - if (bytes == null) { - Com.Error(ERR_DROP, "Could not read maps.lst"); - return; - } - for (String line : new String(bytes).split("\n")) { - Com.Printf(PRINT_ALL, line.trim() + "\n"); - } - }); - commands.add("jvm_memory", args -> { - Runtime runtime = Runtime.getRuntime(); - Com.Printf("vm memory:" + (runtime.totalMemory() - runtime.freeMemory()) + "\n"); - }); - commands.add("games", args -> { - for (GameImportsImpl game : games.values()) { - Com.Printf(game.name + " - " + game.mapcmd + "\n"); - } - Com.Printf("Total " + games.size() + " games\n"); - }); - commands.add("spamgames", args -> { - int total = Integer.parseInt(args.get(1)); - for (int i = 0; i < total; i++) { - SV_GameMap_f(List.of("gamemap", "base1", "1")); - } - }); - } - - /** - * SV_InitGame. - * - * A brand new game has been started. - */ - GameImportsImpl createGameInstance(ChangeMapInfo changeMapInfo, GameImportsImpl previous) { - return GameImportsImpl.create(this, changeMapInfo, previous); - } - - /** - * @return if the game is multiplayer or not - */ - private boolean initializeServerCvars() { - Cvar cvars = Cvar.getInstance(); - cvar_t coop = cvars.find("coop", Boolean.class); - cvar_t deathmatch = cvars.find("deathmatch", Boolean.class); - cvar_t maxclients = cvars.find("maxclients", Integer.class); - - // get any latched variable changes (maxclients, etc) - cvars.updateLatchedVars(); - - if (coop.getTypedValue() && deathmatch.getTypedValue()) { - Com.Printf("Deathmatch and Coop both set, disabling Coop\n"); - coop.forceSet(false); - } - - // dedicated servers are can't be single player and are usually DM - // so unless they explicity set coop, force it to deathmatch - if (Globals.dedicated.getTypedValue()) { - if (!coop.getTypedValue()) - deathmatch.forceSet(true); - } - - - // set max clients based on game mode - - if (deathmatch.getTypedValue()) { - if (maxclients.getTypedValue() <= 1) - maxclients.forceSet(8); - } else if (coop.getTypedValue()) { - if (maxclients.getTypedValue() <= 1 || maxclients.getTypedValue() > 4) - maxclients.forceSet(4); - - } else { - // non-deathmatch, non-coop is one player - maxclients.forceSet(1); - } - return maxclients.getTypedValue() > 1; - } - - void SV_Loadgame_f(List args) { - - if (args.size() != 2) { - Com.Printf("USAGE: load \n"); - return; - } - - Com.Printf("Loading game...\n"); - - String saveGame; - try { - saveGame = ServerSaveJsonStore.normalizeSlot(args.get(1)); - } catch (IllegalArgumentException e) { - Com.Printf("Bad save name.\n"); - return; - } - if ("current".equals(saveGame)) { - Com.Printf("Can't load from 'current'\n"); - return; - } - - // make sure the server metadata file exists - ServerSaveJsonStore store = ServerSaveJsonStore.forWriteDir(EngineWriteRoot.pathString()); - if (!store.hasMapCommand(saveGame)) { - String name = EngineWriteRoot.resolve("save/" + saveGame + "/server_mapcmd.ssv.json").toString(); - Com.Printf("No such savegame: " + name + "\n"); - return; - } - - new ServerSaveFiles(EngineWriteRoot.path()).copy(saveGame, "current"); - - final String mapCommand = SV_ReadMapCommand(); - - // go to the map - GameImportsImpl loaded = spawnServerInstance(new ChangeMapInfo(mapCommand, false, true), 0); - loaded.name = "default"; - games.put("default", loaded); - } - - private String SV_ReadMapCommand() { - Com.DPrintf("SV_ReadMapCommand()\n"); - - try { - ServerSaveJsonStore store = ServerSaveJsonStore.forWriteDir(EngineWriteRoot.pathString()); - ServerSaveJsonStore.ServerMapCommandSnapshot snapshot = store.readMapCommand("current"); - // read the comment field - Com.DPrintf("SV_ReadMapCommand: Loading save: " + snapshot.comment() + "\n"); - - // read the mapcmd - return snapshot.mapCommand(); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - /* -================== -SV_GameMap_f - -Saves the state of the map just being exited and goes to a new map. - -If the initial character of the map string is '*', the next map is -in a new unit, so the current savegame directory is cleared of -map files. - -Example: - -*inter.cin+jail - -Clears the archived maps, plays the inter.cin cinematic, then -goes to map jail.bsp. -================== - -Spins up a new game instance, to be used with either `map` or `join` (TBD) -*/ - void SV_GameMap_f(List args) { - - if (args.size() < 2) { - Com.Printf("USAGE: gamemap \n "); - return; - } - - String mapName = args.get(1); - - final int clientIndex; - if (args.size() > 2 && !args.get(2).isBlank()) { - try { - clientIndex = Integer.parseInt(args.get(2)); - } catch (NumberFormatException e) { - Com.Printf("USAGE: gamemap \n \n"); - return; - } - } else - clientIndex = 0; - - // default.cfg may start a session with "killserver; gamemap *...". - // Shutdown clears the client slots, so gamemap must initialize a fresh - // server just as map does, while preserving clients on level transitions. - if (games.isEmpty()) { - initializeServerSession(); - } - - Com.DPrintf("SV_GameMap(" + mapName + ")\n"); - - EngineWriteRoot.ensureParentDirectories(EngineWriteRoot.resolve("save/current/.keep")); - - // check for clearing the current savegame - if (mapName.charAt(0) == '*') { - // wipe all the *.sav files - new ServerSaveFiles(EngineWriteRoot.path()).wipe("current"); - } else { // save the map just exited - // note: only applicable to the default instance (subject to change in future) - var gameImports = games.get("default"); - if (gameImports != null && gameImports.state == ServerStates.SS_GAME) { - // clear all the client inuse flags before saving so that - // when the level is re-entered, the clients will spawn - // at spawn points instead of occupying body shells - // todo: only relevant to this gameImports clients - boolean[] savedInuse = new boolean[clients.size()]; - int i = 0; - for (ClientNetworkInfo cl : clients) { - if (cl.edict != null) { // free client slots will have null values - savedInuse[i] = cl.edict.inuse; - cl.edict.inuse = false; - } - i++; - } - - gameImports.SV_WriteLevelFile(); - - // we must restore these for clients to transfer over correctly - i = 0; - for (ClientNetworkInfo cl: clients) { - if (cl.edict != null) { // free client slots will have null values - cl.edict.inuse = savedInuse[i]; - } - i++; - } - } - } - - final GameImportsImpl game = spawnServerInstance(new ChangeMapInfo(mapName, false, false), clientIndex); - - if (clientIndex == 0) { - // server just started, `map` cmd was used - // start up the next map - game.name = "default"; - games.put(game.name, game); - for (ClientNetworkInfo cl : clients) { - cl.gameName = "default"; - } - - } else { - // preview of the multi instance mode - final ClientNetworkInfo cl = clients.get(clientIndex - 1); - GameImportsImpl previousGame = games.get(cl.gameName); - previousGame.gameExports.ClientDisconnect(cl.edict); - games.put(game.name, game); - cl.gameName = game.name; - } - - // copy off the level to the autosave slot - if (!Globals.dedicated.getTypedValue()) { - game.SV_WriteServerFile(true); - new ServerSaveFiles(EngineWriteRoot.path()).copy("current", "save0"); - } - - // multi instance preview: - // when playing the regular flow, multi instances are not requried - if (clientIndex == 0) { - games.clear(); - games.put("default", game); - } - } - - /** - * SV_Map - */ - GameImportsImpl spawnServerInstance(ChangeMapInfo changeMapInfo, int clientIndex) { - Globals.server_state = ServerStates.SS_DEAD; //fixme: used by the client code - - GameImportsImpl previous = clientIndex != 0 - ? games.get(clients.get(clientIndex - 1).gameName) - : games.get("default"); - if (previous != null) - previous.prepareLevelTransition(); - - GameImportsImpl gameImports = createGameInstance(changeMapInfo, previous); - - Cvar.getInstance().find("nextserver", String.class).set("gamemap \"" + changeMapInfo.nextServer + "\""); - - Cmd.ExecuteFunction("loading"); // for local system - - if (clientIndex == 0) { - // leave slots at start for clients only - for (ClientNetworkInfo cl : clients) { //todo - // needs to reconnect - if (cl.state == ClientStates.CS_SPAWNED) - cl.state = ClientStates.CS_CONNECTED; - cl.lastReceivedFrame = -1; - } - gameImports.SV_BroadcastCommand("changing"); - - } else { - final ClientNetworkInfo cl = clients.get(clientIndex - 1); - if (cl.state == ClientStates.CS_SPAWNED) - cl.state = ClientStates.CS_CONNECTED; - cl.lastReceivedFrame = -1; - gameImports.unicastMessage(clientIndex, new StuffTextMessage("changing"), true); - } - - SV_SendClientMessages(); - - - // precache and static commands can be issued during - // map initialization - Globals.server_state = gameImports.state; // LOADING - - gameImports.initializeLevel(changeMapInfo, previous); - Globals.server_state = gameImports.state; - - - // set serverinfo variable - Cvar.getInstance().find("mapname", String.class).forceSet(gameImports.mapName); - Com.Println("mapname: " + gameImports.mapName); - - if (changeMapInfo.state == ServerStates.SS_GAME) - Cbuf.CopyToDefer(); - - if (clientIndex == 0) { - gameImports.SV_BroadcastCommand("reconnect"); // doesn't really reconnect but rather issues 'NEW' command - } else { - gameImports.unicastMessage(clientIndex, new StuffTextMessage("reconnect"), true); - } - return gameImports; - } - - /* -================== -SV_Map_f - -Goes directly to a given map without any savegame archiving. -For development work -================== - -Resets server to host a given map, moves all clients to it (via reconnect) -*/ - void SV_Map_f(List args) { - //char expanded[MAX_QPATH]; - if (args.size() < 2 || args.get(1).isBlank()) { - Com.Printf("usage: map \n"); - return; - } - - // if not a pcx, demo, or cinematic, check to make sure the level exists - String mapName = args.get(1); - ChangeMapInfo target = new ChangeMapInfo(mapName, false, false); - if (target.state == ServerStates.SS_GAME) { - String mapPath = "maps/" + target.mapName + ".bsp"; - if (!EngineVfs.exists(mapPath)) { - Com.Printf("Can't find " + mapPath + "\n"); - return; - } - } - var game = games.get("default"); - - if (game != null) - game.state = ServerStates.SS_DEAD; // don't save current level when changing - - new ServerSaveFiles(EngineWriteRoot.path()).wipe("current"); - - // A running server needs its clients reset for map. For a fresh server, - // gamemap performs this initialization itself. - if (!games.isEmpty()) { - initializeServerSession(); - } - SV_GameMap_f(args); - } - - private void initializeServerSession() { - if (games.isEmpty()) { - realtime = 0; - frameNum = 0; - fixedtime = 100; - } - boolean multiplayer = initializeServerCvars(); - if (!NET.configureServer(multiplayer)) { - Com.Error(ERR_DROP, "Could not open the server UDP port"); - return; - } - resetClients(); - } - - /** Send this client's Game snapshot followed by its queued events, recording rate usage. */ - private void sendClientDatagram(ClientNetworkInfo client, GameImportsImpl gameImports) { - // send over all the relevant entity_state_t - // and the player_state_t - Collection unreliable = new ArrayList<>(); - unreliable.addAll(gameImports.buildClientFrameMessages(client)); // 3 messages - - // copy the accumulated multicast datagram - // for this client out to the message - // it is necessary for this to be after the buildClientFrameMessages (SV_BuildClientFrame) - // so that entity references will be current - unreliable.addAll(client.unreliable); - client.unreliable.clear(); - // send the datagram - client.netchan.transmit(unreliable); - - // record the size for rate estimation - client.message_size[gameImports.frameNum % Defines.RATE_MESSAGES] = unreliable.stream().mapToInt(NetworkMessage::getSize).sum(); - } - private final Map userCommands; - - { - Map handlers = new HashMap<>(); - // auto issued - handlers.put(StringCmdMessage.NEW, this::SV_New_f); - handlers.put(StringCmdMessage.CONFIG_STRINGS, this::SV_Configstrings_f); - handlers.put(StringCmdMessage.BASELINES, this::SV_Baselines_f); - handlers.put(StringCmdMessage.BEGIN, this::SV_Begin_f); - handlers.put(StringCmdMessage.NEXT_SERVER, this::SV_Nextserver_f); - handlers.put(StringCmdMessage.DISCONNECT, this::SV_Disconnect_f); - - // issued by hand at client consoles - handlers.put(StringCmdMessage.INFO, this::SV_ShowServerinfo_f); - handlers.put(StringCmdMessage.DOWNLOAD, this::SV_BeginDownload_f); - handlers.put(StringCmdMessage.NEXT_DOWNLOAD, this::SV_NextDownload_f); - - userCommands = Map.copyOf(handlers); - } - - @FunctionalInterface - private interface ServerUserCommand { - void execute(List args, GameImportsImpl game, ClientNetworkInfo client); - } - - /* - * ============================================================ - * - * USER STRINGCMD EXECUTION - * - * sv_client and sv_player will be valid. - * ============================================================ - */ - - /* - * ================ SV_New_f - * - * Sends the first message from the server to a connected client. This will - * be sent on the initial connection and upon each server load. - * ================ - */ - private void SV_New_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { - - Com.DPrintf("New() from " + client.name + "\n"); - - if (client.state != ClientStates.CS_CONNECTED) { - Com.Printf("New not valid -- already spawned\n"); - return; - } - - // - // serverdata needs to go over for all types of servers - // to make sure the protocol is right, and to set the gamedir - // - String gamedir = Cvar.getInstance().find("gamedir", String.class).getTypedValue(); - - // send the serverdata - // send full levelname - - final int clientIndex; - if (gameImports.state == ServerStates.SS_CINEMATIC || gameImports.state == ServerStates.SS_PIC) - clientIndex = -1; - else - clientIndex = client.edict.index - 1; - - - client.netchan.reliablePending.add( - new ServerDataMessage( - Defines.PROTOCOL_VERSION, - gameImports.spawncount, - gameImports.isDemo, - gamedir, - clientIndex, - gameImports.configstrings[Defines.CS_NAME] - )); - // - // game server - // - if (gameImports.state == ServerStates.SS_GAME) { - // set up the entity for the client - ServerEntity ent = gameImports.gameExports.getEdict(clientIndex + 1); - ent.s.index = clientIndex + 1; - client.edict = ent; - client.lastcmd = new usercmd_t(); - - // begin fetching configstrings - client.netchan.reliablePending.add(new StuffTextMessage(String.format("cmd %s %d 0", StringCmdMessage.CONFIG_STRINGS, gameImports.spawncount))); - } - } - - /* - * ================== SV_Configstrings_f ================== - */ - private void SV_Configstrings_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { - - Com.DPrintf("Configstrings() from " + client.name + "\n"); - - if (client.state != ClientStates.CS_CONNECTED) { - Com.Printf("configstrings not valid -- already spawned\n"); - return; - } - - // handle the case of a level changing while a client was connecting - int spawnCount = args.size() >= 2 ? Lib.atoi(args.get(1)) : 0; - if (spawnCount != gameImports.spawncount) { - Com.Printf("SV_Configstrings_f from different level\n"); - SV_New_f(args, gameImports, client); - return; - } - - int start = args.size() >= 3 ? Lib.atoi(args.get(2)) : 0; - - // write a packet full of data - int currentReliableSize = client.netchan.reliablePending.stream().mapToInt(NetworkMessage::getSize).sum(); - while (currentReliableSize < Defines.MAX_MSGLEN / 2 && start < Defines.MAX_CONFIGSTRINGS) { - if (gameImports.configstrings[start] != null && gameImports.configstrings[start].length() != 0) { - final ConfigStringMessage config = new ConfigStringMessage(start, gameImports.configstrings[start]); - currentReliableSize += config.getSize(); - client.netchan.reliablePending.add(config); - } - start++; - } - - // send next command - final String nextCmd; - - if (start == Defines.MAX_CONFIGSTRINGS) { - nextCmd = String.format("cmd %s %d 0", StringCmdMessage.BASELINES, gameImports.spawncount); - } else { - nextCmd = String.format("cmd %s %d %d", StringCmdMessage.CONFIG_STRINGS, gameImports.spawncount, start); - } - client.netchan.reliablePending.add(new StuffTextMessage(nextCmd)); - } - - /* - * ================== SV_Baselines_f ================== - */ - private void SV_Baselines_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { - - Com.DPrintf("Baselines() from " + client.name + "\n"); - - if (client.state != ClientStates.CS_CONNECTED) { - Com.Printf("baselines not valid -- already spawned\n"); - return; - } - - // handle the case of a level changing while a client was connecting - int spawnCount = args.size() >= 2 ? Lib.atoi(args.get(1)) : 0; - if (spawnCount != gameImports.spawncount) { - Com.Printf("SV_Baselines_f from different level\n"); - SV_New_f(args, gameImports, client); - return; - } - - // todo: validate argument - int start = args.size() >= 3 ? Lib.atoi(args.get(2)) : 0; - - // write a packet full of data - int currentReliableSize = client.netchan.reliablePending.stream().mapToInt(NetworkMessage::getSize).sum(); - while (currentReliableSize < Defines.MAX_MSGLEN / 2 && start < Defines.MAX_EDICTS) { - entity_state_t base = gameImports.baselines[start]; - if (base.modelindex != 0 || base.sound != 0 || base.effects != 0) { - final SpawnBaselineMessage spawn = new SpawnBaselineMessage(base); - currentReliableSize += spawn.getSize(); - client.netchan.reliablePending.add(spawn); - } - start++; - } - - // send next command - final String nextCmd; - if (start == Defines.MAX_EDICTS) { - // finished sending baselines - nextCmd = String.format("%s %d", StringCmdMessage.PRECACHE,gameImports.spawncount); - } else { - // continue from where we finished - nextCmd = String.format("cmd %s %d %d", StringCmdMessage.BASELINES, gameImports.spawncount, start); - } - client.netchan.reliablePending.add(new StuffTextMessage(nextCmd)); - } - - /* - * ================== SV_Begin_f ================== - */ - private void SV_Begin_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { - Com.DPrintf("Begin() from " + client.name + "\n"); - - // handle the case of a level changing while a client was connecting - int spawnCount = args.size() >= 2 ? Lib.atoi(args.get(1)) : 0; - if (spawnCount != gameImports.spawncount) { - Com.Printf("SV_Begin_f from different level\n"); - SV_New_f(args, gameImports, client); - return; - } - - client.state = ClientStates.CS_SPAWNED; - - // call the jake2.game begin function - gameImports.gameExports.ClientBegin(client.edict); - - Cbuf.InsertFromDefer(); - } - - //============================================================================= - - /* - * ================== SV_NextDownload_f ================== - */ - private void SV_NextDownload_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { - - if (client.download == null) - return; - - int packet = client.downloadsize - client.downloadcount; - if (packet > 1024) - packet = 1024; - - client.downloadcount += packet; - int size = client.downloadsize; - if (size == 0) - size = 1; - byte percent = (byte) (client.downloadcount * 100 / size); - - byte[] data = new byte[packet]; - System.arraycopy(client.download, client.downloadcount - packet, data, 0, packet); - client.netchan.reliablePending.add(new DownloadMessage(data, percent)); - - if (client.downloadcount == client.downloadsize) { - client.download = null; - } - } - - /* - * ================== SV_BeginDownload_f ================== - */ - private void SV_BeginDownload_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { - int offset = 0; - - if (args.size() < 2) - return; - - String name = args.get(1); - - if (args.size() > 2) - offset = Lib.atoi(args.get(2)); // downloaded offset - - // hacked by zoid to allow more conrol over download - // first off, no .. or global allow check - - if (name.contains("..") - || !allow_download.getTypedValue() // leading dot is no good - || name.charAt(0) == '.' // leading slash bad as well, must be - // in subdir - || name.charAt(0) == '/' // next up, skin check - || (name.startsWith("players/") && !allow_download_players.getTypedValue()) // now - // models - || (name.startsWith("models/") && !allow_download_models.getTypedValue()) // now - // sounds - || (name.startsWith("sound/") && !allow_download_sounds.getTypedValue()) - // now maps (note special case for maps, must not be in pak) - || (name.startsWith("maps/") && !allow_download_maps.getTypedValue()) // MUST - // be - // in a - // subdirectory - || name.indexOf('/') == -1) { // don't allow anything with .. - // path - - // refuse - client.netchan.reliablePending.add(new DownloadMessage()); - return; - } - - client.download = EngineVfs.loadBytes(name); - - // The client waits for either data or an explicit refusal. Silently - // returning here leaves precache stuck waiting for a download forever. - if (client.download == null) { - client.netchan.reliablePending.add(new DownloadMessage()); - return; - } - - client.downloadsize = client.download.length; - client.downloadcount = offset; - - if (offset > client.downloadsize) - client.downloadcount = client.downloadsize; - - // special check for maps, if it came from a pak file, don't allow download ZOID - if (name.startsWith("maps/") && EngineVfs.isFromPack(name)) { - Com.DPrintf("Couldn't download " + name + " to " + client.name + "\n"); - if (client.download != null) { - client.download = null; - } - - // refuse - client.netchan.reliablePending.add(new DownloadMessage()); - return; - } - - SV_NextDownload_f(args, gameImports, client); - Com.DPrintf("Downloading " + name + " to " + client.name + "\n"); - } - - //============================================================================ - - /* - * ================= SV_Disconnect_f - * - * The client is going to disconnect, so remove the connection immediately - * ================= - */ - private void SV_Disconnect_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { - // SV_EndRedirect (); - SV_DropClient(client); - } - - /* - * ================== SV_ShowServerinfo_f - * - * Dumps the serverinfo info string ================== - */ - private void SV_ShowServerinfo_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { - Info.Print(Cvar.getInstance().Serverinfo()); - } - - private void SV_Nextserver(GameImportsImpl gameImports) { - - //ZOID, ss_pic can be nextserver'd in coop mode - if (gameImports.state == ServerStates.SS_GAME - || (gameImports.state == ServerStates.SS_PIC && !Cvar.getInstance().find("coop", Boolean.class).getTypedValue())) - return; // can't nextserver while playing a normal game - - gameImports.spawncount++; // make sure another doesn't sneak in - String v = Cvar.getInstance().find("nextserver", String.class).getTypedValue(); - //if (!v[0]) - if (v.length() == 0) - Cbuf.AddText("killserver\n"); - else { - Cbuf.AddText(v); - Cbuf.AddText("\n"); - } - Cvar.getInstance().find("nextserver", String.class).set(""); - } - - /* - * ================== SV_Nextserver_f - * - * A cinematic has completed or been aborted by a client, so move to the - * next server, ================== - */ - private void SV_Nextserver_f(List args, GameImportsImpl gameImports, ClientNetworkInfo client) { - int spawnCount = args.size() >= 2 ? Lib.atoi(args.get(1)) : 0; - if (spawnCount != gameImports.spawncount) { - Com.DPrintf("Nextserver() from wrong level, from " + client.name + "\n"); - return; // leftover from last server - } - - Com.DPrintf("Nextserver() from " + client.name + "\n"); - SV_Nextserver(gameImports); - } - - /** Resolve a Game at execution time; creating another Game never replaces these callbacks. */ - private void saveGame(List args) { - String target = "default"; - List command = args; - if (args.size() > 1 && args.get(1).equals("--game")) { - if (args.size() < 4) { - Com.Printf("Usage: " + args.get(0) + " --game \n"); - return; - } - target = args.get(2); - command = new ArrayList<>(); - command.add(args.get(0)); - command.addAll(args.subList(3, args.size())); - } - GameImportsImpl game = games.get(target); - if (game == null) { - Com.Printf("No running game: " + target + "\n"); - return; - } - game.saveGame(command); - } - - /* - ================== - SV_Kick_f - - Kick a user off of the server - ================== - */ - private void SV_Kick_f(List args) { - if (args.size() != 2) { - Com.Printf("Usage: kick \n"); - return; - } - ClientNetworkInfo client = SV_SetPlayer(args); - if (client == null) - return; - - SV_BroadcastPrintf(Defines.PRINT_HIGH, client.name + " was kicked\n", client.gameName); - // print directly, because the dropped client won't get the - // SV_BroadcastPrintf message - client.queuePrint(Defines.PRINT_HIGH, "You were kicked from the game\n"); - SV_DropClient(client); - client.lastmessage = games.get(client.gameName).realtime; // min case there is a funny zombie - } - /* - ================ - SV_Status_f - ================ - */ - private void SV_Status_f(List args) { - // todo: check - if (games.isEmpty()) { - Com.Printf("No server running.\n"); - return; - } - for (GameImportsImpl game : games.values()) - Com.Printf("game " + game.name + " : " + game.mapName + "\n"); - - Com.Printf("num score ping name lastmsg address qport \n"); - Com.Printf("--- ----- ---- --------------- ------- --------------------- ------\n"); - - for (int i = 0; i < clients.size(); i++) { - ClientNetworkInfo cl = clients.get(i); - if (ClientStates.CS_FREE == cl.state) - continue; - - Com.Printf("%3i ", new Vargs().add(i)); - Com.Printf("%5i ", new Vargs().add(cl.edict.getClient().getPlayerState().stats[Defines.STAT_FRAGS])); - - if (cl.state == ClientStates.CS_CONNECTED) - Com.Printf("CNCT "); - else if (cl.state == ClientStates.CS_ZOMBIE) - Com.Printf("ZMBI "); - else { - int ping = cl.ping < 9999 ? cl.ping : 9999; - Com.Printf("%4i ", new Vargs().add(ping)); - } - - Com.Printf("%s", new Vargs().add(cl.name)); - int l = 16 - cl.name.length(); - int j; - for (j = 0; j < l; j++) - Com.Printf(" "); - - Com.Printf("%7i ", new Vargs().add(games.get(cl.gameName).realtime - cl.lastmessage)); - - String s = cl.netchan.remote_address.toString(); - Com.Printf(s); - l = 22 - s.length(); - for (j = 0; j < l; j++) - Com.Printf(" "); - - Com.Printf("%5i", new Vargs().add(cl.netchan.qport)); - - Com.Printf("\n"); - } - Com.Printf("\n"); - } - /* - ================== - SV_ConSay_f - ================== - */ - private void SV_ConSay_f(List args) { - - if (args.size() < 2) - return; - - // char[1024]; - String text = "console: "; - String p = Cmd.getArguments(args); - - if (!p.isEmpty() && p.charAt(0) == '"') { - p = p.substring(1, p.length() - 1); - } - - text += p; - - for (ClientNetworkInfo client: clients) { - if (client.state != ClientStates.CS_SPAWNED) - continue; - client.queuePrint(Defines.PRINT_CHAT, text + "\n"); - } - } - /* - ================== - SV_Heartbeat_f - ================== - */ - private void SV_Heartbeat_f(List agrs) { - } - /* - =========== - SV_Serverinfo_f - - Examine or change the serverinfo string - =========== - */ - private void SV_Serverinfo_f(List args) { - Com.Printf("Server info settings:\n"); - Info.Print(Cvar.getInstance().Serverinfo()); - } - /* - =========== - SV_DumpUser_f - - Examine all a users info strings - =========== - */ - private void SV_DumpUser_f(List args) { - if (args.size() != 2) { - Com.Printf("Usage: info \n"); - return; - } - ClientNetworkInfo client = SV_SetPlayer(args); - if (client == null) - return; - - Com.Printf("userinfo\n"); - Com.Printf("--------\n"); - Info.Print(client.userinfo); - - } - - /** - * Sets sv_client and sv_player to the player with idnum Cmd.Argv(1) - */ - private ClientNetworkInfo SV_SetPlayer(List args) { - - if (args.size() < 2) - return null; - - String idOrName = args.get(1); - - // numeric values are just slot numbers - // fixme: player name cannot start with a number? - if (!idOrName.isEmpty() && idOrName.charAt(0) >= '0' && idOrName.charAt(0) <= '9') { - int id = Lib.atoi(idOrName); - if (id < 0 || id >= clients.size()) { - Com.Printf("Bad client slot: " + id + "\n"); - return null; - } - - final ClientNetworkInfo result = clients.get(id); - if (ClientStates.CS_FREE == result.state) { - Com.Printf("Client " + id + " is not active\n"); - return null; - } - return result; - } - - // check for a name match - for (ClientNetworkInfo cl: clients) { - if (ClientStates.CS_FREE == cl.state) - continue; - if (idOrName.equals(cl.name)) { - return cl; - } - } - - Com.Printf("Userid " + idOrName + " is not on the server\n"); - return null; - } - -} diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt new file mode 100644 index 00000000..0a5aaf82 --- /dev/null +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -0,0 +1,2071 @@ +/* + * Copyright (C) 1997-2001 Id Software, Inc. + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 2 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. + * + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program; if not, write to the Free Software Foundation, Inc., 59 Temple + * Place - Suite 330, Boston, MA 02111-1307, USA. + * + */ +// Created on 13.01.2004 by RST. +package jake2.server + +import jake2.qcommon.* +import jake2.qcommon.Com.RD_Flusher +import jake2.qcommon.exec.* +import jake2.qcommon.exec.CvarTypes.ChoiceType +import jake2.qcommon.network.NET +import jake2.qcommon.network.NetAddrType +import jake2.qcommon.network.Netchan +import jake2.qcommon.network.messages.ConnectionlessCommand +import jake2.qcommon.network.messages.NetworkMessage +import jake2.qcommon.network.messages.client.* +import jake2.qcommon.network.messages.server.* +import jake2.qcommon.network.netadr_t +import jake2.qcommon.util.Lib +import jake2.qcommon.util.Vargs +import jake2.qcommon.vfs.EngineVfs +import jake2.qcommon.vfs.EngineWriteRoot +import jake2.server.save.ServerSaveFiles +import jake2.server.save.ServerSaveJsonStore + +/** Owns client connections, Game routing, protocol and commands for one server Host. */ +@Suppress("deprecation") +class ServerHost : AutoCloseable { + private val commands = ServerCommands() + val isRunning: Boolean + get() = !games.isEmpty() + + /** Typed entrypoint for an embedded host; uses the same map path as the console. */ + fun startMap(mapName: String): Boolean { + val previous = games["default"] + SV_Map_f(java.util.List.of("map", mapName)) + return games.get("default") != null && games.get("default") !== previous + } + + /** Release this manager's console hooks after ending its last session. */ + override fun close() { + try { + stop("Server stopped\n", false) + } finally { + commands.close() + } + } + + @JvmField + val games: MutableMap = HashMap() + + fun getClientsForInstance(gameName: String?): MutableList { + if (!games.containsKey(gameName)) { + // happens during the game instance creation, while the instance has random name + Com.Printf("Game " + gameName + " is not found!\n") + } + // nullify unrelated clients + return clients.map { client -> + if (client.gameName != null && client.gameName == gameName) client else null + }.toMutableList() + } + + + // prevent invalid IPs from connecting + private val challenges = Array(Defines.MAX_CHALLENGES) { challenge_t() } + + var clients: MutableList = ArrayList() + private set + + private var realtime: Long = 0 + private var frameNum = 0 + private var fixedtime = 1000 + private var gameCounter = 0 + + fun allocateGameName(): String { + return "jake2_" + gameCounter++ + } + + val isPaused: Boolean + get() = sv_paused.getTypedValue() + + private val sv_paused: cvar_t + + private val sv_timedemo: cvar_t + + private val sv_enforcetime: cvar_t + + @JvmField + val timeout: cvar_t // seconds without any message + + private val zombietime: cvar_t // seconds to sink messages after + + // disconnect + private val allow_download: cvar_t + + private val allow_download_players: cvar_t + + private val allow_download_models: cvar_t + + private val allow_download_sounds: cvar_t + + private val allow_download_maps: cvar_t + + private val sv_airaccelerate: cvar_t + + private val sv_showclamp: cvar_t + + private val hostname: cvar_t + + private val public_server: cvar_t // should heartbeats be sent + + private val sv_reconnect_limit: cvar_t // minimum seconds between connect + + + // messages + /** + * Builds the string that is sent as heartbeats and status replies. + */ + private fun SV_StatusString(): String { + var status = Cvar.getInstance().Serverinfo() + "\n" + + for (cl in clients) { + if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) { + val player = "" + cl.edict.getClient() + .getPlayerState().stats[Defines.STAT_FRAGS] + " " + cl.ping + "\"" + cl.name + "\"\n" + + val playerLength = player.length + val statusLength = status.length + + if (statusLength + playerLength >= 1024) break // can't hold any more + + + status += player + } + } + + return status + } + + /** + * SVC_Info, responds with short info for broadcast scans The second parameter should + * be the current protocol version number. + */ + private fun SVC_Info(args: MutableList, from: netadr_t?): String? { + if (clients.size == 1) return null // ignore in single player + + + val version = if (args.size < 2) 0 else Lib.atoi(args.get(1)) + + val info: String + if (version != Defines.PROTOCOL_VERSION) info = hostname.getTypedValue() + ": wrong version\n" + else { + var players = 0 + for (cl in clients) if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) players++ + + info = + hostname.getTypedValue() + " " + games.get("default")!!.mapName + " " + players + "/" + clients.size + "\n" + } + + return info + } + + /** + * Returns a challenge number that can be used in a subsequent + * client_connect command. We do this to prevent denial of service attacks + * that flood the server with invalid connection IPs. With a challenge, they + * must give a valid IP address. + */ + private fun SVC_GetChallenge(from: netadr_t): Int { + var oldest = 0 + var oldestTime = 0x7fffffff + + var i: Int + i = 0 + while (i < Defines.MAX_CHALLENGES) { + if (from.CompareBaseAdr(challenges[i].adr)) break + if (challenges[i].time < oldestTime) { + oldestTime = challenges[i].time + oldest = i + } + i++ + } + + if (i == Defines.MAX_CHALLENGES) { + // overwrite the oldest + challenges[oldest].challenge = Lib.rand().toInt() and 0x7fff + challenges[oldest].adr = from + challenges[oldest].time = Globals.curtime + i = oldest + } + + // send it back + return challenges[i].challenge + } + + /** + * A connection request that did not come from the master. + */ + private fun SVC_DirectConnect(args: MutableList, adr: netadr_t) { + Com.DPrintf("SVC_DirectConnect ()\n") + + val version = if (args.size >= 2) Lib.atoi(args.get(1)) else 0 + + if (version != Defines.PROTOCOL_VERSION) { + Netchan.sendConnectionlessPacket( + Defines.NS_SERVER, adr, ConnectionlessCommand.print, + "\nServer is version " + Globals.VERSION + "\n" + ) + Com.DPrintf(" rejected connect from version " + version + "\n") + return + } + + val qport = if (args.size >= 3) Lib.atoi(args.get(2)) else 0 + val challenge = if (args.size >= 4) Lib.atoi(args.get(3)) else 0 + var userinfo = if (args.size >= 5) args.get(4) else "" + + // force the IP key/value pair so the game can filter based on ip + userinfo = Info.Info_SetValueForKey(userinfo, "ip", adr.toString()) + + + /* + if (gameImports.isDemo) { + if (!adr.IsLocalAddress()) { + Com.Printf("Remote connect in attract loop. Ignored.\n"); + Netchan.sendConnectionlessPacket(Defines.NS_SERVER, adr, ConnectionlessCommand.print, "\nConnection refused.\n"); + return; + } + } +*/ + + + // see if the challenge is valid + var j: Int + if (!adr.IsLocalAddress()) { + j = 0 + while (j < Defines.MAX_CHALLENGES) { + if (adr.CompareBaseAdr(challenges[j].adr)) { + if (challenge == challenges[j].challenge) break // good + + Netchan.sendConnectionlessPacket( + Defines.NS_SERVER, + adr, + ConnectionlessCommand.print, + "\nBad challenge.\n" + ) + return + } + j++ + } + if (j == Defines.MAX_CHALLENGES) { + Netchan.sendConnectionlessPacket( + Defines.NS_SERVER, + adr, + ConnectionlessCommand.print, + "\nNo challenge for address.\n" + ) + return + } + } + + // if there is already a slot for this ip, reuse it + var i = 0 + for (cl in clients) { + if (cl.state == ClientStates.CS_FREE) continue + if (adr.CompareBaseAdr(cl.netchan.remote_address) + && (cl.netchan.qport == qport || adr.port == cl.netchan.remote_address.port) + ) { + if (!adr.IsLocalAddress() && (realtime - cl.lastconnect) < (sv_reconnect_limit.getTypedValue() * 1000)) { + Com.DPrintf(adr.toString() + ":reconnect rejected : too soon\n") + return + } + Com.Printf(adr.toString() + ":reconnect\n") + + gotnewcl(i, challenge, userinfo, adr, qport) + return + } + i++ + } + + // find a client slot + var index = -1 + i = 0 + for (cl in clients) { + if (cl.state == ClientStates.CS_FREE) { + index = i + break + } + i++ + } + if (index == -1) { + Netchan.sendConnectionlessPacket(Defines.NS_SERVER, adr, ConnectionlessCommand.print, "\nServer is full.\n") + Com.DPrintf("Rejected a connection.\n") + return + } + gotnewcl(index, challenge, userinfo, adr, qport) + } + + /** + * Initializes player structures after successfull connection. + */ + private fun gotnewcl(i: Int, challenge: Int, userinfo: String?, adr: netadr_t, qport: Int) { + // build a new connection + // accept the new client + // this is the only place a ClientNetworkInfo is ever initialized + + val client = clients.get(i) + val gameImports: GameImportsImpl = games.get("default")!! + client.gameName = "default" + + val ent = gameImports.gameExports.getEdict(i + 1) + client.edict = ent // fixme: why? isn't it already set? + + // save challenge for checksumming + client.challenge = challenge + + // get the game a chance to reject this connection or modify the + // userinfo + if (!(gameImports.gameExports.ClientConnect(ent, userinfo))) { + if (Info.Info_ValueForKey(userinfo, "rejmsg") != null) { + Netchan.sendConnectionlessPacket( + Defines.NS_SERVER, + adr, + ConnectionlessCommand.print, + "\n" + Info.Info_ValueForKey(userinfo, "rejmsg") + "\nConnection refused.\n" + ) + } else { + Netchan.sendConnectionlessPacket( + Defines.NS_SERVER, + adr, + ConnectionlessCommand.print, + "\nConnection refused.\n" + ) + } + Com.DPrintf("Game rejected a connection.\n") + return + } + + // parse some info from the info strings + client.userinfo = userinfo + SV_UserinfoChanged(client) + + Netchan.sendConnectionlessPacket(Defines.NS_SERVER, adr, ConnectionlessCommand.client_connect, "") + + client.netchan.setup(Defines.NS_SERVER, adr, qport) + + client.state = ClientStates.CS_CONNECTED + + client.lastmessage = gameImports.realtime // don't timeout + client.lastconnect = gameImports.realtime + Com.DPrintf("new client added.\n") + } + + /** + * A connectionless packet has four leading 0xff characters to distinguish + * it from a game channel. Clients that are in the game can still send + * connectionless packets. It is used also by rcon commands. + */ + fun SV_ConnectionlessPacket(from: netadr_t, messageLine: String?) { + val args = Cmd.TokenizeString(messageLine, false) + + if (args.isEmpty()) { + Com.Printf("empty connectionless packet: " + messageLine) + return + } + + val c = args.get(0) + val cmd = ConnectionlessCommand.fromString(c) + + when (cmd) { + ConnectionlessCommand.ping -> // SVC_Ping, Just responds with an acknowledgement. + Netchan.sendConnectionlessPacket(Defines.NS_SERVER, from, ConnectionlessCommand.ack, "") + + ConnectionlessCommand.ack -> //SVC_Ack + Com.Printf("Ping acknowledge from " + from + "\n") + + ConnectionlessCommand.status -> // SVC_Status, Responds with all the info that qplug or qspy can see + Netchan.sendConnectionlessPacket( + Defines.NS_SERVER, + from, + ConnectionlessCommand.print, + "\n" + SV_StatusString() + ) + + ConnectionlessCommand.info -> { + val info = SVC_Info(args, from) + if (info != null) Netchan.sendConnectionlessPacket( + Defines.NS_SERVER, + from, + ConnectionlessCommand.info, + "\n" + info + ) + } + + ConnectionlessCommand.getchallenge -> { + val challenge = SVC_GetChallenge(from) + Netchan.sendConnectionlessPacket( + Defines.NS_SERVER, + from, + ConnectionlessCommand.challenge, + " " + challenge + ) + } + + ConnectionlessCommand.connect -> SVC_DirectConnect(args, from) + ConnectionlessCommand.rcon -> SVC_RemoteCommand(args, from) + else -> Com.Printf("Unknown ClientConnectionlessCommand: " + c + '\n') + } + } + + /** + * SV_Frame. + */ + fun update(msec: Long) { + // if server is not active, do nothing + // like when connected to another server + if (games.isEmpty()) return + + realtime += msec + for (game in games.values) { + game.realtime += msec.toInt() + } + + // keep the random time dependent + Lib.rand() + + // check timeouts + SV_CheckTimeouts() + + // get packets from clients + SV_ReadPackets() + + // identify if we need to update the game or it's too early (sleep or skip this update). + // fixedtime - the time of the next scheduled game update. + var delay = fixedtime - realtime + if (delay > 0) { + // never let the time get too far off + if (delay > 100) { // how is it even possible? + for (game in games.values) game.realtime = (fixedtime - 100) + delay = 100 + } + NET.Sleep(delay.toInt()) + return + } + + // move autonomous things around if enough time has passed + frameNum++ + fixedtime = 100 * frameNum + + // update ping based on the last known frame from all clients + SV_CalcPings() + + // give the clients some timeslices + SV_GiveMsec() + + // let everything in the world think and move + for (game in games.values) { + game.SV_RunGameFrame(fixedtime.toLong()) + } + + // send messages back to the clients that had packets read this frame + SV_SendClientMessages() + + + // send a heartbeat to the master if needed + //Master_Heartbeat(); + + // clear teleport flags, etc for next frame + for (game in games.values) { + game.clearEntityStateEvents() + } + } + + fun SV_SendClientMessages() { + // send a message to each connected client + + for (c in clients) { + if (c.state == ClientStates.CS_FREE) continue + + val game: GameImportsImpl = games.get(c.gameName)!! + // if the reliable message overflowed, + // drop the client + val pendingReliableSize = + c.netchan.reliablePending.stream().mapToInt { obj: NetworkMessage? -> obj!!.getSize() }.sum() + if (pendingReliableSize > Defines.MAX_MSGLEN - 16) { + c.netchan.reliablePending.clear() + c.unreliable.clear() + SV_BroadcastPrintf(Defines.PRINT_HIGH, c.name + " overflowed\n", c.gameName) + SV_DropClient(c) + } + + if (game.state == ServerStates.SS_CINEMATIC || game.state == ServerStates.SS_DEMO || game.state == ServerStates.SS_PIC) { + // leftover from demo code + c.netchan.transmit(null) + } else if (c.state == ClientStates.CS_SPAWNED) { + // don't overrun bandwidth + if (SV_RateDrop(c)) continue + + sendClientDatagram(c, game) + } else { + // just update reliable if needed + if (c.netchan.reliablePending.size != 0 || Globals.curtime - c.netchan.last_sent > 1000) c.netchan.transmit( + null + ) + } + } + } + + /** + * Called when the player is totally leaving the server, either willingly or + * unwillingly. This is NOT called if the entire server is quiting or + * crashing. + */ + fun SV_DropClient(client: ClientNetworkInfo) { + // add the disconnect + client.netchan.reliablePending.add(DisconnectMessage()) + + if (client.state == ClientStates.CS_SPAWNED) { + // call the prog function for removing a client + // this will remove the body, among other things + games.get(client.gameName)!!.gameExports.ClientDisconnect(client.edict) + } + + if (client.download != null) { + client.download = null + } + + client.state = ClientStates.CS_ZOMBIE // become free in a few seconds + client.name = "" + } + + /** + * Sends text to all active clients + */ + fun SV_BroadcastPrintf(level: Int, message: String?, gameName: String?) { + // echo to console + + if (Globals.dedicated.getTypedValue()) { + Com.Printf(message) + } + + for (cl in getClientsForInstance(gameName)) { + if (cl == null || level < cl.messagelevel) continue + if (cl.state != ClientStates.CS_SPAWNED) continue + cl.netchan.reliablePending.add(PrintMessage(level, message)) + } + } + + /** + * SV_RateDrop + * + * + * Returns true if the client is over its current + * bandwidth estimation and should not be sent another packet + */ + fun SV_RateDrop(c: ClientNetworkInfo): Boolean { + // never drop over the loopback + + if (c.netchan.remote_address.type == NetAddrType.NA_LOOPBACK) return false + + var total = 0 + + for (i in 0.. c.rate) { + c.surpressCount++ + c.message_size[frameNum % Defines.RATE_MESSAGES] = 0 + return true + } + + return false + } + + /** + * Reads packets from the network or loopback. + */ + fun SV_ReadPackets() { + while (true) { + val networkPacket = NET.receiveNetworkPacket( + NET.ip_sockets[Defines.NS_SERVER], + NET.ip_channels[Defines.NS_SERVER], + NET.loopbacks[Defines.NS_SERVER], + true + ) + + if (networkPacket == null) break + + if (networkPacket.isConnectionless()) { + SV_ConnectionlessPacket(networkPacket.from, networkPacket.connectionlessMessage) + continue + } + + // Match incoming packet with the existing client. + // todo: get client by address (hashmap?) + for (cl in clients) { + if (cl.state == ClientStates.CS_FREE) continue + if (!networkPacket.from.CompareBaseAdr(cl.netchan.remote_address)) continue + if (cl.netchan.qport != networkPacket.qport) continue + if (cl.netchan.remote_address.port != networkPacket.from.port) { + Com.Printf("SV_ReadPackets: fixing up a translated port\n") + cl.netchan.remote_address.port = networkPacket.from.port + } + + if (cl.netchan.accept(networkPacket)) { + // this is a valid, sequenced packet, so process it + if (cl.state != ClientStates.CS_ZOMBIE) { + cl.lastmessage = games.get(cl.gameName)!!.realtime // don't timeout + val body = networkPacket.parseBodyFromClient(cl.netchan.incoming_sequence) + if (body == null) SV_DropClient(cl) + else SV_ExecuteClientMessage(cl, body) + } + } + break + } + } + } + + /* + * =================== SV_ExecuteClientMessage + * + * The current net_message is parsed for the given client + * =================== + */ + fun SV_ExecuteClientMessage(cl: ClientNetworkInfo, clientMessages: MutableCollection) { + // only allow one move command + + var move_issued = false + var stringCmdCount = 0 + + + for (msg in clientMessages) { + if (msg is EndOfClientPacketMessage) { + break + } else if (msg is StringCmdMessage) { + val m = msg + // malicious users may try using too many string commands + if (++stringCmdCount < MAX_STRINGCMDS) { + SV_ExecuteUserCommand(cl, m.command) + } + + if (cl.state == ClientStates.CS_ZOMBIE) { + return // disconnect command + } + } else if (msg is UserInfoMessage) { + val m = msg + cl.userinfo = m.userInfo + SV_UserinfoChanged(cl) + } else if (msg is MoveMessage) { + val m = msg + if (move_issued) return // someone is trying to cheat... + + + move_issued = true + val lastReceivedFrame = m.lastReceivedFrame + + if (lastReceivedFrame != cl.lastReceivedFrame) { + cl.lastReceivedFrame = lastReceivedFrame + if (cl.lastReceivedFrame > 0) { + val framaLatency = + games.get(cl.gameName)!!.realtime - cl.frames[cl.lastReceivedFrame and Defines.UPDATE_MASK].senttime + cl.frame_latency[cl.lastReceivedFrame and (Defines.LATENCY_COUNTS - 1)] = framaLatency + } + } + + if (cl.state != ClientStates.CS_SPAWNED) { + cl.lastReceivedFrame = -1 + continue + } + + // if the checksum fails, ignore the rest of the packet + if (!m.valid) { + Com.Printf("Invalid crc\n") + return + } + + + if (!sv_paused.getTypedValue()) { + var net_drop = cl.netchan.dropped + if (net_drop < 20) { + //if (net_drop > 2) + + // Com.Printf ("drop %i\n", net_drop); + + while (net_drop > 2) { + SV_ClientThink(cl, cl.lastcmd) + + net_drop-- + } + if (net_drop > 1) SV_ClientThink(cl, m.oldestCmd) + + if (net_drop > 0) SV_ClientThink(cl, m.oldCmd) + } + SV_ClientThink(cl, m.newCmd) + } + + // copy. + cl.lastcmd.set(m.newCmd) + } else { + Com.Printf("SV_ReadClientMessage: unknown command: " + msg.type + "\n") + SV_DropClient(cl) + } + } + } + + fun SV_ClientThink(cl: ClientNetworkInfo, cmd: usercmd_t) { + cl.commandMsec -= cmd.msec.toInt() and 0xFF + + if (cl.commandMsec < 0 && sv_enforcetime.getTypedValue()) { + Com.DPrintf("commandMsec underflow from " + cl.name + "\n") + return + } + + games.get(cl.gameName)!!.gameExports.ClientThink(cl.edict, cmd) + } + + /** + * Pull specific info from a newly changed userinfo string into a more C + * freindly form. + */ + fun SV_UserinfoChanged(cl: ClientNetworkInfo) { + // call prog code to allow overrides + games.get(cl.gameName)!!.gameExports.ClientUserinfoChanged(cl.edict, cl.userinfo) + + // name for C code + cl.name = Info.Info_ValueForKey(cl.userinfo, "name") + + // mask off high bit + //TODO: masking for german umlaute + //for (i=0 ; i 0) { + cl.rate = Lib.atoi(`val`) + if (cl.rate < 100) cl.rate = 100 + if (cl.rate > 15000) cl.rate = 15000 + } else cl.rate = 5000 + + // msg command + `val` = Info.Info_ValueForKey(cl.userinfo, "msg") + if (`val`.length > 0) { + cl.messagelevel = Lib.atoi(`val`) + } + } + + fun SV_ExecuteUserCommand(cl: ClientNetworkInfo, command: String?) { + Com.dprintln("SV_ExecuteUserCommand:" + command) + + val args = Cmd.TokenizeString(command, true) + if (args.isEmpty()) return + + val game: GameImportsImpl = games.get(cl.gameName)!! + if (userCommands.containsKey(args.get(0))) { + userCommands.get(args.get(0))!!.execute(args, game, cl) + return + } + + if (game.state == ServerStates.SS_GAME) game.gameExports.ClientCommand(cl.edict, args) + } + + /** + * If a packet has not been received from a client for the configured timeout + * in seconds, drop the connection. Server frames are used instead of realtime + * to avoid dropping the local client while debugging. + * + * When a client is normally dropped, the ClientNetworkInfo goes into a zombie state + * for a few seconds to make sure any final reliable message gets resent if + * necessary. + */ + fun SV_CheckTimeouts() { + for (cl in clients) { + if (cl.state == ClientStates.CS_FREE) continue + + val game: GameImportsImpl = games.get(cl.gameName)!! + val droppoint = (game.realtime - 1000 * timeout.getTypedValue()).toInt() + val zombiepoint = (game.realtime - 1000 * zombietime.getTypedValue()).toInt() + + // message times may be wrong across a changelevel + if (cl.lastmessage > game.realtime) cl.lastmessage = game.realtime + + if (cl.state == ClientStates.CS_ZOMBIE && cl.lastmessage < zombiepoint) { + cl.state = ClientStates.CS_FREE // can now be reused + continue + } + if ((cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) && cl.lastmessage < droppoint) { + SV_BroadcastPrintf(Defines.PRINT_HIGH, cl.name + " timed out\n", cl.gameName) + SV_DropClient(cl) + cl.state = ClientStates.CS_FREE // don't bother with zombie state + } + } + } + + /** + * Updates the cl.ping variables. + */ + fun SV_CalcPings() { + for (cl in clients) { + if (cl.state != ClientStates.CS_SPAWNED) continue + + var total = 0 + var count = 0 + for (j in 0.. 0) { + count++ + total += cl.frame_latency[j] + } + } + if (0 == count) cl.ping = 0 + else cl.ping = total / count + + // let the jake2.game dll know about the ping + cl.edict.getClient().setPing(cl.ping) + } + } + + /** + * Every few frames, gives all clients an allotment of milliseconds for + * their command moves. If they exceed it, assume cheating. + */ + fun SV_GiveMsec() { + // why *1111 + if ((frameNum and 15) != 0) return + + for (cl in clients) { + if (cl.state == ClientStates.CS_FREE) continue + + if ((games.get(cl.gameName)!!.frameNum and 15) != 0) continue + + cl.commandMsec = 1800 // 1600 + some slop + } + } + + /** + * Used by stop to send a final message to all connected clients + * before the server goes down. The messages are sent immediately, not just + * stuck on the outgoing message list, because the server is going to + * totally exit after returning from this function. + */ + private fun SV_FinalMessage(message: String?, reconnect: Boolean) { + val msgs: MutableCollection = ArrayList() + msgs.add(PrintMessage(Defines.PRINT_HIGH, message)) + + if (reconnect) msgs.add(ReconnectMessage()) + else msgs.add(DisconnectMessage()) + + // send it twice + // stagger the packets to crutch operating system limited buffers + for (cl in clients) { + if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) cl.netchan.transmit(msgs) + } + for (cl in clients) { + if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) cl.netchan.transmit(msgs) + } + } + + /** + * Called when each game quits, before Sys_Quit or Sys_Error. + * todo: shutdown a particular instance, not the whole server + */ + fun stop(finalmsg: String?, reconnect: Boolean) { + try { + if (!games.isEmpty()) { + if (!clients.isEmpty()) SV_FinalMessage(finalmsg, reconnect) + Com.Printf("==== ShutdownGame ====\n") + } + } finally { + games.clear() + clients.clear() + Globals.server_state = ServerStates.SS_DEAD + realtime = 0 + frameNum = 0 + fixedtime = 100 + } + } + + /** + * Resets the clients when maxclients changes + */ + fun resetClients() { + SV_FinalMessage("Server restarted", true) + val max = Cvar.getInstance().find("maxclients", Int::class.java).getTypedValue() + + // Clear all clients + clients = ArrayList(max) + for (i in 0..("coop", Boolean::class.java) + val deathmatch = cvars.find("deathmatch", Boolean::class.java) + val maxclients = cvars.find("maxclients", Int::class.java) + + // get any latched variable changes (maxclients, etc) + cvars.updateLatchedVars() + + if (coop.getTypedValue() && deathmatch.getTypedValue()) { + Com.Printf("Deathmatch and Coop both set, disabling Coop\n") + coop.forceSet(false) + } + + // dedicated servers are can't be single player and are usually DM + // so unless they explicity set coop, force it to deathmatch + if (Globals.dedicated.getTypedValue()) { + if (!coop.getTypedValue()) deathmatch.forceSet(true) + } + + + // set max clients based on game mode + if (deathmatch.getTypedValue()) { + if (maxclients.getTypedValue() <= 1) maxclients.forceSet(8) + } else if (coop.getTypedValue()) { + if (maxclients.getTypedValue() <= 1 || maxclients.getTypedValue() > 4) maxclients.forceSet(4) + } else { + // non-deathmatch, non-coop is one player + maxclients.forceSet(1) + } + return maxclients.getTypedValue() > 1 + } + + fun SV_Loadgame_f(args: MutableList) { + if (args.size != 2) { + Com.Printf("USAGE: load \n") + return + } + + Com.Printf("Loading game...\n") + + val saveGame: String + try { + saveGame = ServerSaveJsonStore.normalizeSlot(args.get(1)) + } catch (e: IllegalArgumentException) { + Com.Printf("Bad save name.\n") + return + } + if ("current" == saveGame) { + Com.Printf("Can't load from 'current'\n") + return + } + + // make sure the server metadata file exists + val store = ServerSaveJsonStore.forWriteDir(EngineWriteRoot.pathString()) + if (!store.hasMapCommand(saveGame)) { + val name = EngineWriteRoot.resolve("save/" + saveGame + "/server_mapcmd.ssv.json").toString() + Com.Printf("No such savegame: " + name + "\n") + return + } + + ServerSaveFiles(EngineWriteRoot.path()).copy(saveGame, "current") + + val mapCommand = SV_ReadMapCommand() + + // go to the map + val loaded = spawnServerInstance(ChangeMapInfo(mapCommand, false, true), 0) + loaded.name = "default" + games.put("default", loaded) + } + + private fun SV_ReadMapCommand(): String { + Com.DPrintf("SV_ReadMapCommand()\n") + + try { + val store = ServerSaveJsonStore.forWriteDir(EngineWriteRoot.pathString()) + val snapshot = store.readMapCommand("current") + // read the comment field + Com.DPrintf("SV_ReadMapCommand: Loading save: " + snapshot.comment + "\n") + + // read the mapcmd + return snapshot.mapCommand + } catch (e: Exception) { + throw RuntimeException(e) + } + } + + /* +================== +SV_GameMap_f + +Saves the state of the map just being exited and goes to a new map. + +If the initial character of the map string is '*', the next map is +in a new unit, so the current savegame directory is cleared of +map files. + +Example: + +*inter.cin+jail + +Clears the archived maps, plays the inter.cin cinematic, then +goes to map jail.bsp. +================== + +Spins up a new game instance, to be used with either `map` or `join` (TBD) +*/ + fun SV_GameMap_f(args: MutableList) { + if (args.size < 2) { + Com.Printf("USAGE: gamemap \n ") + return + } + + val mapName = args.get(1) + + val clientIndex: Int + if (args.size > 2 && !args.get(2).isBlank()) { + try { + clientIndex = args.get(2).toInt() + } catch (e: NumberFormatException) { + Com.Printf("USAGE: gamemap \n \n") + return + } + } else clientIndex = 0 + + // default.cfg may start a session with "killserver; gamemap *...". + // Shutdown clears the client slots, so gamemap must initialize a fresh + // server just as map does, while preserving clients on level transitions. + if (games.isEmpty()) { + initializeServerSession() + } + + Com.DPrintf("SV_GameMap(" + mapName + ")\n") + + EngineWriteRoot.ensureParentDirectories(EngineWriteRoot.resolve("save/current/.keep")) + + // check for clearing the current savegame + if (mapName.get(0) == '*') { + // wipe all the *.sav files + ServerSaveFiles(EngineWriteRoot.path()).wipe("current") + } else { // save the map just exited + // note: only applicable to the default instance (subject to change in future) + val gameImports = games.get("default") + if (gameImports != null && gameImports.state == ServerStates.SS_GAME) { + // clear all the client inuse flags before saving so that + // when the level is re-entered, the clients will spawn + // at spawn points instead of occupying body shells + // todo: only relevant to this gameImports clients + val savedInuse = BooleanArray(clients.size) + var i = 0 + for (cl in clients) { + if (cl.edict != null) { // free client slots will have null values + savedInuse[i] = cl.edict.inuse + cl.edict.inuse = false + } + i++ + } + + gameImports.SV_WriteLevelFile() + + // we must restore these for clients to transfer over correctly + i = 0 + for (cl in clients) { + if (cl.edict != null) { // free client slots will have null values + cl.edict.inuse = savedInuse[i] + } + i++ + } + } + } + + val game = spawnServerInstance(ChangeMapInfo(mapName, false, false), clientIndex) + + if (clientIndex == 0) { + // server just started, `map` cmd was used + // start up the next map + game.name = "default" + games.put(game.name, game) + for (cl in clients) { + cl.gameName = "default" + } + } else { + // preview of the multi instance mode + val cl = clients.get(clientIndex - 1) + val previousGame: GameImportsImpl = games.get(cl.gameName)!! + previousGame.gameExports.ClientDisconnect(cl.edict) + games.put(game.name, game) + cl.gameName = game.name + } + + // copy off the level to the autosave slot + if (!Globals.dedicated.getTypedValue()) { + game.SV_WriteServerFile(true) + ServerSaveFiles(EngineWriteRoot.path()).copy("current", "save0") + } + + // multi instance preview: + // when playing the regular flow, multi instances are not requried + if (clientIndex == 0) { + games.clear() + games.put("default", game) + } + } + + /** + * SV_Map + */ + fun spawnServerInstance(changeMapInfo: ChangeMapInfo, clientIndex: Int): GameImportsImpl { + Globals.server_state = ServerStates.SS_DEAD //fixme: used by the client code + + val previous = if (clientIndex != 0) + games.get(clients.get(clientIndex - 1).gameName) + else + games.get("default") + if (previous != null) previous.prepareLevelTransition() + + val gameImports = createGameInstance(changeMapInfo, previous) + + Cvar.getInstance().find("nextserver", String::class.java) + .set("gamemap \"" + changeMapInfo.nextServer + "\"") + + Cmd.ExecuteFunction("loading") // for local system + + if (clientIndex == 0) { + // leave slots at start for clients only + for (cl in clients) { //todo + // needs to reconnect + if (cl.state == ClientStates.CS_SPAWNED) cl.state = ClientStates.CS_CONNECTED + cl.lastReceivedFrame = -1 + } + gameImports.SV_BroadcastCommand("changing") + } else { + val cl = clients.get(clientIndex - 1) + if (cl.state == ClientStates.CS_SPAWNED) cl.state = ClientStates.CS_CONNECTED + cl.lastReceivedFrame = -1 + gameImports.unicastMessage(clientIndex, StuffTextMessage("changing"), true) + } + + SV_SendClientMessages() + + + // precache and static commands can be issued during + // map initialization + Globals.server_state = gameImports.state // LOADING + + gameImports.initializeLevel(changeMapInfo, previous) + Globals.server_state = gameImports.state + + + // set serverinfo variable + Cvar.getInstance().find("mapname", String::class.java).forceSet(gameImports.mapName) + Com.Println("mapname: " + gameImports.mapName) + + if (changeMapInfo.state == ServerStates.SS_GAME) Cbuf.CopyToDefer() + + if (clientIndex == 0) { + gameImports.SV_BroadcastCommand("reconnect") // doesn't really reconnect but rather issues 'NEW' command + } else { + gameImports.unicastMessage(clientIndex, StuffTextMessage("reconnect"), true) + } + return gameImports + } + + /* +================== +SV_Map_f + +Goes directly to a given map without any savegame archiving. +For development work +================== + +Resets server to host a given map, moves all clients to it (via reconnect) +*/ + fun SV_Map_f(args: MutableList) { + //char expanded[MAX_QPATH]; + if (args.size < 2 || args.get(1).isBlank()) { + Com.Printf("usage: map \n") + return + } + + // if not a pcx, demo, or cinematic, check to make sure the level exists + val mapName = args.get(1) + val target = ChangeMapInfo(mapName, false, false) + if (target.state == ServerStates.SS_GAME) { + val mapPath = "maps/" + target.mapName + ".bsp" + if (!EngineVfs.exists(mapPath)) { + Com.Printf("Can't find " + mapPath + "\n") + return + } + } + val game = games.get("default") + + if (game != null) game.state = ServerStates.SS_DEAD // don't save current level when changing + + + ServerSaveFiles(EngineWriteRoot.path()).wipe("current") + + // A running server needs its clients reset for map. For a fresh server, + // gamemap performs this initialization itself. + if (!games.isEmpty()) { + initializeServerSession() + } + SV_GameMap_f(args) + } + + private fun initializeServerSession() { + if (games.isEmpty()) { + realtime = 0 + frameNum = 0 + fixedtime = 100 + } + val multiplayer = initializeServerCvars() + if (!NET.configureServer(multiplayer)) { + Com.Error(Defines.ERR_DROP, "Could not open the server UDP port") + return + } + resetClients() + } + + /** Send this client's Game snapshot followed by its queued events, recording rate usage. */ + private fun sendClientDatagram(client: ClientNetworkInfo, gameImports: GameImportsImpl) { + // send over all the relevant entity_state_t + // and the player_state_t + val unreliable: MutableCollection = ArrayList() + unreliable.addAll(gameImports.buildClientFrameMessages(client)) // 3 messages + + // copy the accumulated multicast datagram + // for this client out to the message + // it is necessary for this to be after the buildClientFrameMessages (SV_BuildClientFrame) + // so that entity references will be current + unreliable.addAll(client.unreliable) + client.unreliable.clear() + // send the datagram + client.netchan.transmit(unreliable) + + // record the size for rate estimation + client.message_size[gameImports.frameNum % Defines.RATE_MESSAGES] = + unreliable.stream().mapToInt { obj: NetworkMessage? -> obj!!.getSize() }.sum() + } + + private val userCommands = mapOf( + StringCmdMessage.NEW to ServerUserCommand(::SV_New_f), + StringCmdMessage.CONFIG_STRINGS to ServerUserCommand(::SV_Configstrings_f), + StringCmdMessage.BASELINES to ServerUserCommand(::SV_Baselines_f), + StringCmdMessage.BEGIN to ServerUserCommand(::SV_Begin_f), + StringCmdMessage.NEXT_SERVER to ServerUserCommand(::SV_Nextserver_f), + StringCmdMessage.DISCONNECT to ServerUserCommand(::SV_Disconnect_f), + StringCmdMessage.INFO to ServerUserCommand(::SV_ShowServerinfo_f), + StringCmdMessage.DOWNLOAD to ServerUserCommand(::SV_BeginDownload_f), + StringCmdMessage.NEXT_DOWNLOAD to ServerUserCommand(::SV_NextDownload_f), + ) + + /** + * One manager per dedicated process or embedded session; map changes reuse it. + */ + init { + val cvars = Cvar.getInstance() + cvars.register( + "spectator_password", CvarTypes.STRING, "", Defines.CVAR_USERINFO, + "Spectator admission password; empty or none allows access" + ) + cvars.register( + "needpass", NEEDPASS_TYPE, 0, Defines.CVAR_SERVERINFO, + "Required passwords: 0 none, 1 player, 2 spectator, 3 both" + ) + cvars.register("deathmatch", CvarTypes.BOOLEAN, false, Defines.CVAR_SERVERINFO or Defines.CVAR_LATCH) + cvars.register("coop", CvarTypes.BOOLEAN, false, Defines.CVAR_SERVERINFO or Defines.CVAR_LATCH) + cvars.register( + "dmflags", + CvarTypes.INTEGER, + Defines.DF_INSTANT_ITEMS, + Defines.CVAR_SERVERINFO, + "Deathmatch rule bitmask; combine DF_* flags" + ) + cvars.register( + "fraglimit", + CvarTypes.IntegerType(0, null), + 0, + Defines.CVAR_SERVERINFO, + "End a deathmatch level at this score; 0 disables the limit" + ) + cvars.register( + "timelimit", + CvarTypes.FloatType(0f, null), + 0f, + Defines.CVAR_SERVERINFO, + "Deathmatch level time limit in minutes; 0 disables the limit" + ) + cvars.register("cheats", CvarTypes.BOOLEAN, false, Defines.CVAR_SERVERINFO or Defines.CVAR_LATCH) + cvars.register( + "protocol", + CvarTypes.INTEGER, + Defines.PROTOCOL_VERSION, + Defines.CVAR_SERVERINFO or Defines.CVAR_NOSET + ) + cvars.register("mapname", CvarTypes.STRING, "", Defines.CVAR_SERVERINFO or Defines.CVAR_NOSET) + cvars.register( + "nextserver", + CvarTypes.STRING, + "", + 0, + "Console command to execute after a cinematic or picture" + ) + cvars.register( + "maxclients", + CvarTypes.IntegerType(1, Defines.MAX_CLIENTS), + 1, + Defines.CVAR_SERVERINFO or Defines.CVAR_LATCH, + "Client slot limit; single-player and coop mode also restrict the count" + ) + + hostname = cvars.register( + "hostname", + CvarTypes.STRING, + "noname", + Defines.CVAR_SERVERINFO or Defines.CVAR_ARCHIVE + ) + timeout = cvars.register( + "timeout", + CvarTypes.FloatType(0f, null), + 125f, + 0, + "Seconds without a client message before disconnection" + ) + zombietime = cvars.register( + "zombietime", + CvarTypes.FloatType(0f, null), + 2f, + 0, + "Seconds to retain a disconnected client slot for final reliable messages" + ) + sv_showclamp = cvars.register( + "showclamp", + CvarTypes.BOOLEAN, + false, + 0, + "Legacy frame-time clamp diagnostics; unused by the current server and Cake" + ) + sv_paused = cvars.register("paused", CvarTypes.BOOLEAN, false, 0) + sv_timedemo = cvars.register( + "timedemo", + CvarTypes.BOOLEAN, + false, + 0, + "Legacy demo benchmarking switch; unused by the current server and Cake" + ) + sv_enforcetime = cvars.register( + "sv_enforcetime", + CvarTypes.BOOLEAN, + false, + 0, + "Ignore movement commands that exceed the client time budget" + ) + + // come from MainCommon + allow_download = cvars.find("allow_download", Boolean::class.java) + allow_download_players = cvars.find("allow_download_players", Boolean::class.java) + allow_download_models = cvars.find("allow_download_models", Boolean::class.java) + allow_download_sounds = cvars.find("allow_download_sounds", Boolean::class.java) + allow_download_maps = cvars.find("allow_download_maps", Boolean::class.java) + + cvars.register( + "sv_noreload", + CvarTypes.BOOLEAN, + false, + 0, + "Start revisited levels fresh instead of restoring their saved state" + ) + sv_airaccelerate = cvars.register( + "sv_airaccelerate", + CvarTypes.FLOAT, + 0f, + Defines.CVAR_LATCH, + "Controls air acceleration (currently only for deathmatch)" + ) + public_server = cvars.register( + "public", + CvarTypes.BOOLEAN, + false, + 0, + "Legacy master-server advertising switch; currently unused" + ) + sv_reconnect_limit = cvars.register( + "sv_reconnect_limit", + CvarTypes.FloatType(0f, null), + 3f, + Defines.CVAR_ARCHIVE, + "Minimum seconds between reconnect attempts from the same remote client" + ) + resetClients() + + // Publish Host callbacks only after initialization succeeds. They survive Game replacement. + commands.add("heartbeat", true, Command { agrs: MutableList -> this.SV_Heartbeat_f(agrs) }) + commands.add("kick", true, Command { args: MutableList -> this.SV_Kick_f(args) }) + commands.add("status", true, Command { args: MutableList -> this.SV_Status_f(args) }) + commands.add("serverinfo", true, Command { args: MutableList -> this.SV_Serverinfo_f(args) }) + commands.add("dumpuser", true, Command { args: MutableList -> this.SV_DumpUser_f(args) }) + commands.add("say", true, Command { args: MutableList -> this.SV_ConSay_f(args) }) + commands.add("save", true, Command { args: MutableList -> this.saveGame(args) }) + + commands.add("killserver", Command { args: MutableList -> + stop("Server was killed.\n", false) + NET.configureServer(false) + }) + commands.add("map", Command { args: MutableList -> this.SV_Map_f(args) }) + commands.add("gamemap", Command { args: MutableList -> this.SV_GameMap_f(args) }) + commands.add("load", Command { args: MutableList -> this.SV_Loadgame_f(args) }) + commands.add("sv_shutdown", Command { args: MutableList -> + val reason: String? + if (args.size > 1) { + reason = args.get(1) + } else { + reason = "Server is shut down" + } + stop(reason + "\n", args.size > 2 && args.get(2).toBoolean()) + }) + + commands.add("listmaps", Command { args: MutableList -> + val bytes = EngineVfs.loadBytes("maps.lst") + if (bytes == null) { + Com.Error(Defines.ERR_DROP, "Could not read maps.lst") + return@Command + } + for (line in String(bytes).split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()) { + Com.Printf(Defines.PRINT_ALL, line.trim { it <= ' ' } + "\n") + } + }) + commands.add("jvm_memory", Command { args: MutableList -> + val runtime = Runtime.getRuntime() + Com.Printf("vm memory:" + (runtime.totalMemory() - runtime.freeMemory()) + "\n") + }) + commands.add("games", Command { args: MutableList -> + for (game in games.values) { + Com.Printf(game.name + " - " + game.mapcmd + "\n") + } + Com.Printf("Total " + games.size + " games\n") + }) + commands.add("spamgames", Command { args: MutableList -> + val total = args.get(1).toInt() + for (i in 0..("gamemap", "base1", "1")) + } + }) + } + + private fun interface ServerUserCommand { + fun execute(args: MutableList, game: GameImportsImpl, client: ClientNetworkInfo) + } + + /* + * ============================================================ + * + * USER STRINGCMD EXECUTION + * + * sv_client and sv_player will be valid. + * ============================================================ + */ + /* + * ================ SV_New_f + * + * Sends the first message from the server to a connected client. This will + * be sent on the initial connection and upon each server load. + * ================ + */ + private fun SV_New_f(args: MutableList, gameImports: GameImportsImpl, client: ClientNetworkInfo) { + Com.DPrintf("New() from " + client.name + "\n") + + if (client.state != ClientStates.CS_CONNECTED) { + Com.Printf("New not valid -- already spawned\n") + return + } + + // + // serverdata needs to go over for all types of servers + // to make sure the protocol is right, and to set the gamedir + // + val gamedir = Cvar.getInstance().find("gamedir", String::class.java).getTypedValue() + + // send the serverdata + // send full levelname + val clientIndex: Int + if (gameImports.state == ServerStates.SS_CINEMATIC || gameImports.state == ServerStates.SS_PIC) clientIndex = -1 + else clientIndex = client.edict.index - 1 + + + client.netchan.reliablePending.add( + ServerDataMessage( + Defines.PROTOCOL_VERSION, + gameImports.spawncount, + gameImports.isDemo, + gamedir, + clientIndex, + gameImports.configstrings[Defines.CS_NAME] + ) + ) + // + // game server + // + if (gameImports.state == ServerStates.SS_GAME) { + // set up the entity for the client + val ent = gameImports.gameExports.getEdict(clientIndex + 1) + ent.s.index = clientIndex + 1 + client.edict = ent + client.lastcmd = usercmd_t() + + // begin fetching configstrings + client.netchan.reliablePending.add( + StuffTextMessage( + String.format( + "cmd %s %d 0", + StringCmdMessage.CONFIG_STRINGS, + gameImports.spawncount + ) + ) + ) + } + } + + /* + * ================== SV_Configstrings_f ================== + */ + private fun SV_Configstrings_f( + args: MutableList, + gameImports: GameImportsImpl, + client: ClientNetworkInfo + ) { + Com.DPrintf("Configstrings() from " + client.name + "\n") + + if (client.state != ClientStates.CS_CONNECTED) { + Com.Printf("configstrings not valid -- already spawned\n") + return + } + + // handle the case of a level changing while a client was connecting + val spawnCount = if (args.size >= 2) Lib.atoi(args.get(1)) else 0 + if (spawnCount != gameImports.spawncount) { + Com.Printf("SV_Configstrings_f from different level\n") + SV_New_f(args, gameImports, client) + return + } + + var start = if (args.size >= 3) Lib.atoi(args.get(2)) else 0 + + // write a packet full of data + var currentReliableSize = + client.netchan.reliablePending.stream().mapToInt { obj: NetworkMessage? -> obj!!.getSize() }.sum() + while (currentReliableSize < Defines.MAX_MSGLEN / 2 && start < Defines.MAX_CONFIGSTRINGS) { + if (gameImports.configstrings[start] != null && gameImports.configstrings[start].length != 0) { + val config = ConfigStringMessage(start, gameImports.configstrings[start]) + currentReliableSize += config.getSize() + client.netchan.reliablePending.add(config) + } + start++ + } + + // send next command + val nextCmd: String + + if (start == Defines.MAX_CONFIGSTRINGS) { + nextCmd = String.format("cmd %s %d 0", StringCmdMessage.BASELINES, gameImports.spawncount) + } else { + nextCmd = String.format("cmd %s %d %d", StringCmdMessage.CONFIG_STRINGS, gameImports.spawncount, start) + } + client.netchan.reliablePending.add(StuffTextMessage(nextCmd)) + } + + /* + * ================== SV_Baselines_f ================== + */ + private fun SV_Baselines_f(args: MutableList, gameImports: GameImportsImpl, client: ClientNetworkInfo) { + Com.DPrintf("Baselines() from " + client.name + "\n") + + if (client.state != ClientStates.CS_CONNECTED) { + Com.Printf("baselines not valid -- already spawned\n") + return + } + + // handle the case of a level changing while a client was connecting + val spawnCount = if (args.size >= 2) Lib.atoi(args.get(1)) else 0 + if (spawnCount != gameImports.spawncount) { + Com.Printf("SV_Baselines_f from different level\n") + SV_New_f(args, gameImports, client) + return + } + + // todo: validate argument + var start = if (args.size >= 3) Lib.atoi(args.get(2)) else 0 + + // write a packet full of data + var currentReliableSize = + client.netchan.reliablePending.stream().mapToInt { obj: NetworkMessage? -> obj!!.getSize() }.sum() + while (currentReliableSize < Defines.MAX_MSGLEN / 2 && start < Defines.MAX_EDICTS) { + val base = gameImports.baselines[start] + if (base.modelindex != 0 || base.sound != 0 || base.effects != 0) { + val spawn = SpawnBaselineMessage(base) + currentReliableSize += spawn.getSize() + client.netchan.reliablePending.add(spawn) + } + start++ + } + + // send next command + val nextCmd: String + if (start == Defines.MAX_EDICTS) { + // finished sending baselines + nextCmd = String.format("%s %d", StringCmdMessage.PRECACHE, gameImports.spawncount) + } else { + // continue from where we finished + nextCmd = String.format("cmd %s %d %d", StringCmdMessage.BASELINES, gameImports.spawncount, start) + } + client.netchan.reliablePending.add(StuffTextMessage(nextCmd)) + } + + /* + * ================== SV_Begin_f ================== + */ + private fun SV_Begin_f(args: MutableList, gameImports: GameImportsImpl, client: ClientNetworkInfo) { + Com.DPrintf("Begin() from " + client.name + "\n") + + // handle the case of a level changing while a client was connecting + val spawnCount = if (args.size >= 2) Lib.atoi(args.get(1)) else 0 + if (spawnCount != gameImports.spawncount) { + Com.Printf("SV_Begin_f from different level\n") + SV_New_f(args, gameImports, client) + return + } + + client.state = ClientStates.CS_SPAWNED + + // call the jake2.game begin function + gameImports.gameExports.ClientBegin(client.edict) + + Cbuf.InsertFromDefer() + } + + //============================================================================= + /* + * ================== SV_NextDownload_f ================== + */ + private fun SV_NextDownload_f( + args: MutableList, + gameImports: GameImportsImpl?, + client: ClientNetworkInfo + ) { + if (client.download == null) return + + var packet = client.downloadsize - client.downloadcount + if (packet > 1024) packet = 1024 + + client.downloadcount += packet + var size = client.downloadsize + if (size == 0) size = 1 + val percent = (client.downloadcount * 100 / size).toByte() + + val data = ByteArray(packet) + System.arraycopy(client.download, client.downloadcount - packet, data, 0, packet) + client.netchan.reliablePending.add(DownloadMessage(data, percent)) + + if (client.downloadcount == client.downloadsize) { + client.download = null + } + } + + /* + * ================== SV_BeginDownload_f ================== + */ + private fun SV_BeginDownload_f( + args: MutableList, + gameImports: GameImportsImpl?, + client: ClientNetworkInfo + ) { + var offset = 0 + + if (args.size < 2) return + + val name = args.get(1) + + if (args.size > 2) offset = Lib.atoi(args.get(2)) // downloaded offset + + + // hacked by zoid to allow more conrol over download + // first off, no .. or global allow check + if (name.contains("..") + || !allow_download.getTypedValue() // leading dot is no good + || name.get(0) == '.' // leading slash bad as well, must be + // in subdir + || name.get(0) == '/' // next up, skin check + || (name.startsWith("players/") && !allow_download_players.getTypedValue()) // now + // models + || (name.startsWith("models/") && !allow_download_models.getTypedValue()) // now + // sounds + || (name.startsWith("sound/") && !allow_download_sounds.getTypedValue()) // now maps (note special case for maps, must not be in pak) + || (name.startsWith("maps/") && !allow_download_maps.getTypedValue()) // MUST + // be + // in a + // subdirectory + || name.indexOf('/') == -1 + ) { // don't allow anything with .. + // path + + // refuse + + client.netchan.reliablePending.add(DownloadMessage()) + return + } + + client.download = EngineVfs.loadBytes(name) + + // The client waits for either data or an explicit refusal. Silently + // returning here leaves precache stuck waiting for a download forever. + if (client.download == null) { + client.netchan.reliablePending.add(DownloadMessage()) + return + } + + client.downloadsize = client.download.size + client.downloadcount = offset + + if (offset > client.downloadsize) client.downloadcount = client.downloadsize + + // special check for maps, if it came from a pak file, don't allow download ZOID + if (name.startsWith("maps/") && EngineVfs.isFromPack(name)) { + Com.DPrintf("Couldn't download " + name + " to " + client.name + "\n") + if (client.download != null) { + client.download = null + } + + // refuse + client.netchan.reliablePending.add(DownloadMessage()) + return + } + + SV_NextDownload_f(args, gameImports, client) + Com.DPrintf("Downloading " + name + " to " + client.name + "\n") + } + + //============================================================================ + /* + * ================= SV_Disconnect_f + * + * The client is going to disconnect, so remove the connection immediately + * ================= + */ + private fun SV_Disconnect_f(args: MutableList, gameImports: GameImportsImpl?, client: ClientNetworkInfo) { + // SV_EndRedirect (); + SV_DropClient(client) + } + + /* + * ================== SV_ShowServerinfo_f + * + * Dumps the serverinfo info string ================== + */ + private fun SV_ShowServerinfo_f( + args: MutableList, + gameImports: GameImportsImpl?, + client: ClientNetworkInfo? + ) { + Info.Print(Cvar.getInstance().Serverinfo()) + } + + private fun SV_Nextserver(gameImports: GameImportsImpl) { + //ZOID, ss_pic can be nextserver'd in coop mode + + if (gameImports.state == ServerStates.SS_GAME + || (gameImports.state == ServerStates.SS_PIC && !Cvar.getInstance() + .find("coop", Boolean::class.java).getTypedValue()) + ) return // can't nextserver while playing a normal game + + + gameImports.spawncount++ // make sure another doesn't sneak in + val v = Cvar.getInstance().find("nextserver", String::class.java).getTypedValue() + //if (!v[0]) + if (v.length == 0) Cbuf.AddText("killserver\n") + else { + Cbuf.AddText(v) + Cbuf.AddText("\n") + } + Cvar.getInstance().find("nextserver", String::class.java).set("") + } + + /* + * ================== SV_Nextserver_f + * + * A cinematic has completed or been aborted by a client, so move to the + * next server, ================== + */ + private fun SV_Nextserver_f(args: MutableList, gameImports: GameImportsImpl, client: ClientNetworkInfo) { + val spawnCount = if (args.size >= 2) Lib.atoi(args.get(1)) else 0 + if (spawnCount != gameImports.spawncount) { + Com.DPrintf("Nextserver() from wrong level, from " + client.name + "\n") + return // leftover from last server + } + + Com.DPrintf("Nextserver() from " + client.name + "\n") + SV_Nextserver(gameImports) + } + + /** Resolve a Game at execution time; creating another Game never replaces these callbacks. */ + private fun saveGame(args: MutableList) { + var target = "default" + var command: MutableList = args + if (args.size > 1 && args.get(1) == "--game") { + if (args.size < 4) { + Com.Printf("Usage: " + args.get(0) + " --game \n") + return + } + target = args.get(2) + command = ArrayList() + command.add(args.get(0)) + command.addAll(args.subList(3, args.size)) + } + val game = games.get(target) + if (game == null) { + Com.Printf("No running game: " + target + "\n") + return + } + game.saveGame(command) + } + + /* + ================== + SV_Kick_f + + Kick a user off of the server + ================== + */ + private fun SV_Kick_f(args: MutableList) { + if (args.size != 2) { + Com.Printf("Usage: kick \n") + return + } + val client = SV_SetPlayer(args) + if (client == null) return + + SV_BroadcastPrintf(Defines.PRINT_HIGH, client.name + " was kicked\n", client.gameName) + // print directly, because the dropped client won't get the + // SV_BroadcastPrintf message + client.queuePrint(Defines.PRINT_HIGH, "You were kicked from the game\n") + SV_DropClient(client) + client.lastmessage = games.get(client.gameName)!!.realtime // min case there is a funny zombie + } + + /* + ================ + SV_Status_f + ================ + */ + private fun SV_Status_f(args: MutableList) { + // todo: check + if (games.isEmpty()) { + Com.Printf("No server running.\n") + return + } + for (game in games.values) Com.Printf("game " + game.name + " : " + game.mapName + "\n") + + Com.Printf("num score ping name lastmsg address qport \n") + Com.Printf("--- ----- ---- --------------- ------- --------------------- ------\n") + + for (i in clients.indices) { + val cl = clients.get(i) + if (ClientStates.CS_FREE == cl.state) continue + + Com.Printf("%3i ", Vargs().add(i)) + Com.Printf("%5i ", Vargs().add(cl.edict.getClient().getPlayerState().stats[Defines.STAT_FRAGS])) + + if (cl.state == ClientStates.CS_CONNECTED) Com.Printf("CNCT ") + else if (cl.state == ClientStates.CS_ZOMBIE) Com.Printf("ZMBI ") + else { + val ping = if (cl.ping < 9999) cl.ping else 9999 + Com.Printf("%4i ", Vargs().add(ping)) + } + + Com.Printf("%s", Vargs().add(cl.name)) + var l = 16 - cl.name.length + var j: Int + j = 0 + while (j < l) { + Com.Printf(" ") + j++ + } + + Com.Printf("%7i ", Vargs().add(games.get(cl.gameName)!!.realtime - cl.lastmessage)) + + val s = cl.netchan.remote_address.toString() + Com.Printf(s) + l = 22 - s.length + j = 0 + while (j < l) { + Com.Printf(" ") + j++ + } + + Com.Printf("%5i", Vargs().add(cl.netchan.qport)) + + Com.Printf("\n") + } + Com.Printf("\n") + } + + /* + ================== + SV_ConSay_f + ================== + */ + private fun SV_ConSay_f(args: MutableList) { + if (args.size < 2) return + + // char[1024]; + var text = "console: " + var p = Cmd.getArguments(args) + + if (!p.isEmpty() && p.get(0) == '"') { + p = p.substring(1, p.length - 1) + } + + text += p + + for (client in clients) { + if (client.state != ClientStates.CS_SPAWNED) continue + client.queuePrint(Defines.PRINT_CHAT, text + "\n") + } + } + + /* + ================== + SV_Heartbeat_f + ================== + */ + private fun SV_Heartbeat_f(agrs: MutableList) { + } + + /* + =========== + SV_Serverinfo_f + + Examine or change the serverinfo string + =========== + */ + private fun SV_Serverinfo_f(args: MutableList) { + Com.Printf("Server info settings:\n") + Info.Print(Cvar.getInstance().Serverinfo()) + } + + /* + =========== + SV_DumpUser_f + + Examine all a users info strings + =========== + */ + private fun SV_DumpUser_f(args: MutableList) { + if (args.size != 2) { + Com.Printf("Usage: info \n") + return + } + val client = SV_SetPlayer(args) + if (client == null) return + + Com.Printf("userinfo\n") + Com.Printf("--------\n") + Info.Print(client.userinfo) + } + + /** + * Sets sv_client and sv_player to the player with idnum Cmd.Argv(1) + */ + private fun SV_SetPlayer(args: MutableList): ClientNetworkInfo? { + if (args.size < 2) return null + + val idOrName = args.get(1) + + // numeric values are just slot numbers + // fixme: player name cannot start with a number? + if (!idOrName.isEmpty() && idOrName.get(0) >= '0' && idOrName.get(0) <= '9') { + val id = Lib.atoi(idOrName) + if (id < 0 || id >= clients.size) { + Com.Printf("Bad client slot: " + id + "\n") + return null + } + + val result = clients.get(id) + if (ClientStates.CS_FREE == result.state) { + Com.Printf("Client " + id + " is not active\n") + return null + } + return result + } + + // check for a name match + for (cl in clients) { + if (ClientStates.CS_FREE == cl.state) continue + if (idOrName == cl.name) { + return cl + } + } + + Com.Printf("Userid " + idOrName + " is not on the server\n") + return null + } + + companion object { + // Recreating an embedded manager must reuse the same choice declaration. + private val NEEDPASS_TYPE = ChoiceType(mutableListOf(0, 1, 2, 3)) + + private const val MAX_STRINGCMDS = 8 + + /** + * Checks if the rcon password is corect. + */ + private fun Rcon_Validate(args: MutableList): Boolean { + val rconPassword = Cvar.getInstance().find("rcon_password", String::class.java).getTypedValue() + if (rconPassword.isEmpty()) return false + + return args.size >= 2 && args.get(1) == rconPassword + } + + /** + * A client issued an rcon command. Shift down the remaining args Redirect + * all printfs from the server to the client. + */ + private fun SVC_RemoteCommand(args: MutableList, from: netadr_t?) { + val rconIsValid: Boolean = Rcon_Validate(args) + + if (rconIsValid) { + Com.Printf("Rcon from " + from + ":\n" + args + "\n") + } else { + Com.Printf("Bad rcon from " + from + ":\n" + args + "\n") + } + + + Com.BeginRedirect(RD_Flusher { buffer: StringBuilder? -> + Netchan.sendConnectionlessPacket( + Defines.NS_SERVER, + from, + ConnectionlessCommand.print, + "\n" + buffer.toString() + ) + }) + if (rconIsValid) { + Cmd.ExecuteString(Cmd.getArguments(args, 2)) + } else { + // redirected to client + Com.Printf("Bad rcon_password.\n") + } + + Com.EndRedirect() + } + } +} diff --git a/server/src/test/java/jake2/server/AirAccelerationTest.java b/server/src/test/java/jake2/server/AirAccelerationTest.java index a2ea6a58..c7f7b624 100644 --- a/server/src/test/java/jake2/server/AirAccelerationTest.java +++ b/server/src/test/java/jake2/server/AirAccelerationTest.java @@ -27,7 +27,7 @@ void onlyDeathmatchEnablesAirAccelerationAndEachInstanceKeepsItsSetting() { try { Globals.server_state = ServerStates.SS_DEAD; MainCommon.registerCvars(false); - SV_MAIN server = new SV_MAIN(); + ServerHost server = new ServerHost(); var acceleration = cvars.find("sv_airaccelerate", Float.class); var deathmatch = cvars.find("deathmatch", Boolean.class); acceleration.forceSet(2.5f); @@ -57,7 +57,7 @@ void pendingChangesApplyToNewGamesWithoutChangingExistingGames() { try { Globals.server_state = ServerStates.SS_DEAD; MainCommon.registerCvars(false); - SV_MAIN server = new SV_MAIN(); + ServerHost server = new ServerHost(); cvars.find("deathmatch", Boolean.class).forceSet(true); var acceleration = cvars.find("sv_airaccelerate", Float.class); acceleration.forceSet(1f); @@ -90,7 +90,7 @@ void loadingCapturesRestoredSettingsBeforePublishingMovementConfiguration(@TempD try { Globals.server_state = ServerStates.SS_DEAD; MainCommon.registerCvars(false); - SV_MAIN server = new SV_MAIN(); + ServerHost server = new ServerHost(); ServerSaveJsonStore.forWriteDir(temp.toString()).writeLatchedCvars("current", List.of( new ServerSaveJsonStore.LatchedCvarSnapshot("deathmatch", "1"), new ServerSaveJsonStore.LatchedCvarSnapshot("sv_airaccelerate", "3.5"))); @@ -113,7 +113,7 @@ void savesCaptureTheOwningGamesConfiguredValueRatherThanTheLatestGlobalValue(@Te try { Globals.server_state = ServerStates.SS_DEAD; MainCommon.registerCvars(false); - SV_MAIN server = new SV_MAIN(); + ServerHost server = new ServerHost(); var deathmatch = cvars.find("deathmatch", Boolean.class); var acceleration = cvars.find("sv_airaccelerate", Float.class); deathmatch.forceSet(true); diff --git a/server/src/test/java/jake2/server/GameCvarInitializationTest.java b/server/src/test/java/jake2/server/GameCvarInitializationTest.java index f87667a2..b69491e5 100644 --- a/server/src/test/java/jake2/server/GameCvarInitializationTest.java +++ b/server/src/test/java/jake2/server/GameCvarInitializationTest.java @@ -28,7 +28,7 @@ void gameWritesValidateSharedTypesAndRetainLatchSemantics() { cvars.setResult("maxclients", "3.5"); cvars.setResult("timeout", "0.5"); MainCommon.registerCvars(false); - SV_MAIN server = new SV_MAIN(); + ServerHost server = new ServerHost(); GameImportsImpl imports = new GameImportsImpl(server, new ChangeMapInfo("test.pcx", false, false)); cvar_t maxclients = cvars.find("maxclients", Integer.class); cvar_t deathmatch = cvars.find("deathmatch", Boolean.class); @@ -62,7 +62,7 @@ void successorCopiesSettingsAndAppliesPendingWithoutChangingTheSource() { try { cvars.setResult("skill", "2"); MainCommon.registerCvars(false); - SV_MAIN server = new SV_MAIN(); + ServerHost server = new ServerHost(); GameImportsImpl first = new GameImportsImpl(server, new ChangeMapInfo("first.pcx", false, false)); var skill = first.registerCvar("skill", new CvarTypes.ChoiceType<>(List.of(0, 1, 2, 3)), 0, CVAR_LATCH, null); var gravity = first.registerCvar("sv_gravity", CvarTypes.FLOAT, 800f, 0, null); @@ -98,7 +98,7 @@ void typedDeclarationsRejectInvalidEarlyInputAndRemainIndependent() { cvars.setResult("skill", "4"); cvars.setResult("sv_gravity", "600.5"); MainCommon.registerCvars(false); - SV_MAIN server = new SV_MAIN(); + ServerHost server = new ServerHost(); GameImportsImpl first = new GameImportsImpl(server, new ChangeMapInfo("first.pcx", false, false)); GameImportsImpl second = new GameImportsImpl(server, new ChangeMapInfo("second.pcx", false, false)); var skillType = new CvarTypes.ChoiceType<>(List.of(0, 1, 2, 3)); @@ -132,7 +132,7 @@ void explicitLocalRegistrationShadowsGlobalWithoutChangingIt() { try { MainCommon.registerCvars(false); var globalSetting = cvars.register("test_setting", CvarTypes.STRING, "client-secret", CVAR_USERINFO | CVAR_ARCHIVE); - SV_MAIN server = new SV_MAIN(); + ServerHost server = new ServerHost(); GameImportsImpl imports = new GameImportsImpl(server, new ChangeMapInfo("test.pcx", false, false)); assertSame(globalSetting, imports.findCvar("test_setting", String.class)); var localSetting = imports.registerCvar("test_setting", CvarTypes.STRING, "", CVAR_USERINFO, null); @@ -159,7 +159,7 @@ void savedInputsOverrideTheSourceAndInitializeLocalDeclarations(@TempDir Path te EngineWriteRoot.setRoot(temp); try { MainCommon.registerCvars(false); - SV_MAIN server = new SV_MAIN(); + ServerHost server = new ServerHost(); GameImportsImpl other = new GameImportsImpl(server, new ChangeMapInfo("other.pcx", false, false)); cvar_t otherSkill = other.registerCvar("skill", new CvarTypes.ChoiceType<>(List.of(0, 1, 2, 3)), 1, CVAR_LATCH, null); ServerSaveJsonStore.forWriteDir(temp.toString()).writeLatchedCvars("current", List.of( @@ -189,7 +189,7 @@ void gameDeclarationsConsumeEarlyInputsWithoutSharingTheirLiveState() { cvars.setResult("maxspectators", "8"); cvars.setResult("spectator_password", "secret"); MainCommon.registerCvars(false); - SV_MAIN server = new SV_MAIN(); + ServerHost server = new ServerHost(); // A picture transition constructs real game imports without loading a BSP or opening sockets. GameImportsImpl imports = new GameImportsImpl(server, new ChangeMapInfo("test.pcx", false, false)); From fdfd405a96e1f61af58bcc44d3d15623c9543429 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 18:46:45 +0100 Subject: [PATCH 13/47] Clean up ServerHost Kotlin style and deprecated formatting --- .../java/jake2/server/GameImportsTest.java | 6 +- .../jake2/server/EmbeddedCakeHostTest.kt | 2 +- .../java/jake2/server/GameImportsImpl.java | 2 +- .../main/kotlin/jake2/server/ServerHost.kt | 825 +++++++----------- 4 files changed, 336 insertions(+), 499 deletions(-) diff --git a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java index 12d6921d..5d6f7dd4 100644 --- a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java +++ b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java @@ -364,14 +364,14 @@ public void hostProtocolDispatchAndShutdownUseTheirOwnGameRegistry() { secondClient.edict = second.gameExports.getEdict(2); firstClient.state = secondClient.state = ClientStates.CS_CONNECTED; try { - manager.SV_ExecuteUserCommand(firstClient, "new"); - manager.SV_ExecuteUserCommand(secondClient, "new"); + manager.executeClientCommand(firstClient, "new"); + manager.executeClientCommand(secondClient, "new"); Assertions.assertEquals("first game", ((ServerDataMessage) firstClient.netchan.reliablePending.iterator().next()).levelString); Assertions.assertEquals("second game", ((ServerDataMessage) secondClient.netchan.reliablePending.iterator().next()).levelString); firstClient.netchan.reliablePending.clear(); - manager.SV_ExecuteUserCommand(firstClient, "configstrings " + first.spawncount + " 0"); + manager.executeClientCommand(firstClient, "configstrings " + first.spawncount + " 0"); Assertions.assertTrue(firstClient.netchan.reliablePending.contains( new ConfigStringMessage(Defines.CS_NAME, "first game"))); } finally { diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt index 29d6562a..2a9f1b8e 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt @@ -71,7 +71,7 @@ class EmbeddedCakeHostTest { val downloads = CakeDownloadSession( sendReliable = { command -> client.gameName = "default" - host.server!!.SV_ExecuteUserCommand(client, command) + host.server!!.executeClientCommand(client, command) }, downloadManager = CakeDownloadManager(CakeDownloadCache(cache), cvars), ) diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index dd0ee332..2ee0f865 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -279,7 +279,7 @@ void SV_Read_Latched_Vars() { // special messages @Override public void bprintf(int printlevel, String s) { - serverMain.SV_BroadcastPrintf(printlevel, s, name); + serverMain.broadcastPrint(printlevel, s, name); } @Override diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 0a5aaf82..7ee5adf2 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -21,7 +21,6 @@ package jake2.server import jake2.qcommon.* -import jake2.qcommon.Com.RD_Flusher import jake2.qcommon.exec.* import jake2.qcommon.exec.CvarTypes.ChoiceType import jake2.qcommon.network.NET @@ -33,24 +32,28 @@ import jake2.qcommon.network.messages.client.* import jake2.qcommon.network.messages.server.* import jake2.qcommon.network.netadr_t import jake2.qcommon.util.Lib -import jake2.qcommon.util.Vargs import jake2.qcommon.vfs.EngineVfs import jake2.qcommon.vfs.EngineWriteRoot import jake2.server.save.ServerSaveFiles import jake2.server.save.ServerSaveJsonStore /** Owns client connections, Game routing, protocol and commands for one server Host. */ -@Suppress("deprecation") class ServerHost : AutoCloseable { private val commands = ServerCommands() val isRunning: Boolean - get() = !games.isEmpty() + get() = games.isNotEmpty() + + /** Bridge for client loading state and cvar latching until shared-global cleanup. */ + @Suppress("DEPRECATION") + private fun publishLegacyServerState(state: ServerStates) { + Globals.server_state = state + } /** Typed entrypoint for an embedded host; uses the same map path as the console. */ fun startMap(mapName: String): Boolean { val previous = games["default"] - SV_Map_f(java.util.List.of("map", mapName)) - return games.get("default") != null && games.get("default") !== previous + map(listOf("map", mapName)) + return games["default"] != null && games["default"] !== previous } /** Release this manager's console hooks after ending its last session. */ @@ -76,63 +79,51 @@ class ServerHost : AutoCloseable { }.toMutableList() } - // prevent invalid IPs from connecting private val challenges = Array(Defines.MAX_CHALLENGES) { challenge_t() } - var clients: MutableList = ArrayList() + var clients: MutableList = mutableListOf() private set private var realtime: Long = 0 private var frameNum = 0 - private var fixedtime = 1000 + private var nextFrameTime = 1000 private var gameCounter = 0 fun allocateGameName(): String { - return "jake2_" + gameCounter++ + return "jake2_${gameCounter++}" } val isPaused: Boolean - get() = sv_paused.getTypedValue() + get() = paused.typedValue - private val sv_paused: cvar_t + private val paused: cvar_t - private val sv_timedemo: cvar_t - - private val sv_enforcetime: cvar_t + private val enforceMoveTime: cvar_t @JvmField val timeout: cvar_t // seconds without any message - private val zombietime: cvar_t // seconds to sink messages after - - // disconnect - private val allow_download: cvar_t - - private val allow_download_players: cvar_t + private val zombieTimeout: cvar_t // seconds to sink messages after - private val allow_download_models: cvar_t + private val allowDownload: cvar_t - private val allow_download_sounds: cvar_t + private val allowPlayerDownloads: cvar_t - private val allow_download_maps: cvar_t + private val allowModelDownloads: cvar_t - private val sv_airaccelerate: cvar_t + private val allowSoundDownloads: cvar_t - private val sv_showclamp: cvar_t + private val allowMapDownloads: cvar_t private val hostname: cvar_t - private val public_server: cvar_t // should heartbeats be sent + private val reconnectLimit: cvar_t // minimum seconds between connect - private val sv_reconnect_limit: cvar_t // minimum seconds between connect - - - // messages /** * Builds the string that is sent as heartbeats and status replies. */ - private fun SV_StatusString(): String { + private fun buildStatusResponse(): String { var status = Cvar.getInstance().Serverinfo() + "\n" for (cl in clients) { @@ -145,7 +136,6 @@ class ServerHost : AutoCloseable { if (statusLength + playerLength >= 1024) break // can't hold any more - status += player } } @@ -157,20 +147,19 @@ class ServerHost : AutoCloseable { * SVC_Info, responds with short info for broadcast scans The second parameter should * be the current protocol version number. */ - private fun SVC_Info(args: MutableList, from: netadr_t?): String? { + private fun buildInfoResponse(args: List): String? { if (clients.size == 1) return null // ignore in single player - - val version = if (args.size < 2) 0 else Lib.atoi(args.get(1)) + val version = if (args.size < 2) 0 else Lib.atoi(args[1]) val info: String - if (version != Defines.PROTOCOL_VERSION) info = hostname.getTypedValue() + ": wrong version\n" + if (version != Defines.PROTOCOL_VERSION) info = hostname.typedValue + ": wrong version\n" else { var players = 0 for (cl in clients) if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) players++ info = - hostname.getTypedValue() + " " + games.get("default")!!.mapName + " " + players + "/" + clients.size + "\n" + hostname.typedValue + " " + games["default"]!!.mapName + " " + players + "/" + clients.size + "\n" } return info @@ -182,7 +171,7 @@ class ServerHost : AutoCloseable { * that flood the server with invalid connection IPs. With a challenge, they * must give a valid IP address. */ - private fun SVC_GetChallenge(from: netadr_t): Int { + private fun getChallenge(from: netadr_t): Int { var oldest = 0 var oldestTime = 0x7fffffff @@ -212,10 +201,10 @@ class ServerHost : AutoCloseable { /** * A connection request that did not come from the master. */ - private fun SVC_DirectConnect(args: MutableList, adr: netadr_t) { + private fun connectClient(args: List, adr: netadr_t) { Com.DPrintf("SVC_DirectConnect ()\n") - val version = if (args.size >= 2) Lib.atoi(args.get(1)) else 0 + val version = if (args.size >= 2) Lib.atoi(args[1]) else 0 if (version != Defines.PROTOCOL_VERSION) { Netchan.sendConnectionlessPacket( @@ -226,25 +215,13 @@ class ServerHost : AutoCloseable { return } - val qport = if (args.size >= 3) Lib.atoi(args.get(2)) else 0 - val challenge = if (args.size >= 4) Lib.atoi(args.get(3)) else 0 - var userinfo = if (args.size >= 5) args.get(4) else "" + val qport = if (args.size >= 3) Lib.atoi(args[2]) else 0 + val challenge = if (args.size >= 4) Lib.atoi(args[3]) else 0 + var userinfo = if (args.size >= 5) args[4] else "" // force the IP key/value pair so the game can filter based on ip userinfo = Info.Info_SetValueForKey(userinfo, "ip", adr.toString()) - - /* - if (gameImports.isDemo) { - if (!adr.IsLocalAddress()) { - Com.Printf("Remote connect in attract loop. Ignored.\n"); - Netchan.sendConnectionlessPacket(Defines.NS_SERVER, adr, ConnectionlessCommand.print, "\nConnection refused.\n"); - return; - } - } -*/ - - // see if the challenge is valid var j: Int if (!adr.IsLocalAddress()) { @@ -281,13 +258,13 @@ class ServerHost : AutoCloseable { if (adr.CompareBaseAdr(cl.netchan.remote_address) && (cl.netchan.qport == qport || adr.port == cl.netchan.remote_address.port) ) { - if (!adr.IsLocalAddress() && (realtime - cl.lastconnect) < (sv_reconnect_limit.getTypedValue() * 1000)) { + if (!adr.IsLocalAddress() && (realtime - cl.lastconnect) < (reconnectLimit.typedValue * 1000)) { Com.DPrintf(adr.toString() + ":reconnect rejected : too soon\n") return } Com.Printf(adr.toString() + ":reconnect\n") - gotnewcl(i, challenge, userinfo, adr, qport) + initializeClientConnection(i, challenge, userinfo, adr, qport) return } i++ @@ -308,19 +285,19 @@ class ServerHost : AutoCloseable { Com.DPrintf("Rejected a connection.\n") return } - gotnewcl(index, challenge, userinfo, adr, qport) + initializeClientConnection(index, challenge, userinfo, adr, qport) } /** * Initializes player structures after successfull connection. */ - private fun gotnewcl(i: Int, challenge: Int, userinfo: String?, adr: netadr_t, qport: Int) { + private fun initializeClientConnection(i: Int, challenge: Int, userinfo: String?, adr: netadr_t, qport: Int) { // build a new connection // accept the new client // this is the only place a ClientNetworkInfo is ever initialized - val client = clients.get(i) - val gameImports: GameImportsImpl = games.get("default")!! + val client = clients[i] + val gameImports: GameImportsImpl = games["default"]!! client.gameName = "default" val ent = gameImports.gameExports.getEdict(i + 1) @@ -353,7 +330,7 @@ class ServerHost : AutoCloseable { // parse some info from the info strings client.userinfo = userinfo - SV_UserinfoChanged(client) + updateClientUserinfo(client) Netchan.sendConnectionlessPacket(Defines.NS_SERVER, adr, ConnectionlessCommand.client_connect, "") @@ -371,7 +348,7 @@ class ServerHost : AutoCloseable { * it from a game channel. Clients that are in the game can still send * connectionless packets. It is used also by rcon commands. */ - fun SV_ConnectionlessPacket(from: netadr_t, messageLine: String?) { + fun handleConnectionlessPacket(from: netadr_t, messageLine: String?) { val args = Cmd.TokenizeString(messageLine, false) if (args.isEmpty()) { @@ -379,7 +356,7 @@ class ServerHost : AutoCloseable { return } - val c = args.get(0) + val c = args[0] val cmd = ConnectionlessCommand.fromString(c) when (cmd) { @@ -394,11 +371,11 @@ class ServerHost : AutoCloseable { Defines.NS_SERVER, from, ConnectionlessCommand.print, - "\n" + SV_StatusString() + "\n" + buildStatusResponse() ) ConnectionlessCommand.info -> { - val info = SVC_Info(args, from) + val info = buildInfoResponse(args) if (info != null) Netchan.sendConnectionlessPacket( Defines.NS_SERVER, from, @@ -408,7 +385,7 @@ class ServerHost : AutoCloseable { } ConnectionlessCommand.getchallenge -> { - val challenge = SVC_GetChallenge(from) + val challenge = getChallenge(from) Netchan.sendConnectionlessPacket( Defines.NS_SERVER, from, @@ -417,8 +394,8 @@ class ServerHost : AutoCloseable { ) } - ConnectionlessCommand.connect -> SVC_DirectConnect(args, from) - ConnectionlessCommand.rcon -> SVC_RemoteCommand(args, from) + ConnectionlessCommand.connect -> connectClient(args, from) + ConnectionlessCommand.rcon -> executeRemoteCommand(args, from) else -> Com.Printf("Unknown ClientConnectionlessCommand: " + c + '\n') } } @@ -440,18 +417,18 @@ class ServerHost : AutoCloseable { Lib.rand() // check timeouts - SV_CheckTimeouts() + checkTimeouts() // get packets from clients - SV_ReadPackets() + readPackets() // identify if we need to update the game or it's too early (sleep or skip this update). // fixedtime - the time of the next scheduled game update. - var delay = fixedtime - realtime + var delay = nextFrameTime - realtime if (delay > 0) { // never let the time get too far off if (delay > 100) { // how is it even possible? - for (game in games.values) game.realtime = (fixedtime - 100) + for (game in games.values) game.realtime = (nextFrameTime - 100) delay = 100 } NET.Sleep(delay.toInt()) @@ -460,25 +437,23 @@ class ServerHost : AutoCloseable { // move autonomous things around if enough time has passed frameNum++ - fixedtime = 100 * frameNum + nextFrameTime = 100 * frameNum // update ping based on the last known frame from all clients - SV_CalcPings() + updateClientPings() // give the clients some timeslices - SV_GiveMsec() + replenishClientMoveBudgets() // let everything in the world think and move for (game in games.values) { - game.SV_RunGameFrame(fixedtime.toLong()) + game.SV_RunGameFrame(nextFrameTime.toLong()) } // send messages back to the clients that had packets read this frame - SV_SendClientMessages() - + sendClientMessages() // send a heartbeat to the master if needed - //Master_Heartbeat(); // clear teleport flags, etc for next frame for (game in games.values) { @@ -486,22 +461,22 @@ class ServerHost : AutoCloseable { } } - fun SV_SendClientMessages() { + fun sendClientMessages() { // send a message to each connected client for (c in clients) { if (c.state == ClientStates.CS_FREE) continue - val game: GameImportsImpl = games.get(c.gameName)!! + val game: GameImportsImpl = games[c.gameName]!! // if the reliable message overflowed, // drop the client val pendingReliableSize = - c.netchan.reliablePending.stream().mapToInt { obj: NetworkMessage? -> obj!!.getSize() }.sum() + c.netchan.reliablePending.sumOf { it.size } if (pendingReliableSize > Defines.MAX_MSGLEN - 16) { c.netchan.reliablePending.clear() c.unreliable.clear() - SV_BroadcastPrintf(Defines.PRINT_HIGH, c.name + " overflowed\n", c.gameName) - SV_DropClient(c) + broadcastPrint(Defines.PRINT_HIGH, c.name + " overflowed\n", c.gameName) + dropClient(c) } if (game.state == ServerStates.SS_CINEMATIC || game.state == ServerStates.SS_DEMO || game.state == ServerStates.SS_PIC) { @@ -509,12 +484,12 @@ class ServerHost : AutoCloseable { c.netchan.transmit(null) } else if (c.state == ClientStates.CS_SPAWNED) { // don't overrun bandwidth - if (SV_RateDrop(c)) continue + if (shouldRateLimit(c)) continue sendClientDatagram(c, game) } else { // just update reliable if needed - if (c.netchan.reliablePending.size != 0 || Globals.curtime - c.netchan.last_sent > 1000) c.netchan.transmit( + if (c.netchan.reliablePending.isNotEmpty() || Globals.curtime - c.netchan.last_sent > 1000) c.netchan.transmit( null ) } @@ -526,14 +501,14 @@ class ServerHost : AutoCloseable { * unwillingly. This is NOT called if the entire server is quiting or * crashing. */ - fun SV_DropClient(client: ClientNetworkInfo) { + fun dropClient(client: ClientNetworkInfo) { // add the disconnect client.netchan.reliablePending.add(DisconnectMessage()) if (client.state == ClientStates.CS_SPAWNED) { // call the prog function for removing a client // this will remove the body, among other things - games.get(client.gameName)!!.gameExports.ClientDisconnect(client.edict) + games[client.gameName]!!.gameExports.ClientDisconnect(client.edict) } if (client.download != null) { @@ -547,10 +522,10 @@ class ServerHost : AutoCloseable { /** * Sends text to all active clients */ - fun SV_BroadcastPrintf(level: Int, message: String?, gameName: String?) { + fun broadcastPrint(level: Int, message: String?, gameName: String?) { // echo to console - if (Globals.dedicated.getTypedValue()) { + if (Globals.dedicated.typedValue) { Com.Printf(message) } @@ -568,7 +543,7 @@ class ServerHost : AutoCloseable { * Returns true if the client is over its current * bandwidth estimation and should not be sent another packet */ - fun SV_RateDrop(c: ClientNetworkInfo): Boolean { + fun shouldRateLimit(c: ClientNetworkInfo): Boolean { // never drop over the loopback if (c.netchan.remote_address.type == NetAddrType.NA_LOOPBACK) return false @@ -591,7 +566,7 @@ class ServerHost : AutoCloseable { /** * Reads packets from the network or loopback. */ - fun SV_ReadPackets() { + fun readPackets() { while (true) { val networkPacket = NET.receiveNetworkPacket( NET.ip_sockets[Defines.NS_SERVER], @@ -602,8 +577,8 @@ class ServerHost : AutoCloseable { if (networkPacket == null) break - if (networkPacket.isConnectionless()) { - SV_ConnectionlessPacket(networkPacket.from, networkPacket.connectionlessMessage) + if (networkPacket.isConnectionless) { + handleConnectionlessPacket(networkPacket.from, networkPacket.connectionlessMessage) continue } @@ -621,10 +596,10 @@ class ServerHost : AutoCloseable { if (cl.netchan.accept(networkPacket)) { // this is a valid, sequenced packet, so process it if (cl.state != ClientStates.CS_ZOMBIE) { - cl.lastmessage = games.get(cl.gameName)!!.realtime // don't timeout + cl.lastmessage = games[cl.gameName]!!.realtime // don't timeout val body = networkPacket.parseBodyFromClient(cl.netchan.incoming_sequence) - if (body == null) SV_DropClient(cl) - else SV_ExecuteClientMessage(cl, body) + if (body == null) dropClient(cl) + else handleClientMessages(cl, body) } } break @@ -632,50 +607,39 @@ class ServerHost : AutoCloseable { } } - /* - * =================== SV_ExecuteClientMessage - * - * The current net_message is parsed for the given client - * =================== - */ - fun SV_ExecuteClientMessage(cl: ClientNetworkInfo, clientMessages: MutableCollection) { + fun handleClientMessages(cl: ClientNetworkInfo, clientMessages: Collection) { // only allow one move command - var move_issued = false + var moveIssued = false var stringCmdCount = 0 - for (msg in clientMessages) { if (msg is EndOfClientPacketMessage) { break } else if (msg is StringCmdMessage) { - val m = msg // malicious users may try using too many string commands if (++stringCmdCount < MAX_STRINGCMDS) { - SV_ExecuteUserCommand(cl, m.command) + executeClientCommand(cl, msg.command) } if (cl.state == ClientStates.CS_ZOMBIE) { return // disconnect command } } else if (msg is UserInfoMessage) { - val m = msg - cl.userinfo = m.userInfo - SV_UserinfoChanged(cl) + cl.userinfo = msg.userInfo + updateClientUserinfo(cl) } else if (msg is MoveMessage) { - val m = msg - if (move_issued) return // someone is trying to cheat... - + if (moveIssued) return // someone is trying to cheat... - move_issued = true - val lastReceivedFrame = m.lastReceivedFrame + moveIssued = true + val lastReceivedFrame = msg.lastReceivedFrame if (lastReceivedFrame != cl.lastReceivedFrame) { cl.lastReceivedFrame = lastReceivedFrame if (cl.lastReceivedFrame > 0) { - val framaLatency = - games.get(cl.gameName)!!.realtime - cl.frames[cl.lastReceivedFrame and Defines.UPDATE_MASK].senttime - cl.frame_latency[cl.lastReceivedFrame and (Defines.LATENCY_COUNTS - 1)] = framaLatency + val frameLatency = + games[cl.gameName]!!.realtime - cl.frames[cl.lastReceivedFrame and Defines.UPDATE_MASK].senttime + cl.frame_latency[cl.lastReceivedFrame and (Defines.LATENCY_COUNTS - 1)] = frameLatency } } @@ -685,91 +649,87 @@ class ServerHost : AutoCloseable { } // if the checksum fails, ignore the rest of the packet - if (!m.valid) { + if (!msg.valid) { Com.Printf("Invalid crc\n") return } + if (!paused.typedValue) { + var droppedPackets = cl.netchan.dropped + if (droppedPackets < 20) { + //if (droppedPackets > 2) - if (!sv_paused.getTypedValue()) { - var net_drop = cl.netchan.dropped - if (net_drop < 20) { - //if (net_drop > 2) + // Com.Printf ("drop %i\n", droppedPackets); - // Com.Printf ("drop %i\n", net_drop); + while (droppedPackets > 2) { + processClientMove(cl, cl.lastcmd) - while (net_drop > 2) { - SV_ClientThink(cl, cl.lastcmd) - - net_drop-- + droppedPackets-- } - if (net_drop > 1) SV_ClientThink(cl, m.oldestCmd) + if (droppedPackets > 1) processClientMove(cl, msg.oldestCmd) - if (net_drop > 0) SV_ClientThink(cl, m.oldCmd) + if (droppedPackets > 0) processClientMove(cl, msg.oldCmd) } - SV_ClientThink(cl, m.newCmd) + processClientMove(cl, msg.newCmd) } // copy. - cl.lastcmd.set(m.newCmd) + cl.lastcmd.set(msg.newCmd) } else { Com.Printf("SV_ReadClientMessage: unknown command: " + msg.type + "\n") - SV_DropClient(cl) + dropClient(cl) } } } - fun SV_ClientThink(cl: ClientNetworkInfo, cmd: usercmd_t) { + fun processClientMove(cl: ClientNetworkInfo, cmd: usercmd_t) { cl.commandMsec -= cmd.msec.toInt() and 0xFF - if (cl.commandMsec < 0 && sv_enforcetime.getTypedValue()) { + if (cl.commandMsec < 0 && enforceMoveTime.typedValue) { Com.DPrintf("commandMsec underflow from " + cl.name + "\n") return } - games.get(cl.gameName)!!.gameExports.ClientThink(cl.edict, cmd) + games[cl.gameName]!!.gameExports.ClientThink(cl.edict, cmd) } /** * Pull specific info from a newly changed userinfo string into a more C * freindly form. */ - fun SV_UserinfoChanged(cl: ClientNetworkInfo) { + fun updateClientUserinfo(cl: ClientNetworkInfo) { // call prog code to allow overrides - games.get(cl.gameName)!!.gameExports.ClientUserinfoChanged(cl.edict, cl.userinfo) + games[cl.gameName]!!.gameExports.ClientUserinfoChanged(cl.edict, cl.userinfo) // name for C code cl.name = Info.Info_ValueForKey(cl.userinfo, "name") // mask off high bit - //TODO: masking for german umlaute - //for (i=0 ; i 0) { - cl.rate = Lib.atoi(`val`) + var userinfoValue = Info.Info_ValueForKey(cl.userinfo, "rate") + if (userinfoValue.isNotEmpty()) { + cl.rate = Lib.atoi(userinfoValue) if (cl.rate < 100) cl.rate = 100 if (cl.rate > 15000) cl.rate = 15000 } else cl.rate = 5000 // msg command - `val` = Info.Info_ValueForKey(cl.userinfo, "msg") - if (`val`.length > 0) { - cl.messagelevel = Lib.atoi(`val`) + userinfoValue = Info.Info_ValueForKey(cl.userinfo, "msg") + if (userinfoValue.isNotEmpty()) { + cl.messagelevel = Lib.atoi(userinfoValue) } } - fun SV_ExecuteUserCommand(cl: ClientNetworkInfo, command: String?) { + fun executeClientCommand(cl: ClientNetworkInfo, command: String?) { Com.dprintln("SV_ExecuteUserCommand:" + command) val args = Cmd.TokenizeString(command, true) if (args.isEmpty()) return - val game: GameImportsImpl = games.get(cl.gameName)!! - if (userCommands.containsKey(args.get(0))) { - userCommands.get(args.get(0))!!.execute(args, game, cl) + val game: GameImportsImpl = games[cl.gameName]!! + if (userCommands.containsKey(args[0])) { + userCommands[args[0]]!!.execute(args, game, cl) return } @@ -785,13 +745,13 @@ class ServerHost : AutoCloseable { * for a few seconds to make sure any final reliable message gets resent if * necessary. */ - fun SV_CheckTimeouts() { + fun checkTimeouts() { for (cl in clients) { if (cl.state == ClientStates.CS_FREE) continue - val game: GameImportsImpl = games.get(cl.gameName)!! - val droppoint = (game.realtime - 1000 * timeout.getTypedValue()).toInt() - val zombiepoint = (game.realtime - 1000 * zombietime.getTypedValue()).toInt() + val game: GameImportsImpl = games[cl.gameName]!! + val droppoint = (game.realtime - 1000 * timeout.typedValue).toInt() + val zombiepoint = (game.realtime - 1000 * zombieTimeout.typedValue).toInt() // message times may be wrong across a changelevel if (cl.lastmessage > game.realtime) cl.lastmessage = game.realtime @@ -801,8 +761,8 @@ class ServerHost : AutoCloseable { continue } if ((cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) && cl.lastmessage < droppoint) { - SV_BroadcastPrintf(Defines.PRINT_HIGH, cl.name + " timed out\n", cl.gameName) - SV_DropClient(cl) + broadcastPrint(Defines.PRINT_HIGH, cl.name + " timed out\n", cl.gameName) + dropClient(cl) cl.state = ClientStates.CS_FREE // don't bother with zombie state } } @@ -811,7 +771,7 @@ class ServerHost : AutoCloseable { /** * Updates the cl.ping variables. */ - fun SV_CalcPings() { + fun updateClientPings() { for (cl in clients) { if (cl.state != ClientStates.CS_SPAWNED) continue @@ -835,14 +795,14 @@ class ServerHost : AutoCloseable { * Every few frames, gives all clients an allotment of milliseconds for * their command moves. If they exceed it, assume cheating. */ - fun SV_GiveMsec() { + fun replenishClientMoveBudgets() { // why *1111 if ((frameNum and 15) != 0) return for (cl in clients) { if (cl.state == ClientStates.CS_FREE) continue - if ((games.get(cl.gameName)!!.frameNum and 15) != 0) continue + if ((games[cl.gameName]!!.frameNum and 15) != 0) continue cl.commandMsec = 1800 // 1600 + some slop } @@ -854,8 +814,8 @@ class ServerHost : AutoCloseable { * stuck on the outgoing message list, because the server is going to * totally exit after returning from this function. */ - private fun SV_FinalMessage(message: String?, reconnect: Boolean) { - val msgs: MutableCollection = ArrayList() + private fun sendFinalMessage(message: String?, reconnect: Boolean) { + val msgs: MutableCollection = ArrayList() msgs.add(PrintMessage(Defines.PRINT_HIGH, message)) if (reconnect) msgs.add(ReconnectMessage()) @@ -877,17 +837,17 @@ class ServerHost : AutoCloseable { */ fun stop(finalmsg: String?, reconnect: Boolean) { try { - if (!games.isEmpty()) { - if (!clients.isEmpty()) SV_FinalMessage(finalmsg, reconnect) + if (games.isNotEmpty()) { + if (clients.isNotEmpty()) sendFinalMessage(finalmsg, reconnect) Com.Printf("==== ShutdownGame ====\n") } } finally { games.clear() clients.clear() - Globals.server_state = ServerStates.SS_DEAD + publishLegacyServerState(ServerStates.SS_DEAD) realtime = 0 frameNum = 0 - fixedtime = 100 + nextFrameTime = 100 } } @@ -895,15 +855,12 @@ class ServerHost : AutoCloseable { * Resets the clients when maxclients changes */ fun resetClients() { - SV_FinalMessage("Server restarted", true) - val max = Cvar.getInstance().find("maxclients", Int::class.java).getTypedValue() + sendFinalMessage("Server restarted", true) + val max = Cvar.getInstance().find("maxclients").typedValue // Clear all clients - clients = ArrayList(max) - for (i in 0..("coop", Boolean::class.java) - val deathmatch = cvars.find("deathmatch", Boolean::class.java) - val maxclients = cvars.find("maxclients", Int::class.java) + val coop = cvars.find("coop") + val deathmatch = cvars.find("deathmatch") + val maxclients = cvars.find("maxclients") // get any latched variable changes (maxclients, etc) cvars.updateLatchedVars() - if (coop.getTypedValue() && deathmatch.getTypedValue()) { + if (coop.typedValue && deathmatch.typedValue) { Com.Printf("Deathmatch and Coop both set, disabling Coop\n") coop.forceSet(false) } // dedicated servers are can't be single player and are usually DM // so unless they explicity set coop, force it to deathmatch - if (Globals.dedicated.getTypedValue()) { - if (!coop.getTypedValue()) deathmatch.forceSet(true) + if (Globals.dedicated.typedValue) { + if (!coop.typedValue) deathmatch.forceSet(true) } - // set max clients based on game mode - if (deathmatch.getTypedValue()) { - if (maxclients.getTypedValue() <= 1) maxclients.forceSet(8) - } else if (coop.getTypedValue()) { - if (maxclients.getTypedValue() <= 1 || maxclients.getTypedValue() > 4) maxclients.forceSet(4) + if (deathmatch.typedValue) { + if (maxclients.typedValue <= 1) maxclients.forceSet(8) + } else if (coop.typedValue) { + if (maxclients.typedValue !in 2..4) maxclients.forceSet(4) } else { // non-deathmatch, non-coop is one player maxclients.forceSet(1) } - return maxclients.getTypedValue() > 1 + return maxclients.typedValue > 1 } - fun SV_Loadgame_f(args: MutableList) { + fun loadGame(args: List) { if (args.size != 2) { Com.Printf("USAGE: load \n") return @@ -962,8 +918,8 @@ class ServerHost : AutoCloseable { val saveGame: String try { - saveGame = ServerSaveJsonStore.normalizeSlot(args.get(1)) - } catch (e: IllegalArgumentException) { + saveGame = ServerSaveJsonStore.normalizeSlot(args[1]) + } catch (_: IllegalArgumentException) { Com.Printf("Bad save name.\n") return } @@ -982,15 +938,15 @@ class ServerHost : AutoCloseable { ServerSaveFiles(EngineWriteRoot.path()).copy(saveGame, "current") - val mapCommand = SV_ReadMapCommand() + val mapCommand = readSavedMapCommand() // go to the map val loaded = spawnServerInstance(ChangeMapInfo(mapCommand, false, true), 0) loaded.name = "default" - games.put("default", loaded) + games["default"] = loaded } - private fun SV_ReadMapCommand(): String { + private fun readSavedMapCommand(): String { Com.DPrintf("SV_ReadMapCommand()\n") try { @@ -1026,19 +982,19 @@ goes to map jail.bsp. Spins up a new game instance, to be used with either `map` or `join` (TBD) */ - fun SV_GameMap_f(args: MutableList) { + fun changeMap(args: List) { if (args.size < 2) { Com.Printf("USAGE: gamemap \n ") return } - val mapName = args.get(1) + val mapName = args[1] val clientIndex: Int - if (args.size > 2 && !args.get(2).isBlank()) { + if (args.size > 2 && args[2].isNotBlank()) { try { - clientIndex = args.get(2).toInt() - } catch (e: NumberFormatException) { + clientIndex = args[2].toInt() + } catch (_: NumberFormatException) { Com.Printf("USAGE: gamemap \n \n") return } @@ -1056,12 +1012,12 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) EngineWriteRoot.ensureParentDirectories(EngineWriteRoot.resolve("save/current/.keep")) // check for clearing the current savegame - if (mapName.get(0) == '*') { + if (mapName[0] == '*') { // wipe all the *.sav files ServerSaveFiles(EngineWriteRoot.path()).wipe("current") } else { // save the map just exited // note: only applicable to the default instance (subject to change in future) - val gameImports = games.get("default") + val gameImports = games["default"] if (gameImports != null && gameImports.state == ServerStates.SS_GAME) { // clear all the client inuse flags before saving so that // when the level is re-entered, the clients will spawn @@ -1096,21 +1052,21 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) // server just started, `map` cmd was used // start up the next map game.name = "default" - games.put(game.name, game) + games[game.name] = game for (cl in clients) { cl.gameName = "default" } } else { // preview of the multi instance mode - val cl = clients.get(clientIndex - 1) - val previousGame: GameImportsImpl = games.get(cl.gameName)!! + val cl = clients[clientIndex - 1] + val previousGame: GameImportsImpl = games[cl.gameName]!! previousGame.gameExports.ClientDisconnect(cl.edict) - games.put(game.name, game) + games[game.name] = game cl.gameName = game.name } // copy off the level to the autosave slot - if (!Globals.dedicated.getTypedValue()) { + if (!Globals.dedicated.typedValue) { game.SV_WriteServerFile(true) ServerSaveFiles(EngineWriteRoot.path()).copy("current", "save0") } @@ -1119,7 +1075,7 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) // when playing the regular flow, multi instances are not requried if (clientIndex == 0) { games.clear() - games.put("default", game) + games["default"] = game } } @@ -1127,17 +1083,17 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) * SV_Map */ fun spawnServerInstance(changeMapInfo: ChangeMapInfo, clientIndex: Int): GameImportsImpl { - Globals.server_state = ServerStates.SS_DEAD //fixme: used by the client code + publishLegacyServerState(ServerStates.SS_DEAD) val previous = if (clientIndex != 0) - games.get(clients.get(clientIndex - 1).gameName) + games[clients[clientIndex - 1].gameName] else - games.get("default") - if (previous != null) previous.prepareLevelTransition() + games["default"] + previous?.prepareLevelTransition() val gameImports = createGameInstance(changeMapInfo, previous) - Cvar.getInstance().find("nextserver", String::class.java) + Cvar.getInstance().find("nextserver") .set("gamemap \"" + changeMapInfo.nextServer + "\"") Cmd.ExecuteFunction("loading") // for local system @@ -1151,25 +1107,23 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) } gameImports.SV_BroadcastCommand("changing") } else { - val cl = clients.get(clientIndex - 1) + val cl = clients[clientIndex - 1] if (cl.state == ClientStates.CS_SPAWNED) cl.state = ClientStates.CS_CONNECTED cl.lastReceivedFrame = -1 gameImports.unicastMessage(clientIndex, StuffTextMessage("changing"), true) } - SV_SendClientMessages() - + sendClientMessages() // precache and static commands can be issued during // map initialization - Globals.server_state = gameImports.state // LOADING + publishLegacyServerState(gameImports.state) gameImports.initializeLevel(changeMapInfo, previous) - Globals.server_state = gameImports.state - + publishLegacyServerState(gameImports.state) // set serverinfo variable - Cvar.getInstance().find("mapname", String::class.java).forceSet(gameImports.mapName) + Cvar.getInstance().find("mapname").forceSet(gameImports.mapName) Com.Println("mapname: " + gameImports.mapName) if (changeMapInfo.state == ServerStates.SS_GAME) Cbuf.CopyToDefer() @@ -1192,15 +1146,15 @@ For development work Resets server to host a given map, moves all clients to it (via reconnect) */ - fun SV_Map_f(args: MutableList) { - //char expanded[MAX_QPATH]; - if (args.size < 2 || args.get(1).isBlank()) { + fun map(args: List) { + + if (args.size < 2 || args[1].isBlank()) { Com.Printf("usage: map \n") return } // if not a pcx, demo, or cinematic, check to make sure the level exists - val mapName = args.get(1) + val mapName = args[1] val target = ChangeMapInfo(mapName, false, false) if (target.state == ServerStates.SS_GAME) { val mapPath = "maps/" + target.mapName + ".bsp" @@ -1209,26 +1163,25 @@ Resets server to host a given map, moves all clients to it (via reconnect) return } } - val game = games.get("default") + val game = games["default"] if (game != null) game.state = ServerStates.SS_DEAD // don't save current level when changing - ServerSaveFiles(EngineWriteRoot.path()).wipe("current") // A running server needs its clients reset for map. For a fresh server, // gamemap performs this initialization itself. - if (!games.isEmpty()) { + if (games.isNotEmpty()) { initializeServerSession() } - SV_GameMap_f(args) + changeMap(args) } private fun initializeServerSession() { if (games.isEmpty()) { realtime = 0 frameNum = 0 - fixedtime = 100 + nextFrameTime = 100 } val multiplayer = initializeServerCvars() if (!NET.configureServer(multiplayer)) { @@ -1242,7 +1195,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) private fun sendClientDatagram(client: ClientNetworkInfo, gameImports: GameImportsImpl) { // send over all the relevant entity_state_t // and the player_state_t - val unreliable: MutableCollection = ArrayList() + val unreliable: MutableList = mutableListOf() unreliable.addAll(gameImports.buildClientFrameMessages(client)) // 3 messages // copy the accumulated multicast datagram @@ -1256,19 +1209,19 @@ Resets server to host a given map, moves all clients to it (via reconnect) // record the size for rate estimation client.message_size[gameImports.frameNum % Defines.RATE_MESSAGES] = - unreliable.stream().mapToInt { obj: NetworkMessage? -> obj!!.getSize() }.sum() + unreliable.sumOf { it.size } } private val userCommands = mapOf( - StringCmdMessage.NEW to ServerUserCommand(::SV_New_f), - StringCmdMessage.CONFIG_STRINGS to ServerUserCommand(::SV_Configstrings_f), - StringCmdMessage.BASELINES to ServerUserCommand(::SV_Baselines_f), - StringCmdMessage.BEGIN to ServerUserCommand(::SV_Begin_f), - StringCmdMessage.NEXT_SERVER to ServerUserCommand(::SV_Nextserver_f), - StringCmdMessage.DISCONNECT to ServerUserCommand(::SV_Disconnect_f), - StringCmdMessage.INFO to ServerUserCommand(::SV_ShowServerinfo_f), - StringCmdMessage.DOWNLOAD to ServerUserCommand(::SV_BeginDownload_f), - StringCmdMessage.NEXT_DOWNLOAD to ServerUserCommand(::SV_NextDownload_f), + StringCmdMessage.NEW to ServerUserCommand { _, game, client -> sendInitialGameData(game, client) }, + StringCmdMessage.CONFIG_STRINGS to ServerUserCommand(::sendConfigstrings), + StringCmdMessage.BASELINES to ServerUserCommand(::sendBaselines), + StringCmdMessage.BEGIN to ServerUserCommand(::beginClient), + StringCmdMessage.NEXT_SERVER to ServerUserCommand(::finishCinematic), + StringCmdMessage.DISCONNECT to ServerUserCommand { _, _, client -> dropClient(client) }, + StringCmdMessage.INFO to ServerUserCommand { _, _, _ -> Info.Print(Cvar.getInstance().Serverinfo()) }, + StringCmdMessage.DOWNLOAD to ServerUserCommand { args, _, client -> beginDownload(args, client) }, + StringCmdMessage.NEXT_DOWNLOAD to ServerUserCommand { _, _, client -> sendNextDownloadChunk(client) }, ) /** @@ -1276,53 +1229,53 @@ Resets server to host a given map, moves all clients to it (via reconnect) */ init { val cvars = Cvar.getInstance() - cvars.register( + cvars.register( "spectator_password", CvarTypes.STRING, "", Defines.CVAR_USERINFO, "Spectator admission password; empty or none allows access" ) - cvars.register( + cvars.register( "needpass", NEEDPASS_TYPE, 0, Defines.CVAR_SERVERINFO, "Required passwords: 0 none, 1 player, 2 spectator, 3 both" ) - cvars.register("deathmatch", CvarTypes.BOOLEAN, false, Defines.CVAR_SERVERINFO or Defines.CVAR_LATCH) - cvars.register("coop", CvarTypes.BOOLEAN, false, Defines.CVAR_SERVERINFO or Defines.CVAR_LATCH) - cvars.register( + cvars.register("deathmatch", CvarTypes.BOOLEAN, false, Defines.CVAR_SERVERINFO or Defines.CVAR_LATCH) + cvars.register("coop", CvarTypes.BOOLEAN, false, Defines.CVAR_SERVERINFO or Defines.CVAR_LATCH) + cvars.register( "dmflags", CvarTypes.INTEGER, Defines.DF_INSTANT_ITEMS, Defines.CVAR_SERVERINFO, "Deathmatch rule bitmask; combine DF_* flags" ) - cvars.register( + cvars.register( "fraglimit", CvarTypes.IntegerType(0, null), 0, Defines.CVAR_SERVERINFO, "End a deathmatch level at this score; 0 disables the limit" ) - cvars.register( + cvars.register( "timelimit", CvarTypes.FloatType(0f, null), 0f, Defines.CVAR_SERVERINFO, "Deathmatch level time limit in minutes; 0 disables the limit" ) - cvars.register("cheats", CvarTypes.BOOLEAN, false, Defines.CVAR_SERVERINFO or Defines.CVAR_LATCH) - cvars.register( + cvars.register("cheats", CvarTypes.BOOLEAN, false, Defines.CVAR_SERVERINFO or Defines.CVAR_LATCH) + cvars.register( "protocol", CvarTypes.INTEGER, Defines.PROTOCOL_VERSION, Defines.CVAR_SERVERINFO or Defines.CVAR_NOSET ) - cvars.register("mapname", CvarTypes.STRING, "", Defines.CVAR_SERVERINFO or Defines.CVAR_NOSET) - cvars.register( + cvars.register("mapname", CvarTypes.STRING, "", Defines.CVAR_SERVERINFO or Defines.CVAR_NOSET) + cvars.register( "nextserver", CvarTypes.STRING, "", 0, "Console command to execute after a cinematic or picture" ) - cvars.register( + cvars.register( "maxclients", CvarTypes.IntegerType(1, Defines.MAX_CLIENTS), 1, @@ -1330,42 +1283,42 @@ Resets server to host a given map, moves all clients to it (via reconnect) "Client slot limit; single-player and coop mode also restrict the count" ) - hostname = cvars.register( + hostname = cvars.register( "hostname", CvarTypes.STRING, "noname", Defines.CVAR_SERVERINFO or Defines.CVAR_ARCHIVE ) - timeout = cvars.register( + timeout = cvars.register( "timeout", CvarTypes.FloatType(0f, null), 125f, 0, "Seconds without a client message before disconnection" ) - zombietime = cvars.register( + zombieTimeout = cvars.register( "zombietime", CvarTypes.FloatType(0f, null), 2f, 0, "Seconds to retain a disconnected client slot for final reliable messages" ) - sv_showclamp = cvars.register( + cvars.register( "showclamp", CvarTypes.BOOLEAN, false, 0, "Legacy frame-time clamp diagnostics; unused by the current server and Cake" ) - sv_paused = cvars.register("paused", CvarTypes.BOOLEAN, false, 0) - sv_timedemo = cvars.register( + paused = cvars.register("paused", CvarTypes.BOOLEAN, false, 0) + cvars.register( "timedemo", CvarTypes.BOOLEAN, false, 0, "Legacy demo benchmarking switch; unused by the current server and Cake" ) - sv_enforcetime = cvars.register( + enforceMoveTime = cvars.register( "sv_enforcetime", CvarTypes.BOOLEAN, false, @@ -1374,34 +1327,34 @@ Resets server to host a given map, moves all clients to it (via reconnect) ) // come from MainCommon - allow_download = cvars.find("allow_download", Boolean::class.java) - allow_download_players = cvars.find("allow_download_players", Boolean::class.java) - allow_download_models = cvars.find("allow_download_models", Boolean::class.java) - allow_download_sounds = cvars.find("allow_download_sounds", Boolean::class.java) - allow_download_maps = cvars.find("allow_download_maps", Boolean::class.java) + allowDownload = cvars.find("allow_download") + allowPlayerDownloads = cvars.find("allow_download_players") + allowModelDownloads = cvars.find("allow_download_models") + allowSoundDownloads = cvars.find("allow_download_sounds") + allowMapDownloads = cvars.find("allow_download_maps") - cvars.register( + cvars.register( "sv_noreload", CvarTypes.BOOLEAN, false, 0, "Start revisited levels fresh instead of restoring their saved state" ) - sv_airaccelerate = cvars.register( + cvars.register( "sv_airaccelerate", CvarTypes.FLOAT, 0f, Defines.CVAR_LATCH, "Controls air acceleration (currently only for deathmatch)" ) - public_server = cvars.register( + cvars.register( "public", CvarTypes.BOOLEAN, false, 0, "Legacy master-server advertising switch; currently unused" ) - sv_reconnect_limit = cvars.register( + reconnectLimit = cvars.register( "sv_reconnect_limit", CvarTypes.FloatType(0f, null), 3f, @@ -1411,71 +1364,64 @@ Resets server to host a given map, moves all clients to it (via reconnect) resetClients() // Publish Host callbacks only after initialization succeeds. They survive Game replacement. - commands.add("heartbeat", true, Command { agrs: MutableList -> this.SV_Heartbeat_f(agrs) }) - commands.add("kick", true, Command { args: MutableList -> this.SV_Kick_f(args) }) - commands.add("status", true, Command { args: MutableList -> this.SV_Status_f(args) }) - commands.add("serverinfo", true, Command { args: MutableList -> this.SV_Serverinfo_f(args) }) - commands.add("dumpuser", true, Command { args: MutableList -> this.SV_DumpUser_f(args) }) - commands.add("say", true, Command { args: MutableList -> this.SV_ConSay_f(args) }) - commands.add("save", true, Command { args: MutableList -> this.saveGame(args) }) - - commands.add("killserver", Command { args: MutableList -> + // Retained for console compatibility; master-server advertising is not implemented. + commands.add("heartbeat", true) {} + commands.add("kick", true, Command(::kickClient)) + commands.add("status", true) { printStatus() } + commands.add("serverinfo", true) { printServerInfo() } + commands.add("dumpuser", true, Command(::dumpUser)) + commands.add("say", true, Command(::say)) + commands.add("save", true, Command(::saveGame)) + + commands.add("killserver") { stop("Server was killed.\n", false) NET.configureServer(false) - }) - commands.add("map", Command { args: MutableList -> this.SV_Map_f(args) }) - commands.add("gamemap", Command { args: MutableList -> this.SV_GameMap_f(args) }) - commands.add("load", Command { args: MutableList -> this.SV_Loadgame_f(args) }) - commands.add("sv_shutdown", Command { args: MutableList -> + } + commands.add("map", Command(::map)) + commands.add("gamemap", Command(::changeMap)) + commands.add("load", Command(::loadGame)) + commands.add("sv_shutdown") { args -> val reason: String? if (args.size > 1) { - reason = args.get(1) + reason = args[1] } else { reason = "Server is shut down" } - stop(reason + "\n", args.size > 2 && args.get(2).toBoolean()) - }) + stop(reason + "\n", args.size > 2 && args[2].toBoolean()) + } - commands.add("listmaps", Command { args: MutableList -> + commands.add("listmaps") { val bytes = EngineVfs.loadBytes("maps.lst") if (bytes == null) { Com.Error(Defines.ERR_DROP, "Could not read maps.lst") - return@Command + return@add } for (line in String(bytes).split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()) { Com.Printf(Defines.PRINT_ALL, line.trim { it <= ' ' } + "\n") } - }) - commands.add("jvm_memory", Command { args: MutableList -> + } + commands.add("jvm_memory") { val runtime = Runtime.getRuntime() Com.Printf("vm memory:" + (runtime.totalMemory() - runtime.freeMemory()) + "\n") - }) - commands.add("games", Command { args: MutableList -> + } + commands.add("games") { for (game in games.values) { Com.Printf(game.name + " - " + game.mapcmd + "\n") } Com.Printf("Total " + games.size + " games\n") - }) - commands.add("spamgames", Command { args: MutableList -> - val total = args.get(1).toInt() - for (i in 0..("gamemap", "base1", "1")) + } + commands.add("spamgames") { args -> + val total = args[1].toInt() + repeat(total) { + changeMap(listOf("gamemap", "base1", "1")) } - }) + } } private fun interface ServerUserCommand { - fun execute(args: MutableList, game: GameImportsImpl, client: ClientNetworkInfo) + fun execute(args: List, game: GameImportsImpl, client: ClientNetworkInfo) } - /* - * ============================================================ - * - * USER STRINGCMD EXECUTION - * - * sv_client and sv_player will be valid. - * ============================================================ - */ /* * ================ SV_New_f * @@ -1483,7 +1429,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) * be sent on the initial connection and upon each server load. * ================ */ - private fun SV_New_f(args: MutableList, gameImports: GameImportsImpl, client: ClientNetworkInfo) { + private fun sendInitialGameData(gameImports: GameImportsImpl, client: ClientNetworkInfo) { Com.DPrintf("New() from " + client.name + "\n") if (client.state != ClientStates.CS_CONNECTED) { @@ -1495,7 +1441,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) // serverdata needs to go over for all types of servers // to make sure the protocol is right, and to set the gamedir // - val gamedir = Cvar.getInstance().find("gamedir", String::class.java).getTypedValue() + val gamedir = Cvar.getInstance().find("gamedir").typedValue // send the serverdata // send full levelname @@ -1503,7 +1449,6 @@ Resets server to host a given map, moves all clients to it (via reconnect) if (gameImports.state == ServerStates.SS_CINEMATIC || gameImports.state == ServerStates.SS_PIC) clientIndex = -1 else clientIndex = client.edict.index - 1 - client.netchan.reliablePending.add( ServerDataMessage( Defines.PROTOCOL_VERSION, @@ -1537,11 +1482,8 @@ Resets server to host a given map, moves all clients to it (via reconnect) } } - /* - * ================== SV_Configstrings_f ================== - */ - private fun SV_Configstrings_f( - args: MutableList, + private fun sendConfigstrings( + args: List, gameImports: GameImportsImpl, client: ClientNetworkInfo ) { @@ -1553,22 +1495,22 @@ Resets server to host a given map, moves all clients to it (via reconnect) } // handle the case of a level changing while a client was connecting - val spawnCount = if (args.size >= 2) Lib.atoi(args.get(1)) else 0 + val spawnCount = if (args.size >= 2) Lib.atoi(args[1]) else 0 if (spawnCount != gameImports.spawncount) { Com.Printf("SV_Configstrings_f from different level\n") - SV_New_f(args, gameImports, client) + sendInitialGameData(gameImports, client) return } - var start = if (args.size >= 3) Lib.atoi(args.get(2)) else 0 + var start = if (args.size >= 3) Lib.atoi(args[2]) else 0 // write a packet full of data var currentReliableSize = - client.netchan.reliablePending.stream().mapToInt { obj: NetworkMessage? -> obj!!.getSize() }.sum() + client.netchan.reliablePending.sumOf { it.size } while (currentReliableSize < Defines.MAX_MSGLEN / 2 && start < Defines.MAX_CONFIGSTRINGS) { - if (gameImports.configstrings[start] != null && gameImports.configstrings[start].length != 0) { + if (gameImports.configstrings[start] != null && gameImports.configstrings[start].isNotEmpty()) { val config = ConfigStringMessage(start, gameImports.configstrings[start]) - currentReliableSize += config.getSize() + currentReliableSize += config.size client.netchan.reliablePending.add(config) } start++ @@ -1585,10 +1527,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) client.netchan.reliablePending.add(StuffTextMessage(nextCmd)) } - /* - * ================== SV_Baselines_f ================== - */ - private fun SV_Baselines_f(args: MutableList, gameImports: GameImportsImpl, client: ClientNetworkInfo) { + private fun sendBaselines(args: List, gameImports: GameImportsImpl, client: ClientNetworkInfo) { Com.DPrintf("Baselines() from " + client.name + "\n") if (client.state != ClientStates.CS_CONNECTED) { @@ -1597,24 +1536,24 @@ Resets server to host a given map, moves all clients to it (via reconnect) } // handle the case of a level changing while a client was connecting - val spawnCount = if (args.size >= 2) Lib.atoi(args.get(1)) else 0 + val spawnCount = if (args.size >= 2) Lib.atoi(args[1]) else 0 if (spawnCount != gameImports.spawncount) { Com.Printf("SV_Baselines_f from different level\n") - SV_New_f(args, gameImports, client) + sendInitialGameData(gameImports, client) return } // todo: validate argument - var start = if (args.size >= 3) Lib.atoi(args.get(2)) else 0 + var start = if (args.size >= 3) Lib.atoi(args[2]) else 0 // write a packet full of data var currentReliableSize = - client.netchan.reliablePending.stream().mapToInt { obj: NetworkMessage? -> obj!!.getSize() }.sum() + client.netchan.reliablePending.sumOf { it.size } while (currentReliableSize < Defines.MAX_MSGLEN / 2 && start < Defines.MAX_EDICTS) { val base = gameImports.baselines[start] if (base.modelindex != 0 || base.sound != 0 || base.effects != 0) { val spawn = SpawnBaselineMessage(base) - currentReliableSize += spawn.getSize() + currentReliableSize += spawn.size client.netchan.reliablePending.add(spawn) } start++ @@ -1632,17 +1571,14 @@ Resets server to host a given map, moves all clients to it (via reconnect) client.netchan.reliablePending.add(StuffTextMessage(nextCmd)) } - /* - * ================== SV_Begin_f ================== - */ - private fun SV_Begin_f(args: MutableList, gameImports: GameImportsImpl, client: ClientNetworkInfo) { + private fun beginClient(args: List, gameImports: GameImportsImpl, client: ClientNetworkInfo) { Com.DPrintf("Begin() from " + client.name + "\n") // handle the case of a level changing while a client was connecting - val spawnCount = if (args.size >= 2) Lib.atoi(args.get(1)) else 0 + val spawnCount = if (args.size >= 2) Lib.atoi(args[1]) else 0 if (spawnCount != gameImports.spawncount) { Com.Printf("SV_Begin_f from different level\n") - SV_New_f(args, gameImports, client) + sendInitialGameData(gameImports, client) return } @@ -1654,15 +1590,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) Cbuf.InsertFromDefer() } - //============================================================================= - /* - * ================== SV_NextDownload_f ================== - */ - private fun SV_NextDownload_f( - args: MutableList, - gameImports: GameImportsImpl?, - client: ClientNetworkInfo - ) { + private fun sendNextDownloadChunk(client: ClientNetworkInfo) { if (client.download == null) return var packet = client.downloadsize - client.downloadcount @@ -1682,36 +1610,31 @@ Resets server to host a given map, moves all clients to it (via reconnect) } } - /* - * ================== SV_BeginDownload_f ================== - */ - private fun SV_BeginDownload_f( - args: MutableList, - gameImports: GameImportsImpl?, + private fun beginDownload( + args: List, client: ClientNetworkInfo ) { var offset = 0 if (args.size < 2) return - val name = args.get(1) - - if (args.size > 2) offset = Lib.atoi(args.get(2)) // downloaded offset + val name = args[1] + if (args.size > 2) offset = Lib.atoi(args[2]) // downloaded offset // hacked by zoid to allow more conrol over download // first off, no .. or global allow check if (name.contains("..") - || !allow_download.getTypedValue() // leading dot is no good - || name.get(0) == '.' // leading slash bad as well, must be + || !allowDownload.typedValue // leading dot is no good + || name[0] == '.' // leading slash bad as well, must be // in subdir - || name.get(0) == '/' // next up, skin check - || (name.startsWith("players/") && !allow_download_players.getTypedValue()) // now + || name[0] == '/' // next up, skin check + || (name.startsWith("players/") && !allowPlayerDownloads.typedValue) // now // models - || (name.startsWith("models/") && !allow_download_models.getTypedValue()) // now + || (name.startsWith("models/") && !allowModelDownloads.typedValue) // now // sounds - || (name.startsWith("sound/") && !allow_download_sounds.getTypedValue()) // now maps (note special case for maps, must not be in pak) - || (name.startsWith("maps/") && !allow_download_maps.getTypedValue()) // MUST + || (name.startsWith("sound/") && !allowSoundDownloads.typedValue) // now maps (note special case for maps, must not be in pak) + || (name.startsWith("maps/") && !allowMapDownloads.typedValue) // MUST // be // in a // subdirectory @@ -1751,53 +1674,27 @@ Resets server to host a given map, moves all clients to it (via reconnect) return } - SV_NextDownload_f(args, gameImports, client) + sendNextDownloadChunk(client) Com.DPrintf("Downloading " + name + " to " + client.name + "\n") } - //============================================================================ - /* - * ================= SV_Disconnect_f - * - * The client is going to disconnect, so remove the connection immediately - * ================= - */ - private fun SV_Disconnect_f(args: MutableList, gameImports: GameImportsImpl?, client: ClientNetworkInfo) { - // SV_EndRedirect (); - SV_DropClient(client) - } - - /* - * ================== SV_ShowServerinfo_f - * - * Dumps the serverinfo info string ================== - */ - private fun SV_ShowServerinfo_f( - args: MutableList, - gameImports: GameImportsImpl?, - client: ClientNetworkInfo? - ) { - Info.Print(Cvar.getInstance().Serverinfo()) - } - - private fun SV_Nextserver(gameImports: GameImportsImpl) { + private fun advanceCinematic(gameImports: GameImportsImpl) { //ZOID, ss_pic can be nextserver'd in coop mode if (gameImports.state == ServerStates.SS_GAME || (gameImports.state == ServerStates.SS_PIC && !Cvar.getInstance() - .find("coop", Boolean::class.java).getTypedValue()) + .find("coop").typedValue) ) return // can't nextserver while playing a normal game - gameImports.spawncount++ // make sure another doesn't sneak in - val v = Cvar.getInstance().find("nextserver", String::class.java).getTypedValue() - //if (!v[0]) - if (v.length == 0) Cbuf.AddText("killserver\n") + val v = Cvar.getInstance().find("nextserver").typedValue + + if (v.isEmpty()) Cbuf.AddText("killserver\n") else { Cbuf.AddText(v) Cbuf.AddText("\n") } - Cvar.getInstance().find("nextserver", String::class.java).set("") + Cvar.getInstance().find("nextserver").set("") } /* @@ -1806,32 +1703,30 @@ Resets server to host a given map, moves all clients to it (via reconnect) * A cinematic has completed or been aborted by a client, so move to the * next server, ================== */ - private fun SV_Nextserver_f(args: MutableList, gameImports: GameImportsImpl, client: ClientNetworkInfo) { - val spawnCount = if (args.size >= 2) Lib.atoi(args.get(1)) else 0 + private fun finishCinematic(args: List, gameImports: GameImportsImpl, client: ClientNetworkInfo) { + val spawnCount = if (args.size >= 2) Lib.atoi(args[1]) else 0 if (spawnCount != gameImports.spawncount) { Com.DPrintf("Nextserver() from wrong level, from " + client.name + "\n") return // leftover from last server } Com.DPrintf("Nextserver() from " + client.name + "\n") - SV_Nextserver(gameImports) + advanceCinematic(gameImports) } /** Resolve a Game at execution time; creating another Game never replaces these callbacks. */ - private fun saveGame(args: MutableList) { + private fun saveGame(args: List) { var target = "default" - var command: MutableList = args - if (args.size > 1 && args.get(1) == "--game") { + var command = args + if (args.size > 1 && args[1] == "--game") { if (args.size < 4) { - Com.Printf("Usage: " + args.get(0) + " --game \n") + Com.Printf("Usage: " + args[0] + " --game \n") return } - target = args.get(2) - command = ArrayList() - command.add(args.get(0)) - command.addAll(args.subList(3, args.size)) + target = args[2] + command = listOf(args[0]) + args.drop(3) } - val game = games.get(target) + val game = games[target] if (game == null) { Com.Printf("No running game: " + target + "\n") return @@ -1839,35 +1734,22 @@ Resets server to host a given map, moves all clients to it (via reconnect) game.saveGame(command) } - /* - ================== - SV_Kick_f - - Kick a user off of the server - ================== - */ - private fun SV_Kick_f(args: MutableList) { + private fun kickClient(args: List) { if (args.size != 2) { Com.Printf("Usage: kick \n") return } - val client = SV_SetPlayer(args) - if (client == null) return + val client = findClient(args) ?: return - SV_BroadcastPrintf(Defines.PRINT_HIGH, client.name + " was kicked\n", client.gameName) + broadcastPrint(Defines.PRINT_HIGH, client.name + " was kicked\n", client.gameName) // print directly, because the dropped client won't get the // SV_BroadcastPrintf message client.queuePrint(Defines.PRINT_HIGH, "You were kicked from the game\n") - SV_DropClient(client) - client.lastmessage = games.get(client.gameName)!!.realtime // min case there is a funny zombie + dropClient(client) + client.lastmessage = games[client.gameName]!!.realtime // min case there is a funny zombie } - /* - ================ - SV_Status_f - ================ - */ - private fun SV_Status_f(args: MutableList) { + private fun printStatus() { // todo: check if (games.isEmpty()) { Com.Printf("No server running.\n") @@ -1879,59 +1761,38 @@ Resets server to host a given map, moves all clients to it (via reconnect) Com.Printf("--- ----- ---- --------------- ------- --------------------- ------\n") for (i in clients.indices) { - val cl = clients.get(i) + val cl = clients[i] if (ClientStates.CS_FREE == cl.state) continue - Com.Printf("%3i ", Vargs().add(i)) - Com.Printf("%5i ", Vargs().add(cl.edict.getClient().getPlayerState().stats[Defines.STAT_FRAGS])) + Com.Printf(i.toString().padStart(3) + " ") + Com.Printf(cl.edict.getClient().getPlayerState().stats[Defines.STAT_FRAGS].toString().padStart(5) + " ") if (cl.state == ClientStates.CS_CONNECTED) Com.Printf("CNCT ") else if (cl.state == ClientStates.CS_ZOMBIE) Com.Printf("ZMBI ") else { val ping = if (cl.ping < 9999) cl.ping else 9999 - Com.Printf("%4i ", Vargs().add(ping)) - } - - Com.Printf("%s", Vargs().add(cl.name)) - var l = 16 - cl.name.length - var j: Int - j = 0 - while (j < l) { - Com.Printf(" ") - j++ - } - - Com.Printf("%7i ", Vargs().add(games.get(cl.gameName)!!.realtime - cl.lastmessage)) - - val s = cl.netchan.remote_address.toString() - Com.Printf(s) - l = 22 - s.length - j = 0 - while (j < l) { - Com.Printf(" ") - j++ + Com.Printf(ping.toString().padStart(4) + " ") } - Com.Printf("%5i", Vargs().add(cl.netchan.qport)) + Com.Printf(cl.name.padEnd(16)) + val lastMessageAge = games[cl.gameName]!!.realtime - cl.lastmessage + Com.Printf(lastMessageAge.toString().padStart(7) + " ") + Com.Printf(cl.netchan.remote_address.toString().padEnd(22)) + Com.Printf(cl.netchan.qport.toString().padStart(5)) Com.Printf("\n") } Com.Printf("\n") } - /* - ================== - SV_ConSay_f - ================== - */ - private fun SV_ConSay_f(args: MutableList) { + private fun say(args: List) { if (args.size < 2) return // char[1024]; var text = "console: " var p = Cmd.getArguments(args) - if (!p.isEmpty() && p.get(0) == '"') { + if (p.isNotEmpty() && p[0] == '"') { p = p.substring(1, p.length - 1) } @@ -1943,40 +1804,17 @@ Resets server to host a given map, moves all clients to it (via reconnect) } } - /* - ================== - SV_Heartbeat_f - ================== - */ - private fun SV_Heartbeat_f(agrs: MutableList) { - } - - /* - =========== - SV_Serverinfo_f - - Examine or change the serverinfo string - =========== - */ - private fun SV_Serverinfo_f(args: MutableList) { + private fun printServerInfo() { Com.Printf("Server info settings:\n") Info.Print(Cvar.getInstance().Serverinfo()) } - /* - =========== - SV_DumpUser_f - - Examine all a users info strings - =========== - */ - private fun SV_DumpUser_f(args: MutableList) { + private fun dumpUser(args: List) { if (args.size != 2) { Com.Printf("Usage: info \n") return } - val client = SV_SetPlayer(args) - if (client == null) return + val client = findClient(args) ?: return Com.Printf("userinfo\n") Com.Printf("--------\n") @@ -1986,21 +1824,21 @@ Resets server to host a given map, moves all clients to it (via reconnect) /** * Sets sv_client and sv_player to the player with idnum Cmd.Argv(1) */ - private fun SV_SetPlayer(args: MutableList): ClientNetworkInfo? { + private fun findClient(args: List): ClientNetworkInfo? { if (args.size < 2) return null - val idOrName = args.get(1) + val idOrName = args[1] // numeric values are just slot numbers // fixme: player name cannot start with a number? - if (!idOrName.isEmpty() && idOrName.get(0) >= '0' && idOrName.get(0) <= '9') { + if (idOrName.isNotEmpty() && idOrName[0] >= '0' && idOrName[0] <= '9') { val id = Lib.atoi(idOrName) if (id < 0 || id >= clients.size) { Com.Printf("Bad client slot: " + id + "\n") return null } - val result = clients.get(id) + val result = clients[id] if (ClientStates.CS_FREE == result.state) { Com.Printf("Client " + id + " is not active\n") return null @@ -2022,26 +1860,26 @@ Resets server to host a given map, moves all clients to it (via reconnect) companion object { // Recreating an embedded manager must reuse the same choice declaration. - private val NEEDPASS_TYPE = ChoiceType(mutableListOf(0, 1, 2, 3)) + private val NEEDPASS_TYPE = ChoiceType(listOf(0, 1, 2, 3)) private const val MAX_STRINGCMDS = 8 /** * Checks if the rcon password is corect. */ - private fun Rcon_Validate(args: MutableList): Boolean { - val rconPassword = Cvar.getInstance().find("rcon_password", String::class.java).getTypedValue() + private fun isRconAuthorized(args: List): Boolean { + val rconPassword = Cvar.getInstance().find("rcon_password").typedValue if (rconPassword.isEmpty()) return false - return args.size >= 2 && args.get(1) == rconPassword + return args.size >= 2 && args[1] == rconPassword } /** * A client issued an rcon command. Shift down the remaining args Redirect * all printfs from the server to the client. */ - private fun SVC_RemoteCommand(args: MutableList, from: netadr_t?) { - val rconIsValid: Boolean = Rcon_Validate(args) + private fun executeRemoteCommand(args: List, from: netadr_t?) { + val rconIsValid: Boolean = isRconAuthorized(args) if (rconIsValid) { Com.Printf("Rcon from " + from + ":\n" + args + "\n") @@ -2049,15 +1887,14 @@ Resets server to host a given map, moves all clients to it (via reconnect) Com.Printf("Bad rcon from " + from + ":\n" + args + "\n") } - - Com.BeginRedirect(RD_Flusher { buffer: StringBuilder? -> + Com.BeginRedirect { buffer -> Netchan.sendConnectionlessPacket( Defines.NS_SERVER, from, ConnectionlessCommand.print, "\n" + buffer.toString() ) - }) + } if (rconIsValid) { Cmd.ExecuteString(Cmd.getArguments(args, 2)) } else { From 4b741b529ce08a779b5041f4ca955c2259e57344 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 18:53:25 +0100 Subject: [PATCH 14/47] chore: cleanup ServerHost --- .../main/kotlin/jake2/server/ServerHost.kt | 93 +++++++++---------- 1 file changed, 45 insertions(+), 48 deletions(-) diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 7ee5adf2..7432298c 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -18,6 +18,7 @@ * */ // Created on 13.01.2004 by RST. +// former SV_MAIN package jake2.server import jake2.qcommon.* @@ -58,10 +59,8 @@ class ServerHost : AutoCloseable { /** Release this manager's console hooks after ending its last session. */ override fun close() { - try { + commands.use { stop("Server stopped\n", false) - } finally { - commands.close() } } @@ -71,7 +70,7 @@ class ServerHost : AutoCloseable { fun getClientsForInstance(gameName: String?): MutableList { if (!games.containsKey(gameName)) { // happens during the game instance creation, while the instance has random name - Com.Printf("Game " + gameName + " is not found!\n") + Com.Printf("Game $gameName is not found!\n") } // nullify unrelated clients return clients.map { client -> @@ -211,7 +210,7 @@ class ServerHost : AutoCloseable { Defines.NS_SERVER, adr, ConnectionlessCommand.print, "\nServer is version " + Globals.VERSION + "\n" ) - Com.DPrintf(" rejected connect from version " + version + "\n") + Com.DPrintf(" rejected connect from version $version\n") return } @@ -259,10 +258,10 @@ class ServerHost : AutoCloseable { && (cl.netchan.qport == qport || adr.port == cl.netchan.remote_address.port) ) { if (!adr.IsLocalAddress() && (realtime - cl.lastconnect) < (reconnectLimit.typedValue * 1000)) { - Com.DPrintf(adr.toString() + ":reconnect rejected : too soon\n") + Com.DPrintf("$adr:reconnect rejected : too soon\n") return } - Com.Printf(adr.toString() + ":reconnect\n") + Com.Printf("$adr:reconnect\n") initializeClientConnection(i, challenge, userinfo, adr, qport) return @@ -352,7 +351,7 @@ class ServerHost : AutoCloseable { val args = Cmd.TokenizeString(messageLine, false) if (args.isEmpty()) { - Com.Printf("empty connectionless packet: " + messageLine) + Com.Printf("empty connectionless packet: $messageLine") return } @@ -364,7 +363,7 @@ class ServerHost : AutoCloseable { Netchan.sendConnectionlessPacket(Defines.NS_SERVER, from, ConnectionlessCommand.ack, "") ConnectionlessCommand.ack -> //SVC_Ack - Com.Printf("Ping acknowledge from " + from + "\n") + Com.Printf("Ping acknowledge from $from\n") ConnectionlessCommand.status -> // SVC_Status, Responds with all the info that qplug or qspy can see Netchan.sendConnectionlessPacket( @@ -390,13 +389,13 @@ class ServerHost : AutoCloseable { Defines.NS_SERVER, from, ConnectionlessCommand.challenge, - " " + challenge + " $challenge" ) } ConnectionlessCommand.connect -> connectClient(args, from) ConnectionlessCommand.rcon -> executeRemoteCommand(args, from) - else -> Com.Printf("Unknown ClientConnectionlessCommand: " + c + '\n') + else -> Com.Printf("Unknown ClientConnectionlessCommand: $c\n") } } @@ -722,7 +721,7 @@ class ServerHost : AutoCloseable { } fun executeClientCommand(cl: ClientNetworkInfo, command: String?) { - Com.dprintln("SV_ExecuteUserCommand:" + command) + Com.dprintln("SV_ExecuteUserCommand:$command") val args = Cmd.TokenizeString(command, true) if (args.isEmpty()) return @@ -931,8 +930,8 @@ class ServerHost : AutoCloseable { // make sure the server metadata file exists val store = ServerSaveJsonStore.forWriteDir(EngineWriteRoot.pathString()) if (!store.hasMapCommand(saveGame)) { - val name = EngineWriteRoot.resolve("save/" + saveGame + "/server_mapcmd.ssv.json").toString() - Com.Printf("No such savegame: " + name + "\n") + val name = EngineWriteRoot.resolve("save/$saveGame/server_mapcmd.ssv.json").toString() + Com.Printf("No such savegame: $name\n") return } @@ -989,16 +988,14 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) } val mapName = args[1] - - val clientIndex: Int - if (args.size > 2 && args[2].isNotBlank()) { + val clientIndex: Int = if (args.size > 2 && args[2].isNotBlank()) { try { - clientIndex = args[2].toInt() + args[2].toInt() } catch (_: NumberFormatException) { Com.Printf("USAGE: gamemap \n \n") return } - } else clientIndex = 0 + } else 0 // default.cfg may start a session with "killserver; gamemap *...". // Shutdown clears the client slots, so gamemap must initialize a fresh @@ -1007,7 +1004,7 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) initializeServerSession() } - Com.DPrintf("SV_GameMap(" + mapName + ")\n") + Com.DPrintf("SV_GameMap($mapName)\n") EngineWriteRoot.ensureParentDirectories(EngineWriteRoot.resolve("save/current/.keep")) @@ -1159,7 +1156,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) if (target.state == ServerStates.SS_GAME) { val mapPath = "maps/" + target.mapName + ".bsp" if (!EngineVfs.exists(mapPath)) { - Com.Printf("Can't find " + mapPath + "\n") + Com.Printf("Can't find $mapPath\n") return } } @@ -1381,11 +1378,10 @@ Resets server to host a given map, moves all clients to it (via reconnect) commands.add("gamemap", Command(::changeMap)) commands.add("load", Command(::loadGame)) commands.add("sv_shutdown") { args -> - val reason: String? - if (args.size > 1) { - reason = args[1] + val reason: String? = if (args.size > 1) { + args[1] } else { - reason = "Server is shut down" + "Server is shut down" } stop(reason + "\n", args.size > 2 && args[2].toBoolean()) } @@ -1445,9 +1441,11 @@ Resets server to host a given map, moves all clients to it (via reconnect) // send the serverdata // send full levelname - val clientIndex: Int - if (gameImports.state == ServerStates.SS_CINEMATIC || gameImports.state == ServerStates.SS_PIC) clientIndex = -1 - else clientIndex = client.edict.index - 1 + val clientIndex: Int = if (gameImports.state == ServerStates.SS_CINEMATIC || gameImports.state == ServerStates.SS_PIC) { + -1 + } else { + client.edict.index - 1 + } client.netchan.reliablePending.add( ServerDataMessage( @@ -1517,12 +1515,10 @@ Resets server to host a given map, moves all clients to it (via reconnect) } // send next command - val nextCmd: String - - if (start == Defines.MAX_CONFIGSTRINGS) { - nextCmd = String.format("cmd %s %d 0", StringCmdMessage.BASELINES, gameImports.spawncount) + val nextCmd: String = if (start == Defines.MAX_CONFIGSTRINGS) { + String.format("cmd %s %d 0", StringCmdMessage.BASELINES, gameImports.spawncount) } else { - nextCmd = String.format("cmd %s %d %d", StringCmdMessage.CONFIG_STRINGS, gameImports.spawncount, start) + String.format("cmd %s %d %d", StringCmdMessage.CONFIG_STRINGS, gameImports.spawncount, start) } client.netchan.reliablePending.add(StuffTextMessage(nextCmd)) } @@ -1560,13 +1556,12 @@ Resets server to host a given map, moves all clients to it (via reconnect) } // send next command - val nextCmd: String - if (start == Defines.MAX_EDICTS) { + val nextCmd: String = if (start == Defines.MAX_EDICTS) { // finished sending baselines - nextCmd = String.format("%s %d", StringCmdMessage.PRECACHE, gameImports.spawncount) + String.format("%s %d", StringCmdMessage.PRECACHE, gameImports.spawncount) } else { // continue from where we finished - nextCmd = String.format("cmd %s %d %d", StringCmdMessage.BASELINES, gameImports.spawncount, start) + String.format("cmd %s %d %d", StringCmdMessage.BASELINES, gameImports.spawncount, start) } client.netchan.reliablePending.add(StuffTextMessage(nextCmd)) } @@ -1728,7 +1723,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) } val game = games[target] if (game == null) { - Com.Printf("No running game: " + target + "\n") + Com.Printf("No running game: $target\n") return } game.saveGame(command) @@ -1767,11 +1762,13 @@ Resets server to host a given map, moves all clients to it (via reconnect) Com.Printf(i.toString().padStart(3) + " ") Com.Printf(cl.edict.getClient().getPlayerState().stats[Defines.STAT_FRAGS].toString().padStart(5) + " ") - if (cl.state == ClientStates.CS_CONNECTED) Com.Printf("CNCT ") - else if (cl.state == ClientStates.CS_ZOMBIE) Com.Printf("ZMBI ") - else { - val ping = if (cl.ping < 9999) cl.ping else 9999 - Com.Printf(ping.toString().padStart(4) + " ") + when (cl.state) { + ClientStates.CS_CONNECTED -> Com.Printf("CNCT ") + ClientStates.CS_ZOMBIE -> Com.Printf("ZMBI ") + else -> { + val ping = if (cl.ping < 9999) cl.ping else 9999 + Com.Printf(ping.toString().padStart(4) + " ") + } } Com.Printf(cl.name.padEnd(16)) @@ -1834,13 +1831,13 @@ Resets server to host a given map, moves all clients to it (via reconnect) if (idOrName.isNotEmpty() && idOrName[0] >= '0' && idOrName[0] <= '9') { val id = Lib.atoi(idOrName) if (id < 0 || id >= clients.size) { - Com.Printf("Bad client slot: " + id + "\n") + Com.Printf("Bad client slot: $id\n") return null } val result = clients[id] if (ClientStates.CS_FREE == result.state) { - Com.Printf("Client " + id + " is not active\n") + Com.Printf("Client $id is not active\n") return null } return result @@ -1854,7 +1851,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) } } - Com.Printf("Userid " + idOrName + " is not on the server\n") + Com.Printf("Userid $idOrName is not on the server\n") return null } @@ -1882,9 +1879,9 @@ Resets server to host a given map, moves all clients to it (via reconnect) val rconIsValid: Boolean = isRconAuthorized(args) if (rconIsValid) { - Com.Printf("Rcon from " + from + ":\n" + args + "\n") + Com.Printf("Rcon from $from:\n$args\n") } else { - Com.Printf("Bad rcon from " + from + ":\n" + args + "\n") + Com.Printf("Bad rcon from $from:\n$args\n") } Com.BeginRedirect { buffer -> From ab8108b99df5d2af20b809243fc38c47d8cd1346 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 18:56:09 +0100 Subject: [PATCH 15/47] Group ServerHost members by responsibility --- .../main/kotlin/jake2/server/ServerHost.kt | 2199 +++++++++-------- 1 file changed, 1120 insertions(+), 1079 deletions(-) diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 7432298c..dca002d8 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -40,45 +40,15 @@ import jake2.server.save.ServerSaveJsonStore /** Owns client connections, Game routing, protocol and commands for one server Host. */ class ServerHost : AutoCloseable { + // region Host state and command dispatch + private val commands = ServerCommands() val isRunning: Boolean get() = games.isNotEmpty() - /** Bridge for client loading state and cvar latching until shared-global cleanup. */ - @Suppress("DEPRECATION") - private fun publishLegacyServerState(state: ServerStates) { - Globals.server_state = state - } - - /** Typed entrypoint for an embedded host; uses the same map path as the console. */ - fun startMap(mapName: String): Boolean { - val previous = games["default"] - map(listOf("map", mapName)) - return games["default"] != null && games["default"] !== previous - } - - /** Release this manager's console hooks after ending its last session. */ - override fun close() { - commands.use { - stop("Server stopped\n", false) - } - } - @JvmField val games: MutableMap = HashMap() - fun getClientsForInstance(gameName: String?): MutableList { - if (!games.containsKey(gameName)) { - // happens during the game instance creation, while the instance has random name - Com.Printf("Game $gameName is not found!\n") - } - // nullify unrelated clients - return clients.map { client -> - if (client.gameName != null && client.gameName == gameName) client else null - }.toMutableList() - } - - // prevent invalid IPs from connecting private val challenges = Array(Defines.MAX_CHALLENGES) { challenge_t() } var clients: MutableList = mutableListOf() @@ -89,10 +59,6 @@ class ServerHost : AutoCloseable { private var nextFrameTime = 1000 private var gameCounter = 0 - fun allocateGameName(): String { - return "jake2_${gameCounter++}" - } - val isPaused: Boolean get() = paused.typedValue @@ -119,620 +85,701 @@ class ServerHost : AutoCloseable { private val reconnectLimit: cvar_t // minimum seconds between connect - /** - * Builds the string that is sent as heartbeats and status replies. - */ - private fun buildStatusResponse(): String { - var status = Cvar.getInstance().Serverinfo() + "\n" - - for (cl in clients) { - if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) { - val player = "" + cl.edict.getClient() - .getPlayerState().stats[Defines.STAT_FRAGS] + " " + cl.ping + "\"" + cl.name + "\"\n" - - val playerLength = player.length - val statusLength = status.length + private val userCommands = mapOf( + StringCmdMessage.NEW to ServerUserCommand { _, game, client -> sendInitialGameData(game, client) }, + StringCmdMessage.CONFIG_STRINGS to ServerUserCommand(::sendConfigstrings), + StringCmdMessage.BASELINES to ServerUserCommand(::sendBaselines), + StringCmdMessage.BEGIN to ServerUserCommand(::beginClient), + StringCmdMessage.NEXT_SERVER to ServerUserCommand(::finishCinematic), + StringCmdMessage.DISCONNECT to ServerUserCommand { _, _, client -> dropClient(client) }, + StringCmdMessage.INFO to ServerUserCommand { _, _, _ -> Info.Print(Cvar.getInstance().Serverinfo()) }, + StringCmdMessage.DOWNLOAD to ServerUserCommand { args, _, client -> beginDownload(args, client) }, + StringCmdMessage.NEXT_DOWNLOAD to ServerUserCommand { _, _, client -> sendNextDownloadChunk(client) }, + ) - if (statusLength + playerLength >= 1024) break // can't hold any more + private fun interface ServerUserCommand { + fun execute(args: List, game: GameImportsImpl, client: ClientNetworkInfo) + } - status += player - } - } + // endregion - return status - } + // region Initialization and lifecycle /** - * SVC_Info, responds with short info for broadcast scans The second parameter should - * be the current protocol version number. + * One manager per dedicated process or embedded session; map changes reuse it. */ - private fun buildInfoResponse(args: List): String? { - if (clients.size == 1) return null // ignore in single player - - val version = if (args.size < 2) 0 else Lib.atoi(args[1]) + init { + val cvars = Cvar.getInstance() + cvars.register( + "spectator_password", CvarTypes.STRING, "", Defines.CVAR_USERINFO, + "Spectator admission password; empty or none allows access" + ) + cvars.register( + "needpass", NEEDPASS_TYPE, 0, Defines.CVAR_SERVERINFO, + "Required passwords: 0 none, 1 player, 2 spectator, 3 both" + ) + cvars.register("deathmatch", CvarTypes.BOOLEAN, false, Defines.CVAR_SERVERINFO or Defines.CVAR_LATCH) + cvars.register("coop", CvarTypes.BOOLEAN, false, Defines.CVAR_SERVERINFO or Defines.CVAR_LATCH) + cvars.register( + "dmflags", + CvarTypes.INTEGER, + Defines.DF_INSTANT_ITEMS, + Defines.CVAR_SERVERINFO, + "Deathmatch rule bitmask; combine DF_* flags" + ) + cvars.register( + "fraglimit", + CvarTypes.IntegerType(0, null), + 0, + Defines.CVAR_SERVERINFO, + "End a deathmatch level at this score; 0 disables the limit" + ) + cvars.register( + "timelimit", + CvarTypes.FloatType(0f, null), + 0f, + Defines.CVAR_SERVERINFO, + "Deathmatch level time limit in minutes; 0 disables the limit" + ) + cvars.register("cheats", CvarTypes.BOOLEAN, false, Defines.CVAR_SERVERINFO or Defines.CVAR_LATCH) + cvars.register( + "protocol", + CvarTypes.INTEGER, + Defines.PROTOCOL_VERSION, + Defines.CVAR_SERVERINFO or Defines.CVAR_NOSET + ) + cvars.register("mapname", CvarTypes.STRING, "", Defines.CVAR_SERVERINFO or Defines.CVAR_NOSET) + cvars.register( + "nextserver", + CvarTypes.STRING, + "", + 0, + "Console command to execute after a cinematic or picture" + ) + cvars.register( + "maxclients", + CvarTypes.IntegerType(1, Defines.MAX_CLIENTS), + 1, + Defines.CVAR_SERVERINFO or Defines.CVAR_LATCH, + "Client slot limit; single-player and coop mode also restrict the count" + ) - val info: String - if (version != Defines.PROTOCOL_VERSION) info = hostname.typedValue + ": wrong version\n" - else { - var players = 0 - for (cl in clients) if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) players++ + hostname = cvars.register( + "hostname", + CvarTypes.STRING, + "noname", + Defines.CVAR_SERVERINFO or Defines.CVAR_ARCHIVE + ) + timeout = cvars.register( + "timeout", + CvarTypes.FloatType(0f, null), + 125f, + 0, + "Seconds without a client message before disconnection" + ) + zombieTimeout = cvars.register( + "zombietime", + CvarTypes.FloatType(0f, null), + 2f, + 0, + "Seconds to retain a disconnected client slot for final reliable messages" + ) + cvars.register( + "showclamp", + CvarTypes.BOOLEAN, + false, + 0, + "Legacy frame-time clamp diagnostics; unused by the current server and Cake" + ) + paused = cvars.register("paused", CvarTypes.BOOLEAN, false, 0) + cvars.register( + "timedemo", + CvarTypes.BOOLEAN, + false, + 0, + "Legacy demo benchmarking switch; unused by the current server and Cake" + ) + enforceMoveTime = cvars.register( + "sv_enforcetime", + CvarTypes.BOOLEAN, + false, + 0, + "Ignore movement commands that exceed the client time budget" + ) - info = - hostname.typedValue + " " + games["default"]!!.mapName + " " + players + "/" + clients.size + "\n" - } + // come from MainCommon + allowDownload = cvars.find("allow_download") + allowPlayerDownloads = cvars.find("allow_download_players") + allowModelDownloads = cvars.find("allow_download_models") + allowSoundDownloads = cvars.find("allow_download_sounds") + allowMapDownloads = cvars.find("allow_download_maps") - return info - } + cvars.register( + "sv_noreload", + CvarTypes.BOOLEAN, + false, + 0, + "Start revisited levels fresh instead of restoring their saved state" + ) + cvars.register( + "sv_airaccelerate", + CvarTypes.FLOAT, + 0f, + Defines.CVAR_LATCH, + "Controls air acceleration (currently only for deathmatch)" + ) + cvars.register( + "public", + CvarTypes.BOOLEAN, + false, + 0, + "Legacy master-server advertising switch; currently unused" + ) + reconnectLimit = cvars.register( + "sv_reconnect_limit", + CvarTypes.FloatType(0f, null), + 3f, + Defines.CVAR_ARCHIVE, + "Minimum seconds between reconnect attempts from the same remote client" + ) + resetClients() - /** - * Returns a challenge number that can be used in a subsequent - * client_connect command. We do this to prevent denial of service attacks - * that flood the server with invalid connection IPs. With a challenge, they - * must give a valid IP address. - */ - private fun getChallenge(from: netadr_t): Int { - var oldest = 0 - var oldestTime = 0x7fffffff + // Publish Host callbacks only after initialization succeeds. They survive Game replacement. + // Retained for console compatibility; master-server advertising is not implemented. + commands.add("heartbeat", true) {} + commands.add("kick", true, Command(::kickClient)) + commands.add("status", true) { printStatus() } + commands.add("serverinfo", true) { printServerInfo() } + commands.add("dumpuser", true, Command(::dumpUser)) + commands.add("say", true, Command(::say)) + commands.add("save", true, Command(::saveGame)) - var i: Int - i = 0 - while (i < Defines.MAX_CHALLENGES) { - if (from.CompareBaseAdr(challenges[i].adr)) break - if (challenges[i].time < oldestTime) { - oldestTime = challenges[i].time - oldest = i - } - i++ + commands.add("killserver") { + stop("Server was killed.\n", false) + NET.configureServer(false) } - - if (i == Defines.MAX_CHALLENGES) { - // overwrite the oldest - challenges[oldest].challenge = Lib.rand().toInt() and 0x7fff - challenges[oldest].adr = from - challenges[oldest].time = Globals.curtime - i = oldest + commands.add("map", Command(::map)) + commands.add("gamemap", Command(::changeMap)) + commands.add("load", Command(::loadGame)) + commands.add("sv_shutdown") { args -> + val reason: String? = if (args.size > 1) { + args[1] + } else { + "Server is shut down" + } + stop(reason + "\n", args.size > 2 && args[2].toBoolean()) } - // send it back - return challenges[i].challenge + commands.add("listmaps") { + val bytes = EngineVfs.loadBytes("maps.lst") + if (bytes == null) { + Com.Error(Defines.ERR_DROP, "Could not read maps.lst") + return@add + } + for (line in String(bytes).split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()) { + Com.Printf(Defines.PRINT_ALL, line.trim { it <= ' ' } + "\n") + } + } + commands.add("jvm_memory") { + val runtime = Runtime.getRuntime() + Com.Printf("vm memory:" + (runtime.totalMemory() - runtime.freeMemory()) + "\n") + } + commands.add("games") { + for (game in games.values) { + Com.Printf(game.name + " - " + game.mapcmd + "\n") + } + Com.Printf("Total " + games.size + " games\n") + } + commands.add("spamgames") { args -> + val total = args[1].toInt() + repeat(total) { + changeMap(listOf("gamemap", "base1", "1")) + } + } } - /** - * A connection request that did not come from the master. - */ - private fun connectClient(args: List, adr: netadr_t) { - Com.DPrintf("SVC_DirectConnect ()\n") - - val version = if (args.size >= 2) Lib.atoi(args[1]) else 0 + /** Typed entrypoint for an embedded host; uses the same map path as the console. */ + fun startMap(mapName: String): Boolean { + val previous = games["default"] + map(listOf("map", mapName)) + return games["default"] != null && games["default"] !== previous + } - if (version != Defines.PROTOCOL_VERSION) { - Netchan.sendConnectionlessPacket( - Defines.NS_SERVER, adr, ConnectionlessCommand.print, - "\nServer is version " + Globals.VERSION + "\n" - ) - Com.DPrintf(" rejected connect from version $version\n") - return + /** Release this manager's console hooks after ending its last session. */ + override fun close() { + commands.use { + stop("Server stopped\n", false) } + } - val qport = if (args.size >= 3) Lib.atoi(args[2]) else 0 - val challenge = if (args.size >= 4) Lib.atoi(args[3]) else 0 - var userinfo = if (args.size >= 5) args[4] else "" - - // force the IP key/value pair so the game can filter based on ip - userinfo = Info.Info_SetValueForKey(userinfo, "ip", adr.toString()) - - // see if the challenge is valid - var j: Int - if (!adr.IsLocalAddress()) { - j = 0 - while (j < Defines.MAX_CHALLENGES) { - if (adr.CompareBaseAdr(challenges[j].adr)) { - if (challenge == challenges[j].challenge) break // good - - Netchan.sendConnectionlessPacket( - Defines.NS_SERVER, - adr, - ConnectionlessCommand.print, - "\nBad challenge.\n" - ) - return - } - j++ - } - if (j == Defines.MAX_CHALLENGES) { - Netchan.sendConnectionlessPacket( - Defines.NS_SERVER, - adr, - ConnectionlessCommand.print, - "\nNo challenge for address.\n" - ) - return + /** + * Called when each game quits, before Sys_Quit or Sys_Error. + * todo: shutdown a particular instance, not the whole server + */ + fun stop(finalmsg: String?, reconnect: Boolean) { + try { + if (games.isNotEmpty()) { + if (clients.isNotEmpty()) sendFinalMessage(finalmsg, reconnect) + Com.Printf("==== ShutdownGame ====\n") } + } finally { + games.clear() + clients.clear() + publishLegacyServerState(ServerStates.SS_DEAD) + realtime = 0 + frameNum = 0 + nextFrameTime = 100 } + } - // if there is already a slot for this ip, reuse it - var i = 0 - for (cl in clients) { - if (cl.state == ClientStates.CS_FREE) continue - if (adr.CompareBaseAdr(cl.netchan.remote_address) - && (cl.netchan.qport == qport || adr.port == cl.netchan.remote_address.port) - ) { - if (!adr.IsLocalAddress() && (realtime - cl.lastconnect) < (reconnectLimit.typedValue * 1000)) { - Com.DPrintf("$adr:reconnect rejected : too soon\n") - return - } - Com.Printf("$adr:reconnect\n") + /** + * Resets the clients when maxclients changes + */ + fun resetClients() { + sendFinalMessage("Server restarted", true) + val max = Cvar.getInstance().find("maxclients").typedValue - initializeClientConnection(i, challenge, userinfo, adr, qport) - return - } - i++ + // Clear all clients + clients = MutableList(max) { + ClientNetworkInfo().apply { lastcmd = usercmd_t() } } + } - // find a client slot - var index = -1 - i = 0 - for (cl in clients) { - if (cl.state == ClientStates.CS_FREE) { - index = i - break - } - i++ + private fun initializeServerSession() { + if (games.isEmpty()) { + realtime = 0 + frameNum = 0 + nextFrameTime = 100 } - if (index == -1) { - Netchan.sendConnectionlessPacket(Defines.NS_SERVER, adr, ConnectionlessCommand.print, "\nServer is full.\n") - Com.DPrintf("Rejected a connection.\n") + val multiplayer = initializeServerCvars() + if (!NET.configureServer(multiplayer)) { + Com.Error(Defines.ERR_DROP, "Could not open the server UDP port") return } - initializeClientConnection(index, challenge, userinfo, adr, qport) + resetClients() } /** - * Initializes player structures after successfull connection. + * @return if the game is multiplayer or not */ - private fun initializeClientConnection(i: Int, challenge: Int, userinfo: String?, adr: netadr_t, qport: Int) { - // build a new connection - // accept the new client - // this is the only place a ClientNetworkInfo is ever initialized + private fun initializeServerCvars(): Boolean { + val cvars = Cvar.getInstance() + val coop = cvars.find("coop") + val deathmatch = cvars.find("deathmatch") + val maxclients = cvars.find("maxclients") - val client = clients[i] - val gameImports: GameImportsImpl = games["default"]!! - client.gameName = "default" + // get any latched variable changes (maxclients, etc) + cvars.updateLatchedVars() - val ent = gameImports.gameExports.getEdict(i + 1) - client.edict = ent // fixme: why? isn't it already set? + if (coop.typedValue && deathmatch.typedValue) { + Com.Printf("Deathmatch and Coop both set, disabling Coop\n") + coop.forceSet(false) + } - // save challenge for checksumming - client.challenge = challenge + // dedicated servers are can't be single player and are usually DM + // so unless they explicity set coop, force it to deathmatch + if (Globals.dedicated.typedValue) { + if (!coop.typedValue) deathmatch.forceSet(true) + } - // get the game a chance to reject this connection or modify the - // userinfo - if (!(gameImports.gameExports.ClientConnect(ent, userinfo))) { - if (Info.Info_ValueForKey(userinfo, "rejmsg") != null) { - Netchan.sendConnectionlessPacket( - Defines.NS_SERVER, - adr, - ConnectionlessCommand.print, - "\n" + Info.Info_ValueForKey(userinfo, "rejmsg") + "\nConnection refused.\n" - ) - } else { - Netchan.sendConnectionlessPacket( - Defines.NS_SERVER, - adr, - ConnectionlessCommand.print, - "\nConnection refused.\n" - ) - } - Com.DPrintf("Game rejected a connection.\n") - return + // set max clients based on game mode + if (deathmatch.typedValue) { + if (maxclients.typedValue <= 1) maxclients.forceSet(8) + } else if (coop.typedValue) { + if (maxclients.typedValue !in 2..4) maxclients.forceSet(4) + } else { + // non-deathmatch, non-coop is one player + maxclients.forceSet(1) } + return maxclients.typedValue > 1 + } - // parse some info from the info strings - client.userinfo = userinfo - updateClientUserinfo(client) + /** Bridge for client loading state and cvar latching until shared-global cleanup. */ + @Suppress("DEPRECATION") + private fun publishLegacyServerState(state: ServerStates) { + Globals.server_state = state + } - Netchan.sendConnectionlessPacket(Defines.NS_SERVER, adr, ConnectionlessCommand.client_connect, "") + // endregion - client.netchan.setup(Defines.NS_SERVER, adr, qport) + // region Game routing and map/save transitions - client.state = ClientStates.CS_CONNECTED + fun getClientsForInstance(gameName: String?): MutableList { + if (!games.containsKey(gameName)) { + // happens during the game instance creation, while the instance has random name + Com.Printf("Game $gameName is not found!\n") + } + // nullify unrelated clients + return clients.map { client -> + if (client.gameName != null && client.gameName == gameName) client else null + }.toMutableList() + } - client.lastmessage = gameImports.realtime // don't timeout - client.lastconnect = gameImports.realtime - Com.DPrintf("new client added.\n") + // prevent invalid IPs from connecting + + fun allocateGameName(): String { + return "jake2_${gameCounter++}" } /** - * A connectionless packet has four leading 0xff characters to distinguish - * it from a game channel. Clients that are in the game can still send - * connectionless packets. It is used also by rcon commands. + * SV_InitGame. + * + * A brand new game has been started. */ - fun handleConnectionlessPacket(from: netadr_t, messageLine: String?) { - val args = Cmd.TokenizeString(messageLine, false) + fun createGameInstance(changeMapInfo: ChangeMapInfo, previous: GameImportsImpl?): GameImportsImpl { + return GameImportsImpl.create(this, changeMapInfo, previous) + } - if (args.isEmpty()) { - Com.Printf("empty connectionless packet: $messageLine") + fun loadGame(args: List) { + if (args.size != 2) { + Com.Printf("USAGE: load \n") return } - val c = args[0] - val cmd = ConnectionlessCommand.fromString(c) + Com.Printf("Loading game...\n") - when (cmd) { - ConnectionlessCommand.ping -> // SVC_Ping, Just responds with an acknowledgement. - Netchan.sendConnectionlessPacket(Defines.NS_SERVER, from, ConnectionlessCommand.ack, "") + val saveGame: String + try { + saveGame = ServerSaveJsonStore.normalizeSlot(args[1]) + } catch (_: IllegalArgumentException) { + Com.Printf("Bad save name.\n") + return + } + if ("current" == saveGame) { + Com.Printf("Can't load from 'current'\n") + return + } - ConnectionlessCommand.ack -> //SVC_Ack - Com.Printf("Ping acknowledge from $from\n") + // make sure the server metadata file exists + val store = ServerSaveJsonStore.forWriteDir(EngineWriteRoot.pathString()) + if (!store.hasMapCommand(saveGame)) { + val name = EngineWriteRoot.resolve("save/$saveGame/server_mapcmd.ssv.json").toString() + Com.Printf("No such savegame: $name\n") + return + } - ConnectionlessCommand.status -> // SVC_Status, Responds with all the info that qplug or qspy can see - Netchan.sendConnectionlessPacket( - Defines.NS_SERVER, - from, - ConnectionlessCommand.print, - "\n" + buildStatusResponse() - ) + ServerSaveFiles(EngineWriteRoot.path()).copy(saveGame, "current") - ConnectionlessCommand.info -> { - val info = buildInfoResponse(args) - if (info != null) Netchan.sendConnectionlessPacket( - Defines.NS_SERVER, - from, - ConnectionlessCommand.info, - "\n" + info - ) - } + val mapCommand = readSavedMapCommand() - ConnectionlessCommand.getchallenge -> { - val challenge = getChallenge(from) - Netchan.sendConnectionlessPacket( - Defines.NS_SERVER, - from, - ConnectionlessCommand.challenge, - " $challenge" - ) - } + // go to the map + val loaded = spawnServerInstance(ChangeMapInfo(mapCommand, false, true), 0) + loaded.name = "default" + games["default"] = loaded + } - ConnectionlessCommand.connect -> connectClient(args, from) - ConnectionlessCommand.rcon -> executeRemoteCommand(args, from) - else -> Com.Printf("Unknown ClientConnectionlessCommand: $c\n") + private fun readSavedMapCommand(): String { + Com.DPrintf("SV_ReadMapCommand()\n") + + try { + val store = ServerSaveJsonStore.forWriteDir(EngineWriteRoot.pathString()) + val snapshot = store.readMapCommand("current") + // read the comment field + Com.DPrintf("SV_ReadMapCommand: Loading save: " + snapshot.comment + "\n") + + // read the mapcmd + return snapshot.mapCommand + } catch (e: Exception) { + throw RuntimeException(e) } } - /** - * SV_Frame. - */ - fun update(msec: Long) { - // if server is not active, do nothing - // like when connected to another server - if (games.isEmpty()) return + /* +================== +SV_GameMap_f - realtime += msec - for (game in games.values) { - game.realtime += msec.toInt() - } +Saves the state of the map just being exited and goes to a new map. - // keep the random time dependent - Lib.rand() +If the initial character of the map string is '*', the next map is +in a new unit, so the current savegame directory is cleared of +map files. - // check timeouts - checkTimeouts() +Example: - // get packets from clients - readPackets() +*inter.cin+jail - // identify if we need to update the game or it's too early (sleep or skip this update). - // fixedtime - the time of the next scheduled game update. - var delay = nextFrameTime - realtime - if (delay > 0) { - // never let the time get too far off - if (delay > 100) { // how is it even possible? - for (game in games.values) game.realtime = (nextFrameTime - 100) - delay = 100 - } - NET.Sleep(delay.toInt()) +Clears the archived maps, plays the inter.cin cinematic, then +goes to map jail.bsp. +================== + +Spins up a new game instance, to be used with either `map` or `join` (TBD) +*/ + fun changeMap(args: List) { + if (args.size < 2) { + Com.Printf("USAGE: gamemap \n ") return } - // move autonomous things around if enough time has passed - frameNum++ - nextFrameTime = 100 * frameNum + val mapName = args[1] + val clientIndex: Int = if (args.size > 2 && args[2].isNotBlank()) { + try { + args[2].toInt() + } catch (_: NumberFormatException) { + Com.Printf("USAGE: gamemap \n \n") + return + } + } else 0 - // update ping based on the last known frame from all clients - updateClientPings() + // default.cfg may start a session with "killserver; gamemap *...". + // Shutdown clears the client slots, so gamemap must initialize a fresh + // server just as map does, while preserving clients on level transitions. + if (games.isEmpty()) { + initializeServerSession() + } - // give the clients some timeslices - replenishClientMoveBudgets() + Com.DPrintf("SV_GameMap($mapName)\n") - // let everything in the world think and move - for (game in games.values) { - game.SV_RunGameFrame(nextFrameTime.toLong()) - } + EngineWriteRoot.ensureParentDirectories(EngineWriteRoot.resolve("save/current/.keep")) - // send messages back to the clients that had packets read this frame - sendClientMessages() + // check for clearing the current savegame + if (mapName[0] == '*') { + // wipe all the *.sav files + ServerSaveFiles(EngineWriteRoot.path()).wipe("current") + } else { // save the map just exited + // note: only applicable to the default instance (subject to change in future) + val gameImports = games["default"] + if (gameImports != null && gameImports.state == ServerStates.SS_GAME) { + // clear all the client inuse flags before saving so that + // when the level is re-entered, the clients will spawn + // at spawn points instead of occupying body shells + // todo: only relevant to this gameImports clients + val savedInuse = BooleanArray(clients.size) + var i = 0 + for (cl in clients) { + if (cl.edict != null) { // free client slots will have null values + savedInuse[i] = cl.edict.inuse + cl.edict.inuse = false + } + i++ + } - // send a heartbeat to the master if needed + gameImports.SV_WriteLevelFile() - // clear teleport flags, etc for next frame - for (game in games.values) { - game.clearEntityStateEvents() + // we must restore these for clients to transfer over correctly + i = 0 + for (cl in clients) { + if (cl.edict != null) { // free client slots will have null values + cl.edict.inuse = savedInuse[i] + } + i++ + } + } } - } - - fun sendClientMessages() { - // send a message to each connected client - for (c in clients) { - if (c.state == ClientStates.CS_FREE) continue + val game = spawnServerInstance(ChangeMapInfo(mapName, false, false), clientIndex) - val game: GameImportsImpl = games[c.gameName]!! - // if the reliable message overflowed, - // drop the client - val pendingReliableSize = - c.netchan.reliablePending.sumOf { it.size } - if (pendingReliableSize > Defines.MAX_MSGLEN - 16) { - c.netchan.reliablePending.clear() - c.unreliable.clear() - broadcastPrint(Defines.PRINT_HIGH, c.name + " overflowed\n", c.gameName) - dropClient(c) + if (clientIndex == 0) { + // server just started, `map` cmd was used + // start up the next map + game.name = "default" + games[game.name] = game + for (cl in clients) { + cl.gameName = "default" } + } else { + // preview of the multi instance mode + val cl = clients[clientIndex - 1] + val previousGame: GameImportsImpl = games[cl.gameName]!! + previousGame.gameExports.ClientDisconnect(cl.edict) + games[game.name] = game + cl.gameName = game.name + } - if (game.state == ServerStates.SS_CINEMATIC || game.state == ServerStates.SS_DEMO || game.state == ServerStates.SS_PIC) { - // leftover from demo code - c.netchan.transmit(null) - } else if (c.state == ClientStates.CS_SPAWNED) { - // don't overrun bandwidth - if (shouldRateLimit(c)) continue + // copy off the level to the autosave slot + if (!Globals.dedicated.typedValue) { + game.SV_WriteServerFile(true) + ServerSaveFiles(EngineWriteRoot.path()).copy("current", "save0") + } - sendClientDatagram(c, game) - } else { - // just update reliable if needed - if (c.netchan.reliablePending.isNotEmpty() || Globals.curtime - c.netchan.last_sent > 1000) c.netchan.transmit( - null - ) - } + // multi instance preview: + // when playing the regular flow, multi instances are not requried + if (clientIndex == 0) { + games.clear() + games["default"] = game } } /** - * Called when the player is totally leaving the server, either willingly or - * unwillingly. This is NOT called if the entire server is quiting or - * crashing. + * SV_Map */ - fun dropClient(client: ClientNetworkInfo) { - // add the disconnect - client.netchan.reliablePending.add(DisconnectMessage()) + fun spawnServerInstance(changeMapInfo: ChangeMapInfo, clientIndex: Int): GameImportsImpl { + publishLegacyServerState(ServerStates.SS_DEAD) - if (client.state == ClientStates.CS_SPAWNED) { - // call the prog function for removing a client - // this will remove the body, among other things - games[client.gameName]!!.gameExports.ClientDisconnect(client.edict) - } + val previous = if (clientIndex != 0) + games[clients[clientIndex - 1].gameName] + else + games["default"] + previous?.prepareLevelTransition() - if (client.download != null) { - client.download = null - } + val gameImports = createGameInstance(changeMapInfo, previous) - client.state = ClientStates.CS_ZOMBIE // become free in a few seconds - client.name = "" - } + Cvar.getInstance().find("nextserver") + .set("gamemap \"" + changeMapInfo.nextServer + "\"") - /** - * Sends text to all active clients - */ - fun broadcastPrint(level: Int, message: String?, gameName: String?) { - // echo to console + Cmd.ExecuteFunction("loading") // for local system - if (Globals.dedicated.typedValue) { - Com.Printf(message) + if (clientIndex == 0) { + // leave slots at start for clients only + for (cl in clients) { //todo + // needs to reconnect + if (cl.state == ClientStates.CS_SPAWNED) cl.state = ClientStates.CS_CONNECTED + cl.lastReceivedFrame = -1 + } + gameImports.SV_BroadcastCommand("changing") + } else { + val cl = clients[clientIndex - 1] + if (cl.state == ClientStates.CS_SPAWNED) cl.state = ClientStates.CS_CONNECTED + cl.lastReceivedFrame = -1 + gameImports.unicastMessage(clientIndex, StuffTextMessage("changing"), true) } - for (cl in getClientsForInstance(gameName)) { - if (cl == null || level < cl.messagelevel) continue - if (cl.state != ClientStates.CS_SPAWNED) continue - cl.netchan.reliablePending.add(PrintMessage(level, message)) - } - } + sendClientMessages() - /** - * SV_RateDrop - * - * - * Returns true if the client is over its current - * bandwidth estimation and should not be sent another packet - */ - fun shouldRateLimit(c: ClientNetworkInfo): Boolean { - // never drop over the loopback + // precache and static commands can be issued during + // map initialization + publishLegacyServerState(gameImports.state) - if (c.netchan.remote_address.type == NetAddrType.NA_LOOPBACK) return false + gameImports.initializeLevel(changeMapInfo, previous) + publishLegacyServerState(gameImports.state) - var total = 0 + // set serverinfo variable + Cvar.getInstance().find("mapname").forceSet(gameImports.mapName) + Com.Println("mapname: " + gameImports.mapName) - for (i in 0.. c.rate) { - c.surpressCount++ - c.message_size[frameNum % Defines.RATE_MESSAGES] = 0 - return true + if (clientIndex == 0) { + gameImports.SV_BroadcastCommand("reconnect") // doesn't really reconnect but rather issues 'NEW' command + } else { + gameImports.unicastMessage(clientIndex, StuffTextMessage("reconnect"), true) } - - return false + return gameImports } - /** - * Reads packets from the network or loopback. - */ - fun readPackets() { - while (true) { - val networkPacket = NET.receiveNetworkPacket( - NET.ip_sockets[Defines.NS_SERVER], - NET.ip_channels[Defines.NS_SERVER], - NET.loopbacks[Defines.NS_SERVER], - true - ) + /* +================== +SV_Map_f - if (networkPacket == null) break +Goes directly to a given map without any savegame archiving. +For development work +================== - if (networkPacket.isConnectionless) { - handleConnectionlessPacket(networkPacket.from, networkPacket.connectionlessMessage) - continue - } +Resets server to host a given map, moves all clients to it (via reconnect) +*/ + fun map(args: List) { - // Match incoming packet with the existing client. - // todo: get client by address (hashmap?) - for (cl in clients) { - if (cl.state == ClientStates.CS_FREE) continue - if (!networkPacket.from.CompareBaseAdr(cl.netchan.remote_address)) continue - if (cl.netchan.qport != networkPacket.qport) continue - if (cl.netchan.remote_address.port != networkPacket.from.port) { - Com.Printf("SV_ReadPackets: fixing up a translated port\n") - cl.netchan.remote_address.port = networkPacket.from.port - } + if (args.size < 2 || args[1].isBlank()) { + Com.Printf("usage: map \n") + return + } - if (cl.netchan.accept(networkPacket)) { - // this is a valid, sequenced packet, so process it - if (cl.state != ClientStates.CS_ZOMBIE) { - cl.lastmessage = games[cl.gameName]!!.realtime // don't timeout - val body = networkPacket.parseBodyFromClient(cl.netchan.incoming_sequence) - if (body == null) dropClient(cl) - else handleClientMessages(cl, body) - } - } - break + // if not a pcx, demo, or cinematic, check to make sure the level exists + val mapName = args[1] + val target = ChangeMapInfo(mapName, false, false) + if (target.state == ServerStates.SS_GAME) { + val mapPath = "maps/" + target.mapName + ".bsp" + if (!EngineVfs.exists(mapPath)) { + Com.Printf("Can't find $mapPath\n") + return } } - } - - fun handleClientMessages(cl: ClientNetworkInfo, clientMessages: Collection) { - // only allow one move command - - var moveIssued = false - var stringCmdCount = 0 + val game = games["default"] - for (msg in clientMessages) { - if (msg is EndOfClientPacketMessage) { - break - } else if (msg is StringCmdMessage) { - // malicious users may try using too many string commands - if (++stringCmdCount < MAX_STRINGCMDS) { - executeClientCommand(cl, msg.command) - } + if (game != null) game.state = ServerStates.SS_DEAD // don't save current level when changing - if (cl.state == ClientStates.CS_ZOMBIE) { - return // disconnect command - } - } else if (msg is UserInfoMessage) { - cl.userinfo = msg.userInfo - updateClientUserinfo(cl) - } else if (msg is MoveMessage) { - if (moveIssued) return // someone is trying to cheat... + ServerSaveFiles(EngineWriteRoot.path()).wipe("current") - moveIssued = true - val lastReceivedFrame = msg.lastReceivedFrame + // A running server needs its clients reset for map. For a fresh server, + // gamemap performs this initialization itself. + if (games.isNotEmpty()) { + initializeServerSession() + } + changeMap(args) + } - if (lastReceivedFrame != cl.lastReceivedFrame) { - cl.lastReceivedFrame = lastReceivedFrame - if (cl.lastReceivedFrame > 0) { - val frameLatency = - games[cl.gameName]!!.realtime - cl.frames[cl.lastReceivedFrame and Defines.UPDATE_MASK].senttime - cl.frame_latency[cl.lastReceivedFrame and (Defines.LATENCY_COUNTS - 1)] = frameLatency - } - } + /** Resolve a Game at execution time; creating another Game never replaces these callbacks. */ + private fun saveGame(args: List) { + var target = "default" + var command = args + if (args.size > 1 && args[1] == "--game") { + if (args.size < 4) { + Com.Printf("Usage: " + args[0] + " --game \n") + return + } + target = args[2] + command = listOf(args[0]) + args.drop(3) + } + val game = games[target] + if (game == null) { + Com.Printf("No running game: $target\n") + return + } + game.saveGame(command) + } - if (cl.state != ClientStates.CS_SPAWNED) { - cl.lastReceivedFrame = -1 - continue - } + // endregion - // if the checksum fails, ignore the rest of the packet - if (!msg.valid) { - Com.Printf("Invalid crc\n") - return - } + // region Frame scheduling and client time accounting - if (!paused.typedValue) { - var droppedPackets = cl.netchan.dropped - if (droppedPackets < 20) { - //if (droppedPackets > 2) + /** + * SV_Frame. + */ + fun update(msec: Long) { + // if server is not active, do nothing + // like when connected to another server + if (games.isEmpty()) return - // Com.Printf ("drop %i\n", droppedPackets); + realtime += msec + for (game in games.values) { + game.realtime += msec.toInt() + } - while (droppedPackets > 2) { - processClientMove(cl, cl.lastcmd) + // keep the random time dependent + Lib.rand() - droppedPackets-- - } - if (droppedPackets > 1) processClientMove(cl, msg.oldestCmd) + // check timeouts + checkTimeouts() - if (droppedPackets > 0) processClientMove(cl, msg.oldCmd) - } - processClientMove(cl, msg.newCmd) - } + // get packets from clients + readPackets() - // copy. - cl.lastcmd.set(msg.newCmd) - } else { - Com.Printf("SV_ReadClientMessage: unknown command: " + msg.type + "\n") - dropClient(cl) + // identify if we need to update the game or it's too early (sleep or skip this update). + // fixedtime - the time of the next scheduled game update. + var delay = nextFrameTime - realtime + if (delay > 0) { + // never let the time get too far off + if (delay > 100) { // how is it even possible? + for (game in games.values) game.realtime = (nextFrameTime - 100) + delay = 100 } + NET.Sleep(delay.toInt()) + return } - } - fun processClientMove(cl: ClientNetworkInfo, cmd: usercmd_t) { - cl.commandMsec -= cmd.msec.toInt() and 0xFF - - if (cl.commandMsec < 0 && enforceMoveTime.typedValue) { - Com.DPrintf("commandMsec underflow from " + cl.name + "\n") - return - } - - games[cl.gameName]!!.gameExports.ClientThink(cl.edict, cmd) - } - - /** - * Pull specific info from a newly changed userinfo string into a more C - * freindly form. - */ - fun updateClientUserinfo(cl: ClientNetworkInfo) { - // call prog code to allow overrides - games[cl.gameName]!!.gameExports.ClientUserinfoChanged(cl.edict, cl.userinfo) - - // name for C code - cl.name = Info.Info_ValueForKey(cl.userinfo, "name") + // move autonomous things around if enough time has passed + frameNum++ + nextFrameTime = 100 * frameNum - // mask off high bit + // update ping based on the last known frame from all clients + updateClientPings() - // rate command - var userinfoValue = Info.Info_ValueForKey(cl.userinfo, "rate") - if (userinfoValue.isNotEmpty()) { - cl.rate = Lib.atoi(userinfoValue) - if (cl.rate < 100) cl.rate = 100 - if (cl.rate > 15000) cl.rate = 15000 - } else cl.rate = 5000 + // give the clients some timeslices + replenishClientMoveBudgets() - // msg command - userinfoValue = Info.Info_ValueForKey(cl.userinfo, "msg") - if (userinfoValue.isNotEmpty()) { - cl.messagelevel = Lib.atoi(userinfoValue) + // let everything in the world think and move + for (game in games.values) { + game.SV_RunGameFrame(nextFrameTime.toLong()) } - } - fun executeClientCommand(cl: ClientNetworkInfo, command: String?) { - Com.dprintln("SV_ExecuteUserCommand:$command") + // send messages back to the clients that had packets read this frame + sendClientMessages() - val args = Cmd.TokenizeString(command, true) - if (args.isEmpty()) return + // send a heartbeat to the master if needed - val game: GameImportsImpl = games[cl.gameName]!! - if (userCommands.containsKey(args[0])) { - userCommands[args[0]]!!.execute(args, game, cl) - return + // clear teleport flags, etc for next frame + for (game in games.values) { + game.clearEntityStateEvents() } - - if (game.state == ServerStates.SS_GAME) game.gameExports.ClientCommand(cl.edict, args) } /** @@ -807,617 +854,494 @@ class ServerHost : AutoCloseable { } } + // endregion + + // region Connection admission and disconnection + /** - * Used by stop to send a final message to all connected clients - * before the server goes down. The messages are sent immediately, not just - * stuck on the outgoing message list, because the server is going to - * totally exit after returning from this function. + * Builds the string that is sent as heartbeats and status replies. */ - private fun sendFinalMessage(message: String?, reconnect: Boolean) { - val msgs: MutableCollection = ArrayList() - msgs.add(PrintMessage(Defines.PRINT_HIGH, message)) - - if (reconnect) msgs.add(ReconnectMessage()) - else msgs.add(DisconnectMessage()) + private fun buildStatusResponse(): String { + var status = Cvar.getInstance().Serverinfo() + "\n" - // send it twice - // stagger the packets to crutch operating system limited buffers - for (cl in clients) { - if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) cl.netchan.transmit(msgs) - } for (cl in clients) { - if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) cl.netchan.transmit(msgs) - } - } + if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) { + val player = "" + cl.edict.getClient() + .getPlayerState().stats[Defines.STAT_FRAGS] + " " + cl.ping + "\"" + cl.name + "\"\n" - /** - * Called when each game quits, before Sys_Quit or Sys_Error. - * todo: shutdown a particular instance, not the whole server - */ - fun stop(finalmsg: String?, reconnect: Boolean) { - try { - if (games.isNotEmpty()) { - if (clients.isNotEmpty()) sendFinalMessage(finalmsg, reconnect) - Com.Printf("==== ShutdownGame ====\n") + val playerLength = player.length + val statusLength = status.length + + if (statusLength + playerLength >= 1024) break // can't hold any more + + status += player } - } finally { - games.clear() - clients.clear() - publishLegacyServerState(ServerStates.SS_DEAD) - realtime = 0 - frameNum = 0 - nextFrameTime = 100 } + + return status } /** - * Resets the clients when maxclients changes + * SVC_Info, responds with short info for broadcast scans The second parameter should + * be the current protocol version number. */ - fun resetClients() { - sendFinalMessage("Server restarted", true) - val max = Cvar.getInstance().find("maxclients").typedValue + private fun buildInfoResponse(args: List): String? { + if (clients.size == 1) return null // ignore in single player - // Clear all clients - clients = MutableList(max) { - ClientNetworkInfo().apply { lastcmd = usercmd_t() } + val version = if (args.size < 2) 0 else Lib.atoi(args[1]) + + val info: String + if (version != Defines.PROTOCOL_VERSION) info = hostname.typedValue + ": wrong version\n" + else { + var players = 0 + for (cl in clients) if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) players++ + + info = + hostname.typedValue + " " + games["default"]!!.mapName + " " + players + "/" + clients.size + "\n" } - } - /** - * SV_InitGame. - * - * A brand new game has been started. - */ - fun createGameInstance(changeMapInfo: ChangeMapInfo, previous: GameImportsImpl?): GameImportsImpl { - return GameImportsImpl.create(this, changeMapInfo, previous) + return info } /** - * @return if the game is multiplayer or not + * Returns a challenge number that can be used in a subsequent + * client_connect command. We do this to prevent denial of service attacks + * that flood the server with invalid connection IPs. With a challenge, they + * must give a valid IP address. */ - private fun initializeServerCvars(): Boolean { - val cvars = Cvar.getInstance() - val coop = cvars.find("coop") - val deathmatch = cvars.find("deathmatch") - val maxclients = cvars.find("maxclients") - - // get any latched variable changes (maxclients, etc) - cvars.updateLatchedVars() + private fun getChallenge(from: netadr_t): Int { + var oldest = 0 + var oldestTime = 0x7fffffff - if (coop.typedValue && deathmatch.typedValue) { - Com.Printf("Deathmatch and Coop both set, disabling Coop\n") - coop.forceSet(false) + var i: Int + i = 0 + while (i < Defines.MAX_CHALLENGES) { + if (from.CompareBaseAdr(challenges[i].adr)) break + if (challenges[i].time < oldestTime) { + oldestTime = challenges[i].time + oldest = i + } + i++ } - // dedicated servers are can't be single player and are usually DM - // so unless they explicity set coop, force it to deathmatch - if (Globals.dedicated.typedValue) { - if (!coop.typedValue) deathmatch.forceSet(true) + if (i == Defines.MAX_CHALLENGES) { + // overwrite the oldest + challenges[oldest].challenge = Lib.rand().toInt() and 0x7fff + challenges[oldest].adr = from + challenges[oldest].time = Globals.curtime + i = oldest } - // set max clients based on game mode - if (deathmatch.typedValue) { - if (maxclients.typedValue <= 1) maxclients.forceSet(8) - } else if (coop.typedValue) { - if (maxclients.typedValue !in 2..4) maxclients.forceSet(4) - } else { - // non-deathmatch, non-coop is one player - maxclients.forceSet(1) - } - return maxclients.typedValue > 1 + // send it back + return challenges[i].challenge } - fun loadGame(args: List) { - if (args.size != 2) { - Com.Printf("USAGE: load \n") - return - } - - Com.Printf("Loading game...\n") + /** + * A connection request that did not come from the master. + */ + private fun connectClient(args: List, adr: netadr_t) { + Com.DPrintf("SVC_DirectConnect ()\n") - val saveGame: String - try { - saveGame = ServerSaveJsonStore.normalizeSlot(args[1]) - } catch (_: IllegalArgumentException) { - Com.Printf("Bad save name.\n") - return - } - if ("current" == saveGame) { - Com.Printf("Can't load from 'current'\n") - return - } + val version = if (args.size >= 2) Lib.atoi(args[1]) else 0 - // make sure the server metadata file exists - val store = ServerSaveJsonStore.forWriteDir(EngineWriteRoot.pathString()) - if (!store.hasMapCommand(saveGame)) { - val name = EngineWriteRoot.resolve("save/$saveGame/server_mapcmd.ssv.json").toString() - Com.Printf("No such savegame: $name\n") + if (version != Defines.PROTOCOL_VERSION) { + Netchan.sendConnectionlessPacket( + Defines.NS_SERVER, adr, ConnectionlessCommand.print, + "\nServer is version " + Globals.VERSION + "\n" + ) + Com.DPrintf(" rejected connect from version $version\n") return } - ServerSaveFiles(EngineWriteRoot.path()).copy(saveGame, "current") - - val mapCommand = readSavedMapCommand() - - // go to the map - val loaded = spawnServerInstance(ChangeMapInfo(mapCommand, false, true), 0) - loaded.name = "default" - games["default"] = loaded - } + val qport = if (args.size >= 3) Lib.atoi(args[2]) else 0 + val challenge = if (args.size >= 4) Lib.atoi(args[3]) else 0 + var userinfo = if (args.size >= 5) args[4] else "" - private fun readSavedMapCommand(): String { - Com.DPrintf("SV_ReadMapCommand()\n") + // force the IP key/value pair so the game can filter based on ip + userinfo = Info.Info_SetValueForKey(userinfo, "ip", adr.toString()) - try { - val store = ServerSaveJsonStore.forWriteDir(EngineWriteRoot.pathString()) - val snapshot = store.readMapCommand("current") - // read the comment field - Com.DPrintf("SV_ReadMapCommand: Loading save: " + snapshot.comment + "\n") + // see if the challenge is valid + var j: Int + if (!adr.IsLocalAddress()) { + j = 0 + while (j < Defines.MAX_CHALLENGES) { + if (adr.CompareBaseAdr(challenges[j].adr)) { + if (challenge == challenges[j].challenge) break // good - // read the mapcmd - return snapshot.mapCommand - } catch (e: Exception) { - throw RuntimeException(e) - } - } - - /* -================== -SV_GameMap_f - -Saves the state of the map just being exited and goes to a new map. - -If the initial character of the map string is '*', the next map is -in a new unit, so the current savegame directory is cleared of -map files. - -Example: - -*inter.cin+jail - -Clears the archived maps, plays the inter.cin cinematic, then -goes to map jail.bsp. -================== - -Spins up a new game instance, to be used with either `map` or `join` (TBD) -*/ - fun changeMap(args: List) { - if (args.size < 2) { - Com.Printf("USAGE: gamemap \n ") - return - } - - val mapName = args[1] - val clientIndex: Int = if (args.size > 2 && args[2].isNotBlank()) { - try { - args[2].toInt() - } catch (_: NumberFormatException) { - Com.Printf("USAGE: gamemap \n \n") + Netchan.sendConnectionlessPacket( + Defines.NS_SERVER, + adr, + ConnectionlessCommand.print, + "\nBad challenge.\n" + ) + return + } + j++ + } + if (j == Defines.MAX_CHALLENGES) { + Netchan.sendConnectionlessPacket( + Defines.NS_SERVER, + adr, + ConnectionlessCommand.print, + "\nNo challenge for address.\n" + ) return } - } else 0 - - // default.cfg may start a session with "killserver; gamemap *...". - // Shutdown clears the client slots, so gamemap must initialize a fresh - // server just as map does, while preserving clients on level transitions. - if (games.isEmpty()) { - initializeServerSession() } - Com.DPrintf("SV_GameMap($mapName)\n") - - EngineWriteRoot.ensureParentDirectories(EngineWriteRoot.resolve("save/current/.keep")) - - // check for clearing the current savegame - if (mapName[0] == '*') { - // wipe all the *.sav files - ServerSaveFiles(EngineWriteRoot.path()).wipe("current") - } else { // save the map just exited - // note: only applicable to the default instance (subject to change in future) - val gameImports = games["default"] - if (gameImports != null && gameImports.state == ServerStates.SS_GAME) { - // clear all the client inuse flags before saving so that - // when the level is re-entered, the clients will spawn - // at spawn points instead of occupying body shells - // todo: only relevant to this gameImports clients - val savedInuse = BooleanArray(clients.size) - var i = 0 - for (cl in clients) { - if (cl.edict != null) { // free client slots will have null values - savedInuse[i] = cl.edict.inuse - cl.edict.inuse = false - } - i++ + // if there is already a slot for this ip, reuse it + var i = 0 + for (cl in clients) { + if (cl.state == ClientStates.CS_FREE) continue + if (adr.CompareBaseAdr(cl.netchan.remote_address) + && (cl.netchan.qport == qport || adr.port == cl.netchan.remote_address.port) + ) { + if (!adr.IsLocalAddress() && (realtime - cl.lastconnect) < (reconnectLimit.typedValue * 1000)) { + Com.DPrintf("$adr:reconnect rejected : too soon\n") + return } + Com.Printf("$adr:reconnect\n") - gameImports.SV_WriteLevelFile() - - // we must restore these for clients to transfer over correctly - i = 0 - for (cl in clients) { - if (cl.edict != null) { // free client slots will have null values - cl.edict.inuse = savedInuse[i] - } - i++ - } + initializeClientConnection(i, challenge, userinfo, adr, qport) + return } + i++ } - val game = spawnServerInstance(ChangeMapInfo(mapName, false, false), clientIndex) - - if (clientIndex == 0) { - // server just started, `map` cmd was used - // start up the next map - game.name = "default" - games[game.name] = game - for (cl in clients) { - cl.gameName = "default" + // find a client slot + var index = -1 + i = 0 + for (cl in clients) { + if (cl.state == ClientStates.CS_FREE) { + index = i + break } - } else { - // preview of the multi instance mode - val cl = clients[clientIndex - 1] - val previousGame: GameImportsImpl = games[cl.gameName]!! - previousGame.gameExports.ClientDisconnect(cl.edict) - games[game.name] = game - cl.gameName = game.name - } - - // copy off the level to the autosave slot - if (!Globals.dedicated.typedValue) { - game.SV_WriteServerFile(true) - ServerSaveFiles(EngineWriteRoot.path()).copy("current", "save0") + i++ } - - // multi instance preview: - // when playing the regular flow, multi instances are not requried - if (clientIndex == 0) { - games.clear() - games["default"] = game + if (index == -1) { + Netchan.sendConnectionlessPacket(Defines.NS_SERVER, adr, ConnectionlessCommand.print, "\nServer is full.\n") + Com.DPrintf("Rejected a connection.\n") + return } + initializeClientConnection(index, challenge, userinfo, adr, qport) } /** - * SV_Map + * Initializes player structures after successfull connection. */ - fun spawnServerInstance(changeMapInfo: ChangeMapInfo, clientIndex: Int): GameImportsImpl { - publishLegacyServerState(ServerStates.SS_DEAD) - - val previous = if (clientIndex != 0) - games[clients[clientIndex - 1].gameName] - else - games["default"] - previous?.prepareLevelTransition() + private fun initializeClientConnection(i: Int, challenge: Int, userinfo: String?, adr: netadr_t, qport: Int) { + // build a new connection + // accept the new client + // this is the only place a ClientNetworkInfo is ever initialized - val gameImports = createGameInstance(changeMapInfo, previous) + val client = clients[i] + val gameImports: GameImportsImpl = games["default"]!! + client.gameName = "default" - Cvar.getInstance().find("nextserver") - .set("gamemap \"" + changeMapInfo.nextServer + "\"") + val ent = gameImports.gameExports.getEdict(i + 1) + client.edict = ent // fixme: why? isn't it already set? - Cmd.ExecuteFunction("loading") // for local system + // save challenge for checksumming + client.challenge = challenge - if (clientIndex == 0) { - // leave slots at start for clients only - for (cl in clients) { //todo - // needs to reconnect - if (cl.state == ClientStates.CS_SPAWNED) cl.state = ClientStates.CS_CONNECTED - cl.lastReceivedFrame = -1 + // get the game a chance to reject this connection or modify the + // userinfo + if (!(gameImports.gameExports.ClientConnect(ent, userinfo))) { + if (Info.Info_ValueForKey(userinfo, "rejmsg") != null) { + Netchan.sendConnectionlessPacket( + Defines.NS_SERVER, + adr, + ConnectionlessCommand.print, + "\n" + Info.Info_ValueForKey(userinfo, "rejmsg") + "\nConnection refused.\n" + ) + } else { + Netchan.sendConnectionlessPacket( + Defines.NS_SERVER, + adr, + ConnectionlessCommand.print, + "\nConnection refused.\n" + ) } - gameImports.SV_BroadcastCommand("changing") - } else { - val cl = clients[clientIndex - 1] - if (cl.state == ClientStates.CS_SPAWNED) cl.state = ClientStates.CS_CONNECTED - cl.lastReceivedFrame = -1 - gameImports.unicastMessage(clientIndex, StuffTextMessage("changing"), true) + Com.DPrintf("Game rejected a connection.\n") + return } - sendClientMessages() - - // precache and static commands can be issued during - // map initialization - publishLegacyServerState(gameImports.state) + // parse some info from the info strings + client.userinfo = userinfo + updateClientUserinfo(client) - gameImports.initializeLevel(changeMapInfo, previous) - publishLegacyServerState(gameImports.state) + Netchan.sendConnectionlessPacket(Defines.NS_SERVER, adr, ConnectionlessCommand.client_connect, "") - // set serverinfo variable - Cvar.getInstance().find("mapname").forceSet(gameImports.mapName) - Com.Println("mapname: " + gameImports.mapName) + client.netchan.setup(Defines.NS_SERVER, adr, qport) - if (changeMapInfo.state == ServerStates.SS_GAME) Cbuf.CopyToDefer() + client.state = ClientStates.CS_CONNECTED - if (clientIndex == 0) { - gameImports.SV_BroadcastCommand("reconnect") // doesn't really reconnect but rather issues 'NEW' command - } else { - gameImports.unicastMessage(clientIndex, StuffTextMessage("reconnect"), true) - } - return gameImports + client.lastmessage = gameImports.realtime // don't timeout + client.lastconnect = gameImports.realtime + Com.DPrintf("new client added.\n") } - /* -================== -SV_Map_f - -Goes directly to a given map without any savegame archiving. -For development work -================== - -Resets server to host a given map, moves all clients to it (via reconnect) -*/ - fun map(args: List) { + /** + * A connectionless packet has four leading 0xff characters to distinguish + * it from a game channel. Clients that are in the game can still send + * connectionless packets. It is used also by rcon commands. + */ + fun handleConnectionlessPacket(from: netadr_t, messageLine: String?) { + val args = Cmd.TokenizeString(messageLine, false) - if (args.size < 2 || args[1].isBlank()) { - Com.Printf("usage: map \n") + if (args.isEmpty()) { + Com.Printf("empty connectionless packet: $messageLine") return } - // if not a pcx, demo, or cinematic, check to make sure the level exists - val mapName = args[1] - val target = ChangeMapInfo(mapName, false, false) - if (target.state == ServerStates.SS_GAME) { - val mapPath = "maps/" + target.mapName + ".bsp" - if (!EngineVfs.exists(mapPath)) { - Com.Printf("Can't find $mapPath\n") - return - } - } - val game = games["default"] + val c = args[0] + val cmd = ConnectionlessCommand.fromString(c) - if (game != null) game.state = ServerStates.SS_DEAD // don't save current level when changing + when (cmd) { + ConnectionlessCommand.ping -> // SVC_Ping, Just responds with an acknowledgement. + Netchan.sendConnectionlessPacket(Defines.NS_SERVER, from, ConnectionlessCommand.ack, "") - ServerSaveFiles(EngineWriteRoot.path()).wipe("current") + ConnectionlessCommand.ack -> //SVC_Ack + Com.Printf("Ping acknowledge from $from\n") - // A running server needs its clients reset for map. For a fresh server, - // gamemap performs this initialization itself. - if (games.isNotEmpty()) { - initializeServerSession() - } - changeMap(args) - } + ConnectionlessCommand.status -> // SVC_Status, Responds with all the info that qplug or qspy can see + Netchan.sendConnectionlessPacket( + Defines.NS_SERVER, + from, + ConnectionlessCommand.print, + "\n" + buildStatusResponse() + ) - private fun initializeServerSession() { - if (games.isEmpty()) { - realtime = 0 - frameNum = 0 - nextFrameTime = 100 - } - val multiplayer = initializeServerCvars() - if (!NET.configureServer(multiplayer)) { - Com.Error(Defines.ERR_DROP, "Could not open the server UDP port") - return + ConnectionlessCommand.info -> { + val info = buildInfoResponse(args) + if (info != null) Netchan.sendConnectionlessPacket( + Defines.NS_SERVER, + from, + ConnectionlessCommand.info, + "\n" + info + ) + } + + ConnectionlessCommand.getchallenge -> { + val challenge = getChallenge(from) + Netchan.sendConnectionlessPacket( + Defines.NS_SERVER, + from, + ConnectionlessCommand.challenge, + " $challenge" + ) + } + + ConnectionlessCommand.connect -> connectClient(args, from) + ConnectionlessCommand.rcon -> executeRemoteCommand(args, from) + else -> Com.Printf("Unknown ClientConnectionlessCommand: $c\n") } - resetClients() } - /** Send this client's Game snapshot followed by its queued events, recording rate usage. */ - private fun sendClientDatagram(client: ClientNetworkInfo, gameImports: GameImportsImpl) { - // send over all the relevant entity_state_t - // and the player_state_t - val unreliable: MutableList = mutableListOf() - unreliable.addAll(gameImports.buildClientFrameMessages(client)) // 3 messages + /** + * Called when the player is totally leaving the server, either willingly or + * unwillingly. This is NOT called if the entire server is quiting or + * crashing. + */ + fun dropClient(client: ClientNetworkInfo) { + // add the disconnect + client.netchan.reliablePending.add(DisconnectMessage()) - // copy the accumulated multicast datagram - // for this client out to the message - // it is necessary for this to be after the buildClientFrameMessages (SV_BuildClientFrame) - // so that entity references will be current - unreliable.addAll(client.unreliable) - client.unreliable.clear() - // send the datagram - client.netchan.transmit(unreliable) + if (client.state == ClientStates.CS_SPAWNED) { + // call the prog function for removing a client + // this will remove the body, among other things + games[client.gameName]!!.gameExports.ClientDisconnect(client.edict) + } - // record the size for rate estimation - client.message_size[gameImports.frameNum % Defines.RATE_MESSAGES] = - unreliable.sumOf { it.size } + if (client.download != null) { + client.download = null + } + + client.state = ClientStates.CS_ZOMBIE // become free in a few seconds + client.name = "" } - private val userCommands = mapOf( - StringCmdMessage.NEW to ServerUserCommand { _, game, client -> sendInitialGameData(game, client) }, - StringCmdMessage.CONFIG_STRINGS to ServerUserCommand(::sendConfigstrings), - StringCmdMessage.BASELINES to ServerUserCommand(::sendBaselines), - StringCmdMessage.BEGIN to ServerUserCommand(::beginClient), - StringCmdMessage.NEXT_SERVER to ServerUserCommand(::finishCinematic), - StringCmdMessage.DISCONNECT to ServerUserCommand { _, _, client -> dropClient(client) }, - StringCmdMessage.INFO to ServerUserCommand { _, _, _ -> Info.Print(Cvar.getInstance().Serverinfo()) }, - StringCmdMessage.DOWNLOAD to ServerUserCommand { args, _, client -> beginDownload(args, client) }, - StringCmdMessage.NEXT_DOWNLOAD to ServerUserCommand { _, _, client -> sendNextDownloadChunk(client) }, - ) + // endregion + + // region Incoming packets and client commands /** - * One manager per dedicated process or embedded session; map changes reuse it. + * Reads packets from the network or loopback. */ - init { - val cvars = Cvar.getInstance() - cvars.register( - "spectator_password", CvarTypes.STRING, "", Defines.CVAR_USERINFO, - "Spectator admission password; empty or none allows access" - ) - cvars.register( - "needpass", NEEDPASS_TYPE, 0, Defines.CVAR_SERVERINFO, - "Required passwords: 0 none, 1 player, 2 spectator, 3 both" - ) - cvars.register("deathmatch", CvarTypes.BOOLEAN, false, Defines.CVAR_SERVERINFO or Defines.CVAR_LATCH) - cvars.register("coop", CvarTypes.BOOLEAN, false, Defines.CVAR_SERVERINFO or Defines.CVAR_LATCH) - cvars.register( - "dmflags", - CvarTypes.INTEGER, - Defines.DF_INSTANT_ITEMS, - Defines.CVAR_SERVERINFO, - "Deathmatch rule bitmask; combine DF_* flags" - ) - cvars.register( - "fraglimit", - CvarTypes.IntegerType(0, null), - 0, - Defines.CVAR_SERVERINFO, - "End a deathmatch level at this score; 0 disables the limit" - ) - cvars.register( - "timelimit", - CvarTypes.FloatType(0f, null), - 0f, - Defines.CVAR_SERVERINFO, - "Deathmatch level time limit in minutes; 0 disables the limit" - ) - cvars.register("cheats", CvarTypes.BOOLEAN, false, Defines.CVAR_SERVERINFO or Defines.CVAR_LATCH) - cvars.register( - "protocol", - CvarTypes.INTEGER, - Defines.PROTOCOL_VERSION, - Defines.CVAR_SERVERINFO or Defines.CVAR_NOSET - ) - cvars.register("mapname", CvarTypes.STRING, "", Defines.CVAR_SERVERINFO or Defines.CVAR_NOSET) - cvars.register( - "nextserver", - CvarTypes.STRING, - "", - 0, - "Console command to execute after a cinematic or picture" - ) - cvars.register( - "maxclients", - CvarTypes.IntegerType(1, Defines.MAX_CLIENTS), - 1, - Defines.CVAR_SERVERINFO or Defines.CVAR_LATCH, - "Client slot limit; single-player and coop mode also restrict the count" - ) - - hostname = cvars.register( - "hostname", - CvarTypes.STRING, - "noname", - Defines.CVAR_SERVERINFO or Defines.CVAR_ARCHIVE - ) - timeout = cvars.register( - "timeout", - CvarTypes.FloatType(0f, null), - 125f, - 0, - "Seconds without a client message before disconnection" - ) - zombieTimeout = cvars.register( - "zombietime", - CvarTypes.FloatType(0f, null), - 2f, - 0, - "Seconds to retain a disconnected client slot for final reliable messages" - ) - cvars.register( - "showclamp", - CvarTypes.BOOLEAN, - false, - 0, - "Legacy frame-time clamp diagnostics; unused by the current server and Cake" - ) - paused = cvars.register("paused", CvarTypes.BOOLEAN, false, 0) - cvars.register( - "timedemo", - CvarTypes.BOOLEAN, - false, - 0, - "Legacy demo benchmarking switch; unused by the current server and Cake" - ) - enforceMoveTime = cvars.register( - "sv_enforcetime", - CvarTypes.BOOLEAN, - false, - 0, - "Ignore movement commands that exceed the client time budget" - ) + fun readPackets() { + while (true) { + val networkPacket = NET.receiveNetworkPacket( + NET.ip_sockets[Defines.NS_SERVER], + NET.ip_channels[Defines.NS_SERVER], + NET.loopbacks[Defines.NS_SERVER], + true + ) - // come from MainCommon - allowDownload = cvars.find("allow_download") - allowPlayerDownloads = cvars.find("allow_download_players") - allowModelDownloads = cvars.find("allow_download_models") - allowSoundDownloads = cvars.find("allow_download_sounds") - allowMapDownloads = cvars.find("allow_download_maps") + if (networkPacket == null) break - cvars.register( - "sv_noreload", - CvarTypes.BOOLEAN, - false, - 0, - "Start revisited levels fresh instead of restoring their saved state" - ) - cvars.register( - "sv_airaccelerate", - CvarTypes.FLOAT, - 0f, - Defines.CVAR_LATCH, - "Controls air acceleration (currently only for deathmatch)" - ) - cvars.register( - "public", - CvarTypes.BOOLEAN, - false, - 0, - "Legacy master-server advertising switch; currently unused" - ) - reconnectLimit = cvars.register( - "sv_reconnect_limit", - CvarTypes.FloatType(0f, null), - 3f, - Defines.CVAR_ARCHIVE, - "Minimum seconds between reconnect attempts from the same remote client" - ) - resetClients() + if (networkPacket.isConnectionless) { + handleConnectionlessPacket(networkPacket.from, networkPacket.connectionlessMessage) + continue + } - // Publish Host callbacks only after initialization succeeds. They survive Game replacement. - // Retained for console compatibility; master-server advertising is not implemented. - commands.add("heartbeat", true) {} - commands.add("kick", true, Command(::kickClient)) - commands.add("status", true) { printStatus() } - commands.add("serverinfo", true) { printServerInfo() } - commands.add("dumpuser", true, Command(::dumpUser)) - commands.add("say", true, Command(::say)) - commands.add("save", true, Command(::saveGame)) + // Match incoming packet with the existing client. + // todo: get client by address (hashmap?) + for (cl in clients) { + if (cl.state == ClientStates.CS_FREE) continue + if (!networkPacket.from.CompareBaseAdr(cl.netchan.remote_address)) continue + if (cl.netchan.qport != networkPacket.qport) continue + if (cl.netchan.remote_address.port != networkPacket.from.port) { + Com.Printf("SV_ReadPackets: fixing up a translated port\n") + cl.netchan.remote_address.port = networkPacket.from.port + } - commands.add("killserver") { - stop("Server was killed.\n", false) - NET.configureServer(false) - } - commands.add("map", Command(::map)) - commands.add("gamemap", Command(::changeMap)) - commands.add("load", Command(::loadGame)) - commands.add("sv_shutdown") { args -> - val reason: String? = if (args.size > 1) { - args[1] - } else { - "Server is shut down" + if (cl.netchan.accept(networkPacket)) { + // this is a valid, sequenced packet, so process it + if (cl.state != ClientStates.CS_ZOMBIE) { + cl.lastmessage = games[cl.gameName]!!.realtime // don't timeout + val body = networkPacket.parseBodyFromClient(cl.netchan.incoming_sequence) + if (body == null) dropClient(cl) + else handleClientMessages(cl, body) + } + } + break } - stop(reason + "\n", args.size > 2 && args[2].toBoolean()) } + } - commands.add("listmaps") { - val bytes = EngineVfs.loadBytes("maps.lst") - if (bytes == null) { - Com.Error(Defines.ERR_DROP, "Could not read maps.lst") - return@add - } - for (line in String(bytes).split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()) { - Com.Printf(Defines.PRINT_ALL, line.trim { it <= ' ' } + "\n") - } - } - commands.add("jvm_memory") { - val runtime = Runtime.getRuntime() - Com.Printf("vm memory:" + (runtime.totalMemory() - runtime.freeMemory()) + "\n") - } - commands.add("games") { - for (game in games.values) { - Com.Printf(game.name + " - " + game.mapcmd + "\n") + fun handleClientMessages(cl: ClientNetworkInfo, clientMessages: Collection) { + // only allow one move command + + var moveIssued = false + var stringCmdCount = 0 + + for (msg in clientMessages) { + if (msg is EndOfClientPacketMessage) { + break + } else if (msg is StringCmdMessage) { + // malicious users may try using too many string commands + if (++stringCmdCount < MAX_STRINGCMDS) { + executeClientCommand(cl, msg.command) + } + + if (cl.state == ClientStates.CS_ZOMBIE) { + return // disconnect command + } + } else if (msg is UserInfoMessage) { + cl.userinfo = msg.userInfo + updateClientUserinfo(cl) + } else if (msg is MoveMessage) { + if (moveIssued) return // someone is trying to cheat... + + moveIssued = true + val lastReceivedFrame = msg.lastReceivedFrame + + if (lastReceivedFrame != cl.lastReceivedFrame) { + cl.lastReceivedFrame = lastReceivedFrame + if (cl.lastReceivedFrame > 0) { + val frameLatency = + games[cl.gameName]!!.realtime - cl.frames[cl.lastReceivedFrame and Defines.UPDATE_MASK].senttime + cl.frame_latency[cl.lastReceivedFrame and (Defines.LATENCY_COUNTS - 1)] = frameLatency + } + } + + if (cl.state != ClientStates.CS_SPAWNED) { + cl.lastReceivedFrame = -1 + continue + } + + // if the checksum fails, ignore the rest of the packet + if (!msg.valid) { + Com.Printf("Invalid crc\n") + return + } + + if (!paused.typedValue) { + var droppedPackets = cl.netchan.dropped + if (droppedPackets < 20) { + //if (droppedPackets > 2) + + // Com.Printf ("drop %i\n", droppedPackets); + + while (droppedPackets > 2) { + processClientMove(cl, cl.lastcmd) + + droppedPackets-- + } + if (droppedPackets > 1) processClientMove(cl, msg.oldestCmd) + + if (droppedPackets > 0) processClientMove(cl, msg.oldCmd) + } + processClientMove(cl, msg.newCmd) + } + + // copy. + cl.lastcmd.set(msg.newCmd) + } else { + Com.Printf("SV_ReadClientMessage: unknown command: " + msg.type + "\n") + dropClient(cl) } - Com.Printf("Total " + games.size + " games\n") } - commands.add("spamgames") { args -> - val total = args[1].toInt() - repeat(total) { - changeMap(listOf("gamemap", "base1", "1")) - } + } + + fun processClientMove(cl: ClientNetworkInfo, cmd: usercmd_t) { + cl.commandMsec -= cmd.msec.toInt() and 0xFF + + if (cl.commandMsec < 0 && enforceMoveTime.typedValue) { + Com.DPrintf("commandMsec underflow from " + cl.name + "\n") + return } + + games[cl.gameName]!!.gameExports.ClientThink(cl.edict, cmd) } - private fun interface ServerUserCommand { - fun execute(args: List, game: GameImportsImpl, client: ClientNetworkInfo) + /** + * Pull specific info from a newly changed userinfo string into a more C + * freindly form. + */ + fun updateClientUserinfo(cl: ClientNetworkInfo) { + // call prog code to allow overrides + games[cl.gameName]!!.gameExports.ClientUserinfoChanged(cl.edict, cl.userinfo) + + // name for C code + cl.name = Info.Info_ValueForKey(cl.userinfo, "name") + + // mask off high bit + + // rate command + var userinfoValue = Info.Info_ValueForKey(cl.userinfo, "rate") + if (userinfoValue.isNotEmpty()) { + cl.rate = Lib.atoi(userinfoValue) + if (cl.rate < 100) cl.rate = 100 + if (cl.rate > 15000) cl.rate = 15000 + } else cl.rate = 5000 + + // msg command + userinfoValue = Info.Info_ValueForKey(cl.userinfo, "msg") + if (userinfoValue.isNotEmpty()) { + cl.messagelevel = Lib.atoi(userinfoValue) + } + } + + fun executeClientCommand(cl: ClientNetworkInfo, command: String?) { + Com.dprintln("SV_ExecuteUserCommand:$command") + + val args = Cmd.TokenizeString(command, true) + if (args.isEmpty()) return + + val game: GameImportsImpl = games[cl.gameName]!! + if (userCommands.containsKey(args[0])) { + userCommands[args[0]]!!.execute(args, game, cl) + return + } + + if (game.state == ServerStates.SS_GAME) game.gameExports.ClientCommand(cl.edict, args) } + // endregion + + // region Game handshake and cinematic progression + /* * ================ SV_New_f * @@ -1585,6 +1509,46 @@ Resets server to host a given map, moves all clients to it (via reconnect) Cbuf.InsertFromDefer() } + private fun advanceCinematic(gameImports: GameImportsImpl) { + //ZOID, ss_pic can be nextserver'd in coop mode + + if (gameImports.state == ServerStates.SS_GAME + || (gameImports.state == ServerStates.SS_PIC && !Cvar.getInstance() + .find("coop").typedValue) + ) return // can't nextserver while playing a normal game + + gameImports.spawncount++ // make sure another doesn't sneak in + val v = Cvar.getInstance().find("nextserver").typedValue + + if (v.isEmpty()) Cbuf.AddText("killserver\n") + else { + Cbuf.AddText(v) + Cbuf.AddText("\n") + } + Cvar.getInstance().find("nextserver").set("") + } + + /* + * ================== SV_Nextserver_f + * + * A cinematic has completed or been aborted by a client, so move to the + * next server, ================== + */ + private fun finishCinematic(args: List, gameImports: GameImportsImpl, client: ClientNetworkInfo) { + val spawnCount = if (args.size >= 2) Lib.atoi(args[1]) else 0 + if (spawnCount != gameImports.spawncount) { + Com.DPrintf("Nextserver() from wrong level, from " + client.name + "\n") + return // leftover from last server + } + + Com.DPrintf("Nextserver() from " + client.name + "\n") + advanceCinematic(gameImports) + } + + // endregion + + // region Downloads + private fun sendNextDownloadChunk(client: ClientNetworkInfo) { if (client.download == null) return @@ -1673,62 +1637,137 @@ Resets server to host a given map, moves all clients to it (via reconnect) Com.DPrintf("Downloading " + name + " to " + client.name + "\n") } - private fun advanceCinematic(gameImports: GameImportsImpl) { - //ZOID, ss_pic can be nextserver'd in coop mode + // endregion - if (gameImports.state == ServerStates.SS_GAME - || (gameImports.state == ServerStates.SS_PIC && !Cvar.getInstance() - .find("coop").typedValue) - ) return // can't nextserver while playing a normal game + // region Outgoing messages and rate limiting - gameImports.spawncount++ // make sure another doesn't sneak in - val v = Cvar.getInstance().find("nextserver").typedValue + fun sendClientMessages() { + // send a message to each connected client - if (v.isEmpty()) Cbuf.AddText("killserver\n") - else { - Cbuf.AddText(v) - Cbuf.AddText("\n") + for (c in clients) { + if (c.state == ClientStates.CS_FREE) continue + + val game: GameImportsImpl = games[c.gameName]!! + // if the reliable message overflowed, + // drop the client + val pendingReliableSize = + c.netchan.reliablePending.sumOf { it.size } + if (pendingReliableSize > Defines.MAX_MSGLEN - 16) { + c.netchan.reliablePending.clear() + c.unreliable.clear() + broadcastPrint(Defines.PRINT_HIGH, c.name + " overflowed\n", c.gameName) + dropClient(c) + } + + if (game.state == ServerStates.SS_CINEMATIC || game.state == ServerStates.SS_DEMO || game.state == ServerStates.SS_PIC) { + // leftover from demo code + c.netchan.transmit(null) + } else if (c.state == ClientStates.CS_SPAWNED) { + // don't overrun bandwidth + if (shouldRateLimit(c)) continue + + sendClientDatagram(c, game) + } else { + // just update reliable if needed + if (c.netchan.reliablePending.isNotEmpty() || Globals.curtime - c.netchan.last_sent > 1000) c.netchan.transmit( + null + ) + } } - Cvar.getInstance().find("nextserver").set("") } - /* - * ================== SV_Nextserver_f + /** + * Sends text to all active clients + */ + fun broadcastPrint(level: Int, message: String?, gameName: String?) { + // echo to console + + if (Globals.dedicated.typedValue) { + Com.Printf(message) + } + + for (cl in getClientsForInstance(gameName)) { + if (cl == null || level < cl.messagelevel) continue + if (cl.state != ClientStates.CS_SPAWNED) continue + cl.netchan.reliablePending.add(PrintMessage(level, message)) + } + } + + /** + * SV_RateDrop * - * A cinematic has completed or been aborted by a client, so move to the - * next server, ================== + * + * Returns true if the client is over its current + * bandwidth estimation and should not be sent another packet */ - private fun finishCinematic(args: List, gameImports: GameImportsImpl, client: ClientNetworkInfo) { - val spawnCount = if (args.size >= 2) Lib.atoi(args[1]) else 0 - if (spawnCount != gameImports.spawncount) { - Com.DPrintf("Nextserver() from wrong level, from " + client.name + "\n") - return // leftover from last server + fun shouldRateLimit(c: ClientNetworkInfo): Boolean { + // never drop over the loopback + + if (c.netchan.remote_address.type == NetAddrType.NA_LOOPBACK) return false + + var total = 0 + + for (i in 0.. c.rate) { + c.surpressCount++ + c.message_size[frameNum % Defines.RATE_MESSAGES] = 0 + return true + } + + return false } - /** Resolve a Game at execution time; creating another Game never replaces these callbacks. */ - private fun saveGame(args: List) { - var target = "default" - var command = args - if (args.size > 1 && args[1] == "--game") { - if (args.size < 4) { - Com.Printf("Usage: " + args[0] + " --game \n") - return - } - target = args[2] - command = listOf(args[0]) + args.drop(3) + /** + * Used by stop to send a final message to all connected clients + * before the server goes down. The messages are sent immediately, not just + * stuck on the outgoing message list, because the server is going to + * totally exit after returning from this function. + */ + private fun sendFinalMessage(message: String?, reconnect: Boolean) { + val msgs: MutableCollection = ArrayList() + msgs.add(PrintMessage(Defines.PRINT_HIGH, message)) + + if (reconnect) msgs.add(ReconnectMessage()) + else msgs.add(DisconnectMessage()) + + // send it twice + // stagger the packets to crutch operating system limited buffers + for (cl in clients) { + if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) cl.netchan.transmit(msgs) } - val game = games[target] - if (game == null) { - Com.Printf("No running game: $target\n") - return + for (cl in clients) { + if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) cl.netchan.transmit(msgs) } - game.saveGame(command) } + /** Send this client's Game snapshot followed by its queued events, recording rate usage. */ + private fun sendClientDatagram(client: ClientNetworkInfo, gameImports: GameImportsImpl) { + // send over all the relevant entity_state_t + // and the player_state_t + val unreliable: MutableList = mutableListOf() + unreliable.addAll(gameImports.buildClientFrameMessages(client)) // 3 messages + + // copy the accumulated multicast datagram + // for this client out to the message + // it is necessary for this to be after the buildClientFrameMessages (SV_BuildClientFrame) + // so that entity references will be current + unreliable.addAll(client.unreliable) + client.unreliable.clear() + // send the datagram + client.netchan.transmit(unreliable) + + // record the size for rate estimation + client.message_size[gameImports.frameNum % Defines.RATE_MESSAGES] = + unreliable.sumOf { it.size } + } + + // endregion + + // region Operator commands and diagnostics + private fun kickClient(args: List) { if (args.size != 2) { Com.Printf("Usage: kick \n") @@ -1855,6 +1894,8 @@ Resets server to host a given map, moves all clients to it (via reconnect) return null } + // endregion + companion object { // Recreating an embedded manager must reuse the same choice declaration. private val NEEDPASS_TYPE = ChoiceType(listOf(0, 1, 2, 3)) From da81095445675d5026f892c764162ee234169751 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 19:06:28 +0100 Subject: [PATCH 16/47] Move safe transition archival into Game --- .../jake2/server/TransitionArchiveTest.kt | 133 ++++++++++++++++++ .../main/java/jake2/game/GameExportsImpl.java | 2 +- .../java/jake2/server/GameImportsImpl.java | 27 +++- .../main/kotlin/jake2/server/ServerHost.kt | 25 +--- 4 files changed, 160 insertions(+), 27 deletions(-) create mode 100644 cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt new file mode 100644 index 00000000..bbf48ed9 --- /dev/null +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt @@ -0,0 +1,133 @@ +package jake2.server + +import jake2.game.LevelSaveJsonStore +import jake2.qcommon.* +import jake2.qcommon.exec.Cvar +import jake2.qcommon.exec.find +import jake2.qcommon.vfs.EngineVfs +import jake2.qcommon.vfs.EngineWriteRoot +import jake2.qcommon.vfs.VfsConfig +import org.junit.jupiter.api.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.io.TempDir +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.nio.file.Files +import java.nio.file.Path + +@Suppress("DEPRECATION") +class TransitionArchiveTest { + @TempDir lateinit var root: Path + private lateinit var previousRoot: Path + private lateinit var previousState: ServerStates + private lateinit var host: ServerHost + private lateinit var first: GameImportsImpl + private lateinit var second: GameImportsImpl + + @BeforeEach + fun setup() { + previousRoot = EngineWriteRoot.path() + previousState = Globals.server_state + Globals.server_state = ServerStates.SS_DEAD + Cvar.getInstance().clear() + MainCommon.registerCvars(false) + EngineVfs.configure(VfsConfig(Path.of("src/test/resources"), "baseq2", null, + true, false, false, emptyList(), setOf("pak"))) + EngineWriteRoot.setRoot(root) + host = ServerHost() + Cvar.getInstance().find("maxclients").forceSet(4) + host.resetClients() + first = host.createGameInstance(ChangeMapInfo("testbox", false, false), null) + second = host.createGameInstance(ChangeMapInfo("testbox2", false, false), null) + first.name = "default" + host.games[first.name] = first + host.games[second.name] = second + first.state = ServerStates.SS_GAME + second.state = ServerStates.SS_GAME + host.clients[0].apply { + gameName = first.name + edict = first.gameExports.getEdict(1) + edict.inuse = true + } + host.clients[1].apply { + gameName = second.name + edict = second.gameExports.getEdict(2) + edict.inuse = true + } + host.clients[2].apply { + gameName = first.name + edict = first.gameExports.getEdict(3) + edict.inuse = false + } + // A routed but unused client slot has no entity yet. + host.clients[3].gameName = first.name + first.gameExports.getEdict(0).inuse = true + } + + @AfterEach + fun cleanup() { + host.close() + EngineWriteRoot.setRoot(previousRoot) + Globals.server_state = previousState + Cvar.getInstance().clear() + } + + @Test + fun `archive excludes player bodies and preserves live state in both games`() { + val gameplay = first.gameExports + var wroteGameplay = false + first.gameExports = object : GameExports by gameplay { + override fun WriteLevel(filename: String) { + assertFalse(host.clients[0].edict.inuse) + assertFalse(host.clients[2].edict.inuse) + assertTrue(host.clients[1].edict.inuse, "Unrelated Game must stay intact during serialization") + assertTrue(gameplay.getEdict(0).inuse) + wroteGameplay = true + gameplay.WriteLevel(filename) + } + } + + first.archiveLevelForTransition() + + assertTrue(wroteGameplay) + assertLiveState() + val saved = LevelSaveJsonStore.forWriteDir(root.toString()) + .read(root.resolve("save/current/testbox.sav.json").toString()) + assertTrue(saved.entities.any { it.entityNumber == 0 }) + assertFalse(saved.entities.any { it.entityNumber == 1 || it.entityNumber == 3 }) + assertTrue(Files.exists(root.resolve("save/current/testbox.sv2.json"))) + } + + @ParameterizedTest + @ValueSource(strings = ["sv2", "sav"]) + fun `write failure restores entities and aborts map transition`(extension: String) { + // A directory at the output filename forces a real filesystem write failure. + Files.createDirectories(root.resolve("save/current/testbox.$extension.json")) + val originalGameplay = first.gameExports + val failure = assertThrows(IllegalStateException::class.java) { + host.changeMap(listOf("gamemap", "testbox2")) + } + + assertNotNull(failure.cause) + assertLiveState() + assertSame(first, host.games["default"]) + assertSame(second, host.games[second.name]) + assertEquals(2, host.games.size) + assertSame(originalGameplay, first.gameExports) + assertEquals(ServerStates.SS_GAME, first.state) + assertEquals("default", host.clients[0].gameName) + assertFalse(Files.exists(root.resolve("save/save0")), "Failed archival must not continue to autosave") + if (extension == "sv2") { + assertFalse(Files.exists(root.resolve("save/current/testbox.sav.json")), + "Server-state failure must stop before gameplay serialization") + } + } + + private fun assertLiveState() { + assertTrue(host.clients[0].edict.inuse) + assertFalse(host.clients[2].edict.inuse) + assertTrue(host.clients[1].edict.inuse) + assertTrue(first.gameExports.getEdict(0).inuse) + assertNull(host.clients[3].edict) + } +} diff --git a/game/src/main/java/jake2/game/GameExportsImpl.java b/game/src/main/java/jake2/game/GameExportsImpl.java index 93d89b24..aa13f3a2 100644 --- a/game/src/main/java/jake2/game/GameExportsImpl.java +++ b/game/src/main/java/jake2/game/GameExportsImpl.java @@ -1633,7 +1633,7 @@ public void WriteLevel(String filename) { entities )); } catch (Exception e) { - e.printStackTrace(); + throw new IllegalStateException("Failed to write gameplay level to " + filename, e); } } diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index 2ee0f865..8cbc610d 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -1126,6 +1126,30 @@ void readLevelFile() { gameExports.ReadLevel(name); } + /** Archive this level without player bodies, restoring live entities even if writing fails. */ + void archiveLevelForTransition() { + List players = new ArrayList<>(); + for (ClientNetworkInfo client : serverMain.getClientsForInstance(name)) { + if (client != null && client.edict != null) { + players.add(client.edict); + } + } + boolean[] savedInuse = new boolean[players.size()]; + for (int i = 0; i < players.size(); i++) { + savedInuse[i] = players.get(i).inuse; + } + try { + for (ServerEntity player : players) { + player.inuse = false; + } + SV_WriteLevelFile(); + } finally { + for (int i = 0; i < players.size(); i++) { + players.get(i).inuse = savedInuse[i]; + } + } + } + void SV_WriteLevelFile() { Com.DPrintf("SV_WriteLevelFile()\n"); @@ -1135,8 +1159,7 @@ void SV_WriteLevelFile() { store.writeLevelState("current", mapName, configstrings, cm.portalopen); } catch (Exception e) { - Com.Printf("Failed to write current level state for " + mapName + "\n"); - e.printStackTrace(); + throw new IllegalStateException("Failed to write current level state for " + mapName, e); } String name = EngineWriteRoot.resolve("save/current/" + mapName + ".sav.json").toString(); diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index dca002d8..917ef190 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -539,30 +539,7 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) // note: only applicable to the default instance (subject to change in future) val gameImports = games["default"] if (gameImports != null && gameImports.state == ServerStates.SS_GAME) { - // clear all the client inuse flags before saving so that - // when the level is re-entered, the clients will spawn - // at spawn points instead of occupying body shells - // todo: only relevant to this gameImports clients - val savedInuse = BooleanArray(clients.size) - var i = 0 - for (cl in clients) { - if (cl.edict != null) { // free client slots will have null values - savedInuse[i] = cl.edict.inuse - cl.edict.inuse = false - } - i++ - } - - gameImports.SV_WriteLevelFile() - - // we must restore these for clients to transfer over correctly - i = 0 - for (cl in clients) { - if (cl.edict != null) { // free client slots will have null values - cl.edict.inuse = savedInuse[i] - } - i++ - } + gameImports.archiveLevelForTransition() } } From 0856ada0bb6e15ea193954441efac6374819df77 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 19:10:24 +0100 Subject: [PATCH 17/47] chore: remove suppress deprecation --- .../src/test/kotlin/jake2/server/TransitionArchiveTest.kt | 1 - server/src/main/java/jake2/server/GameImportsImpl.java | 1 - server/src/main/kotlin/jake2/server/ServerHost.kt | 1 - 3 files changed, 3 deletions(-) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt index bbf48ed9..b49e3731 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt @@ -15,7 +15,6 @@ import org.junit.jupiter.params.provider.ValueSource import java.nio.file.Files import java.nio.file.Path -@Suppress("DEPRECATION") class TransitionArchiveTest { @TempDir lateinit var root: Path private lateinit var previousRoot: Path diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index 8cbc610d..1e968810 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -58,7 +58,6 @@ import static jake2.qcommon.Defines.CS_MODELS; /** Owns one server-side game instance and exposes engine services to its gameplay module. */ -@SuppressWarnings("deprecation") public class GameImportsImpl implements GameImports { String name; diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 917ef190..16a09027 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -395,7 +395,6 @@ class ServerHost : AutoCloseable { } /** Bridge for client loading state and cvar latching until shared-global cleanup. */ - @Suppress("DEPRECATION") private fun publishLegacyServerState(state: ServerStates) { Globals.server_state = state } From 9ad23e3c7c5e34824ba016da60ad1eb7d564e613 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 19:18:35 +0100 Subject: [PATCH 18/47] Use Host time and frames for client accounting --- .../jake2/server/HostClientAccountingTest.kt | 198 ++++++++++++++++++ .../java/jake2/server/ClientNetworkInfo.java | 4 +- .../java/jake2/server/GameImportsImpl.java | 1 - .../java/jake2/server/client_frame_t.java | 2 +- .../main/kotlin/jake2/server/ServerHost.kt | 30 ++- 5 files changed, 214 insertions(+), 21 deletions(-) create mode 100644 cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt new file mode 100644 index 00000000..76d148c0 --- /dev/null +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt @@ -0,0 +1,198 @@ +package jake2.server + +import jake2.game.GameEntity +import jake2.game.GamePlayerInfo +import jake2.qcommon.* +import jake2.qcommon.exec.Cvar +import jake2.qcommon.exec.find +import jake2.qcommon.network.* +import jake2.qcommon.network.messages.client.MoveMessage +import jake2.qcommon.vfs.* +import org.junit.jupiter.api.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +class HostClientAccountingTest { + @TempDir lateinit var root: Path + private lateinit var previousRoot: Path + private var previousState: ServerStates? = null + private lateinit var host: ServerHost + private lateinit var first: GameImportsImpl + private lateinit var second: GameImportsImpl + private val cvars = Cvar.getInstance() + + @BeforeEach + fun setup() { + previousRoot = EngineWriteRoot.path() + previousState = Globals.server_state + Globals.server_state = ServerStates.SS_DEAD + cvars.clear() + MainCommon.registerCvars(false) + Netchan.Netchan_Init() + drainPackets() + EngineVfs.configure(VfsConfig(Path.of("src/test/resources"), "baseq2", null, + true, false, false, emptyList(), setOf("pak"))) + EngineWriteRoot.setRoot(root) + host = ServerHost() + assertTrue(host.startMap("testbox")) + host.games.clear() + cvars.find("maxclients").forceSet(2) + host.resetClients() + first = createGame("default") + second = createGame("second") + first.frameNum = 5 + second.frameNum = 100 + host.update(100) + } + + private fun createGame(name: String): GameImportsImpl { + val game = host.createGameInstance(ChangeMapInfo("testbox", false, false), null) + game.name = name + host.games[name] = game + return game + } + + private fun bind(index: Int, game: GameImportsImpl): ClientNetworkInfo = host.clients[index].apply { + gameName = game.name + edict = game.gameExports.getEdict(index + 1) + (edict as GameEntity).setClient(GamePlayerInfo(index)) + edict.getClient().playerState.pmove.origin[2] = (64 * 8).toShort() + lastmessage = 100 + lastconnect = 100 + rate = 100000 + state = ClientStates.CS_CONNECTED + netchan.setup(Defines.NS_SERVER, netadr_t(), index) + } + + @AfterEach + fun cleanup() { + host.clients.forEach { it.state = ClientStates.CS_FREE } + host.close() + drainPackets() + EngineWriteRoot.setRoot(previousRoot) + Globals.server_state = previousState + cvars.clear() + } + + private fun drainPackets() { + for (side in listOf(Defines.NS_CLIENT, Defines.NS_SERVER)) { + while (NET.receiveNetworkPacket(null, null, NET.loopbacks[side], side == Defines.NS_SERVER) != null) { } + } + } + + @Test + fun `timeout survives Game clock reset and expires at Host boundary`() { + val client = bind(0, first) + host.timeout.forceSet(0.5f) + first.realtime = -10000 + host.update(500) + assertEquals(ClientStates.CS_CONNECTED, client.state) + assertEquals(100L, client.lastmessage) + // Simulate transfer to a newly allocated Game with a different clock. + client.gameName = second.name + second.realtime = 0 + host.update(1) + assertEquals(ClientStates.CS_FREE, client.state) + } + + @Test + fun `targeted map change preserves connection timestamps`() { + val client = bind(0, first) + host.update(400) + host.changeMap(listOf("gamemap", "testbox2", "1")) + assertNotEquals(first.name, client.gameName) + assertEquals("testbox2", host.games.getValue(client.gameName).mapName) + assertSame(second, host.games[second.name]) + assertEquals(100L, client.lastmessage) + assertEquals(100L, client.lastconnect) + host.timeout.forceSet(0.5f) + host.update(100) + assertEquals(ClientStates.CS_CONNECTED, client.state) + host.update(1) + assertEquals(ClientStates.CS_FREE, client.state) + } + + @Test + fun `dropping starts a full Host zombie grace period`() { + val client = bind(0, first) + cvars.find("zombietime").forceSet(0.5f) + host.update(400) + host.dropClient(client) + assertEquals(500L, client.lastmessage) + host.update(500) + assertEquals(ClientStates.CS_ZOMBIE, client.state) + first.realtime = -10000 + host.update(1) + assertEquals(ClientStates.CS_FREE, client.state) + } + + @Test + fun `snapshots use Game identity but Host send time and rate bucket`() { + val client = bind(0, first) + client.state = ClientStates.CS_SPAWNED + first.realtime = -9000 + host.sendClientMessages() + val snapshot = first.frameNum + assertEquals(100L, client.frames[snapshot and Defines.UPDATE_MASK].senttime) + assertTrue(client.message_size[1] > 0) + assertEquals(0, client.message_size[snapshot % Defines.RATE_MESSAGES]) + + client.state = ClientStates.CS_CONNECTED + host.update(100) + first.realtime = 0 + val move = MoveMessage(false, snapshot, usercmd_t(), usercmd_t(), usercmd_t(), 0) + host.handleClientMessages(client, listOf(move)) + client.state = ClientStates.CS_SPAWNED + host.updateClientPings() + assertEquals(100, client.ping) + } + + @Test + fun `rate history expires by Host frames and movement budget ignores Game age`() { + val a = bind(0, first) + val b = bind(1, second) + a.netchan.remote_address.type = NetAddrType.NA_IP + a.rate = 1 + a.message_size[1] = 100 + repeat(10) { + host.update(100) + host.shouldRateLimit(a) + } + assertTrue(a.message_size.all { it == 0 }) + assertFalse(host.shouldRateLimit(a)) + a.commandMsec = 10 + b.commandMsec = 20 + repeat(4) { host.update(100) } + assertEquals(10, a.commandMsec) + assertEquals(20, b.commandMsec) + host.update(100) // Host frame 16; neither Game frame is a multiple of 16. + assertEquals(1800, a.commandMsec) + assertEquals(1800, b.commandMsec) + } + + @Test + fun `remote reconnect uses Host acceptance time across Game clock changes`() { + // Loopback transport with a nonlocal port exercises remote admission without a UDP socket. + val address = netadr_t().apply { port = 12345 } + host.handleConnectionlessPacket(address, "getchallenge") + val reply = NET.receiveNetworkPacket(null, null, NET.loopbacks[Defines.NS_CLIENT], false)!!.connectionlessMessage + val challenge = reply.trim().substringAfterLast(' ') + val connect = "connect ${Defines.PROTOCOL_VERSION} 7 $challenge \\name\\tester" + first.realtime = -9000 + host.handleConnectionlessPacket(address, connect) + val client = host.clients[0] + assertEquals(ClientStates.CS_CONNECTED, client.state) + assertEquals(100L, client.lastconnect) + drainPackets() + cvars.find("sv_reconnect_limit").forceSet(0.5f) + host.update(499) + first.realtime = 50000 + host.handleConnectionlessPacket(address, connect) + assertEquals(100L, client.lastconnect, "Reconnect before the boundary must be rejected") + host.update(1) + host.handleConnectionlessPacket(address, connect) + assertEquals(600L, client.lastconnect) + assertEquals(600L, client.lastmessage) + } +} diff --git a/server/src/main/java/jake2/server/ClientNetworkInfo.java b/server/src/main/java/jake2/server/ClientNetworkInfo.java index 0b43888a..d4eff93b 100644 --- a/server/src/main/java/jake2/server/ClientNetworkInfo.java +++ b/server/src/main/java/jake2/server/ClientNetworkInfo.java @@ -85,8 +85,8 @@ public ClientNetworkInfo() { int downloadsize; // total bytes (can't use EOF because of paks) int downloadcount; // bytes sent - int lastmessage; // sv.framenum when packet was last received - int lastconnect; + long lastmessage; // Host elapsed milliseconds at last packet or start of zombie grace period + long lastconnect; // Host elapsed milliseconds at connection acceptance int challenge; // challenge of this user, randomly generated diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index 1e968810..b2456526 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -1510,7 +1510,6 @@ private void buildClientFrame(ClientNetworkInfo client) { // this is the frame we are creating client_frame_t frame = client.frames[frameNum & Defines.UPDATE_MASK]; - frame.senttime = realtime; // save it for ping calc later // find the client's PVS int i; diff --git a/server/src/main/java/jake2/server/client_frame_t.java b/server/src/main/java/jake2/server/client_frame_t.java index 7654f0d6..9ca7e7a0 100644 --- a/server/src/main/java/jake2/server/client_frame_t.java +++ b/server/src/main/java/jake2/server/client_frame_t.java @@ -32,5 +32,5 @@ public class client_frame_t { player_state_t ps = new player_state_t(); int num_entities; int first_entity; // into the circular sv_packet_entities[] - int senttime; // for ping calculations + long senttime; // Host elapsed milliseconds at transmission, for ping calculations } diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 16a09027..6696908b 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -760,8 +760,8 @@ Resets server to host a given map, moves all clients to it (via reconnect) /** * If a packet has not been received from a client for the configured timeout - * in seconds, drop the connection. Server frames are used instead of realtime - * to avoid dropping the local client while debugging. + * in seconds, drop the connection. All connection timestamps use Host elapsed + * time, independent of Game replacement and simulation clocks. * * When a client is normally dropped, the ClientNetworkInfo goes into a zombie state * for a few seconds to make sure any final reliable message gets resent if @@ -771,12 +771,8 @@ Resets server to host a given map, moves all clients to it (via reconnect) for (cl in clients) { if (cl.state == ClientStates.CS_FREE) continue - val game: GameImportsImpl = games[cl.gameName]!! - val droppoint = (game.realtime - 1000 * timeout.typedValue).toInt() - val zombiepoint = (game.realtime - 1000 * zombieTimeout.typedValue).toInt() - - // message times may be wrong across a changelevel - if (cl.lastmessage > game.realtime) cl.lastmessage = game.realtime + val droppoint = realtime - (1000 * timeout.typedValue).toLong() + val zombiepoint = realtime - (1000 * zombieTimeout.typedValue).toLong() if (cl.state == ClientStates.CS_ZOMBIE && cl.lastmessage < zombiepoint) { cl.state = ClientStates.CS_FREE // can now be reused @@ -824,8 +820,6 @@ Resets server to host a given map, moves all clients to it (via reconnect) for (cl in clients) { if (cl.state == ClientStates.CS_FREE) continue - if ((games[cl.gameName]!!.frameNum and 15) != 0) continue - cl.commandMsec = 1800 // 1600 + some slop } } @@ -1052,8 +1046,8 @@ Resets server to host a given map, moves all clients to it (via reconnect) client.state = ClientStates.CS_CONNECTED - client.lastmessage = gameImports.realtime // don't timeout - client.lastconnect = gameImports.realtime + client.lastmessage = realtime // don't timeout + client.lastconnect = realtime Com.DPrintf("new client added.\n") } @@ -1133,6 +1127,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) client.download = null } + client.lastmessage = realtime // start the zombie grace period on the Host clock client.state = ClientStates.CS_ZOMBIE // become free in a few seconds client.name = "" } @@ -1174,7 +1169,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) if (cl.netchan.accept(networkPacket)) { // this is a valid, sequenced packet, so process it if (cl.state != ClientStates.CS_ZOMBIE) { - cl.lastmessage = games[cl.gameName]!!.realtime // don't timeout + cl.lastmessage = realtime // don't timeout val body = networkPacket.parseBodyFromClient(cl.netchan.incoming_sequence) if (body == null) dropClient(cl) else handleClientMessages(cl, body) @@ -1216,7 +1211,8 @@ Resets server to host a given map, moves all clients to it (via reconnect) cl.lastReceivedFrame = lastReceivedFrame if (cl.lastReceivedFrame > 0) { val frameLatency = - games[cl.gameName]!!.realtime - cl.frames[cl.lastReceivedFrame and Defines.UPDATE_MASK].senttime + (realtime - cl.frames[cl.lastReceivedFrame and Defines.UPDATE_MASK].senttime) + .coerceIn(0, Int.MAX_VALUE.toLong()).toInt() cl.frame_latency[cl.lastReceivedFrame and (Defines.LATENCY_COUNTS - 1)] = frameLatency } } @@ -1733,10 +1729,11 @@ Resets server to host a given map, moves all clients to it (via reconnect) unreliable.addAll(client.unreliable) client.unreliable.clear() // send the datagram + client.frames[gameImports.frameNum and Defines.UPDATE_MASK].senttime = realtime client.netchan.transmit(unreliable) // record the size for rate estimation - client.message_size[gameImports.frameNum % Defines.RATE_MESSAGES] = + client.message_size[frameNum % Defines.RATE_MESSAGES] = unreliable.sumOf { it.size } } @@ -1756,7 +1753,6 @@ Resets server to host a given map, moves all clients to it (via reconnect) // SV_BroadcastPrintf message client.queuePrint(Defines.PRINT_HIGH, "You were kicked from the game\n") dropClient(client) - client.lastmessage = games[client.gameName]!!.realtime // min case there is a funny zombie } private fun printStatus() { @@ -1787,7 +1783,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) } Com.Printf(cl.name.padEnd(16)) - val lastMessageAge = games[cl.gameName]!!.realtime - cl.lastmessage + val lastMessageAge = realtime - cl.lastmessage Com.Printf(lastMessageAge.toString().padStart(7) + " ") Com.Printf(cl.netchan.remote_address.toString().padEnd(22)) Com.Printf(cl.netchan.qport.toString().padStart(5)) From d6652cb4d4b0107bd17eead2508affae7fa5767e Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 19:27:43 +0100 Subject: [PATCH 19/47] Own cinematic continuation per Game and target Host transitions --- .../jake2/server/GameContinuationTest.kt | 174 ++++++++++++++++++ .../java/jake2/server/GameImportsImpl.java | 14 ++ .../main/kotlin/jake2/server/ServerHost.kt | 116 +++++++----- 3 files changed, 255 insertions(+), 49 deletions(-) create mode 100644 cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt new file mode 100644 index 00000000..82708184 --- /dev/null +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt @@ -0,0 +1,174 @@ +package jake2.server + +import jake2.qcommon.* +import jake2.qcommon.exec.Cbuf +import jake2.qcommon.exec.Cvar +import jake2.qcommon.exec.find +import jake2.qcommon.network.Netchan +import jake2.qcommon.network.netadr_t +import jake2.qcommon.network.messages.server.StuffTextMessage +import jake2.qcommon.vfs.* +import org.junit.jupiter.api.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +class GameContinuationTest { + @TempDir lateinit var root: Path + private lateinit var previousRoot: Path + private var previousState: ServerStates? = null + private lateinit var host: ServerHost + + @BeforeEach + fun setup() { + previousRoot = EngineWriteRoot.path() + previousState = Globals.server_state + Globals.server_state = ServerStates.SS_DEAD + Cvar.getInstance().clear() + MainCommon.registerCvars(false) + Netchan.Netchan_Init() + EngineVfs.configure(VfsConfig(Path.of("src/test/resources"), "baseq2", null, + true, false, false, emptyList(), setOf("pak"))) + EngineWriteRoot.setRoot(root) + host = ServerHost() + Cvar.getInstance().find("maxclients").forceSet(3) + host.resetClients() + } + + @AfterEach + fun cleanup() { + host.clients.forEach { it.state = ClientStates.CS_FREE } + host.close() + Cbuf.ClearDeferred() + EngineWriteRoot.setRoot(previousRoot) + Globals.server_state = previousState + Cvar.getInstance().clear() + } + + private fun game(name: String, expression: String): GameImportsImpl { + val change = ChangeMapInfo(expression, false, false) + return host.createGameInstance(change, null).also { + it.name = name + it.state = change.state + host.games[name] = it + } + } + + private fun bind(index: Int, game: GameImportsImpl): ClientNetworkInfo = host.clients[index].apply { + gameName = game.name + edict = game.gameExports.getEdict(index + 1) + state = ClientStates.CS_CONNECTED + netchan.setup(Defines.NS_SERVER, netadr_t(), index) + } + + private fun finish(client: ClientNetworkInfo, game: GameImportsImpl) = + host.executeClientCommand(client, "nextserver ${game.spawncount}") + + @Test + fun `two Games advance independently and retain all routed clients`() { + val first = game("default", "intro.cin+testbox") + val second = game("other", "end.cin+testbox2") + val a = bind(0, first) + val b = bind(1, second) + val c = bind(2, first) + val oldSpawn = first.spawncount + finish(a, first) + host.executeClientCommand(c, "nextserver $oldSpawn") // duplicate from another client + assertSame(first, host.games["default"], "Replacement must wait until packet handling finishes") + host.update(100) + assertEquals("testbox", host.games.getValue("default").mapName) + assertSame(second, host.games["other"]) + assertEquals("default", a.gameName) + assertEquals("default", c.gameName) + assertTrue(a.netchan.reliablePending.contains(StuffTextMessage("reconnect"))) + assertTrue(c.netchan.reliablePending.contains(StuffTextMessage("reconnect"))) + assertFalse(b.netchan.reliablePending.contains(StuffTextMessage("reconnect"))) + + finish(b, second) + host.update(100) + assertEquals("testbox2", host.games.getValue("other").mapName) + assertEquals("testbox", host.games.getValue("default").mapName) + assertEquals(2, host.games.size) + } + + @Test + fun `same-tick completions both run despite command-buffer deferral`() { + val first = game("default", "intro.cin+testbox") + val second = game("other", "intro.cin+testbox2") + finish(bind(0, first), first) + finish(bind(1, second), second) + host.update(100) + assertEquals("testbox", host.games.getValue("default").mapName) + assertEquals("testbox2", host.games.getValue("other").mapName) + assertEquals(2, host.games.size) + } + + @Test + fun `ending a sequence disconnects only its Game`() { + val first = game("default", "end.cin") + val second = game("other", "intro.cin+testbox2") + val a = bind(0, first) + val b = bind(1, second) + finish(a, first) + host.update(100) + assertFalse(host.games.containsKey("default")) + assertSame(second, host.games["other"]) + assertEquals(ClientStates.CS_FREE, a.state) + assertNull(a.gameName) + assertEquals(ClientStates.CS_CONNECTED, b.state) + finish(b, second) + host.update(100) + assertEquals("testbox2", host.games.getValue("other").mapName) + } + + @Test + fun `singleplayer chain preserves unit syntax and ends in an idle Host`() { + val intro = game("default", "intro.cin+end.cin+*testbox") + val client = bind(0, intro) + finish(client, intro) + host.update(100) + val end = host.games.getValue("default") + assertEquals("end.cin", end.mapName) + finish(client, end) + host.update(100) + assertEquals("*testbox", host.games.getValue("default").mapcmd) + val terminal = game("default", "end.cin") + finish(client, terminal) + host.update(100) + assertFalse(host.isRunning) + assertTrue(host.clients.isEmpty()) + } + + @Test + fun `stale requests and replaced queued Games cannot advance`() { + val first = game("default", "intro.cin+testbox") + val client = bind(0, first) + host.executeClientCommand(client, "nextserver ${first.spawncount - 1}") + host.update(100) + assertSame(first, host.games["default"]) + finish(client, first) + val replacement = game("default", "intro.cin+testbox2") + host.update(100) + assertSame(replacement, host.games["default"]) + finish(client, replacement) + host.update(100) + assertEquals("testbox2", host.games.getValue("default").mapName) + } + + @Test + fun `gameplay and non-coop pictures reject completion without consuming continuation`() { + val normal = game("default", "testbox+testbox2") + val client = bind(0, normal) + finish(client, normal) + host.update(100) + assertSame(normal, host.games["default"]) + val picture = game("default", "end.pcx+testbox2") + finish(client, picture) + host.update(100) + assertSame(picture, host.games["default"]) + Cvar.getInstance().find("coop").forceSet(true) + finish(client, picture) + host.update(100) + assertEquals("testbox2", host.games.getValue("default").mapName) + } +} diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index b2456526..f5df9d41 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -82,6 +82,7 @@ public class GameImportsImpl implements GameImports { public String firstmap = ""; String mapcmd = ""; // ie: *intro.cin+base + private String nextMap; // null after consumption; empty means the sequence has ended // Spatial indexing and query scratch state belong to this Game. private static final int MAX_TOTAL_ENT_LEAFS = 128; @@ -158,6 +159,7 @@ public GameImportsImpl(ServerHost serverMain, ChangeMapInfo changeMapInfo) { // archive server state to be used in savegame mapcmd = changeMapInfo.levelString; + nextMap = changeMapInfo.nextServer; if (changeMapInfo.isLoadgame) { @@ -202,6 +204,18 @@ private GameExports createGameModInstance() { } } + /** Consume this Game's cinematic continuation once; null means no eligible request. */ + String takeNextMap() { + if (nextMap == null || state == ServerStates.SS_GAME + || (state == ServerStates.SS_PIC && !Cvar.getInstance().find("coop", Boolean.class).getTypedValue())) { + return null; + } + String result = nextMap; + nextMap = null; + spawncount++; // reject duplicate completion messages from other clients + return result; + } + /** Capture persistent player data before allocating the replacement Game. */ void prepareLevelTransition() { gameExports.SaveClientData(); diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 6696908b..7394fa88 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -43,6 +43,7 @@ class ServerHost : AutoCloseable { // region Host state and command dispatch private val commands = ServerCommands() + private val pendingContinuations = linkedMapOf() val isRunning: Boolean get() = games.isNotEmpty() @@ -149,13 +150,6 @@ class ServerHost : AutoCloseable { Defines.CVAR_SERVERINFO or Defines.CVAR_NOSET ) cvars.register("mapname", CvarTypes.STRING, "", Defines.CVAR_SERVERINFO or Defines.CVAR_NOSET) - cvars.register( - "nextserver", - CvarTypes.STRING, - "", - 0, - "Console command to execute after a cinematic or picture" - ) cvars.register( "maxclients", CvarTypes.IntegerType(1, Defines.MAX_CLIENTS), @@ -324,6 +318,7 @@ class ServerHost : AutoCloseable { } } finally { games.clear() + pendingContinuations.clear() clients.clear() publishLegacyServerState(ServerStates.SS_DEAD) realtime = 0 @@ -579,34 +574,32 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) * SV_Map */ fun spawnServerInstance(changeMapInfo: ChangeMapInfo, clientIndex: Int): GameImportsImpl { - publishLegacyServerState(ServerStates.SS_DEAD) - val previous = if (clientIndex != 0) games[clients[clientIndex - 1].gameName] else games["default"] + val transitionClients = if (clientIndex == 0) clients.toList() else listOf(clients[clientIndex - 1]) + return spawnServerInstance(changeMapInfo, previous, transitionClients) + } + + private fun spawnServerInstance( + changeMapInfo: ChangeMapInfo, + previous: GameImportsImpl?, + transitionClients: List + ): GameImportsImpl { + publishLegacyServerState(ServerStates.SS_DEAD) previous?.prepareLevelTransition() val gameImports = createGameInstance(changeMapInfo, previous) - Cvar.getInstance().find("nextserver") - .set("gamemap \"" + changeMapInfo.nextServer + "\"") - Cmd.ExecuteFunction("loading") // for local system - if (clientIndex == 0) { - // leave slots at start for clients only - for (cl in clients) { //todo - // needs to reconnect - if (cl.state == ClientStates.CS_SPAWNED) cl.state = ClientStates.CS_CONNECTED - cl.lastReceivedFrame = -1 + for (client in transitionClients) { + if (client.state == ClientStates.CS_SPAWNED) client.state = ClientStates.CS_CONNECTED + client.lastReceivedFrame = -1 + if (client.state == ClientStates.CS_CONNECTED) { + client.netchan.reliablePending.add(StuffTextMessage("changing")) } - gameImports.SV_BroadcastCommand("changing") - } else { - val cl = clients[clientIndex - 1] - if (cl.state == ClientStates.CS_SPAWNED) cl.state = ClientStates.CS_CONNECTED - cl.lastReceivedFrame = -1 - gameImports.unicastMessage(clientIndex, StuffTextMessage("changing"), true) } sendClientMessages() @@ -624,10 +617,10 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) if (changeMapInfo.state == ServerStates.SS_GAME) Cbuf.CopyToDefer() - if (clientIndex == 0) { - gameImports.SV_BroadcastCommand("reconnect") // doesn't really reconnect but rather issues 'NEW' command - } else { - gameImports.unicastMessage(clientIndex, StuffTextMessage("reconnect"), true) + for (client in transitionClients) { + if (client.state == ClientStates.CS_CONNECTED) { + client.netchan.reliablePending.add(StuffTextMessage("reconnect")) + } } return gameImports } @@ -716,8 +709,10 @@ Resets server to host a given map, moves all clients to it (via reconnect) // check timeouts checkTimeouts() - // get packets from clients + // Finish packet processing before replacing Games referenced by packet handlers. readPackets() + runPendingContinuations() + if (games.isEmpty()) return // identify if we need to update the game or it's too early (sleep or skip this update). // fixedtime - the time of the next scheduled game update. @@ -1481,23 +1476,41 @@ Resets server to host a given map, moves all clients to it (via reconnect) Cbuf.InsertFromDefer() } - private fun advanceCinematic(gameImports: GameImportsImpl) { - //ZOID, ss_pic can be nextserver'd in coop mode - - if (gameImports.state == ServerStates.SS_GAME - || (gameImports.state == ServerStates.SS_PIC && !Cvar.getInstance() - .find("coop").typedValue) - ) return // can't nextserver while playing a normal game - - gameImports.spawncount++ // make sure another doesn't sneak in - val v = Cvar.getInstance().find("nextserver").typedValue - - if (v.isEmpty()) Cbuf.AddText("killserver\n") - else { - Cbuf.AddText(v) - Cbuf.AddText("\n") + private fun runPendingContinuations() { + val pending = pendingContinuations.toList() + pendingContinuations.clear() + for ((previous, nextMap) in pending) { + if (games[previous.name] !== previous) continue // replaced or stopped before execution + val routedClients = clients.filter { it.gameName == previous.name } + if (nextMap.isEmpty()) { + if (games.size == 1) { + stop("Game sequence ended.\n", false) + NET.configureServer(false) + } else { + sendFinalMessage("Game sequence ended.\n", false, routedClients) + for (client in routedClients) { + client.state = ClientStates.CS_FREE + client.gameName = null + client.edict = null + client.download = null + client.netchan.reliablePending.clear() + client.unreliable.clear() + } + games.remove(previous.name) + } + continue + } + val target = ChangeMapInfo(nextMap, false, false) + // Unit changes and autosaves still share the existing persistence workspace. + if (target.newUnit) ServerSaveFiles(EngineWriteRoot.path()).wipe("current") + val replacement = spawnServerInstance(target, previous, routedClients) + replacement.name = previous.name + games[replacement.name] = replacement + if (!Globals.dedicated.typedValue) { + replacement.SV_WriteServerFile(true) + ServerSaveFiles(EngineWriteRoot.path()).copy("current", "save0") + } } - Cvar.getInstance().find("nextserver").set("") } /* @@ -1514,7 +1527,8 @@ Resets server to host a given map, moves all clients to it (via reconnect) } Com.DPrintf("Nextserver() from " + client.name + "\n") - advanceCinematic(gameImports) + val nextMap = gameImports.takeNextMap() ?: return + pendingContinuations[gameImports] = nextMap } // endregion @@ -1698,7 +1712,11 @@ Resets server to host a given map, moves all clients to it (via reconnect) * stuck on the outgoing message list, because the server is going to * totally exit after returning from this function. */ - private fun sendFinalMessage(message: String?, reconnect: Boolean) { + private fun sendFinalMessage( + message: String?, + reconnect: Boolean, + recipients: List = clients + ) { val msgs: MutableCollection = ArrayList() msgs.add(PrintMessage(Defines.PRINT_HIGH, message)) @@ -1707,10 +1725,10 @@ Resets server to host a given map, moves all clients to it (via reconnect) // send it twice // stagger the packets to crutch operating system limited buffers - for (cl in clients) { + for (cl in recipients) { if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) cl.netchan.transmit(msgs) } - for (cl in clients) { + for (cl in recipients) { if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) cl.netchan.transmit(msgs) } } From 2de9df4ad6725944cb3dec104f702469c6a2f36a Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 20:18:09 +0100 Subject: [PATCH 20/47] Isolate Game coop and deathmatch cvars --- .../demoth/cake/fullgame/EmbeddedCakeHost.kt | 4 +- .../jake2/server/EmbeddedCakeHostTest.kt | 11 ++ .../jake2/server/GameContinuationTest.kt | 2 +- .../java/jake2/server/GameImportsImpl.java | 43 +++++- .../main/kotlin/jake2/server/ServerHost.kt | 6 +- .../server/GameCvarInitializationTest.java | 20 +-- .../kotlin/jake2/server/GameModeCvarsTest.kt | 124 ++++++++++++++++++ 7 files changed, 191 insertions(+), 19 deletions(-) create mode 100644 server/src/test/kotlin/jake2/server/GameModeCvarsTest.kt diff --git a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt index b9227c46..a5c0ddb6 100644 --- a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt +++ b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt @@ -152,8 +152,8 @@ class EmbeddedCakeHost( checkThread() if (server?.isRunning != true) return val cvars = Cvar.getInstance() - val singlePlayer = !cvars.find("deathmatch").typedValue && - !cvars.find("coop").typedValue && cvars.find("maxclients").typedValue == 1 + val game = server?.games?.get("default") ?: return + val singlePlayer = !game.isDeathmatch && !game.isCoop && server?.clients?.size == 1 cvars.find("paused").forceSet(paused && singlePlayer) } diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt index 2a9f1b8e..77f76ee4 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/EmbeddedCakeHostTest.kt @@ -54,6 +54,17 @@ class EmbeddedCakeHostTest { cvars.clear() } + @Test + fun `pause checks the hosted Game mode rather than startup defaults`() { + assertEquals(HostStartResult.Started, host.start(request())) + cvars.find("deathmatch").forceSet(true) + host.setPaused(true) + assertTrue(host.server!!.isPaused) + host.server!!.games.getValue("default").findCvar("coop", Boolean::class.java).forceSet(true) + host.setPaused(true) + assertFalse(host.server!!.isPaused) + } + private fun game() = host.server!!.games.getValue("default").gameExports as GameExportsImpl @Test diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt index 82708184..341091d0 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt @@ -166,7 +166,7 @@ class GameContinuationTest { finish(client, picture) host.update(100) assertSame(picture, host.games["default"]) - Cvar.getInstance().find("coop").forceSet(true) + picture.findCvar("coop", Boolean::class.java).forceSet(true) finish(client, picture) host.update(100) assertEquals("testbox2", host.games.getValue("default").mapName) diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index f5df9d41..6ea5bfea 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -26,6 +26,7 @@ import jake2.qcommon.exec.Cbuf; import jake2.qcommon.exec.Cvar; import jake2.qcommon.exec.CvarType; +import jake2.qcommon.exec.CvarTypes; import jake2.qcommon.exec.cvar_t; import jake2.qcommon.vfs.EngineWriteRoot; import jake2.qcommon.network.MulticastTypes; @@ -100,6 +101,8 @@ public class GameImportsImpl implements GameImports { private final int[] entityClusters = new int[MAX_TOTAL_ENT_LEAFS]; private final Cvar localCvars = new Cvar(); + private final cvar_t deathmatch; + private final cvar_t coop; CM cm; @@ -122,6 +125,20 @@ public GameImportsImpl(ServerHost serverMain, ChangeMapInfo changeMapInfo) { if (previous != null && !changeMapInfo.isLoadgame) previous.localCvars.copyValuesTo(localCvars); + // Host cvars are startup defaults; live mode entries belong to this Game. + deathmatch = localCvars.register("deathmatch", CvarTypes.BOOLEAN, + Cvar.getInstance().find("deathmatch", Boolean.class).getTypedValue(), + Defines.CVAR_SERVERINFO | Defines.CVAR_LATCH, null); + coop = localCvars.register("coop", CvarTypes.BOOLEAN, + Cvar.getInstance().find("coop", Boolean.class).getTypedValue(), + Defines.CVAR_SERVERINFO | Defines.CVAR_LATCH, null); + + // map starts a new session from Host defaults; gamemap inherits the previous Game. + if (previous != null && previous.state == ServerStates.SS_DEAD && !changeMapInfo.isLoadgame) { + deathmatch.forceSet(Cvar.getInstance().find("deathmatch", Boolean.class).getTypedValue()); + coop.forceSet(Cvar.getInstance().find("coop", Boolean.class).getTypedValue()); + } + spawncount = Lib.rand(); cm = new CM(); @@ -166,9 +183,14 @@ public GameImportsImpl(ServerHost serverMain, ChangeMapInfo changeMapInfo) { SV_Read_Latched_Vars(); } + if (isDeathmatch() && isCoop()) { + Com.Printf("Deathmatch and Coop both set, disabling Coop for this Game\n"); + coop.forceSet(false); + } + // Snapshot the applied setting for this game; client prediction receives the same value. configuredAirAcceleration = Cvar.getInstance().find("sv_airaccelerate", Float.class).getTypedValue(); - airAcceleration = Cvar.getInstance().find("deathmatch", Boolean.class).getTypedValue() + airAcceleration = isDeathmatch() ? configuredAirAcceleration : 0f; configstrings[Defines.CS_AIRACCEL] = Float.toString(airAcceleration); @@ -204,10 +226,21 @@ private GameExports createGameModInstance() { } } + public boolean isDeathmatch() { return deathmatch.getTypedValue(); } + + public boolean isCoop() { return coop.getTypedValue(); } + + /** Overlay this Game's mode on the remaining shared server-info settings. */ + public String serverInfo() { + String info = Cvar.getInstance().Serverinfo(); + info = Info.Info_SetValueForKey(info, "deathmatch", deathmatch.getRawString()); + return Info.Info_SetValueForKey(info, "coop", coop.getRawString()); + } + /** Consume this Game's cinematic continuation once; null means no eligible request. */ String takeNextMap() { if (nextMap == null || state == ServerStates.SS_GAME - || (state == ServerStates.SS_PIC && !Cvar.getInstance().find("coop", Boolean.class).getTypedValue())) { + || (state == ServerStates.SS_PIC && !isCoop())) { return null; } String result = nextMap; @@ -792,7 +825,7 @@ void saveGame(List args) { return; } - if (Cvar.getInstance().find("deathmatch", Boolean.class).getTypedValue()) { + if (isDeathmatch()) { Com.Printf("Can't savegame in a deathmatch\n"); return; } @@ -852,7 +885,7 @@ void SV_RunGameFrame(long fixedtime) { // has the "current" frame frameNum++; - if (serverMain.isPaused() && serverMain.getClients().size() <= 1) { + if (serverMain.isPaused() && !isDeathmatch() && !isCoop() && serverMain.getClients().size() <= 1) { return; } @@ -1114,7 +1147,7 @@ boolean isPreviousLevel() { if (Cvar.getInstance().find("sv_noreload", Boolean.class).getTypedValue()) return false; - if (Cvar.getInstance().find("deathmatch", Boolean.class).getTypedValue()) + if (isDeathmatch()) return false; String name = EngineWriteRoot.resolve("save/current/" + mapName + ".sav.json").toString(); diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 7394fa88..ae36a17f 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -93,7 +93,7 @@ class ServerHost : AutoCloseable { StringCmdMessage.BEGIN to ServerUserCommand(::beginClient), StringCmdMessage.NEXT_SERVER to ServerUserCommand(::finishCinematic), StringCmdMessage.DISCONNECT to ServerUserCommand { _, _, client -> dropClient(client) }, - StringCmdMessage.INFO to ServerUserCommand { _, _, _ -> Info.Print(Cvar.getInstance().Serverinfo()) }, + StringCmdMessage.INFO to ServerUserCommand { _, game, _ -> Info.Print(game.serverInfo()) }, StringCmdMessage.DOWNLOAD to ServerUserCommand { args, _, client -> beginDownload(args, client) }, StringCmdMessage.NEXT_DOWNLOAD to ServerUserCommand { _, _, client -> sendNextDownloadChunk(client) }, ) @@ -827,7 +827,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) * Builds the string that is sent as heartbeats and status replies. */ private fun buildStatusResponse(): String { - var status = Cvar.getInstance().Serverinfo() + "\n" + var status = (games["default"]?.serverInfo() ?: Cvar.getInstance().Serverinfo()) + "\n" for (cl in clients) { if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) { @@ -1832,7 +1832,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) private fun printServerInfo() { Com.Printf("Server info settings:\n") - Info.Print(Cvar.getInstance().Serverinfo()) + Info.Print(games["default"]?.serverInfo() ?: Cvar.getInstance().Serverinfo()) } private fun dumpUser(args: List) { diff --git a/server/src/test/java/jake2/server/GameCvarInitializationTest.java b/server/src/test/java/jake2/server/GameCvarInitializationTest.java index b69491e5..a7851fd1 100644 --- a/server/src/test/java/jake2/server/GameCvarInitializationTest.java +++ b/server/src/test/java/jake2/server/GameCvarInitializationTest.java @@ -20,7 +20,7 @@ /** Checks early config consumption, game-instance isolation and shared engine entries without map assets. */ class GameCvarInitializationTest { @Test - void gameWritesValidateSharedTypesAndRetainLatchSemantics() { + void gameModeWritesStayLocalAndRetainLatchSemantics() { Cvar cvars = Cvar.getInstance(); var previousState = Globals.server_state; cvars.clear(); @@ -31,10 +31,10 @@ void gameWritesValidateSharedTypesAndRetainLatchSemantics() { ServerHost server = new ServerHost(); GameImportsImpl imports = new GameImportsImpl(server, new ChangeMapInfo("test.pcx", false, false)); cvar_t maxclients = cvars.find("maxclients", Integer.class); - cvar_t deathmatch = cvars.find("deathmatch", Boolean.class); + cvar_t deathmatch = imports.findCvar("deathmatch", Boolean.class); assertEquals(1, maxclients.getTypedValue()); assertEquals(0.5f, server.timeout.getTypedValue()); - assertSame(deathmatch, imports.findCvar("deathmatch", Boolean.class)); + assertNotSame(cvars.find("deathmatch", Boolean.class), deathmatch); Globals.server_state = ServerStates.SS_GAME; imports.findCvar("deathmatch", Boolean.class).set(true); @@ -44,8 +44,11 @@ void gameWritesValidateSharedTypesAndRetainLatchSemantics() { assertFalse(deathmatch.getTypedValue()); assertEquals(Boolean.TRUE, deathmatch.getPendingValue()); cvars.updateLatchedVars(); - assertTrue(deathmatch.getTypedValue()); - assertEquals("1", deathmatch.getRawString()); + assertFalse(deathmatch.getTypedValue()); + assertFalse(cvars.find("deathmatch", Boolean.class).getTypedValue()); + GameImportsImpl successor = new GameImportsImpl(server, new ChangeMapInfo("next.pcx", false, false), imports); + assertTrue(successor.isDeathmatch()); + assertEquals(Boolean.TRUE, deathmatch.getPendingValue()); imports.findCvar("maxclients", Integer.class).forceSet(0); assertEquals(1, maxclients.getTypedValue()); } finally { @@ -83,7 +86,7 @@ void successorCopiesSettingsAndAppliesPendingWithoutChangingTheSource() { nextGravity.set(400f); assertEquals(2, skill.getTypedValue()); assertEquals(600f, gravity.getTypedValue()); - assertSame(first.findCvar("deathmatch", Boolean.class), next.findCvar("deathmatch", Boolean.class)); + assertNotSame(first.findCvar("deathmatch", Boolean.class), next.findCvar("deathmatch", Boolean.class)); } finally { Globals.server_state = previousState; cvars.clear(); @@ -172,8 +175,9 @@ void savedInputsOverrideTheSourceAndInitializeLocalDeclarations(@TempDir Path te assertNull(loadedSkill.getRawPendingString()); assertEquals(1, otherSkill.getTypedValue()); assertFalse(cvars.isRegistered("skill")); - assertSame(cvars.find("deathmatch"), loaded.findCvar("deathmatch", Boolean.class)); - assertEquals("1", cvars.find("deathmatch", Boolean.class).getRawString()); + assertNotSame(cvars.find("deathmatch"), loaded.findCvar("deathmatch", Boolean.class)); + assertTrue(loaded.isDeathmatch()); + assertFalse(cvars.find("deathmatch", Boolean.class).getTypedValue()); } finally { EngineWriteRoot.setRoot(previousRoot); cvars.clear(); diff --git a/server/src/test/kotlin/jake2/server/GameModeCvarsTest.kt b/server/src/test/kotlin/jake2/server/GameModeCvarsTest.kt new file mode 100644 index 00000000..c29f22e0 --- /dev/null +++ b/server/src/test/kotlin/jake2/server/GameModeCvarsTest.kt @@ -0,0 +1,124 @@ +package jake2.server + +import jake2.qcommon.Globals +import jake2.qcommon.MainCommon +import jake2.qcommon.ServerStates +import jake2.qcommon.exec.Cvar +import jake2.qcommon.exec.find +import jake2.qcommon.vfs.EngineWriteRoot +import jake2.server.save.ServerSaveJsonStore +import org.junit.jupiter.api.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import java.nio.file.Files + +class GameModeCvarsTest { + @TempDir lateinit var root: Path + private lateinit var host: ServerHost + private lateinit var previousRoot: Path + private var previousState: ServerStates? = null + private val cvars = Cvar.getInstance() + + @BeforeEach + fun setup() { + previousRoot = EngineWriteRoot.path() + previousState = Globals.server_state + Globals.server_state = ServerStates.SS_DEAD + EngineWriteRoot.setRoot(root) + cvars.clear() + MainCommon.registerCvars(false) + host = ServerHost() + } + + @AfterEach + fun cleanup() { + host.close() + cvars.clear() + EngineWriteRoot.setRoot(previousRoot) + Globals.server_state = previousState + } + + private fun game(previous: GameImportsImpl? = null, load: Boolean = false) = + GameImportsImpl(host, ChangeMapInfo("end.pcx+testbox", false, load), previous).apply { + state = ServerStates.SS_PIC + } + + @Test + fun `independent modes control picture continuation air acceleration and server info`() { + cvars.find("deathmatch").forceSet(true) + cvars.find("sv_airaccelerate").forceSet(5f) + val dm = game() + cvars.find("deathmatch").forceSet(false) + cvars.find("coop").forceSet(true) + val coop = game() + cvars.find("coop").forceSet(false) + assertTrue(dm.isDeathmatch) + assertFalse(dm.isCoop) + assertTrue(coop.isCoop) + assertFalse(coop.isDeathmatch) + assertEquals(5f, dm.airAcceleration) + assertEquals(0f, coop.airAcceleration) + assertNull(dm.takeNextMap()) + assertEquals("testbox", coop.takeNextMap()) + assertTrue(dm.serverInfo().contains("\\deathmatch\\1")) + assertTrue(coop.serverInfo().contains("\\coop\\1")) + assertFalse(cvars.find("coop").typedValue) + val archivedLevel = root.resolve("save/current/end.pcx.sav.json") + Files.createDirectories(archivedLevel.parent) + Files.writeString(archivedLevel, "existing archive") + cvars.find("deathmatch").forceSet(true) + assertFalse(dm.isPreviousLevel) + assertTrue(coop.isPreviousLevel) + } + + @Test + fun `pending mode changes apply only to successor and normalize conflicting modes`() { + cvars.find("coop").forceSet(true) + val original = game() + Globals.server_state = ServerStates.SS_GAME + original.findCvar("deathmatch", Boolean::class.java).set(true) + assertFalse(original.isDeathmatch) + val successor = game(original) + assertTrue(successor.isDeathmatch) + assertFalse(successor.isCoop) + assertTrue(original.isCoop) + assertFalse(original.isDeathmatch) + assertFalse(cvars.find("deathmatch").typedValue) + assertTrue(cvars.find("coop").typedValue) + } + + @Test + fun `fresh map uses Host mode defaults instead of the stopped Game mode`() { + cvars.find("coop").forceSet(true) + val previous = game() + previous.state = ServerStates.SS_DEAD + cvars.find("coop").forceSet(false) + cvars.find("deathmatch").forceSet(true) + val next = game(previous) + assertTrue(next.isDeathmatch) + assertFalse(next.isCoop) + assertTrue(previous.isCoop) + } + + @Test + fun `saved mode overrides defaults only in loaded Game`() { + cvars.find("coop").forceSet(true) + val original = game() + val store = ServerSaveJsonStore.forWriteDir(root.toString()) + cvars.find("coop").forceSet(false) + cvars.find("deathmatch").forceSet(true) + val other = game() + original.writeLatchedCvars(store) + val values = store.readLatchedCvars("current").cvars().associate { it.name() to it.value() } + assertEquals("1", values["coop"]) + assertEquals("0", values["deathmatch"]) + val loaded = game(load = true) + assertTrue(loaded.isCoop) + assertFalse(loaded.isDeathmatch) + assertTrue(other.isDeathmatch) + assertFalse(other.isCoop) + assertTrue(cvars.find("deathmatch").typedValue) + assertFalse(cvars.find("coop").typedValue) + } +} From 0c15a8c40357bc400e2c63302f3d687431aacef7 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 21:23:18 +0100 Subject: [PATCH 21/47] Own configured air acceleration in Game cvars --- .../java/jake2/server/GameImportsImpl.java | 15 ++-- .../jake2/server/AirAccelerationTest.java | 6 +- .../jake2/server/LocalAirAccelerationTest.kt | 85 +++++++++++++++++++ 3 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 server/src/test/kotlin/jake2/server/LocalAirAccelerationTest.kt diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index 6ea5bfea..f2eac311 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -111,7 +111,7 @@ public class GameImportsImpl implements GameImports { private final entity_state_t[] clientEntityStates; private int nextClientEntity; - private final float configuredAirAcceleration; + private final cvar_t configuredAirAcceleration; final float airAcceleration; private final PMove.PmoveProcessor pmoveProcessor = PMove.newLegacyProcessor(); @@ -133,10 +133,15 @@ public GameImportsImpl(ServerHost serverMain, ChangeMapInfo changeMapInfo) { Cvar.getInstance().find("coop", Boolean.class).getTypedValue(), Defines.CVAR_SERVERINFO | Defines.CVAR_LATCH, null); + configuredAirAcceleration = localCvars.register("sv_airaccelerate", CvarTypes.FLOAT, + Cvar.getInstance().find("sv_airaccelerate", Float.class).getTypedValue(), + Defines.CVAR_LATCH, "Controls air acceleration (currently only for deathmatch)"); + // map starts a new session from Host defaults; gamemap inherits the previous Game. if (previous != null && previous.state == ServerStates.SS_DEAD && !changeMapInfo.isLoadgame) { deathmatch.forceSet(Cvar.getInstance().find("deathmatch", Boolean.class).getTypedValue()); coop.forceSet(Cvar.getInstance().find("coop", Boolean.class).getTypedValue()); + configuredAirAcceleration.forceSet(Cvar.getInstance().find("sv_airaccelerate", Float.class).getTypedValue()); } spawncount = Lib.rand(); @@ -189,9 +194,8 @@ public GameImportsImpl(ServerHost serverMain, ChangeMapInfo changeMapInfo) { } // Snapshot the applied setting for this game; client prediction receives the same value. - configuredAirAcceleration = Cvar.getInstance().find("sv_airaccelerate", Float.class).getTypedValue(); airAcceleration = isDeathmatch() - ? configuredAirAcceleration : 0f; + ? configuredAirAcceleration.getTypedValue() : 0f; configstrings[Defines.CS_AIRACCEL] = Float.toString(airAcceleration); // clear physics interaction links @@ -1252,10 +1256,7 @@ void writeLatchedCvars(ServerSaveJsonStore store) throws IOException { List latchedCvars = new ArrayList<>(); Cvar.getInstance().eachCvarByFlags(Defines.CVAR_LATCH, var -> { if (!localCvars.isRegistered(var.name)) { - // Air acceleration is captured per instance; the global value may now belong to another game. - String value = var.name.equals("sv_airaccelerate") - ? Float.toString(configuredAirAcceleration) : var.getRawString(); - latchedCvars.add(new ServerSaveJsonStore.LatchedCvarSnapshot(var.name, value)); + latchedCvars.add(new ServerSaveJsonStore.LatchedCvarSnapshot(var.name, var.getRawString())); } }); localCvars.eachCvarByFlags(Defines.CVAR_LATCH, var -> diff --git a/server/src/test/java/jake2/server/AirAccelerationTest.java b/server/src/test/java/jake2/server/AirAccelerationTest.java index c7f7b624..f19fd19c 100644 --- a/server/src/test/java/jake2/server/AirAccelerationTest.java +++ b/server/src/test/java/jake2/server/AirAccelerationTest.java @@ -124,7 +124,8 @@ void savesCaptureTheOwningGamesConfiguredValueRatherThanTheLatestGlobalValue(@Te var store = ServerSaveJsonStore.forWriteDir(temp.toString()); first.writeLatchedCvars(store); GameImportsImpl loadedFirst = new GameImportsImpl(server, new ChangeMapInfo("first.pcx", false, true)); - assertEquals(1f, acceleration.getTypedValue()); + assertEquals(7f, acceleration.getTypedValue()); + assertEquals(1f, loadedFirst.findCvar("sv_airaccelerate", Float.class).getTypedValue()); assertMovementSetting(loadedFirst, frozenMovement(), 1f); assertMovementSetting(second, frozenMovement(), 7f); @@ -134,7 +135,8 @@ void savesCaptureTheOwningGamesConfiguredValueRatherThanTheLatestGlobalValue(@Te acceleration.forceSet(9f); singlePlayer.writeLatchedCvars(store); GameImportsImpl loadedSinglePlayer = new GameImportsImpl(server, new ChangeMapInfo("single.pcx", false, true)); - assertEquals(5f, acceleration.getTypedValue()); + assertEquals(9f, acceleration.getTypedValue()); + assertEquals(5f, loadedSinglePlayer.findCvar("sv_airaccelerate", Float.class).getTypedValue()); assertMovementSetting(loadedSinglePlayer, frozenMovement(), 0f); assertMovementSetting(singlePlayer, frozenMovement(), 0f); } finally { diff --git a/server/src/test/kotlin/jake2/server/LocalAirAccelerationTest.kt b/server/src/test/kotlin/jake2/server/LocalAirAccelerationTest.kt new file mode 100644 index 00000000..8793b6d9 --- /dev/null +++ b/server/src/test/kotlin/jake2/server/LocalAirAccelerationTest.kt @@ -0,0 +1,85 @@ +package jake2.server + +import jake2.qcommon.* +import jake2.qcommon.exec.Cvar +import jake2.qcommon.exec.find +import jake2.qcommon.vfs.EngineWriteRoot +import jake2.server.save.ServerSaveJsonStore +import org.junit.jupiter.api.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +class LocalAirAccelerationTest { + @TempDir lateinit var root: Path + private lateinit var host: ServerHost + private lateinit var previousRoot: Path + private var previousState: ServerStates? = null + private val cvars = Cvar.getInstance() + + @BeforeEach + fun setup() { + previousRoot = EngineWriteRoot.path() + previousState = Globals.server_state + EngineWriteRoot.setRoot(root) + Globals.server_state = ServerStates.SS_DEAD + cvars.clear() + MainCommon.registerCvars(false) + host = ServerHost() + cvars.find("deathmatch").forceSet(true) + cvars.find("sv_airaccelerate").forceSet(2f) + } + + @AfterEach + fun cleanup() { + host.close() + cvars.clear() + EngineWriteRoot.setRoot(previousRoot) + Globals.server_state = previousState + } + + private fun game(previous: GameImportsImpl? = null, load: Boolean = false) = + GameImportsImpl(host, ChangeMapInfo("test.pcx", false, load), previous) + + private fun assertEffective(game: GameImportsImpl, expected: Float) { + val movement = pmove_t().apply { s.pm_type = Defines.PM_FREEZE } + game.Pmove(movement) + assertEquals(expected, movement.airAccelerate) + assertEquals(expected, game.airAcceleration) + assertEquals(expected.toString(), game.configstrings[Defines.CS_AIRACCEL]) + } + + @Test + fun `local pending value follows successor but not fresh map defaults`() { + val original = game() + val local = original.findCvar("sv_airaccelerate", Float::class.java) + assertNotSame(cvars.find("sv_airaccelerate"), local) + Globals.server_state = ServerStates.SS_GAME + local.set(4f) + cvars.find("sv_airaccelerate").forceSet(9f) + val successor = game(original) + assertEffective(original, 2f) + assertEffective(successor, 4f) + assertEquals(4f, local.pendingValue) + assertNull(successor.findCvar("sv_airaccelerate", Float::class.java).pendingValue) + original.state = ServerStates.SS_DEAD + assertEffective(game(original), 9f) + } + + @Test + fun `save records current local configuration not pending value or Host default`() { + val original = game() + Globals.server_state = ServerStates.SS_GAME + original.findCvar("sv_airaccelerate", Float::class.java).set(4f) + cvars.find("sv_airaccelerate").forceSet(9f) + val store = ServerSaveJsonStore.forWriteDir(root.toString()) + original.writeLatchedCvars(store) + val saved = store.readLatchedCvars("current").cvars().filter { it.name() == "sv_airaccelerate" } + assertEquals(1, saved.size) + assertEquals("2.0", saved.single().value()) + val loaded = game(load = true) + assertEffective(loaded, 2f) + assertEffective(original, 2f) + assertEquals(9f, cvars.find("sv_airaccelerate").typedValue) + } +} From 4a6d636bb03b12b22f0595baa0e7c57a4fbc51ef Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 21:42:44 +0100 Subject: [PATCH 22/47] Isolate live Game rules --- .../jake2/server/GameContinuationTest.kt | 22 ++++++++++ .../java/jake2/server/GameImportsImpl.java | 28 +++++++++++-- .../kotlin/jake2/server/GameModeCvarsTest.kt | 40 +++++++++++++++++++ 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt index 341091d0..e75c6782 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt @@ -64,6 +64,28 @@ class GameContinuationTest { private fun finish(client: ClientNetworkInfo, game: GameImportsImpl) = host.executeClientCommand(client, "nextserver ${game.spawncount}") + @Test + fun `live timelimit ends only its owning Game`() { + Cvar.getInstance().find("deathmatch").forceSet(true) + val first = host.spawnServerInstance(ChangeMapInfo("testbox", false, false), 0) + first.name = "default" + host.games[first.name] = first + val second = host.spawnServerInstance(ChangeMapInfo("testbox2", false, false), 0) + second.name = "other" + host.games[second.name] = second + first.findCvar("dmflags", Int::class.java).set(Defines.DF_SAME_LEVEL) + first.findCvar("timelimit", Float::class.java).set(0.001f) + second.findCvar("fraglimit", Int::class.java).set(10) + val firstGameplay = first.gameExports as jake2.game.GameExportsImpl + val secondGameplay = second.gameExports as jake2.game.GameExportsImpl + firstGameplay.G_RunFrame() + secondGameplay.G_RunFrame() + assertTrue(firstGameplay.level.intermissiontime > 0) + assertEquals(0f, secondGameplay.level.intermissiontime) + assertEquals(10, secondGameplay.gameCvars.fraglimit.typedValue) + assertEquals(0, firstGameplay.gameCvars.fraglimit.typedValue) + } + @Test fun `two Games advance independently and retain all routed clients`() { val first = game("default", "intro.cin+testbox") diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index f2eac311..fa0978b0 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -100,6 +100,12 @@ public class GameImportsImpl implements GameImports { private final int[] entityLeafs = new int[MAX_TOTAL_ENT_LEAFS]; private final int[] entityClusters = new int[MAX_TOTAL_ENT_LEAFS]; + // TODO: Give the entire Game-local cvar registry consistent console access via scoped execution. + // GameImports resolves local entries, but console set, shorthand queries/writes and $ expansion + // use the global registry, even with one running Game. Writes affect separate global entries + // or early inputs, not this Game's values. This accepted gap applies to all local cvars + // (skill, sv_gravity, modes, rules, etc.), not just the recently migrated settings. + // For now these cvars are effectively read only private final Cvar localCvars = new Cvar(); private final cvar_t deathmatch; private final cvar_t coop; @@ -137,11 +143,21 @@ public GameImportsImpl(ServerHost serverMain, ChangeMapInfo changeMapInfo) { Cvar.getInstance().find("sv_airaccelerate", Float.class).getTypedValue(), Defines.CVAR_LATCH, "Controls air acceleration (currently only for deathmatch)"); + localCvars.register("dmflags", CvarTypes.INTEGER, + Cvar.getInstance().find("dmflags", Integer.class).getTypedValue(), Defines.CVAR_SERVERINFO, null); + localCvars.register("fraglimit", new CvarTypes.IntegerType(0, null), + Cvar.getInstance().find("fraglimit", Integer.class).getTypedValue(), Defines.CVAR_SERVERINFO, null); + localCvars.register("timelimit", new CvarTypes.FloatType(0f, null), + Cvar.getInstance().find("timelimit", Float.class).getTypedValue(), Defines.CVAR_SERVERINFO, null); + // map starts a new session from Host defaults; gamemap inherits the previous Game. if (previous != null && previous.state == ServerStates.SS_DEAD && !changeMapInfo.isLoadgame) { deathmatch.forceSet(Cvar.getInstance().find("deathmatch", Boolean.class).getTypedValue()); coop.forceSet(Cvar.getInstance().find("coop", Boolean.class).getTypedValue()); configuredAirAcceleration.forceSet(Cvar.getInstance().find("sv_airaccelerate", Float.class).getTypedValue()); + for (String rule : List.of("dmflags", "fraglimit", "timelimit")) { + localCvars.forceSetResult(rule, Cvar.getInstance().find(rule).getRawString()); + } } spawncount = Lib.rand(); @@ -234,11 +250,15 @@ private GameExports createGameModInstance() { public boolean isCoop() { return coop.getTypedValue(); } - /** Overlay this Game's mode on the remaining shared server-info settings. */ + /** Overlay this Game's mode and rules on the remaining shared server-info settings. */ public String serverInfo() { String info = Cvar.getInstance().Serverinfo(); info = Info.Info_SetValueForKey(info, "deathmatch", deathmatch.getRawString()); - return Info.Info_SetValueForKey(info, "coop", coop.getRawString()); + info = Info.Info_SetValueForKey(info, "coop", coop.getRawString()); + for (String rule : List.of("dmflags", "fraglimit", "timelimit")) { + info = Info.Info_SetValueForKey(info, rule, localCvars.find(rule).getRawString()); + } + return info; } /** Consume this Game's cinematic continuation once; null means no eligible request. */ @@ -312,7 +332,7 @@ void SV_Read_Latched_Vars() { ServerSaveJsonStore store = ServerSaveJsonStore.forWriteDir(EngineWriteRoot.pathString()); ServerSaveJsonStore.ServerLatchedCvarsSnapshot snapshot = store.readLatchedCvars("current"); - // read all CVAR_LATCH cvars + // Restore saved latched settings // these will be things like coop, skill, deathmatch, etc for (ServerSaveJsonStore.LatchedCvarSnapshot cvar : snapshot.cvars()) { String name = cvar.name(); @@ -1251,7 +1271,7 @@ void SV_WriteServerFile(boolean autosave) { gameExports.WriteGame(EngineWriteRoot.resolve("save/current/game.ssv.json").toString(), autosave); } - /** Save shared and local latched cvars, with local entries taking precedence. */ + /** Save shared and local latched settings, with local entries taking precedence. */ void writeLatchedCvars(ServerSaveJsonStore store) throws IOException { List latchedCvars = new ArrayList<>(); Cvar.getInstance().eachCvarByFlags(Defines.CVAR_LATCH, var -> { diff --git a/server/src/test/kotlin/jake2/server/GameModeCvarsTest.kt b/server/src/test/kotlin/jake2/server/GameModeCvarsTest.kt index c29f22e0..3e5c0684 100644 --- a/server/src/test/kotlin/jake2/server/GameModeCvarsTest.kt +++ b/server/src/test/kotlin/jake2/server/GameModeCvarsTest.kt @@ -44,6 +44,46 @@ class GameModeCvarsTest { state = ServerStates.SS_PIC } + @Test + fun `local rule edits stay isolated and validate without changing defaults`() { + val first = game().apply { name = "default" } + val second = game().apply { name = "other" } + host.games[first.name] = first + host.games[second.name] = second + first.findCvar("fraglimit", Int::class.java).setText("12") + second.findCvar("timelimit", Float::class.java).setText("2.5") + second.findCvar("dmflags", Int::class.java).setText("128") + assertEquals(12, first.findCvar("fraglimit", Int::class.java).typedValue) + assertEquals(0, second.findCvar("fraglimit", Int::class.java).typedValue) + assertEquals(2.5f, second.findCvar("timelimit", Float::class.java).typedValue) + assertEquals(128, second.findCvar("dmflags", Int::class.java).typedValue) + assertEquals(0f, first.findCvar("timelimit", Float::class.java).typedValue) + assertEquals(0, cvars.find("fraglimit").typedValue) + assertTrue(first.serverInfo().contains("\\fraglimit\\12")) + assertFalse(first.findCvar("fraglimit", Int::class.java).setText("-1").accepted) + assertFalse(second.findCvar("timelimit", Float::class.java).setText("invalid").accepted) + assertEquals(12, first.findCvar("fraglimit", Int::class.java).typedValue) + assertEquals(2.5f, second.findCvar("timelimit", Float::class.java).typedValue) + assertFalse(first.isCoop) + } + + @Test + fun `live rules inherit locally while fresh maps use defaults`() { + val first = game() + first.findCvar("fraglimit", Int::class.java).set(12) + first.findCvar("timelimit", Float::class.java).set(2.5f) + first.findCvar("dmflags", Int::class.java).set(128) + cvars.find("fraglimit").forceSet(30) + val successor = game(first) + assertNotSame(first.findCvar("fraglimit", Int::class.java), successor.findCvar("fraglimit", Int::class.java)) + assertEquals(12, successor.findCvar("fraglimit", Int::class.java).typedValue) + assertEquals(2.5f, successor.findCvar("timelimit", Float::class.java).typedValue) + assertEquals(128, successor.findCvar("dmflags", Int::class.java).typedValue) + assertEquals(30, cvars.find("fraglimit").typedValue) + first.state = ServerStates.SS_DEAD + assertEquals(30, game(first).findCvar("fraglimit", Int::class.java).typedValue) + } + @Test fun `independent modes control picture continuation air acceleration and server info`() { cvars.find("deathmatch").forceSet(true) From c4446ec1f555b049506eaae9f6a5ab769c5a4327 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 22:16:35 +0100 Subject: [PATCH 23/47] Share Host session preparation across map and load --- .../server/HostSessionPreparationTest.kt | 137 ++++++++++++++++++ .../main/kotlin/jake2/server/ServerHost.kt | 71 ++++----- 2 files changed, 175 insertions(+), 33 deletions(-) create mode 100644 cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt new file mode 100644 index 00000000..d834bddc --- /dev/null +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt @@ -0,0 +1,137 @@ +package jake2.server + +import jake2.qcommon.* +import jake2.qcommon.exec.Cvar +import jake2.qcommon.exec.find +import jake2.qcommon.network.NET +import jake2.qcommon.vfs.* +import jake2.server.save.ServerSaveFiles +import org.junit.jupiter.api.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.io.TempDir +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.nio.file.Files +import java.nio.file.Path + +class HostSessionPreparationTest { + @TempDir lateinit var root: Path + private lateinit var previousRoot: Path + private var previousState: ServerStates? = null + private lateinit var host: ServerHost + private val cvars = Cvar.getInstance() + + @BeforeEach + fun setup() { + previousRoot = EngineWriteRoot.path() + previousState = Globals.server_state + Globals.server_state = ServerStates.SS_DEAD + cvars.clear() + MainCommon.registerCvars(false) + jake2.qcommon.network.Netchan.Netchan_Init() + cvars.find("port").forceSet(0) + EngineVfs.configure(VfsConfig(Path.of("src/test/resources"), "baseq2", null, + true, false, false, emptyList(), setOf("pak"))) + EngineWriteRoot.setRoot(root) + host = ServerHost() + } + + @AfterEach + fun cleanup() { + host.close() + NET.configureServer(false) + cvars.clear() + EngineWriteRoot.setRoot(previousRoot) + Globals.server_state = previousState + } + + private fun save(coop: Boolean) { + cvars.find("coop").forceSet(coop) + host.map(listOf("map", "testbox")) + val game = host.games.getValue("default") + game.archiveLevelForTransition() + game.SV_WriteServerFile(true) + ServerSaveFiles(root).copy("current", "slot") + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun `load after stop prepares saved capacity before Game allocation`(coop: Boolean) { + save(coop) + host.stop("test", false) + NET.configureServer(false) + cvars.find("coop").forceSet(!coop) + cvars.find("maxclients").forceSet(if (coop) 1 else 4) + + host.loadGame(listOf("load", "slot")) + + val loaded = host.games.getValue("default") + val capacity = if (coop) 4 else 1 + assertEquals(capacity, host.clients.size) + assertNotNull(loaded.gameExports.getEdict(capacity).getClient()) + assertEquals(coop, loaded.isCoop) + assertEquals(!coop, cvars.find("coop").typedValue, "Loading must preserve Host mode defaults") + assertTrue(host.clients.all { it.gameName == "default" }) + assertEquals(coop, NET.ip_sockets[Defines.NS_SERVER] != null) + // Exercise the snapshot ring allocated from the prepared client capacity. + host.clients[0].edict = loaded.gameExports.getEdict(1) + loaded.buildClientFrameMessages(host.clients[0]) + } + + @Test + fun `fresh Host loads save and can restart again`() { + save(false) + host.close() + host = ServerHost() + host.loadGame(listOf("load", "slot")) + assertEquals(1, host.clients.size) + assertEquals("testbox", host.games.getValue("default").mapName) + host.stop("test", false) + host.changeMap(listOf("gamemap", "testbox2")) + assertEquals(1, host.clients.size) + assertEquals("testbox2", host.games.getValue("default").mapName) + } + + @Test + fun `gamemap retains client slots while map prepares new slots`() { + host.map(listOf("map", "testbox")) + val original = host.clients[0] + host.changeMap(listOf("gamemap", "testbox2")) + assertSame(original, host.clients[0]) + host.map(listOf("map", "testbox")) + assertNotSame(original, host.clients[0]) + } + + @Test + fun `load cannot resize clients belonging to another Game`() { + save(false) + host.stop("test", false) + cvars.find("coop").forceSet(true) + host.map(listOf("map", "testbox2")) + val other = host.games.remove("default")!! + other.name = "other" + host.games[other.name] = other + host.clients.forEach { it.gameName = other.name } + val client = host.clients[0] + + host.loadGame(listOf("load", "slot")) + + assertSame(other, host.games["other"]) + assertFalse(host.games.containsKey("default")) + assertSame(client, host.clients[0]) + assertEquals(4, host.clients.size) + assertEquals("testbox2", jake2.server.save.ServerSaveJsonStore.forWriteDir(root.toString()) + .readMapCommand("current").mapCommand()) + } + + @Test + fun `unreadable allocation metadata preserves running session`() { + save(false) + val game = host.games.getValue("default") + val client = host.clients[0] + Files.writeString(root.resolve("save/slot/server_latched_cvars.ssv.json"), "invalid") + host.loadGame(listOf("load", "slot")) + assertSame(game, host.games["default"]) + assertSame(client, host.clients[0]) + } +} diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index ae36a17f..ab3c5930 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -340,18 +340,26 @@ class ServerHost : AutoCloseable { } } - private fun initializeServerSession() { + /** Prepare Host resources before allocating a Game; ordinary transitions retain client slots. */ + private fun prepareServerSession(restart: Boolean = false, savedMaxClients: Int? = null) { + if (!restart && games.isNotEmpty()) return if (games.isEmpty()) { realtime = 0 frameNum = 0 nextFrameTime = 100 } - val multiplayer = initializeServerCvars() + val multiplayer = if (savedMaxClients == null) { + initializeServerCvars() + } else { + // Saved capacity is authoritative. Host mode defaults must not resize a loaded Game. + Cvar.getInstance().find("maxclients").forceSet(savedMaxClients) + savedMaxClients > 1 + } if (!NET.configureServer(multiplayer)) { Com.Error(Defines.ERR_DROP, "Could not open the server UDP port") return } - resetClients() + if (savedMaxClients == null || clients.size != savedMaxClients) resetClients() } /** @@ -452,30 +460,31 @@ class ServerHost : AutoCloseable { return } + // Read Host allocation inputs before touching the current workspace or running Game. + val mapCommand: String + val savedMaxClients: Int + try { + mapCommand = store.readMapCommand(saveGame).mapCommand() + savedMaxClients = store.readLatchedCvars(saveGame).cvars() + .last { it.name() == "maxclients" }.value().toInt() + require(savedMaxClients in 1..Defines.MAX_CLIENTS) { "Invalid saved maxclients" } + } catch (e: Exception) { + Com.Printf("Couldn't read save metadata: ${e.message}\n") + return + } + if (games.keys.any { it != "default" } && clients.size != savedMaxClients) { + Com.Printf("Can't change saved client capacity while other Games are running\n") + return + } + val target = ChangeMapInfo(mapCommand, false, true) ServerSaveFiles(EngineWriteRoot.path()).copy(saveGame, "current") + prepareServerSession(restart = true, savedMaxClients = savedMaxClients) - val mapCommand = readSavedMapCommand() - - // go to the map - val loaded = spawnServerInstance(ChangeMapInfo(mapCommand, false, true), 0) + val transitionClients = clients.filter { it.gameName == null || it.gameName == "default" } + val loaded = spawnServerInstance(target, games["default"], transitionClients) loaded.name = "default" games["default"] = loaded - } - - private fun readSavedMapCommand(): String { - Com.DPrintf("SV_ReadMapCommand()\n") - - try { - val store = ServerSaveJsonStore.forWriteDir(EngineWriteRoot.pathString()) - val snapshot = store.readMapCommand("current") - // read the comment field - Com.DPrintf("SV_ReadMapCommand: Loading save: " + snapshot.comment + "\n") - - // read the mapcmd - return snapshot.mapCommand - } catch (e: Exception) { - throw RuntimeException(e) - } + transitionClients.forEach { it.gameName = loaded.name } } /* @@ -498,7 +507,9 @@ goes to map jail.bsp. Spins up a new game instance, to be used with either `map` or `join` (TBD) */ - fun changeMap(args: List) { + fun changeMap(args: List) = changeMap(args, sessionPrepared = false) + + private fun changeMap(args: List, sessionPrepared: Boolean) { if (args.size < 2) { Com.Printf("USAGE: gamemap \n ") return @@ -517,9 +528,7 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) // default.cfg may start a session with "killserver; gamemap *...". // Shutdown clears the client slots, so gamemap must initialize a fresh // server just as map does, while preserving clients on level transitions. - if (games.isEmpty()) { - initializeServerSession() - } + if (!sessionPrepared) prepareServerSession() Com.DPrintf("SV_GameMap($mapName)\n") @@ -658,12 +667,8 @@ Resets server to host a given map, moves all clients to it (via reconnect) ServerSaveFiles(EngineWriteRoot.path()).wipe("current") - // A running server needs its clients reset for map. For a fresh server, - // gamemap performs this initialization itself. - if (games.isNotEmpty()) { - initializeServerSession() - } - changeMap(args) + prepareServerSession(restart = true) + changeMap(args, sessionPrepared = true) } /** Resolve a Game at execution time; creating another Game never replaces these callbacks. */ From 22a37e8be4a492464ffe620499da377c87548a58 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 22:23:34 +0100 Subject: [PATCH 24/47] Validate saves before replacement and propagate persistence failures --- .../server/HostSessionPreparationTest.kt | 117 ++++++++++++++++++ .../main/java/jake2/game/GameExportsImpl.java | 6 +- .../java/jake2/server/GameImportsImpl.java | 16 +-- .../main/kotlin/jake2/server/SavePreflight.kt | 75 +++++++++++ .../main/kotlin/jake2/server/ServerHost.kt | 15 +-- 5 files changed, 205 insertions(+), 24 deletions(-) create mode 100644 server/src/main/kotlin/jake2/server/SavePreflight.kt diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt index d834bddc..58e0e569 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt @@ -124,6 +124,123 @@ class HostSessionPreparationTest { .readMapCommand("current").mapCommand()) } + @ParameterizedTest + @ValueSource(strings = ["game.ssv.json", "testbox.sav.json", "testbox.sv2.json"]) + fun `missing save components preserve Game and working save`(file: String) { + save(false) + Files.delete(root.resolve("save/slot/$file")) + // Require a level archive by making this a manual save envelope. + val gameFile = root.resolve("save/slot/game.ssv.json") + if (Files.exists(gameFile)) Files.writeString(gameFile, + Files.readString(gameFile).replace("\"autoSaved\":true", "\"autoSaved\":false")) + val game = host.games.getValue("default") + val original = Files.readString(root.resolve("save/current/game.ssv.json")) + host.loadGame(listOf("load", "slot")) + assertSame(game, host.games["default"]) + assertEquals(original, Files.readString(root.resolve("save/current/game.ssv.json"))) + } + + @ParameterizedTest + @ValueSource(strings = ["game.ssv.json", "testbox.sav.json", "testbox.sv2.json"]) + fun `malformed save components are rejected before replacement`(file: String) { + save(false) + Files.writeString(root.resolve("save/slot/$file"), "{ broken json") + val game = host.games.getValue("default") + host.loadGame(listOf("load", "slot")) + assertSame(game, host.games["default"]) + } + + @Test + fun `manual save requires current level even when both archive files are absent`() { + save(false) + Files.delete(root.resolve("save/slot/testbox.sav.json")) + Files.delete(root.resolve("save/slot/testbox.sv2.json")) + val path = root.resolve("save/slot/game.ssv.json") + val json = Files.readString(path) + assertTrue(json.contains("\"autoSaved\":true")) + Files.writeString(path, json.replace("\"autoSaved\":true", "\"autoSaved\":false")) + val game = host.games.getValue("default") + host.loadGame(listOf("load", "slot")) + assertSame(game, host.games["default"]) + } + + @ParameterizedTest + @ValueSource(strings = ["version", "capacity", "setting"]) + fun `inconsistent metadata is rejected without modifying live settings`(damage: String) { + save(false) + val store = jake2.server.save.ServerSaveJsonStore.forWriteDir(root.toString()) + when (damage) { + "version" -> { + val file = root.resolve("save/slot/server_mapcmd.ssv.json") + Files.writeString(file, Files.readString(file).replace("\"schemaVersion\":1", "\"schemaVersion\":99")) + } + else -> { + val name = if (damage == "capacity") "maxclients" else "coop" + val value = if (damage == "capacity") "4" else "invalid" + store.writeLatchedCvars("slot", store.readLatchedCvars("slot").cvars().map { + if (it.name() == name) jake2.server.save.ServerSaveJsonStore.LatchedCvarSnapshot(name, value) else it + }) + } + } + val game = host.games.getValue("default") + host.loadGame(listOf("load", "slot")) + assertSame(game, host.games["default"]) + assertEquals(1, cvars.find("maxclients").typedValue) + assertFalse(cvars.find("coop").typedValue) + } + + @Test + fun `explicit load restores archived state even when level revisit is disabled`() { + save(false) + val original = host.games.getValue("default") + original.configstrings[Defines.CS_NAME] = "Saved level name" + original.SV_WriteLevelFile() + ServerSaveFiles(root).copy("current", "slot") + cvars.find("sv_noreload").forceSet(true) + host.stop("test", false) + host.loadGame(listOf("load", "slot")) + assertEquals("Saved level name", host.games.getValue("default").configstrings[Defines.CS_NAME]) + } + + @Test + fun `server level read failure stops before gameplay restoration`() { + save(false) + val game = host.games.getValue("default") + val gameplay = game.gameExports + var restoredGameplay = false + game.gameExports = object : GameExports by gameplay { + override fun ReadLevel(filename: String) { restoredGameplay = true } + } + Files.delete(root.resolve("save/current/testbox.sv2.json")) + assertThrows(IllegalStateException::class.java) { game.readLevelFile() } + assertFalse(restoredGameplay) + } + + @Test + fun `level entry autosave without archives remains loadable`() { + host.map(listOf("map", "testbox")) + host.stop("test", false) + host.loadGame(listOf("load", "save0")) + assertEquals("testbox", host.games.getValue("default").mapName) + } + + @Test + fun `gameplay IO failures propagate and failed save does not replace slot`() { + save(true) + val game = host.games.getValue("default") + val original = Files.readString(root.resolve("save/slot/game.ssv.json")) + assertThrows(IllegalStateException::class.java) { + game.gameExports.readGameLocals(root.resolve("missing.json").toString()) + } + assertThrows(IllegalStateException::class.java) { + game.gameExports.ReadLevel(root.resolve("missing.json").toString()) + } + Files.delete(root.resolve("save/current/game.ssv.json")) + Files.createDirectory(root.resolve("save/current/game.ssv.json")) + assertThrows(IllegalStateException::class.java) { game.saveGame(listOf("save", "slot")) } + assertEquals(original, Files.readString(root.resolve("save/slot/game.ssv.json"))) + } + @Test fun `unreadable allocation metadata preserves running session`() { save(false) diff --git a/game/src/main/java/jake2/game/GameExportsImpl.java b/game/src/main/java/jake2/game/GameExportsImpl.java index aa13f3a2..3586e9a7 100644 --- a/game/src/main/java/jake2/game/GameExportsImpl.java +++ b/game/src/main/java/jake2/game/GameExportsImpl.java @@ -1590,7 +1590,7 @@ public void WriteGame(String filename, boolean autosave) { game.autosaved = false; } } catch (Exception e) { - e.printStackTrace(); + throw new IllegalStateException("Failed to write gameplay save " + filename, e); } } @@ -1611,7 +1611,7 @@ public void readGameLocals(String filename) { } catch (Exception e) { - e.printStackTrace(); + throw new IllegalStateException("Failed to restore gameplay save " + filename, e); } } @@ -1678,7 +1678,7 @@ public void ReadLevel(String filename) { ent.think.nextTime = level.time + ent.delay; } } catch (Exception e) { - e.printStackTrace(); + throw new IllegalStateException("Failed to restore gameplay level " + filename, e); } } diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index fa0978b0..259ac60c 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -339,10 +339,10 @@ void SV_Read_Latched_Vars() { String value = cvar.value(); Com.DPrintf("Set " + name + " = " + value + "\n"); var result = writeRegistry(name).forceSetResult(name, value); - if (result.getDiagnostic() != null) Com.Printf(result.getDiagnostic() + "\n"); + if (!result.getAccepted()) throw new IllegalArgumentException(result.getDiagnostic()); } } catch (Exception e) { - Com.Printf("Couldn't read file " + e.getMessage() + "\n"); + throw new IllegalStateException("Failed to restore saved Game settings", e); } } @@ -886,12 +886,7 @@ void saveGame(List args) { SV_WriteLevelFile(); // save server state - try { - SV_WriteServerFile(false); - } - catch (Exception e) { - Com.Printf("IOError in SV_WriteServerFile: " + e); - } + SV_WriteServerFile(false); // copy it off new ServerSaveFiles(EngineWriteRoot.path()).copy("current", saveGame); @@ -1168,7 +1163,7 @@ void restorePreviousGame() { } boolean isPreviousLevel() { - if (Cvar.getInstance().find("sv_noreload", Boolean.class).getTypedValue()) + if (!loadgame && Cvar.getInstance().find("sv_noreload", Boolean.class).getTypedValue()) return false; if (isDeathmatch()) @@ -1188,8 +1183,7 @@ void readLevelFile() { ServerLevelJsonStore store = ServerLevelJsonStore.forWriteDir(EngineWriteRoot.pathString()); store.applyLevelState(store.readLevelState("current", mapName), configstrings, cm); } catch (Exception e) { - Com.Printf("Failed to open current level state for " + mapName + "\n"); - e.printStackTrace(); + throw new IllegalStateException("Failed to restore server level " + mapName, e); } String name = EngineWriteRoot.resolve("save/current/" + mapName + ".sav.json").toString(); diff --git a/server/src/main/kotlin/jake2/server/SavePreflight.kt b/server/src/main/kotlin/jake2/server/SavePreflight.kt new file mode 100644 index 00000000..ebc37e59 --- /dev/null +++ b/server/src/main/kotlin/jake2/server/SavePreflight.kt @@ -0,0 +1,75 @@ +package jake2.server + +import jake2.qcommon.Defines +import jake2.qcommon.ServerStates +import jake2.qcommon.exec.Cvar +import jake2.qcommon.save.SaveJson +import jake2.qcommon.vfs.EngineVfs +import jake2.server.save.ServerLevelJsonStore +import jake2.server.save.ServerSaveJsonStore +import java.nio.file.Files +import java.nio.file.Path + +/** Read-only checks before a slot replaces the working save. Gameplay reference resolution stays in Game. */ +internal class SavePreflight(private val root: Path) { + fun inspect(slot: String): PreparedSave { + val store = ServerSaveJsonStore.forWriteDir(root.toString()) + val map = store.readMapCommand(slot) + require(map.schemaVersion() == ServerSaveJsonStore.SCHEMA_VERSION) { "Unsupported map metadata version" } + require(!map.mapCommand().isNullOrBlank()) { "Missing saved map command" } + val target = ChangeMapInfo(map.mapCommand(), false, true) + require(target.mapName.isNotBlank() && !target.mapName.contains("..") && + !target.mapName.contains('/') && !target.mapName.contains('\\')) { "Invalid saved map name" } + if (target.state == ServerStates.SS_GAME) { + require(EngineVfs.exists("maps/${target.mapName}.bsp")) { "Missing map: ${target.mapName}" } + } + + val settings = store.readLatchedCvars(slot) + require(settings.schemaVersion() == ServerSaveJsonStore.SCHEMA_VERSION) { "Unsupported saved settings version" } + val values = settings.cvars().associate { entry -> + require(!entry.name().isNullOrBlank() && entry.value() != null) { "Invalid saved setting" } + val registry = Cvar.getInstance() + registry.find(entry.name())?.type?.parse(entry.value()) + entry.name() to entry.value() + } + require(values.size == settings.cvars().size) { "Duplicate saved settings" } + val capacity = values.getValue("maxclients").toInt() + require(capacity in 1..Defines.MAX_CLIENTS) { "Invalid saved maxclients" } + + val directory = root.resolve("save").resolve(ServerSaveJsonStore.normalizeSlot(slot)) + // Only inspect the gameplay envelope here: the server must not depend on gameplay DTOs. + val gameFile = readEnvelope(directory.resolve("game.ssv.json")) + val game = gameFile["game"] as? Map<*, *> ?: error("Missing saved game state") + require(game["maxClients"] == capacity) { "Saved client capacities disagree" } + val clients = gameFile["clients"] as? List<*> ?: error("Missing saved clients") + require(clients.size == capacity && clients.all { it is Map<*, *> }) { "Incomplete saved clients" } + val autoSaved = game["autoSaved"] as? Boolean ?: error("Missing autosave marker") + + val archives = linkedSetOf() + Files.newDirectoryStream(directory).use { files -> + for (file in files) { + val name = file.fileName.toString() + if (name.endsWith(".sav.json")) archives.add(name.removeSuffix(".sav.json")) + if (name.endsWith(".sv2.json")) archives.add(name.removeSuffix(".sv2.json")) + } + } + // Level-entry autosaves intentionally have no archive for the new map yet. + if (!autoSaved && target.state == ServerStates.SS_GAME) archives.add(target.mapName) + for (name in archives) { + val level = readEnvelope(directory.resolve("$name.sav.json")) + require(level["level"] is Map<*, *> && level["entities"] is List<*>) { "Incomplete level: $name" } + val server = ServerLevelJsonStore.forWriteDir(root.toString()).readLevelState(slot, name) + require(server.schemaVersion == ServerLevelJsonStore.SCHEMA_VERSION) { "Unsupported server level version" } + require(server.configstrings.size == Defines.MAX_CONFIGSTRINGS) { "Incomplete configstrings: $name" } + } + return PreparedSave(target, capacity) + } + + private fun readEnvelope(path: Path): Map<*, *> = Files.newInputStream(path).use { + val value = SaveJson.read(it, Map::class.java) + require(value["schemaVersion"] == 1) { "Unsupported save version: ${path.fileName}" } + value + } +} + +internal data class PreparedSave(val target: ChangeMapInfo, val maxClients: Int) diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index ab3c5930..489f3e99 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -460,23 +460,18 @@ class ServerHost : AutoCloseable { return } - // Read Host allocation inputs before touching the current workspace or running Game. - val mapCommand: String - val savedMaxClients: Int - try { - mapCommand = store.readMapCommand(saveGame).mapCommand() - savedMaxClients = store.readLatchedCvars(saveGame).cvars() - .last { it.name() == "maxclients" }.value().toInt() - require(savedMaxClients in 1..Defines.MAX_CLIENTS) { "Invalid saved maxclients" } + val prepared = try { + SavePreflight(EngineWriteRoot.path()).inspect(saveGame) } catch (e: Exception) { - Com.Printf("Couldn't read save metadata: ${e.message}\n") + Com.Printf("Couldn't load save: ${e.message}\n") return } + val savedMaxClients = prepared.maxClients if (games.keys.any { it != "default" } && clients.size != savedMaxClients) { Com.Printf("Can't change saved client capacity while other Games are running\n") return } - val target = ChangeMapInfo(mapCommand, false, true) + val target = prepared.target ServerSaveFiles(EngineWriteRoot.path()).copy(saveGame, "current") prepareServerSession(restart = true, savedMaxClients = savedMaxClients) From fe2a11cd6a64037ad5b7344e2939e35a0ef20ed7 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 22:29:15 +0100 Subject: [PATCH 25/47] Replace precache entities during restoration and remove sv_noreload --- .../server/HostSessionPreparationTest.kt | 47 ++++++++++++++++++- .../main/java/jake2/game/GameExportsImpl.java | 8 ++++ .../java/jake2/server/GameImportsImpl.java | 7 +-- .../main/kotlin/jake2/server/ServerHost.kt | 7 --- 4 files changed, 55 insertions(+), 14 deletions(-) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt index 58e0e569..872460dd 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt @@ -189,14 +189,57 @@ class HostSessionPreparationTest { assertFalse(cvars.find("coop").typedValue) } + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun `restoration keeps removed entities absent and restores surviving references`(load: Boolean) { + host.map(listOf("map", "testbox2")) + val original = host.games.getValue("default") + val gameplay = original.gameExports as jake2.game.GameExportsImpl + fun entity(classname: String) = gameplay.g_edicts.first { it.inuse && it.classname == classname } + val removed = entity("ammo_bullets") + val survivor = entity("ammo_cells") + val target = entity("ammo_shells") + val removedIndex = removed.index + val survivorIndex = survivor.index + val targetIndex = target.index + assertTrue(removedIndex < targetIndex, "Removed slot must be inside the restored entity range") + survivor.health = 73 + survivor.enemy = target + gameplay.game.clients[0].chase_target = target + gameplay.level.sound_entity = target + gameplay.freeEntity(removed) + original.SV_WriteLevelFile() + original.SV_WriteServerFile(true) + ServerSaveFiles(root).copy("current", "slot") + + if (load) { + host.loadGame(listOf("load", "slot")) + } else { + host.changeMap(listOf("gamemap", "testbox")) + host.changeMap(listOf("gamemap", "testbox2")) + } + + val restored = host.games.getValue("default") + assertFalse(restored.gameExports.getEdict(removedIndex).inuse) + val restoredSurvivor = restored.gameExports.getEdict(survivorIndex) as jake2.game.GameEntity + assertTrue(restoredSurvivor.inuse) + assertEquals(73, restoredSurvivor.health) + assertSame(restored.gameExports.getEdict(targetIndex), restoredSurvivor.enemy) + if (load) { + val restoredGameplay = restored.gameExports as jake2.game.GameExportsImpl + assertSame(restored.gameExports.getEdict(targetIndex), restoredGameplay.game.clients[0].chase_target) + assertSame(restored.gameExports.getEdict(targetIndex), restoredGameplay.level.sound_entity) + } + assertEquals(0, restored.baselines[removedIndex].modelindex) + } + @Test - fun `explicit load restores archived state even when level revisit is disabled`() { + fun `explicit load restores archived state`() { save(false) val original = host.games.getValue("default") original.configstrings[Defines.CS_NAME] = "Saved level name" original.SV_WriteLevelFile() ServerSaveFiles(root).copy("current", "slot") - cvars.find("sv_noreload").forceSet(true) host.stop("test", false) host.loadGame(listOf("load", "slot")) assertEquals("Saved level name", host.games.getValue("default").configstrings[Defines.CS_NAME]) diff --git a/game/src/main/java/jake2/game/GameExportsImpl.java b/game/src/main/java/jake2/game/GameExportsImpl.java index 3586e9a7..2ccc4fb9 100644 --- a/game/src/main/java/jake2/game/GameExportsImpl.java +++ b/game/src/main/java/jake2/game/GameExportsImpl.java @@ -1643,6 +1643,14 @@ public void ReadLevel(String filename) { LevelSaveJsonStore store = LevelSaveJsonStore.forWriteDir(EngineWriteRoot.pathString()); LevelSaveFileSnapshot snapshot = store.read(filename); + // SpawnEntities ran for precaching. Replace that world, rather than overlaying it: + // entities absent from the save must remain unused. Allocate every slot before + // resolving saved references, including references to later entity numbers. + CreateEdicts(game.maxentities); + for (GamePlayerInfo client : game.clients) { + if (client.chase_target != null) + client.chase_target = g_edicts[client.chase_target.index]; + } num_edicts = game.maxclients + 1; LevelSaveSnapshots.apply(level, snapshot.getLevel(), g_edicts); diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index 259ac60c..a3d7d647 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -291,8 +291,8 @@ void initializeLevel(ChangeMapInfo changeMapInfo, GameImportsImpl previous) { gameExports.SpawnEntities(mapName, cm.CM_EntityString(), changeMapInfo.spawnPoint); if (isPreviousLevel()) { - // fixme: need to spawn all entities and then remove them, - // because otherwise precaching will not work properly - configstrings will not match. + // Spawn first for precaching; restoration replaces the gameplay entities. + // Clear spatial links before linking the restored world. clearWorld(); // loads a stored game, runs 100 frames afterwards @@ -1163,9 +1163,6 @@ void restorePreviousGame() { } boolean isPreviousLevel() { - if (!loadgame && Cvar.getInstance().find("sv_noreload", Boolean.class).getTypedValue()) - return false; - if (isDeathmatch()) return false; diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 489f3e99..5858d7b0 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -208,13 +208,6 @@ class ServerHost : AutoCloseable { allowSoundDownloads = cvars.find("allow_download_sounds") allowMapDownloads = cvars.find("allow_download_maps") - cvars.register( - "sv_noreload", - CvarTypes.BOOLEAN, - false, - 0, - "Start revisited levels fresh instead of restoring their saved state" - ) cvars.register( "sv_airaccelerate", CvarTypes.FLOAT, From 8103ab6991ebd267bc9a9d52b407447fb676f699 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 22:38:39 +0100 Subject: [PATCH 26/47] Persist entity components when restoring saved levels --- .../server/HostSessionPreparationTest.kt | 26 +++++++ .../jake2/game/EntityComponentsSnapshot.kt | 34 +++++++++ .../main/kotlin/jake2/game/LevelSaveJson.kt | 14 +++- .../kotlin/jake2/game/components/MoveInfo.kt | 74 ++++++++++++++++++- .../game/EntityComponentsSnapshotTest.kt | 57 ++++++++++++++ .../main/kotlin/jake2/server/SavePreflight.kt | 7 +- 6 files changed, 204 insertions(+), 8 deletions(-) create mode 100644 game/src/main/kotlin/jake2/game/EntityComponentsSnapshot.kt create mode 100644 game/src/test/kotlin/jake2/game/EntityComponentsSnapshotTest.kt diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt index 872460dd..6f461517 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt @@ -233,6 +233,32 @@ class HostSessionPreparationTest { assertEquals(0, restored.baselines[removedIndex].modelindex) } + @Test + fun `revisited moving entity retains movement component and completes movement`() { + host.map(listOf("map", "testbox")) + val original = host.games.getValue("default") + val gameplay = original.gameExports as jake2.game.GameExportsImpl + val mover = gameplay.G_Spawn() + mover.classname = "saved_mover" + mover.movetype = jake2.game.GameDefines.MOVETYPE_PUSH + val movement = jake2.game.components.MoveInfo(speed = 100f, accel = 100f, decel = 100f) + mover.components[jake2.game.components.MoveInfo::class.java.name] = movement + val completed = jake2.game.adapters.SuperAdapter.registerThink("test_saved_mover_done") { self, _ -> + self.health = 73 + true + } + jake2.game.func.startMovement(mover, jake2.qcommon.math.Vector3f(100f, 0f, 0f), completed, gameplay) + movement.remaining_distance = 5f + mover.think.action = jake2.game.adapters.SuperAdapter.think("move_final") + mover.think.nextTime = gameplay.level.time + Defines.FRAMETIME + val index = mover.index + host.changeMap(listOf("gamemap", "testbox2")) + host.changeMap(listOf("gamemap", "testbox")) + val restored = host.games.getValue("default").gameExports.getEdict(index) as jake2.game.GameEntity + assertEquals(73, restored.health) + assertNotNull(restored.components[jake2.game.components.MoveInfo::class.java.name]) + } + @Test fun `explicit load restores archived state`() { save(false) diff --git a/game/src/main/kotlin/jake2/game/EntityComponentsSnapshot.kt b/game/src/main/kotlin/jake2/game/EntityComponentsSnapshot.kt new file mode 100644 index 00000000..7590d3e7 --- /dev/null +++ b/game/src/main/kotlin/jake2/game/EntityComponentsSnapshot.kt @@ -0,0 +1,34 @@ +package jake2.game + +import jake2.game.components.* + +/** Explicit saved component set. Value-only components are copied; mover callbacks use IDs. */ +data class EntityComponentsSnapshot( + val movement: MoveInfoSnapshot?, + val medkit: Medkit?, + val light: Light?, + val lightRamp: LightRamp?, + val areaPortal: AreaPortal?, + val earthquake: Earthquake? +) { + fun restore(entity: GameEntity) { + entity.components.clear() + movement?.let { entity.addComponent(it.restore()) } + medkit?.let { entity.addComponent(it.copy()) } + light?.let { entity.addComponent(it.copy()) } + lightRamp?.let { entity.addComponent(it.copy()) } + areaPortal?.let { entity.addComponent(it.copy()) } + earthquake?.let { entity.addComponent(it.copy()) } + } + + companion object { + fun capture(entity: GameEntity) = EntityComponentsSnapshot( + entity.getComponent()?.snapshot(), + entity.getComponent()?.copy(), + entity.getComponent()?.copy(), + entity.getComponent()?.copy(), + entity.getComponent()?.copy(), + entity.getComponent()?.copy() + ) + } +} diff --git a/game/src/main/kotlin/jake2/game/LevelSaveJson.kt b/game/src/main/kotlin/jake2/game/LevelSaveJson.kt index 1a12acbd..7b144016 100644 --- a/game/src/main/kotlin/jake2/game/LevelSaveJson.kt +++ b/game/src/main/kotlin/jake2/game/LevelSaveJson.kt @@ -11,6 +11,7 @@ import jake2.game.adapters.EntTouchAdapter import jake2.game.adapters.EntUseAdapter import jake2.game.adapters.SuperAdapter import jake2.game.items.GameItem +import jake2.game.components.* import jake2.game.monsters.mframe_t import jake2.game.monsters.mmove_t import jake2.game.monsters.monsterinfo_t @@ -221,7 +222,8 @@ data class LevelEntitySnapshot( val itemIndex: Int?, val monsterInfo: MonsterInfoSnapshot, val clientIndex: Int?, - val ownerIndex: Int? + val ownerIndex: Int?, + val components: EntityComponentsSnapshot ) data class LevelSaveFileSnapshot( @@ -399,7 +401,8 @@ object LevelSaveSnapshots { itemIndex = entity.item?.index, monsterInfo = snapshot(entity.monsterinfo), clientIndex = entity.client?.index, - ownerIndex = entity.owner?.index + ownerIndex = entity.owner?.index, + components = EntityComponentsSnapshot.capture(entity) ) @JvmStatic @@ -512,6 +515,7 @@ object LevelSaveSnapshots { apply(target.monsterinfo, snapshot.monsterInfo) target.client = snapshot.clientIndex?.let { clients.getOrNull(it) } target.owner = entityRef(snapshot.ownerIndex, edicts) + snapshot.components.restore(target) } @JvmStatic @@ -696,7 +700,9 @@ object LevelSaveSnapshots { class LevelSaveJsonStore(private val writable: WritableFileSystem) { fun read(logicalPath: String): LevelSaveFileSnapshot { - return read(normalizePath(logicalPath), LevelSaveFileSnapshot::class.java) + return read(normalizePath(logicalPath), LevelSaveFileSnapshot::class.java).also { + require(it.schemaVersion == SCHEMA_VERSION) { "Unsupported gameplay level version: ${it.schemaVersion}" } + } } fun write(logicalPath: String, snapshot: LevelSaveFileSnapshot) { @@ -737,7 +743,7 @@ class LevelSaveJsonStore(private val writable: WritableFileSystem) { } companion object { - const val SCHEMA_VERSION: Int = 1 + const val SCHEMA_VERSION: Int = 2 @JvmStatic fun forWriteDir(writeDir: String): LevelSaveJsonStore = diff --git a/game/src/main/kotlin/jake2/game/components/MoveInfo.kt b/game/src/main/kotlin/jake2/game/components/MoveInfo.kt index a67d9676..4dc8d3f6 100644 --- a/game/src/main/kotlin/jake2/game/components/MoveInfo.kt +++ b/game/src/main/kotlin/jake2/game/components/MoveInfo.kt @@ -1,6 +1,8 @@ package jake2.game.components import jake2.game.adapters.EntThinkAdapter +import jake2.game.adapters.SuperAdapter +import jake2.qcommon.math.toVector3f import jake2.game.func.MovementState import jake2.qcommon.math.Vector3f import kotlin.math.sqrt @@ -33,6 +35,30 @@ data class MoveInfo( private var decel_distance: Float = 0f, var endfunc: EntThinkAdapter? = null ) { + /** Capture all movement state, including private acceleration bookkeeping. */ + fun snapshot() = MoveInfoSnapshot( + startOrigin = start_origin.toArray(), + startAngles = start_angles.toArray(), + endOrigin = end_origin.toArray(), + endAngles = end_angles.toArray(), + soundStart = sound_start, + soundMiddle = sound_middle, + soundEnd = sound_end, + accel = accel, + speed = speed, + decel = decel, + distance = distance, + wait = wait, + state = state, + dir = dir.toArray(), + currentSpeed = current_speed, + moveSpeed = move_speed, + nextSpeed = next_speed, + remainingDistance = remaining_distance, + decelDistance = decel_distance, + endFuncId = endfunc?.iD + ) + /** * plat_CalcAcceleratedMove */ @@ -112,4 +138,50 @@ data class MoveInfo( // we are at constant velocity (move_speed) } -} \ No newline at end of file +} +/** Persisted mover data; callbacks are stored by adapter ID, never as runtime objects. */ +data class MoveInfoSnapshot( + val startOrigin: FloatArray, + val startAngles: FloatArray, + val endOrigin: FloatArray, + val endAngles: FloatArray, + val soundStart: Int, + val soundMiddle: Int, + val soundEnd: Int, + val accel: Float, + val speed: Float, + val decel: Float, + val distance: Float, + val wait: Float, + val state: MovementState?, + val dir: FloatArray, + val currentSpeed: Float, + val moveSpeed: Float, + val nextSpeed: Float, + val remainingDistance: Float, + val decelDistance: Float, + val endFuncId: String? +) { + fun restore() = MoveInfo( + start_origin = startOrigin.toVector3f(), + start_angles = startAngles.toVector3f(), + end_origin = endOrigin.toVector3f(), + end_angles = endAngles.toVector3f(), + sound_start = soundStart, + sound_middle = soundMiddle, + sound_end = soundEnd, + accel = accel, + speed = speed, + decel = decel, + distance = distance, + wait = wait, + state = state, + dir = dir.toVector3f(), + current_speed = currentSpeed, + move_speed = moveSpeed, + next_speed = nextSpeed, + remaining_distance = remainingDistance, + decel_distance = decelDistance, + endfunc = endFuncId?.let { requireNotNull(SuperAdapter.think(it)) { "Unknown mover callback: $it" } } + ) +} diff --git a/game/src/test/kotlin/jake2/game/EntityComponentsSnapshotTest.kt b/game/src/test/kotlin/jake2/game/EntityComponentsSnapshotTest.kt new file mode 100644 index 00000000..cbde378d --- /dev/null +++ b/game/src/test/kotlin/jake2/game/EntityComponentsSnapshotTest.kt @@ -0,0 +1,57 @@ +package jake2.game + +import jake2.game.adapters.SuperAdapter +import jake2.game.components.* +import jake2.game.func.MovementState +import jake2.qcommon.math.Vector3f +import jake2.qcommon.save.SaveJson +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +class EntityComponentsSnapshotTest { + @Test + fun `component state survives JSON round trip into an empty entity`() { + val entity = GameEntity(1) + val callback = SuperAdapter.registerThink("test.component.end") { _, _ -> true } + val movement = entity.addComponent(MoveInfo( + start_origin = Vector3f(1f, 2f, 3f), end_origin = Vector3f(4f, 5f, 6f), + speed = 20f, accel = 2f, decel = 3f, remaining_distance = 90f, + state = MovementState.UP, endfunc = callback + )) + movement.platCalculateAcceleratedMove() + repeat(3) { + movement.platAccelerate() + movement.remaining_distance -= movement.current_speed + } + entity.addComponent(Medkit(100, 12, ignoreMax = true, timed = true)) + entity.addComponent(Light(35, false)) + entity.addComponent(LightRamp(97, 122, 3f, targetTime = 10f, + toggleable = true, fraction = 0.4f, targetLightStyle = 35)) + entity.addComponent(AreaPortal(4, true)) + entity.addComponent(Earthquake(13, duration = 9f, magnitude = 250f, soundTime = 4f, stopTime = 12f)) + + val bytes = ByteArrayOutputStream().also { SaveJson.write(it, EntityComponentsSnapshot.capture(entity)) } + val snapshot = SaveJson.read(ByteArrayInputStream(bytes.toByteArray()), EntityComponentsSnapshot::class.java) + val restored = GameEntity(1) + snapshot.restore(restored) + val restoredMovement = restored.getComponent()!! + assertSame(callback, restoredMovement.endfunc) + assertEquals(movement.state, restoredMovement.state) + assertArrayEquals(movement.start_origin.toArray(), restoredMovement.start_origin.toArray()) + // Continuing acceleration/deceleration also checks the private movement bookkeeping. + repeat(20) { + movement.platAccelerate() + restoredMovement.platAccelerate() + assertEquals(movement.current_speed, restoredMovement.current_speed) + movement.remaining_distance -= movement.current_speed + restoredMovement.remaining_distance -= restoredMovement.current_speed + } + for ((key, value) in entity.components) { + if (value is MoveInfo) continue + assertEquals(value, restored.components[key]) + assertNotSame(value, restored.components[key]) + } + } +} diff --git a/server/src/main/kotlin/jake2/server/SavePreflight.kt b/server/src/main/kotlin/jake2/server/SavePreflight.kt index ebc37e59..f3deecb2 100644 --- a/server/src/main/kotlin/jake2/server/SavePreflight.kt +++ b/server/src/main/kotlin/jake2/server/SavePreflight.kt @@ -56,7 +56,8 @@ internal class SavePreflight(private val root: Path) { // Level-entry autosaves intentionally have no archive for the new map yet. if (!autoSaved && target.state == ServerStates.SS_GAME) archives.add(target.mapName) for (name in archives) { - val level = readEnvelope(directory.resolve("$name.sav.json")) + // Gameplay level v2 includes the component state required by restored callbacks. + val level = readEnvelope(directory.resolve("$name.sav.json"), version = 2) require(level["level"] is Map<*, *> && level["entities"] is List<*>) { "Incomplete level: $name" } val server = ServerLevelJsonStore.forWriteDir(root.toString()).readLevelState(slot, name) require(server.schemaVersion == ServerLevelJsonStore.SCHEMA_VERSION) { "Unsupported server level version" } @@ -65,9 +66,9 @@ internal class SavePreflight(private val root: Path) { return PreparedSave(target, capacity) } - private fun readEnvelope(path: Path): Map<*, *> = Files.newInputStream(path).use { + private fun readEnvelope(path: Path, version: Int = 1): Map<*, *> = Files.newInputStream(path).use { val value = SaveJson.read(it, Map::class.java) - require(value["schemaVersion"] == 1) { "Unsupported save version: ${path.fileName}" } + require(value["schemaVersion"] == version) { "Unsupported save version: ${path.fileName}" } value } } From 88d105e98fc824ab506361a6690fbcbb8426b15a Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 22:46:53 +0100 Subject: [PATCH 27/47] Recover failed loads to a reusable idle Host --- .../server/HostSessionPreparationTest.kt | 89 ++++++++++++++++++- .../java/jake2/server/GameImportsImpl.java | 4 +- .../main/kotlin/jake2/server/ServerHost.kt | 82 ++++++++++------- 3 files changed, 141 insertions(+), 34 deletions(-) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt index 6f461517..48d49420 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt @@ -310,13 +310,100 @@ class HostSessionPreparationTest { assertEquals(original, Files.readString(root.resolve("save/slot/game.ssv.json"))) } + @ParameterizedTest + @ValueSource(strings = ["copy", "game", "level", "idle"]) + fun `late load failure leaves idle Host and allows retry`(stage: String) { + save(true) + // A shared-state failure must also discard unrelated Games, not leave half a Host alive. + val other = host.createGameInstance(ChangeMapInfo("testbox2", false, false), null) + host.games[other.name] = other + host.clients[1].gameName = other.name + cvars.find("paused").forceSet(true) + assertNotNull(NET.ip_sockets[Defines.NS_SERVER]) + if (stage == "idle") { + host.stop("test", false) + NET.configureServer(false) + } + val path = when (stage) { + "copy" -> root.resolve("save/current/game.ssv.json") + "game" -> root.resolve("save/slot/game.ssv.json") + else -> root.resolve("save/slot/testbox.sav.json") + } + val original = Files.readString(path) + when (stage) { + "copy" -> { + Files.delete(path) + Files.createDirectory(path) + Files.writeString(path.resolve("blocker"), "nonempty directory") + } + // These have valid envelopes, but invalid typed gameplay data/reference indices. + "game" -> Files.writeString(path, original.replace("\"playerState\":", "\"missingPlayerState\":")) + else -> Files.writeString(path, original.replace("\"entityNumber\":0", "\"entityNumber\":999999")) + } + var deferredRan = false + jake2.qcommon.exec.Cmd.AddCommand("test_failed_load_deferred") { deferredRan = true } + try { + jake2.qcommon.exec.Cbuf.AddText("test_failed_load_deferred") + jake2.qcommon.exec.Cbuf.CopyToDefer() + val result = host.loadGame("slot") + assertInstanceOf(ServerHost.LoadResult.Failed::class.java, result) + assertTrue(host.games.isEmpty()) + assertTrue(host.clients.isEmpty()) + assertFalse(host.isPaused) + assertNull(NET.ip_sockets[Defines.NS_SERVER]) + assertEquals(ServerStates.SS_DEAD, Globals.server_state) + assertEquals("", cvars.find("mapname").typedValue) + assertNotNull(jake2.qcommon.exec.Cmd.FindCommand("load")) + host.update(100) + jake2.qcommon.exec.Cbuf.InsertFromDefer() + jake2.qcommon.exec.Cbuf.Execute() + assertFalse(deferredRan) + + if (stage == "copy") { + Files.delete(path.resolve("blocker")) + Files.delete(path) + } + Files.writeString(path, original) + assertEquals(ServerHost.LoadResult.Loaded, host.loadGame("slot")) + assertEquals("testbox", host.games.getValue("default").mapName) + } finally { + jake2.qcommon.exec.Cmd.RemoveCommand("test_failed_load_deferred") + } + } + + @Test + fun `load port failure leaves reusable idle Host`() { + save(true) + host.stop("test", false) + NET.configureServer(false) + java.net.DatagramSocket(0).use { occupied -> + cvars.find("port").forceSet(occupied.localPort) + assertInstanceOf(ServerHost.LoadResult.Failed::class.java, host.loadGame("slot")) + assertTrue(host.games.isEmpty()) + assertTrue(host.clients.isEmpty()) + assertNull(NET.ip_sockets[Defines.NS_SERVER]) + } + cvars.find("port").forceSet(0) + assertEquals(ServerHost.LoadResult.Loaded, host.loadGame("slot")) + } + + @Test + fun `console load failure does not abort following startup command`() { + save(false) + val file = root.resolve("save/slot/testbox.sav.json") + Files.writeString(file, Files.readString(file).replace("\"entityNumber\":0", "\"entityNumber\":999999")) + jake2.qcommon.exec.Cbuf.AddText("load slot; map testbox2") + jake2.qcommon.exec.Cbuf.Execute() + assertEquals("testbox2", host.games.getValue("default").mapName) + } + @Test fun `unreadable allocation metadata preserves running session`() { save(false) val game = host.games.getValue("default") val client = host.clients[0] Files.writeString(root.resolve("save/slot/server_latched_cvars.ssv.json"), "invalid") - host.loadGame(listOf("load", "slot")) + assertInstanceOf(ServerHost.LoadResult.Rejected::class.java, host.loadGame("slot")) assertSame(game, host.games["default"]) assertSame(client, host.clients[0]) } diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index a3d7d647..88ba0e12 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -240,9 +240,7 @@ private GameExports createGameModInstance() { Constructor constructor = game.getConstructor(GameImports.class); return (GameExports) constructor.newInstance(this); } catch (Exception e) { - e.printStackTrace(); - Com.Error(Defines.ERR_FATAL, "Could not initialise game subsystem due to : " + e.getMessage() + "\n"); - return null; + throw new IllegalStateException("Could not initialise game subsystem", e); } } diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 5858d7b0..3c6ae654 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -425,54 +425,76 @@ class ServerHost : AutoCloseable { return GameImportsImpl.create(this, changeMapInfo, previous) } + sealed interface LoadResult { + data object Loaded : LoadResult + data class Rejected(val reason: String) : LoadResult + data class Failed(val reason: String, val cause: Exception) : LoadResult + } + + /** Console adapter; applications can inspect the same operation's typed result. */ fun loadGame(args: List) { if (args.size != 2) { Com.Printf("USAGE: load \n") return } - Com.Printf("Loading game...\n") - - val saveGame: String - try { - saveGame = ServerSaveJsonStore.normalizeSlot(args[1]) - } catch (_: IllegalArgumentException) { - Com.Printf("Bad save name.\n") - return - } - if ("current" == saveGame) { - Com.Printf("Can't load from 'current'\n") - return + when (val result = loadGame(args[1])) { + LoadResult.Loaded -> Unit + is LoadResult.Rejected -> Com.Printf("Couldn't load save: ${result.reason}\n") + is LoadResult.Failed -> Com.Printf("Load failed; server stopped: ${result.reason}\n") } + } - // make sure the server metadata file exists - val store = ServerSaveJsonStore.forWriteDir(EngineWriteRoot.pathString()) - if (!store.hasMapCommand(saveGame)) { - val name = EngineWriteRoot.resolve("save/$saveGame/server_mapcmd.ssv.json").toString() - Com.Printf("No such savegame: $name\n") - return + /** Preflight preserves the live session; failures after copying begins leave an idle Host. */ + fun loadGame(slot: String): LoadResult { + val saveGame = try { + ServerSaveJsonStore.normalizeSlot(slot) + } catch (_: IllegalArgumentException) { + return LoadResult.Rejected("Bad save name") } + if (saveGame == "current") return LoadResult.Rejected("Can't load from 'current'") val prepared = try { SavePreflight(EngineWriteRoot.path()).inspect(saveGame) } catch (e: Exception) { - Com.Printf("Couldn't load save: ${e.message}\n") - return + return LoadResult.Rejected(e.message ?: "Invalid save") } val savedMaxClients = prepared.maxClients if (games.keys.any { it != "default" } && clients.size != savedMaxClients) { - Com.Printf("Can't change saved client capacity while other Games are running\n") - return + return LoadResult.Rejected("Can't change saved client capacity while other Games are running") } - val target = prepared.target - ServerSaveFiles(EngineWriteRoot.path()).copy(saveGame, "current") - prepareServerSession(restart = true, savedMaxClients = savedMaxClients) - val transitionClients = clients.filter { it.gameName == null || it.gameName == "default" } - val loaded = spawnServerInstance(target, games["default"], transitionClients) - loaded.name = "default" - games["default"] = loaded - transitionClients.forEach { it.gameName = loaded.name } + return try { + // From here on the shared workspace/resources may be partially replaced. + ServerSaveFiles(EngineWriteRoot.path()).copy(saveGame, "current") + prepareServerSession(restart = true, savedMaxClients = savedMaxClients) + + val transitionClients = clients.filter { it.gameName == null || it.gameName == "default" } + val loaded = spawnServerInstance(prepared.target, games["default"], transitionClients) + loaded.name = "default" + games["default"] = loaded + transitionClients.forEach { it.gameName = loaded.name } + LoadResult.Loaded + } catch (failure: Exception) { + recoverFailedLoad(failure) + LoadResult.Failed(failure.message ?: "Save restoration failed", failure) + } + } + + private fun recoverFailedLoad(failure: Exception) { + try { + stop("Load failed\n", false) + } catch (cleanupFailure: Exception) { + // stop clears Host state in finally even if notifying clients fails. + failure.addSuppressed(cleanupFailure) + } finally { + NET.configureServer(false) + paused.forceSet(false) + Cvar.getInstance().find("mapname").forceSet("") + for (i in challenges.indices) challenges[i] = challenge_t() + // Keep ordinary queued commands (e.g. a subsequent map); only abandon session-deferred work. + Cbuf.ClearDeferred() + } } /* From 588289f9476dc0637b6fe5406cc27252aa248dd6 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 22:50:36 +0100 Subject: [PATCH 28/47] Share map and load transition outcomes and recovery --- .../server/HostSessionPreparationTest.kt | 77 ++++++++- .../jake2/server/TransitionArchiveTest.kt | 6 +- .../main/kotlin/jake2/server/ServerHost.kt | 160 +++++++++--------- 3 files changed, 154 insertions(+), 89 deletions(-) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt index 48d49420..4f5ea3a3 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt @@ -346,7 +346,7 @@ class HostSessionPreparationTest { jake2.qcommon.exec.Cbuf.AddText("test_failed_load_deferred") jake2.qcommon.exec.Cbuf.CopyToDefer() val result = host.loadGame("slot") - assertInstanceOf(ServerHost.LoadResult.Failed::class.java, result) + assertInstanceOf(ServerHost.TransitionResult.Failed::class.java, result) assertTrue(host.games.isEmpty()) assertTrue(host.clients.isEmpty()) assertFalse(host.isPaused) @@ -364,7 +364,7 @@ class HostSessionPreparationTest { Files.delete(path) } Files.writeString(path, original) - assertEquals(ServerHost.LoadResult.Loaded, host.loadGame("slot")) + assertEquals(ServerHost.TransitionResult.Completed, host.loadGame("slot")) assertEquals("testbox", host.games.getValue("default").mapName) } finally { jake2.qcommon.exec.Cmd.RemoveCommand("test_failed_load_deferred") @@ -378,13 +378,13 @@ class HostSessionPreparationTest { NET.configureServer(false) java.net.DatagramSocket(0).use { occupied -> cvars.find("port").forceSet(occupied.localPort) - assertInstanceOf(ServerHost.LoadResult.Failed::class.java, host.loadGame("slot")) + assertInstanceOf(ServerHost.TransitionResult.Failed::class.java, host.loadGame("slot")) assertTrue(host.games.isEmpty()) assertTrue(host.clients.isEmpty()) assertNull(NET.ip_sockets[Defines.NS_SERVER]) } cvars.find("port").forceSet(0) - assertEquals(ServerHost.LoadResult.Loaded, host.loadGame("slot")) + assertEquals(ServerHost.TransitionResult.Completed, host.loadGame("slot")) } @Test @@ -397,13 +397,80 @@ class HostSessionPreparationTest { assertEquals("testbox2", host.games.getValue("default").mapName) } + @ParameterizedTest + @ValueSource(strings = ["", "*", "missing", "unsupported.dm2"]) + fun `invalid map requests preserve live Game and workspace`(level: String) { + save(false) + val game = host.games.getValue("default") + val client = host.clients[0] + val metadata = Files.readString(root.resolve("save/current/server_mapcmd.ssv.json")) + assertInstanceOf(ServerHost.TransitionResult.Rejected::class.java, host.map(level)) + assertInstanceOf(ServerHost.TransitionResult.Rejected::class.java, host.changeMap(level)) + assertSame(game, host.games["default"]) + assertSame(client, host.clients[0]) + assertEquals(ServerStates.SS_GAME, game.state) + assertEquals(metadata, Files.readString(root.resolve("save/current/server_mapcmd.ssv.json"))) + } + + @ParameterizedTest + @ValueSource(ints = [-1, 2]) + fun `invalid targeted gamemap leaves session untouched`(clientIndex: Int) { + save(false) + val game = host.games.getValue("default") + assertInstanceOf(ServerHost.TransitionResult.Rejected::class.java, host.changeMap("testbox2", clientIndex)) + assertSame(game, host.games["default"]) + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun `map autosave failure leaves idle Host and permits restart`(newSession: Boolean) { + save(false) + val blocked = root.resolve("save/save0/game.ssv.json") + Files.delete(blocked) + Files.createDirectory(blocked) + Files.writeString(blocked.resolve("blocker"), "prevent replacement") + val result = if (newSession) host.map("testbox2") else host.changeMap("testbox2") + assertInstanceOf(ServerHost.TransitionResult.Failed::class.java, result) + assertTrue(host.games.isEmpty()) + assertTrue(host.clients.isEmpty()) + assertFalse(host.isPaused) + assertNull(NET.ip_sockets[Defines.NS_SERVER]) + assertEquals(ServerStates.SS_DEAD, Globals.server_state) + assertNotNull(jake2.qcommon.exec.Cmd.FindCommand("map")) + Files.delete(blocked.resolve("blocker")) + Files.delete(blocked) + assertEquals(ServerHost.TransitionResult.Completed, host.map("testbox")) + } + + @Test + fun `gamemap port failure is recoverable without global shutdown callbacks`() { + cvars.find("coop").forceSet(true) + var globalShutdown = false + val previous = jake2.qcommon.exec.Cmd.FindCommand("cl_drop") + jake2.qcommon.exec.Cmd.AddCommand("cl_drop", true) { globalShutdown = true } + try { + java.net.DatagramSocket(0).use { occupied -> + cvars.find("port").forceSet(occupied.localPort) + assertInstanceOf(ServerHost.TransitionResult.Failed::class.java, host.changeMap("testbox")) + assertTrue(host.games.isEmpty()) + assertTrue(host.clients.isEmpty()) + assertFalse(globalShutdown) + } + cvars.find("port").forceSet(0) + assertEquals(ServerHost.TransitionResult.Completed, host.changeMap("testbox")) + } finally { + jake2.qcommon.exec.Cmd.RemoveCommand("cl_drop") + if (previous != null) jake2.qcommon.exec.Cmd.AddCommand("cl_drop", previous.description, previous.function) + } + } + @Test fun `unreadable allocation metadata preserves running session`() { save(false) val game = host.games.getValue("default") val client = host.clients[0] Files.writeString(root.resolve("save/slot/server_latched_cvars.ssv.json"), "invalid") - assertInstanceOf(ServerHost.LoadResult.Rejected::class.java, host.loadGame("slot")) + assertInstanceOf(ServerHost.TransitionResult.Rejected::class.java, host.loadGame("slot")) assertSame(game, host.games["default"]) assertSame(client, host.clients[0]) } diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt index b49e3731..497835ac 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt @@ -103,11 +103,7 @@ class TransitionArchiveTest { // A directory at the output filename forces a real filesystem write failure. Files.createDirectories(root.resolve("save/current/testbox.$extension.json")) val originalGameplay = first.gameExports - val failure = assertThrows(IllegalStateException::class.java) { - host.changeMap(listOf("gamemap", "testbox2")) - } - - assertNotNull(failure.cause) + assertInstanceOf(ServerHost.TransitionResult.Rejected::class.java, host.changeMap("testbox2")) assertLiveState() assertSame(first, host.games["default"]) assertSame(second, host.games[second.name]) diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 3c6ae654..216ceb9a 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -287,9 +287,9 @@ class ServerHost : AutoCloseable { /** Typed entrypoint for an embedded host; uses the same map path as the console. */ fun startMap(mapName: String): Boolean { - val previous = games["default"] - map(listOf("map", mapName)) - return games["default"] != null && games["default"] !== previous + val result = map(mapName) + reportTransition("map", result) + return result == TransitionResult.Completed } /** Release this manager's console hooks after ending its last session. */ @@ -348,10 +348,7 @@ class ServerHost : AutoCloseable { Cvar.getInstance().find("maxclients").forceSet(savedMaxClients) savedMaxClients > 1 } - if (!NET.configureServer(multiplayer)) { - Com.Error(Defines.ERR_DROP, "Could not open the server UDP port") - return - } + check(NET.configureServer(multiplayer)) { "Could not open the server UDP port" } if (savedMaxClients == null || clients.size != savedMaxClients) resetClients() } @@ -425,10 +422,10 @@ class ServerHost : AutoCloseable { return GameImportsImpl.create(this, changeMapInfo, previous) } - sealed interface LoadResult { - data object Loaded : LoadResult - data class Rejected(val reason: String) : LoadResult - data class Failed(val reason: String, val cause: Exception) : LoadResult + sealed interface TransitionResult { + data object Completed : TransitionResult + data class Rejected(val reason: String) : TransitionResult + data class Failed(val reason: String, val cause: Exception) : TransitionResult } /** Console adapter; applications can inspect the same operation's typed result. */ @@ -438,30 +435,26 @@ class ServerHost : AutoCloseable { return } Com.Printf("Loading game...\n") - when (val result = loadGame(args[1])) { - LoadResult.Loaded -> Unit - is LoadResult.Rejected -> Com.Printf("Couldn't load save: ${result.reason}\n") - is LoadResult.Failed -> Com.Printf("Load failed; server stopped: ${result.reason}\n") - } + reportTransition("load", loadGame(args[1])) } /** Preflight preserves the live session; failures after copying begins leave an idle Host. */ - fun loadGame(slot: String): LoadResult { + fun loadGame(slot: String): TransitionResult { val saveGame = try { ServerSaveJsonStore.normalizeSlot(slot) } catch (_: IllegalArgumentException) { - return LoadResult.Rejected("Bad save name") + return TransitionResult.Rejected("Bad save name") } - if (saveGame == "current") return LoadResult.Rejected("Can't load from 'current'") + if (saveGame == "current") return TransitionResult.Rejected("Can't load from 'current'") val prepared = try { SavePreflight(EngineWriteRoot.path()).inspect(saveGame) } catch (e: Exception) { - return LoadResult.Rejected(e.message ?: "Invalid save") + return TransitionResult.Rejected(e.message ?: "Invalid save") } val savedMaxClients = prepared.maxClients if (games.keys.any { it != "default" } && clients.size != savedMaxClients) { - return LoadResult.Rejected("Can't change saved client capacity while other Games are running") + return TransitionResult.Rejected("Can't change saved client capacity while other Games are running") } return try { @@ -474,16 +467,16 @@ class ServerHost : AutoCloseable { loaded.name = "default" games["default"] = loaded transitionClients.forEach { it.gameName = loaded.name } - LoadResult.Loaded + TransitionResult.Completed } catch (failure: Exception) { - recoverFailedLoad(failure) - LoadResult.Failed(failure.message ?: "Save restoration failed", failure) + recoverFailedTransition(failure) + TransitionResult.Failed(failure.message ?: "Save restoration failed", failure) } } - private fun recoverFailedLoad(failure: Exception) { + private fun recoverFailedTransition(failure: Exception) { try { - stop("Load failed\n", false) + stop("Server transition failed\n", false) } catch (cleanupFailure: Exception) { // stop clears Host state in finally even if notifying clients fails. failure.addSuppressed(cleanupFailure) @@ -517,46 +510,74 @@ goes to map jail.bsp. Spins up a new game instance, to be used with either `map` or `join` (TBD) */ - fun changeMap(args: List) = changeMap(args, sessionPrepared = false) - - private fun changeMap(args: List, sessionPrepared: Boolean) { + fun changeMap(args: List) { if (args.size < 2) { - Com.Printf("USAGE: gamemap \n ") + Com.Printf("USAGE: gamemap [client]\n") return } + val clientIndex = if (args.size > 2 && args[2].isNotBlank()) args[2].toIntOrNull() else 0 + if (clientIndex == null) { + reportTransition("gamemap", TransitionResult.Rejected("Invalid client index")) + return + } + reportTransition("gamemap", changeMap(args[1], clientIndex)) + } - val mapName = args[1] - val clientIndex: Int = if (args.size > 2 && args[2].isNotBlank()) { - try { - args[2].toInt() - } catch (_: NumberFormatException) { - Com.Printf("USAGE: gamemap \n \n") - return + fun changeMap(level: String, clientIndex: Int = 0): TransitionResult = + transitionMap(level, clientIndex, newSession = false) + + fun map(level: String): TransitionResult = transitionMap(level, 0, newSession = true) + + private fun transitionMap(level: String, clientIndex: Int, newSession: Boolean): TransitionResult { + val target = try { + require(level.isNotBlank()) { "Missing map name" } + val parsed = ChangeMapInfo(level, false, false) + require(parsed.mapName.isNotBlank()) { "Missing map name" } + if (parsed.state == ServerStates.SS_GAME) { + require(EngineVfs.exists("maps/${parsed.mapName}.bsp")) { "Can't find maps/${parsed.mapName}.bsp" } } - } else 0 - - // default.cfg may start a session with "killserver; gamemap *...". - // Shutdown clears the client slots, so gamemap must initialize a fresh - // server just as map does, while preserving clients on level transitions. - if (!sessionPrepared) prepareServerSession() - - Com.DPrintf("SV_GameMap($mapName)\n") - - EngineWriteRoot.ensureParentDirectories(EngineWriteRoot.resolve("save/current/.keep")) - - // check for clearing the current savegame - if (mapName[0] == '*') { - // wipe all the *.sav files - ServerSaveFiles(EngineWriteRoot.path()).wipe("current") - } else { // save the map just exited - // note: only applicable to the default instance (subject to change in future) - val gameImports = games["default"] - if (gameImports != null && gameImports.state == ServerStates.SS_GAME) { - gameImports.archiveLevelForTransition() + require(clientIndex in 0..clients.size) { "Invalid client index: $clientIndex" } + if (clientIndex != 0) { + require(games.containsKey(clients[clientIndex - 1].gameName)) { "Client has no running Game" } } + parsed + } catch (failure: Exception) { + return TransitionResult.Rejected(failure.message ?: "Invalid map request") } - val game = spawnServerInstance(ChangeMapInfo(mapName, false, false), clientIndex) + // Archival restores temporarily hidden player entities in finally. A write failure here + // leaves the live world usable, so do not tear it down or proceed to replacement. + if (!newSession && !target.newUnit) { + try { + games["default"]?.takeIf { it.state == ServerStates.SS_GAME }?.archiveLevelForTransition() + } catch (failure: Exception) { + return TransitionResult.Rejected("Could not archive current level: ${failure.message}") + } + } + + return try { + if (newSession) games["default"]?.state = ServerStates.SS_DEAD + if (newSession || target.newUnit) ServerSaveFiles(EngineWriteRoot.path()).wipe("current") + prepareServerSession(restart = newSession) + EngineWriteRoot.ensureParentDirectories(EngineWriteRoot.resolve("save/current/.keep")) + installMap(target, clientIndex) + TransitionResult.Completed + } catch (failure: Exception) { + recoverFailedTransition(failure) + TransitionResult.Failed(failure.message ?: "Map transition failed", failure) + } + } + + private fun reportTransition(operation: String, result: TransitionResult) { + when (result) { + TransitionResult.Completed -> Unit + is TransitionResult.Rejected -> Com.Printf("$operation rejected: ${result.reason}\n") + is TransitionResult.Failed -> Com.Printf("$operation failed; server stopped: ${result.reason}\n") + } + } + + private fun installMap(target: ChangeMapInfo, clientIndex: Int) { + val game = spawnServerInstance(target, clientIndex) if (clientIndex == 0) { // server just started, `map` cmd was used @@ -655,30 +676,11 @@ For development work Resets server to host a given map, moves all clients to it (via reconnect) */ fun map(args: List) { - - if (args.size < 2 || args[1].isBlank()) { + if (args.size != 2) { Com.Printf("usage: map \n") return } - - // if not a pcx, demo, or cinematic, check to make sure the level exists - val mapName = args[1] - val target = ChangeMapInfo(mapName, false, false) - if (target.state == ServerStates.SS_GAME) { - val mapPath = "maps/" + target.mapName + ".bsp" - if (!EngineVfs.exists(mapPath)) { - Com.Printf("Can't find $mapPath\n") - return - } - } - val game = games["default"] - - if (game != null) game.state = ServerStates.SS_DEAD // don't save current level when changing - - ServerSaveFiles(EngineWriteRoot.path()).wipe("current") - - prepareServerSession(restart = true) - changeMap(args, sessionPrepared = true) + reportTransition("map", map(args[1])) } /** Resolve a Game at execution time; creating another Game never replaces these callbacks. */ From 505f44cdf25956c75632c2cb695b2fcb199761ed Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 22:59:40 +0100 Subject: [PATCH 29/47] Recover automatic map continuation failures --- .../jake2/server/GameContinuationTest.kt | 61 +++++++++++++++++++ .../main/kotlin/jake2/server/ServerHost.kt | 39 +++++++++--- 2 files changed, 91 insertions(+), 9 deletions(-) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt index e75c6782..269fdfcb 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt @@ -12,6 +12,10 @@ import org.junit.jupiter.api.* import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.io.TempDir import java.nio.file.Path +import java.nio.file.Files +import jake2.qcommon.network.NET +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource class GameContinuationTest { @TempDir lateinit var root: Path @@ -177,6 +181,63 @@ class GameContinuationTest { assertEquals("testbox2", host.games.getValue("default").mapName) } + @ParameterizedTest + @ValueSource(strings = ["missing", "*", "unsupported.dm2"]) + fun `invalid continuation preserves its Game while another queued Game advances`(target: String) { + val first = game("default", "intro.cin+$target") + val second = game("other", "intro.cin+testbox2") + val client = bind(0, first) + finish(client, first) + finish(bind(1, second), second) + host.update(100) + assertSame(first, host.games["default"]) + assertEquals(ClientStates.CS_CONNECTED, client.state) + assertEquals("default", client.gameName) + assertEquals("testbox2", host.games.getValue("other").mapName) + assertNull(first.takeNextMap(), "Rejected continuation remains consumed") + assertFalse(client.netchan.reliablePending.contains(StuffTextMessage("reconnect"))) + } + + @ParameterizedTest + @ValueSource(strings = ["wipe", "restore", "autosave"]) + fun `late continuation failure stops the batch and permits Host restart`(stage: String) { + val first = game("default", "intro.cin+${if (stage == "wipe") "*" else ""}testbox") + val second = game("other", "intro.cin+testbox2") + finish(bind(0, first), first) + finish(bind(1, second), second) + Cvar.getInstance().find("paused").forceSet(true) + Cvar.getInstance().find("port").forceSet(0) + assertTrue(NET.configureServer(true)) + val broken = when (stage) { + "wipe" -> root.resolve("save/current/game.ssv.json") + "autosave" -> root.resolve("save/save0/game.ssv.json") + else -> root.resolve("save/current/testbox.sav.json") + } + if (stage == "restore") { + Files.createDirectories(broken.parent) + Files.writeString(broken, "invalid level") + } else { + Files.createDirectories(broken) + Files.writeString(broken.resolve("blocker"), "prevent replacement") + } + host.update(100) + assertTrue(host.games.isEmpty()) + assertTrue(host.clients.isEmpty()) + assertFalse(host.isPaused) + assertNull(NET.ip_sockets[Defines.NS_SERVER]) + assertNotNull(jake2.qcommon.exec.Cmd.FindCommand("gamemap")) + host.update(100) + assertTrue(host.games.isEmpty(), "Remaining queued continuation must not restart a Game") + + if (stage != "restore") { + Files.delete(broken.resolve("blocker")) + Files.delete(broken) + } + assertEquals(ServerHost.TransitionResult.Completed, host.map("testbox")) + assertEquals(1, host.games.size) + assertEquals("testbox", host.games.getValue("default").mapName) + } + @Test fun `gameplay and non-coop pictures reject completion without consuming continuation`() { val normal = game("default", "testbox+testbox2") diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 216ceb9a..c4002fe6 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -530,12 +530,7 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) private fun transitionMap(level: String, clientIndex: Int, newSession: Boolean): TransitionResult { val target = try { - require(level.isNotBlank()) { "Missing map name" } - val parsed = ChangeMapInfo(level, false, false) - require(parsed.mapName.isNotBlank()) { "Missing map name" } - if (parsed.state == ServerStates.SS_GAME) { - require(EngineVfs.exists("maps/${parsed.mapName}.bsp")) { "Can't find maps/${parsed.mapName}.bsp" } - } + val parsed = prepareMapTarget(level) require(clientIndex in 0..clients.size) { "Invalid client index: $clientIndex" } if (clientIndex != 0) { require(games.containsKey(clients[clientIndex - 1].gameName)) { "Client has no running Game" } @@ -568,6 +563,16 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) } } + private fun prepareMapTarget(level: String): ChangeMapInfo { + require(level.isNotBlank()) { "Missing map name" } + val target = ChangeMapInfo(level, false, false) + require(target.mapName.isNotBlank()) { "Missing map name" } + if (target.state == ServerStates.SS_GAME) { + require(EngineVfs.exists("maps/${target.mapName}.bsp")) { "Can't find maps/${target.mapName}.bsp" } + } + return target + } + private fun reportTransition(operation: String, result: TransitionResult) { when (result) { TransitionResult.Completed -> Unit @@ -1498,8 +1503,21 @@ Resets server to host a given map, moves all clients to it (via reconnect) pendingContinuations.clear() for ((previous, nextMap) in pending) { if (games[previous.name] !== previous) continue // replaced or stopped before execution + val result = continueGame(previous, nextMap) + reportTransition("Continuation for ${previous.name}", result) + if (result is TransitionResult.Failed) break // Recovery invalidated the whole queued batch. + } + } + + private fun continueGame(previous: GameImportsImpl, nextMap: String): TransitionResult { + val target = try { + if (nextMap.isEmpty()) null else prepareMapTarget(nextMap) + } catch (failure: Exception) { + return TransitionResult.Rejected(failure.message ?: "Invalid continuation target") + } + return try { val routedClients = clients.filter { it.gameName == previous.name } - if (nextMap.isEmpty()) { + if (target == null) { if (games.size == 1) { stop("Game sequence ended.\n", false) NET.configureServer(false) @@ -1515,9 +1533,8 @@ Resets server to host a given map, moves all clients to it (via reconnect) } games.remove(previous.name) } - continue + return TransitionResult.Completed } - val target = ChangeMapInfo(nextMap, false, false) // Unit changes and autosaves still share the existing persistence workspace. if (target.newUnit) ServerSaveFiles(EngineWriteRoot.path()).wipe("current") val replacement = spawnServerInstance(target, previous, routedClients) @@ -1527,6 +1544,10 @@ Resets server to host a given map, moves all clients to it (via reconnect) replacement.SV_WriteServerFile(true) ServerSaveFiles(EngineWriteRoot.path()).copy("current", "save0") } + TransitionResult.Completed + } catch (failure: Exception) { + recoverFailedTransition(failure) + TransitionResult.Failed(failure.message ?: "Continuation failed", failure) } } From 5e667f749262b0a7b48bc418c6bd7dcb90a6c6c0 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 23:05:08 +0100 Subject: [PATCH 30/47] Consolidate server session cleanup in Host stop --- .../demoth/cake/fullgame/EmbeddedCakeHost.kt | 6 +- .../server/HostSessionPreparationTest.kt | 73 +++++++++++++++++++ .../main/java/jake2/qcommon/network/NET.java | 12 ++- .../main/kotlin/jake2/server/ServerHost.kt | 22 +++--- 4 files changed, 91 insertions(+), 22 deletions(-) diff --git a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt index a5c0ddb6..1e5557d3 100644 --- a/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt +++ b/cake/cake-fullgame/src/main/kotlin/org/demoth/cake/fullgame/EmbeddedCakeHost.kt @@ -135,12 +135,8 @@ class EmbeddedCakeHost( manager?.close() } finally { if (ownedSession) { - // Shutdown notification is sent before sockets close. - NET.configureServer(false) + // This adapter also owns disposal of the local client's queued traffic. NET.clearLoopbacks() - Cbuf.ClearDeferred() - val cvars = Cvar.getInstance() - if (cvars.isRegistered("paused")) cvars.forceSetResult("paused", "0") } previousWriteRoot?.let(EngineWriteRoot::setRoot) previousWriteRoot = null diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt index 4f5ea3a3..8ab69286 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt @@ -464,6 +464,79 @@ class HostSessionPreparationTest { } } + @Test + fun `stop owns cleanup and preserves final packets and client socket`() { + save(true) + NET.clearLoopbacks() + cvars.find("clientport").forceSet(0) + assertTrue(NET.configureClient(true)) + val clientSocket = NET.ip_sockets[Defines.NS_CLIENT] + val serverSocket = NET.ip_sockets[Defines.NS_SERVER] + val mapCommand = jake2.qcommon.exec.Cmd.FindCommand("map") + val client = host.clients[0] + client.state = ClientStates.CS_CONNECTED + client.netchan.setup(Defines.NS_SERVER, jake2.qcommon.network.netadr_t(), 0) + NET.SendLoopPacket(Defines.NS_CLIENT, 8, ByteArray(8), jake2.qcommon.network.netadr_t()) + cvars.find("paused").forceSet(true) + var deferredRan = false + jake2.qcommon.exec.Cmd.AddCommand("test_stopped_deferred") { deferredRan = true } + try { + jake2.qcommon.exec.Cbuf.AddText("test_stopped_deferred") + jake2.qcommon.exec.Cbuf.CopyToDefer() + host.stop("test stop", false) + assertTrue(serverSocket.isClosed) + assertNull(NET.ip_sockets[Defines.NS_SERVER]) + assertSame(clientSocket, NET.ip_sockets[Defines.NS_CLIENT]) + assertFalse(clientSocket.isClosed) + assertTrue(host.games.isEmpty()) + assertTrue(host.clients.isEmpty()) + assertFalse(host.isPaused) + assertEquals("", cvars.find("mapname").typedValue) + assertSame(mapCommand, jake2.qcommon.exec.Cmd.FindCommand("map")) + assertNull(NET.receiveNetworkPacket(null, null, NET.loopbacks[Defines.NS_SERVER], true)) + assertNotNull(NET.receiveNetworkPacket(null, null, NET.loopbacks[Defines.NS_CLIENT], false)) + assertNotNull(NET.receiveNetworkPacket(null, null, NET.loopbacks[Defines.NS_CLIENT], false)) + host.stop("again", false) + assertNull(NET.receiveNetworkPacket(null, null, NET.loopbacks[Defines.NS_CLIENT], false)) + jake2.qcommon.exec.Cbuf.InsertFromDefer() + jake2.qcommon.exec.Cbuf.Execute() + assertFalse(deferredRan) + host.close() + host.close() + assertNull(jake2.qcommon.exec.Cmd.FindCommand("map")) + } finally { + jake2.qcommon.exec.Cmd.RemoveCommand("test_stopped_deferred") + NET.configureClient(false) + NET.clearLoopbacks() + } + } + + @Test + fun `killserver preserves queued gamemap startup`() { + save(true) + val mapCommand = jake2.qcommon.exec.Cmd.FindCommand("map") + jake2.qcommon.exec.Cbuf.AddText("killserver; gamemap testbox2") + jake2.qcommon.exec.Cbuf.Execute() + assertEquals("testbox2", host.games.getValue("default").mapName) + assertSame(mapCommand, jake2.qcommon.exec.Cmd.FindCommand("map")) + assertNotNull(NET.ip_sockets[Defines.NS_SERVER]) + } + + @Test + fun `notification failure still cleans session and close releases commands`() { + save(true) + val client = host.clients[0] + client.state = ClientStates.CS_CONNECTED + client.netchan.remote_address = null // Fault injection at final packet transmission. + assertThrows(NullPointerException::class.java) { host.close() } + assertTrue(host.games.isEmpty()) + assertTrue(host.clients.isEmpty()) + assertNull(NET.ip_sockets[Defines.NS_SERVER]) + assertFalse(host.isPaused) + assertNull(jake2.qcommon.exec.Cmd.FindCommand("map")) + host.close() + } + @Test fun `unreadable allocation metadata preserves running session`() { save(false) diff --git a/qcommon/src/main/java/jake2/qcommon/network/NET.java b/qcommon/src/main/java/jake2/qcommon/network/NET.java index 8b1dbb50..418727f6 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/NET.java +++ b/qcommon/src/main/java/jake2/qcommon/network/NET.java @@ -228,10 +228,14 @@ private static void closeRole(int role) { /** Whole-session boundary only: do not discard reconnect traffic during map changes. */ public static void clearLoopbacks() { - for (loopback_t loop : loopbacks) { - loop.get = 0; - loop.send = 0; - } + clearLoopback(NS_SERVER); + clearLoopback(Defines.NS_CLIENT); + } + + /** Discard incoming packets for one role without dropping the other role's final messages. */ + public static void clearLoopback(int role) { + loopbacks[role].get = 0; + loopbacks[role].send = 0; } /** Legacy combined policy for callers that own both socket roles. */ diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index c4002fe6..913b2212 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -243,7 +243,6 @@ class ServerHost : AutoCloseable { commands.add("killserver") { stop("Server was killed.\n", false) - NET.configureServer(false) } commands.add("map", Command(::map)) commands.add("gamemap", Command(::changeMap)) @@ -299,10 +298,7 @@ class ServerHost : AutoCloseable { } } - /** - * Called when each game quits, before Sys_Quit or Sys_Error. - * todo: shutdown a particular instance, not the whole server - */ + /** End the whole server session, retaining Host commands for a subsequent start. */ fun stop(finalmsg: String?, reconnect: Boolean) { try { if (games.isNotEmpty()) { @@ -317,6 +313,14 @@ class ServerHost : AutoCloseable { realtime = 0 frameNum = 0 nextFrameTime = 100 + paused.forceSet(false) + Cvar.getInstance().find("mapname").forceSet("") + for (i in challenges.indices) challenges[i] = challenge_t() + // Ordinary queued commands may start the next session (killserver; gamemap ...). + Cbuf.ClearDeferred() + NET.configureServer(false) + // Keep outgoing final messages available to the local client. + NET.clearLoopback(Defines.NS_SERVER) } } @@ -480,13 +484,6 @@ class ServerHost : AutoCloseable { } catch (cleanupFailure: Exception) { // stop clears Host state in finally even if notifying clients fails. failure.addSuppressed(cleanupFailure) - } finally { - NET.configureServer(false) - paused.forceSet(false) - Cvar.getInstance().find("mapname").forceSet("") - for (i in challenges.indices) challenges[i] = challenge_t() - // Keep ordinary queued commands (e.g. a subsequent map); only abandon session-deferred work. - Cbuf.ClearDeferred() } } @@ -1520,7 +1517,6 @@ Resets server to host a given map, moves all clients to it (via reconnect) if (target == null) { if (games.size == 1) { stop("Game sequence ended.\n", false) - NET.configureServer(false) } else { sendFinalMessage("Game sequence ended.\n", false, routedClients) for (client in routedClients) { From e88a059898d15588356e05ea98fbdd6a95695d1c Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 23:09:30 +0100 Subject: [PATCH 31/47] Document deferred multi-Game persistence isolation --- server/src/main/java/jake2/server/GameImportsImpl.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index 88ba0e12..98f46d13 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -835,6 +835,10 @@ public int getPointContents(float[] p) { return contents; } + // TODO: Isolate Game persistence after multi-instance workspace ownership, transition + // inheritance and dedicated save/load targeting are decided. All Game save/restore paths + // currently share EngineWriteRoot/save/current; transitions and unit resets can overwrite + // another Game's archives. This is explicitly deferred, not isolated by save --game targeting. void saveGame(List args) { if (state != ServerStates.SS_GAME) { From 5d6d47eff2f72a05347dc1b68a617f797d90c5d3 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 23:13:04 +0100 Subject: [PATCH 32/47] Remove deferred multi-Game save targeting --- .../java/jake2/server/GameImportsTest.java | 11 ++++++----- .../java/jake2/server/GameImportsImpl.java | 2 +- .../src/main/kotlin/jake2/server/ServerHost.kt | 18 ++++-------------- 3 files changed, 11 insertions(+), 20 deletions(-) diff --git a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java index 5d6f7dd4..4def69d0 100644 --- a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java +++ b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java @@ -391,7 +391,7 @@ public void hostProtocolDispatchAndShutdownUseTheirOwnGameRegistry() { } @Test - public void saveCommandResolvesDefaultAndExplicitGamesWithoutReregistration() throws Exception { + public void saveCommandResolvesOnlyDefaultGameWithoutReregistration() throws Exception { manager = new ServerHost(); Cvar.getInstance().find("maxclients", Integer.class).forceSet(2); manager.resetClients(); @@ -408,10 +408,11 @@ public void saveCommandResolvesDefaultAndExplicitGamesWithoutReregistration() th jake2.qcommon.exec.Cmd.ExecuteString("save --game " + second.name + " second"); var store = jake2.server.save.ServerSaveJsonStore.forWriteDir(writeRoot.toString()); Assertions.assertEquals("testbox", store.readMapCommand("first").mapCommand()); - Assertions.assertEquals("testbox2", store.readMapCommand("second").mapCommand()); - - jake2.qcommon.exec.Cmd.ExecuteString("save --game missing absent"); - Assertions.assertFalse(store.hasMapCommand("absent")); + Assertions.assertFalse(store.hasMapCommand("second")); + manager.games.put("default", second); + jake2.qcommon.exec.Cmd.ExecuteString("save replaced"); + Assertions.assertEquals("testbox2", store.readMapCommand("replaced").mapCommand()); + Assertions.assertSame(saveRegistration, jake2.qcommon.exec.Cmd.FindCommand("save")); manager.stop("test", false); Assertions.assertSame(saveRegistration, jake2.qcommon.exec.Cmd.FindCommand("save")); jake2.qcommon.exec.Cmd.ExecuteString("save idle"); diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index 98f46d13..570b5cc1 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -838,7 +838,7 @@ public int getPointContents(float[] p) { // TODO: Isolate Game persistence after multi-instance workspace ownership, transition // inheritance and dedicated save/load targeting are decided. All Game save/restore paths // currently share EngineWriteRoot/save/current; transitions and unit resets can overwrite - // another Game's archives. This is explicitly deferred, not isolated by save --game targeting. + // another Game's archives. Multi-Game persistence is explicitly deferred; save targets the default Game. void saveGame(List args) { if (state != ServerStates.SS_GAME) { diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 913b2212..08bb7406 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -685,24 +685,14 @@ Resets server to host a given map, moves all clients to it (via reconnect) reportTransition("map", map(args[1])) } - /** Resolve a Game at execution time; creating another Game never replaces these callbacks. */ + /** Resolve the default Game at execution time; command registration belongs to the Host. */ private fun saveGame(args: List) { - var target = "default" - var command = args - if (args.size > 1 && args[1] == "--game") { - if (args.size < 4) { - Com.Printf("Usage: " + args[0] + " --game \n") - return - } - target = args[2] - command = listOf(args[0]) + args.drop(3) - } - val game = games[target] + val game = games["default"] if (game == null) { - Com.Printf("No running game: $target\n") + Com.Printf("No running default game\n") return } - game.saveGame(command) + game.saveGame(args) } // endregion From a8b8b210e6002ab3287c8fd2389f3e8efc1e1efa Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 23:15:51 +0100 Subject: [PATCH 33/47] Give saving explicit outcomes at the Host boundary --- .../jake2/server/GameContinuationTest.kt | 2 +- .../server/HostSessionPreparationTest.kt | 97 ++++++++++++++++--- .../jake2/server/TransitionArchiveTest.kt | 2 +- .../java/jake2/server/GameImportsImpl.java | 61 +++--------- .../main/kotlin/jake2/server/ServerHost.kt | 92 +++++++++++------- 5 files changed, 157 insertions(+), 97 deletions(-) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt index 269fdfcb..ac59f29a 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt @@ -233,7 +233,7 @@ class GameContinuationTest { Files.delete(broken.resolve("blocker")) Files.delete(broken) } - assertEquals(ServerHost.TransitionResult.Completed, host.map("testbox")) + assertEquals(ServerHost.OperationResult.Completed, host.map("testbox")) assertEquals(1, host.games.size) assertEquals("testbox", host.games.getValue("default").mapName) } diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt index 8ab69286..6aff97de 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt @@ -306,7 +306,7 @@ class HostSessionPreparationTest { } Files.delete(root.resolve("save/current/game.ssv.json")) Files.createDirectory(root.resolve("save/current/game.ssv.json")) - assertThrows(IllegalStateException::class.java) { game.saveGame(listOf("save", "slot")) } + assertInstanceOf(ServerHost.OperationResult.Failed::class.java, host.saveGame("slot")) assertEquals(original, Files.readString(root.resolve("save/slot/game.ssv.json"))) } @@ -346,7 +346,7 @@ class HostSessionPreparationTest { jake2.qcommon.exec.Cbuf.AddText("test_failed_load_deferred") jake2.qcommon.exec.Cbuf.CopyToDefer() val result = host.loadGame("slot") - assertInstanceOf(ServerHost.TransitionResult.Failed::class.java, result) + assertInstanceOf(ServerHost.OperationResult.Failed::class.java, result) assertTrue(host.games.isEmpty()) assertTrue(host.clients.isEmpty()) assertFalse(host.isPaused) @@ -364,7 +364,7 @@ class HostSessionPreparationTest { Files.delete(path) } Files.writeString(path, original) - assertEquals(ServerHost.TransitionResult.Completed, host.loadGame("slot")) + assertEquals(ServerHost.OperationResult.Completed, host.loadGame("slot")) assertEquals("testbox", host.games.getValue("default").mapName) } finally { jake2.qcommon.exec.Cmd.RemoveCommand("test_failed_load_deferred") @@ -378,13 +378,13 @@ class HostSessionPreparationTest { NET.configureServer(false) java.net.DatagramSocket(0).use { occupied -> cvars.find("port").forceSet(occupied.localPort) - assertInstanceOf(ServerHost.TransitionResult.Failed::class.java, host.loadGame("slot")) + assertInstanceOf(ServerHost.OperationResult.Failed::class.java, host.loadGame("slot")) assertTrue(host.games.isEmpty()) assertTrue(host.clients.isEmpty()) assertNull(NET.ip_sockets[Defines.NS_SERVER]) } cvars.find("port").forceSet(0) - assertEquals(ServerHost.TransitionResult.Completed, host.loadGame("slot")) + assertEquals(ServerHost.OperationResult.Completed, host.loadGame("slot")) } @Test @@ -404,8 +404,8 @@ class HostSessionPreparationTest { val game = host.games.getValue("default") val client = host.clients[0] val metadata = Files.readString(root.resolve("save/current/server_mapcmd.ssv.json")) - assertInstanceOf(ServerHost.TransitionResult.Rejected::class.java, host.map(level)) - assertInstanceOf(ServerHost.TransitionResult.Rejected::class.java, host.changeMap(level)) + assertInstanceOf(ServerHost.OperationResult.Rejected::class.java, host.map(level)) + assertInstanceOf(ServerHost.OperationResult.Rejected::class.java, host.changeMap(level)) assertSame(game, host.games["default"]) assertSame(client, host.clients[0]) assertEquals(ServerStates.SS_GAME, game.state) @@ -417,7 +417,7 @@ class HostSessionPreparationTest { fun `invalid targeted gamemap leaves session untouched`(clientIndex: Int) { save(false) val game = host.games.getValue("default") - assertInstanceOf(ServerHost.TransitionResult.Rejected::class.java, host.changeMap("testbox2", clientIndex)) + assertInstanceOf(ServerHost.OperationResult.Rejected::class.java, host.changeMap("testbox2", clientIndex)) assertSame(game, host.games["default"]) } @@ -430,7 +430,7 @@ class HostSessionPreparationTest { Files.createDirectory(blocked) Files.writeString(blocked.resolve("blocker"), "prevent replacement") val result = if (newSession) host.map("testbox2") else host.changeMap("testbox2") - assertInstanceOf(ServerHost.TransitionResult.Failed::class.java, result) + assertInstanceOf(ServerHost.OperationResult.Failed::class.java, result) assertTrue(host.games.isEmpty()) assertTrue(host.clients.isEmpty()) assertFalse(host.isPaused) @@ -439,7 +439,7 @@ class HostSessionPreparationTest { assertNotNull(jake2.qcommon.exec.Cmd.FindCommand("map")) Files.delete(blocked.resolve("blocker")) Files.delete(blocked) - assertEquals(ServerHost.TransitionResult.Completed, host.map("testbox")) + assertEquals(ServerHost.OperationResult.Completed, host.map("testbox")) } @Test @@ -451,13 +451,13 @@ class HostSessionPreparationTest { try { java.net.DatagramSocket(0).use { occupied -> cvars.find("port").forceSet(occupied.localPort) - assertInstanceOf(ServerHost.TransitionResult.Failed::class.java, host.changeMap("testbox")) + assertInstanceOf(ServerHost.OperationResult.Failed::class.java, host.changeMap("testbox")) assertTrue(host.games.isEmpty()) assertTrue(host.clients.isEmpty()) assertFalse(globalShutdown) } cvars.find("port").forceSet(0) - assertEquals(ServerHost.TransitionResult.Completed, host.changeMap("testbox")) + assertEquals(ServerHost.OperationResult.Completed, host.changeMap("testbox")) } finally { jake2.qcommon.exec.Cmd.RemoveCommand("cl_drop") if (previous != null) jake2.qcommon.exec.Cmd.AddCommand("cl_drop", previous.description, previous.function) @@ -537,13 +537,84 @@ class HostSessionPreparationTest { host.close() } + @ParameterizedTest + @ValueSource(strings = ["server_mapcmd.ssv.json", "game.ssv.json", "destination"]) + fun `save failures preserve live Game and can be retried`(stage: String) { + save(true) + val game = host.games.getValue("default") + val client = host.clients[0] + val blocked = if (stage == "destination") root.resolve("save/slot/game.ssv.json") + else root.resolve("save/current/$stage") + Files.delete(blocked) + Files.createDirectory(blocked) + Files.writeString(blocked.resolve("blocker"), "prevent replacement") + val result = host.saveGame("slot") + assertInstanceOf(ServerHost.OperationResult.Failed::class.java, result) + assertSame(game, host.games["default"]) + assertSame(client, host.clients[0]) + assertTrue(host.isRunning) + assertNotNull(NET.ip_sockets[Defines.NS_SERVER]) + host.update(100) + Files.delete(blocked.resolve("blocker")) + Files.delete(blocked) + assertEquals(ServerHost.OperationResult.Completed, host.saveGame("slot")) + assertEquals(ServerHost.OperationResult.Completed, host.loadGame("slot")) + } + + @Test + fun `save eligibility rejects idle unspawned dead and deathmatch sessions`() { + assertInstanceOf(ServerHost.OperationResult.Rejected::class.java, host.saveGame("slot")) + host.map("testbox") + assertInstanceOf(ServerHost.OperationResult.Rejected::class.java, host.saveGame("slot")) + val game = host.games.getValue("default") + val client = host.clients[0] + client.state = ClientStates.CS_SPAWNED + client.edict = game.gameExports.getEdict(1) + client.edict.getClient().playerState.stats[Defines.STAT_HEALTH] = 0 + assertInstanceOf(ServerHost.OperationResult.Rejected::class.java, host.saveGame("slot")) + client.edict.getClient().playerState.stats[Defines.STAT_HEALTH] = 100 + assertInstanceOf(ServerHost.OperationResult.Rejected::class.java, host.saveGame("current")) + assertInstanceOf(ServerHost.OperationResult.Rejected::class.java, host.saveGame("../bad")) + assertFalse(Files.exists(root.resolve("save/slot"))) + assertEquals(ServerHost.OperationResult.Completed, host.saveGame("slot")) + client.state = ClientStates.CS_FREE + cvars.find("deathmatch").forceSet(true) + host.map("testbox") + assertInstanceOf(ServerHost.OperationResult.Rejected::class.java, host.saveGame("dm")) + assertFalse(Files.exists(root.resolve("save/dm"))) + } + + @Test + fun `console save failure reports failure and continues queued commands`() { + save(true) + val blocked = root.resolve("save/current/game.ssv.json") + Files.delete(blocked) + Files.createDirectory(blocked) + val output = StringBuilder() + val sink = Com.ConsoleSink { _, message -> output.append(message) } + var continued = false + jake2.qcommon.exec.Cmd.AddCommand("test_after_save") { continued = true } + Com.SetConsoleSink(sink) + try { + jake2.qcommon.exec.Cbuf.AddText("save slot; test_after_save") + jake2.qcommon.exec.Cbuf.Execute() + assertTrue(continued) + assertTrue(output.contains("Save failed:")) + assertFalse(output.contains("Done.")) + assertTrue(host.isRunning) + } finally { + Com.ClearConsoleSink(sink) + jake2.qcommon.exec.Cmd.RemoveCommand("test_after_save") + } + } + @Test fun `unreadable allocation metadata preserves running session`() { save(false) val game = host.games.getValue("default") val client = host.clients[0] Files.writeString(root.resolve("save/slot/server_latched_cvars.ssv.json"), "invalid") - assertInstanceOf(ServerHost.TransitionResult.Rejected::class.java, host.loadGame("slot")) + assertInstanceOf(ServerHost.OperationResult.Rejected::class.java, host.loadGame("slot")) assertSame(game, host.games["default"]) assertSame(client, host.clients[0]) } diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt index 497835ac..8e65d941 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt @@ -103,7 +103,7 @@ class TransitionArchiveTest { // A directory at the output filename forces a real filesystem write failure. Files.createDirectories(root.resolve("save/current/testbox.$extension.json")) val originalGameplay = first.gameExports - assertInstanceOf(ServerHost.TransitionResult.Rejected::class.java, host.changeMap("testbox2")) + assertInstanceOf(ServerHost.OperationResult.Rejected::class.java, host.changeMap("testbox2")) assertLiveState() assertSame(first, host.games["default"]) assertSame(second, host.games[second.name]) diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index 570b5cc1..f1dc8e95 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -839,60 +839,27 @@ public int getPointContents(float[] p) { // inheritance and dedicated save/load targeting are decided. All Game save/restore paths // currently share EngineWriteRoot/save/current; transitions and unit resets can overwrite // another Game's archives. Multi-Game persistence is explicitly deferred; save targets the default Game. - void saveGame(List args) { - - if (state != ServerStates.SS_GAME) { - Com.Printf("You must be in a game to save.\n"); - return; - } - - if (args.size() != 2) { - Com.Printf("USAGE: save \n"); - return; - } - - if (isDeathmatch()) { - Com.Printf("Can't savegame in a deathmatch\n"); - return; - } - - String saveGame; - try { - saveGame = ServerSaveJsonStore.normalizeSlot(args.get(1)); - } catch (IllegalArgumentException e) { - Com.Printf("Bad save name.\n"); - return; - } - if ("current".equals(saveGame)) { - Com.Printf("Can't save to 'current'\n"); - return; - } + /** Return a user-facing rejection reason, or null when this Game can be saved. */ + String saveRejectionReason() { + if (state != ServerStates.SS_GAME) return "You must be in a game to save."; + if (isDeathmatch()) return "Can't savegame in a deathmatch"; if (serverMain.getClients().size() == 1) { ClientNetworkInfo client = serverMain.getClients().get(0); - if (client.state != ClientStates.CS_SPAWNED || client.edict == null) { - Com.Printf("Can't savegame before the player has spawned.\n"); - return; - } - if (client.edict.getClient().getPlayerState().stats[Defines.STAT_HEALTH] <= 0) { - Com.Printf("\nCan't savegame while dead!\n"); - return; - } + if (client.state != ClientStates.CS_SPAWNED || client.edict == null) + return "Can't savegame before the player has spawned."; + if (client.edict.getClient().getPlayerState().stats[Defines.STAT_HEALTH] <= 0) + return "Can't savegame while dead!"; } + return null; + } - Com.Printf("Saving game...\n"); - - // archive current level, including all client edicts. - // when the level is reloaded, they will be shells awaiting - // a connecting client + /** Write this Game to a validated slot; failures propagate to the Host operation boundary. */ + void saveGame(String slot) { + // Archive client bodies as shells awaiting reconnect when the level is restored. SV_WriteLevelFile(); - - // save server state SV_WriteServerFile(false); - - // copy it off - new ServerSaveFiles(EngineWriteRoot.path()).copy("current", saveGame); - Com.Printf("Done.\n"); + new ServerSaveFiles(EngineWriteRoot.path()).copy("current", slot); } /** diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 08bb7406..3e61ddce 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -288,7 +288,7 @@ class ServerHost : AutoCloseable { fun startMap(mapName: String): Boolean { val result = map(mapName) reportTransition("map", result) - return result == TransitionResult.Completed + return result == OperationResult.Completed } /** Release this manager's console hooks after ending its last session. */ @@ -426,10 +426,10 @@ class ServerHost : AutoCloseable { return GameImportsImpl.create(this, changeMapInfo, previous) } - sealed interface TransitionResult { - data object Completed : TransitionResult - data class Rejected(val reason: String) : TransitionResult - data class Failed(val reason: String, val cause: Exception) : TransitionResult + sealed interface OperationResult { + data object Completed : OperationResult + data class Rejected(val reason: String) : OperationResult + data class Failed(val reason: String, val cause: Exception) : OperationResult } /** Console adapter; applications can inspect the same operation's typed result. */ @@ -443,22 +443,22 @@ class ServerHost : AutoCloseable { } /** Preflight preserves the live session; failures after copying begins leave an idle Host. */ - fun loadGame(slot: String): TransitionResult { + fun loadGame(slot: String): OperationResult { val saveGame = try { ServerSaveJsonStore.normalizeSlot(slot) } catch (_: IllegalArgumentException) { - return TransitionResult.Rejected("Bad save name") + return OperationResult.Rejected("Bad save name") } - if (saveGame == "current") return TransitionResult.Rejected("Can't load from 'current'") + if (saveGame == "current") return OperationResult.Rejected("Can't load from 'current'") val prepared = try { SavePreflight(EngineWriteRoot.path()).inspect(saveGame) } catch (e: Exception) { - return TransitionResult.Rejected(e.message ?: "Invalid save") + return OperationResult.Rejected(e.message ?: "Invalid save") } val savedMaxClients = prepared.maxClients if (games.keys.any { it != "default" } && clients.size != savedMaxClients) { - return TransitionResult.Rejected("Can't change saved client capacity while other Games are running") + return OperationResult.Rejected("Can't change saved client capacity while other Games are running") } return try { @@ -471,10 +471,10 @@ class ServerHost : AutoCloseable { loaded.name = "default" games["default"] = loaded transitionClients.forEach { it.gameName = loaded.name } - TransitionResult.Completed + OperationResult.Completed } catch (failure: Exception) { recoverFailedTransition(failure) - TransitionResult.Failed(failure.message ?: "Save restoration failed", failure) + OperationResult.Failed(failure.message ?: "Save restoration failed", failure) } } @@ -514,18 +514,18 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) } val clientIndex = if (args.size > 2 && args[2].isNotBlank()) args[2].toIntOrNull() else 0 if (clientIndex == null) { - reportTransition("gamemap", TransitionResult.Rejected("Invalid client index")) + reportTransition("gamemap", OperationResult.Rejected("Invalid client index")) return } reportTransition("gamemap", changeMap(args[1], clientIndex)) } - fun changeMap(level: String, clientIndex: Int = 0): TransitionResult = + fun changeMap(level: String, clientIndex: Int = 0): OperationResult = transitionMap(level, clientIndex, newSession = false) - fun map(level: String): TransitionResult = transitionMap(level, 0, newSession = true) + fun map(level: String): OperationResult = transitionMap(level, 0, newSession = true) - private fun transitionMap(level: String, clientIndex: Int, newSession: Boolean): TransitionResult { + private fun transitionMap(level: String, clientIndex: Int, newSession: Boolean): OperationResult { val target = try { val parsed = prepareMapTarget(level) require(clientIndex in 0..clients.size) { "Invalid client index: $clientIndex" } @@ -534,7 +534,7 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) } parsed } catch (failure: Exception) { - return TransitionResult.Rejected(failure.message ?: "Invalid map request") + return OperationResult.Rejected(failure.message ?: "Invalid map request") } // Archival restores temporarily hidden player entities in finally. A write failure here @@ -543,7 +543,7 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) try { games["default"]?.takeIf { it.state == ServerStates.SS_GAME }?.archiveLevelForTransition() } catch (failure: Exception) { - return TransitionResult.Rejected("Could not archive current level: ${failure.message}") + return OperationResult.Rejected("Could not archive current level: ${failure.message}") } } @@ -553,10 +553,10 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) prepareServerSession(restart = newSession) EngineWriteRoot.ensureParentDirectories(EngineWriteRoot.resolve("save/current/.keep")) installMap(target, clientIndex) - TransitionResult.Completed + OperationResult.Completed } catch (failure: Exception) { recoverFailedTransition(failure) - TransitionResult.Failed(failure.message ?: "Map transition failed", failure) + OperationResult.Failed(failure.message ?: "Map transition failed", failure) } } @@ -570,11 +570,11 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) return target } - private fun reportTransition(operation: String, result: TransitionResult) { + private fun reportTransition(operation: String, result: OperationResult) { when (result) { - TransitionResult.Completed -> Unit - is TransitionResult.Rejected -> Com.Printf("$operation rejected: ${result.reason}\n") - is TransitionResult.Failed -> Com.Printf("$operation failed; server stopped: ${result.reason}\n") + OperationResult.Completed -> Unit + is OperationResult.Rejected -> Com.Printf("$operation rejected: ${result.reason}\n") + is OperationResult.Failed -> Com.Printf("$operation failed; server stopped: ${result.reason}\n") } } @@ -685,14 +685,36 @@ Resets server to host a given map, moves all clients to it (via reconnect) reportTransition("map", map(args[1])) } - /** Resolve the default Game at execution time; command registration belongs to the Host. */ + /** Console syntax stays here; Game owns save eligibility and serialization. */ private fun saveGame(args: List) { - val game = games["default"] - if (game == null) { - Com.Printf("No running default game\n") + if (args.size != 2) { + Com.Printf("USAGE: save \n") return } - game.saveGame(args) + Com.Printf("Saving game...\n") + when (val result = saveGame(args[1])) { + OperationResult.Completed -> Com.Printf("Done.\n") + is OperationResult.Rejected -> Com.Printf("Save rejected: ${result.reason}\n") + is OperationResult.Failed -> Com.Printf("Save failed: ${result.reason}\n") + } + } + + /** Saving does not replace the live Game; an IO failure leaves it running for retry. */ + fun saveGame(slot: String): OperationResult { + val destination = try { + ServerSaveJsonStore.normalizeSlot(slot) + } catch (_: IllegalArgumentException) { + return OperationResult.Rejected("Bad save name") + } + if (destination == "current") return OperationResult.Rejected("Can't save to 'current'") + val game = games["default"] ?: return OperationResult.Rejected("No running default game") + game.saveRejectionReason()?.let { return OperationResult.Rejected(it) } + return try { + game.saveGame(destination) + OperationResult.Completed + } catch (failure: Exception) { + OperationResult.Failed(failure.message ?: "Save failed", failure) + } } // endregion @@ -1492,15 +1514,15 @@ Resets server to host a given map, moves all clients to it (via reconnect) if (games[previous.name] !== previous) continue // replaced or stopped before execution val result = continueGame(previous, nextMap) reportTransition("Continuation for ${previous.name}", result) - if (result is TransitionResult.Failed) break // Recovery invalidated the whole queued batch. + if (result is OperationResult.Failed) break // Recovery invalidated the whole queued batch. } } - private fun continueGame(previous: GameImportsImpl, nextMap: String): TransitionResult { + private fun continueGame(previous: GameImportsImpl, nextMap: String): OperationResult { val target = try { if (nextMap.isEmpty()) null else prepareMapTarget(nextMap) } catch (failure: Exception) { - return TransitionResult.Rejected(failure.message ?: "Invalid continuation target") + return OperationResult.Rejected(failure.message ?: "Invalid continuation target") } return try { val routedClients = clients.filter { it.gameName == previous.name } @@ -1519,7 +1541,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) } games.remove(previous.name) } - return TransitionResult.Completed + return OperationResult.Completed } // Unit changes and autosaves still share the existing persistence workspace. if (target.newUnit) ServerSaveFiles(EngineWriteRoot.path()).wipe("current") @@ -1530,10 +1552,10 @@ Resets server to host a given map, moves all clients to it (via reconnect) replacement.SV_WriteServerFile(true) ServerSaveFiles(EngineWriteRoot.path()).copy("current", "save0") } - TransitionResult.Completed + OperationResult.Completed } catch (failure: Exception) { recoverFailedTransition(failure) - TransitionResult.Failed(failure.message ?: "Continuation failed", failure) + OperationResult.Failed(failure.message ?: "Continuation failed", failure) } } From 4d7c42cb49d4936d887e62bfe1bdc9512fe85e78 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 23:23:16 +0100 Subject: [PATCH 34/47] Keep missing map lists from stopping the Host --- .../server/HostSessionPreparationTest.kt | 29 +++++++++++++++++++ .../main/kotlin/jake2/server/ServerHost.kt | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt index 6aff97de..dec45dad 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt @@ -608,6 +608,35 @@ class HostSessionPreparationTest { } } + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun `missing map list preserves Host and continues queued commands`(running: Boolean) { + if (running) save(true) + assertNull(EngineVfs.loadBytes("maps.lst"), "Fixture must have no optional map list") + val games = host.games.toMap() + val clients = host.clients.toList() + val socket = NET.ip_sockets[Defines.NS_SERVER] + val output = StringBuilder() + val sink = Com.ConsoleSink { _, message -> output.append(message) } + var continued = false + jake2.qcommon.exec.Cmd.AddCommand("test_after_listmaps") { continued = true } + Com.SetConsoleSink(sink) + try { + jake2.qcommon.exec.Cbuf.AddText("listmaps; test_after_listmaps") + jake2.qcommon.exec.Cbuf.Execute() + + assertTrue(output.contains("Could not read maps.lst")) + assertTrue(continued) + assertEquals(running, host.isRunning) + assertEquals(games, host.games) + assertEquals(clients, host.clients.toList()) + assertSame(socket, NET.ip_sockets[Defines.NS_SERVER]) + } finally { + Com.ClearConsoleSink(sink) + jake2.qcommon.exec.Cmd.RemoveCommand("test_after_listmaps") + } + } + @Test fun `unreadable allocation metadata preserves running session`() { save(false) diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 3e61ddce..4b292218 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -259,7 +259,7 @@ class ServerHost : AutoCloseable { commands.add("listmaps") { val bytes = EngineVfs.loadBytes("maps.lst") if (bytes == null) { - Com.Error(Defines.ERR_DROP, "Could not read maps.lst") + Com.Printf("Could not read maps.lst\n") return@add } for (line in String(bytes).split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()) { From 7b11ede5580a6d3acb5096afb72e70ecefccedbd Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 23:32:45 +0100 Subject: [PATCH 35/47] Centralize targeted Game shutdown in Host --- .../jake2/server/GameContinuationTest.kt | 54 +++++++++++++++++++ .../main/kotlin/jake2/server/ServerHost.kt | 50 ++++++++++++----- 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt index ac59f29a..7bc0d657 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt @@ -147,6 +147,60 @@ class GameContinuationTest { assertEquals("testbox2", host.games.getValue("other").mapName) } + @Test + fun `targeted stop survives notification failure and cancels only its pending work`() { + val first = game("default", "intro.cin+testbox") + val second = game("other", "intro.cin+testbox2") + val a = bind(0, first) + val b = bind(1, second) + val c = bind(2, first) + finish(a, first) + finish(b, second) + a.state = ClientStates.CS_SPAWNED + a.download = byteArrayOf(1) + a.netchan.reliablePending.add(StuffTextMessage("stale")) + a.unreliable.add(StuffTextMessage("stale")) + a.netchan.remote_address = null // Fail the first final notification. + first.gameExports = null // Discarding a Game must not call back into its gameplay. + Cvar.getInstance().find("port").forceSet(0) + assertTrue(NET.configureServer(true)) + val socket = NET.ip_sockets[Defines.NS_SERVER] + val peerChannel = b.netchan + + host.stopGame(first, "Game failed\n") + + for (client in listOf(a, c)) { + assertEquals(ClientStates.CS_FREE, client.state) + assertNull(client.gameName) + assertNull(client.edict) + assertNull(client.download) + assertTrue(client.netchan.reliablePending.isEmpty()) + assertTrue(client.unreliable.isEmpty()) + } + assertSame(second, host.games["other"]) + assertSame(socket, NET.ip_sockets[Defines.NS_SERVER]) + assertSame(peerChannel, b.netchan) + assertEquals(ClientStates.CS_CONNECTED, b.state) + host.update(100) + assertFalse(host.games.containsKey("default")) + assertEquals("testbox2", host.games.getValue("other").mapName) + host.stopGame(first, "Already stopped\n") + assertTrue(host.isRunning) + } + + @Test + fun `stale Game stop cannot remove its replacement or disconnect its clients`() { + val old = game("default", "end.cin") + val replacement = game("default", "intro.cin+testbox") + val client = bind(0, replacement) + host.stopGame(old, "Stale failure\n") + assertSame(replacement, host.games["default"]) + assertEquals(ClientStates.CS_CONNECTED, client.state) + finish(client, replacement) + host.update(100) + assertEquals("testbox", host.games.getValue("default").mapName) + } + @Test fun `singleplayer chain preserves unit syntax and ends in an idle Host`() { val intro = game("default", "intro.cin+end.cin+*testbox") diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 4b292218..d651d4ac 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -324,6 +324,41 @@ class ServerHost : AutoCloseable { } } + /** + * End one owned Game without invoking gameplay disconnect callbacks. + * Call after unwinding Game execution, outside iteration over the Game registry. + * Identity prevents stale work from stopping a replacement with the same name. + */ + fun stopGame(game: GameImportsImpl, reason: String) { + if (games[game.name] !== game) return + if (games.size == 1) { + stop(reason, false) + return + } + val routedClients = clients.filter { it.gameName == game.name } + try { + for (client in routedClients) { + try { + sendFinalMessage(reason, false, listOf(client)) + } catch (failure: Exception) { + // A failed final packet must not prevent other clients from being released. + Com.Printf("Could not notify client of Game ${game.name} shutdown: $failure\n") + } + } + } finally { + for (client in routedClients) { + client.state = ClientStates.CS_FREE + client.gameName = null + client.edict = null + client.download = null + client.netchan.reliablePending.clear() + client.unreliable.clear() + } + pendingContinuations.remove(game) + games.remove(game.name) + } + } + /** * Resets the clients when maxclients changes */ @@ -1527,20 +1562,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) return try { val routedClients = clients.filter { it.gameName == previous.name } if (target == null) { - if (games.size == 1) { - stop("Game sequence ended.\n", false) - } else { - sendFinalMessage("Game sequence ended.\n", false, routedClients) - for (client in routedClients) { - client.state = ClientStates.CS_FREE - client.gameName = null - client.edict = null - client.download = null - client.netchan.reliablePending.clear() - client.unreliable.clear() - } - games.remove(previous.name) - } + stopGame(previous, "Game sequence ended.\n") return OperationResult.Completed } // Unit changes and autosaves still share the existing persistence workspace. From 000bfc345eae4c073e1290afc77bd51b160a310a Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Mon, 26 Oct 2026 23:38:05 +0100 Subject: [PATCH 36/47] Contain Game import failures at Host execution boundaries --- .../jake2/server/GameContinuationTest.kt | 84 +++++++++++++++++++ .../server/HostSessionPreparationTest.kt | 17 ++++ .../jake2/server/TransitionArchiveTest.kt | 2 +- .../java/jake2/server/GameImportsImpl.java | 24 +++--- .../main/kotlin/jake2/server/GameFailure.kt | 4 + .../main/kotlin/jake2/server/ServerHost.kt | 48 ++++++++++- 6 files changed, 163 insertions(+), 16 deletions(-) create mode 100644 server/src/main/kotlin/jake2/server/GameFailure.kt diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt index 7bc0d657..42b0ef33 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt @@ -201,6 +201,90 @@ class GameContinuationTest { assertEquals("testbox", host.games.getValue("default").mapName) } + @Test + fun `Game frame failure stops only its owner and other Game runs in the same tick`() { + val first = game("default", "testbox") + val second = game("other", "testbox2") + val a = bind(0, first) + val b = bind(1, second) + var secondFrames = 0 + first.gameExports = object : GameExports by first.gameExports { + override fun G_RunFrame() { first.configstring(-1, "invalid") } + } + second.gameExports = object : GameExports by second.gameExports { + override fun G_RunFrame() { secondFrames++ } + } + host.update(1000) + assertFalse(host.games.containsKey("default")) + assertSame(second, host.games["other"]) + assertEquals(ClientStates.CS_FREE, a.state) + assertEquals(ClientStates.CS_CONNECTED, b.state) + assertEquals(1, secondFrames) + host.update(1000) + assertEquals(2, secondFrames) + } + + @Test + fun `Game failure in client command aborts packet and stops Game rather than just client`() { + val first = game("default", "testbox") + val second = game("other", "end.cin") + val a = bind(0, first) + val peer = bind(1, first) + val other = bind(2, second) + var commands = 0 + first.gameExports = object : GameExports by first.gameExports { + override fun ClientCommand(ent: ServerEntity, args: MutableList) { + commands++ + first.error(Defines.ERR_FATAL, "broken Game state") + } + } + host.handleClientMessages(a, listOf( + jake2.qcommon.network.messages.client.StringCmdMessage("fail"), + jake2.qcommon.network.messages.client.StringCmdMessage("must_not_run"))) + assertEquals(1, commands) + assertEquals(ClientStates.CS_FREE, a.state) + assertEquals(ClientStates.CS_FREE, peer.state) + assertEquals(ClientStates.CS_CONNECTED, other.state) + assertSame(second, host.games["other"]) + } + + @Test + fun `last Game failure leaves reusable idle Host`() { + val first = game("default", "testbox") + first.gameExports = object : GameExports by first.gameExports { + override fun G_RunFrame() { first.error("failed frame") } + } + host.update(1000) + assertFalse(host.isRunning) + assertTrue(host.clients.isEmpty()) + assertEquals(ServerHost.OperationResult.Completed, host.map("testbox")) + } + + @Test + fun `failed cleanup escapes nested Game boundaries with original failure`() { + val first = game("default", "testbox") + bind(0, first).netchan.remote_address = null + val failure = GameFailure(first, "failed frame") + first.gameExports = object : GameExports by first.gameExports { + override fun G_RunFrame() { throw failure } + } + val escaped = assertThrows(IllegalStateException::class.java) { host.update(1000) } + assertSame(failure, escaped.cause) + assertEquals(1, failure.suppressed.size) + assertFalse(host.isRunning) + assertTrue(host.clients.isEmpty()) + } + + @Test + fun `unclassified programming failures are not swallowed by Game boundary`() { + val first = game("default", "testbox") + val bug = IllegalStateException("unexpected bug") + first.gameExports = object : GameExports by first.gameExports { + override fun G_RunFrame() { throw bug } + } + assertSame(bug, assertThrows(IllegalStateException::class.java) { host.update(1000) }) + } + @Test fun `singleplayer chain preserves unit syntax and ends in an idle Host`() { val intro = game("default", "intro.cin+end.cin+*testbox") diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt index dec45dad..ba5a3894 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt @@ -637,6 +637,23 @@ class HostSessionPreparationTest { } } + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun `Game failure during save or archival stops unhealthy Game`(archive: Boolean) { + save(true) + val game = host.games.getValue("default") + val failure = GameFailure(game, "invalid gameplay persistence state") + game.gameExports = object : GameExports by game.gameExports { + override fun WriteLevel(filename: String) { throw failure } + } + val result = if (archive) host.changeMap("testbox2") else host.saveGame("broken") + assertInstanceOf(ServerHost.OperationResult.Failed::class.java, result) + assertSame(failure, (result as ServerHost.OperationResult.Failed).cause) + assertFalse(host.isRunning) + assertTrue(host.clients.isEmpty()) + assertEquals(ServerHost.OperationResult.Completed, host.map("testbox")) + } + @Test fun `unreadable allocation metadata preserves running session`() { save(false) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt index 8e65d941..8ba39151 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt @@ -18,7 +18,7 @@ import java.nio.file.Path class TransitionArchiveTest { @TempDir lateinit var root: Path private lateinit var previousRoot: Path - private lateinit var previousState: ServerStates + private var previousState: ServerStates? = null private lateinit var host: ServerHost private lateinit var first: GameImportsImpl private lateinit var second: GameImportsImpl diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index f1dc8e95..342337f4 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -363,7 +363,7 @@ public void cprintf(ServerEntity ent, int printlevel, String s) { if (ent != null) { n = ent.index; if (n < 1 || n > serverMain.getClients().size()) - Com.Error(Defines.ERR_DROP, "cprintf to a non-client"); + throw new GameFailure(this, "cprintf to a non-client"); } if (ent != null) @@ -390,16 +390,16 @@ public void positioned_sound(float[] origin, ServerEntity ent, int channel, int soundindex, float volume, float attenuation, float timeofs) { if (volume < 0 || volume > 1.0) - Com.Error(Defines.ERR_FATAL, "SV_StartSound: volume = " + volume); + throw new GameFailure(this, "SV_StartSound: volume = " + volume); if (attenuation < 0 || attenuation > 4) - Com.Error(Defines.ERR_FATAL, "SV_StartSound: attenuation = " + attenuation); + throw new GameFailure(this, "SV_StartSound: attenuation = " + attenuation); // if (channel < 0 || channel > 15) // Com_Error (ERR_FATAL, "SV_StartSound: channel = %i", channel); if (timeofs < 0 || timeofs > 0.255) - Com.Error(Defines.ERR_FATAL, "SV_StartSound: timeofs = " + timeofs); + throw new GameFailure(this, "SV_StartSound: timeofs = " + timeofs); int entityIndex = ent.index; @@ -478,7 +478,7 @@ public void positioned_sound(float[] origin, ServerEntity ent, int channel, @Override public void configstring(int num, String string) { if (num < 0 || num >= Defines.MAX_CONFIGSTRINGS) - Com.Error(Defines.ERR_DROP, "configstring: bad index " + num + throw new GameFailure(this, "configstring: bad index " + num + "\n"); if (string == null) @@ -495,12 +495,13 @@ public void configstring(int num, String string) { @Override public void error(String err) { - Com.Error(Defines.ERR_FATAL, err); + throw new GameFailure(this, err); } @Override public void error(int level, String err) { - Com.Error(level, err); + // Legacy severity codes do not select process/client ownership here. + throw new GameFailure(this, err); } /* the *index functions create configstrings and some internal server state */ @@ -1002,8 +1003,7 @@ private int hullForEntity(ServerEntity ent) { // explicit hulls in the BSP model model = models[ent.s.modelindex]; if (null == model) - Com.Error(Defines.ERR_FATAL, - "MOVETYPE_PUSH with a non bsp model"); + throw new GameFailure(this, "MOVETYPE_PUSH with a non bsp model"); return model.headnode; } // create a temp hull from bounding box sizes @@ -1087,7 +1087,7 @@ private int findConfigstringIndex(String val, int start, int max) { return i; if (i == max) - Com.Error(Defines.ERR_DROP, "*Index: overflow"); + throw new GameFailure(this, "*Index: overflow"); configstring(start + i, val); @@ -1298,7 +1298,7 @@ private void SV_Multicast(float[] origin, MulticastTypes to, NetworkMessage msg) default: mask = null; - Com.Error(Defines.ERR_FATAL, "SV_Multicast: bad to:" + to + "\n"); + throw new GameFailure(this, "SV_Multicast: bad to:" + to + "\n"); } // send the data to all relevent clients @@ -1493,7 +1493,7 @@ private void buildFatPvs(float[] org) { count = cm.CM_BoxLeafnums(mins, maxs, leafs, 64, null); if (count < 1) - Com.Error(Defines.ERR_FATAL, "SV_FatPVS: count < 1"); + throw new GameFailure(this, "SV_FatPVS: count < 1"); longs = (cm.CM_NumClusters() + 31) >> 5; diff --git a/server/src/main/kotlin/jake2/server/GameFailure.kt b/server/src/main/kotlin/jake2/server/GameFailure.kt new file mode 100644 index 00000000..2a109e61 --- /dev/null +++ b/server/src/main/kotlin/jake2/server/GameFailure.kt @@ -0,0 +1,4 @@ +package jake2.server + +/** A failure of Game-owned state. Lower layers propagate; the Host chooses cleanup. */ +class GameFailure(val game: GameImportsImpl, message: String) : RuntimeException(message) diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index d651d4ac..089744e4 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -577,6 +577,9 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) if (!newSession && !target.newUnit) { try { games["default"]?.takeIf { it.state == ServerStates.SS_GAME }?.archiveLevelForTransition() + } catch (failure: GameFailure) { + handleGameFailure(failure) + return OperationResult.Failed(failure.message ?: "Game failed during archival", failure) } catch (failure: Exception) { return OperationResult.Rejected("Could not archive current level: ${failure.message}") } @@ -609,7 +612,7 @@ Spins up a new game instance, to be used with either `map` or `join` (TBD) when (result) { OperationResult.Completed -> Unit is OperationResult.Rejected -> Com.Printf("$operation rejected: ${result.reason}\n") - is OperationResult.Failed -> Com.Printf("$operation failed; server stopped: ${result.reason}\n") + is OperationResult.Failed -> Com.Printf("$operation failed: ${result.reason}\n") } } @@ -747,6 +750,9 @@ Resets server to host a given map, moves all clients to it (via reconnect) return try { game.saveGame(destination) OperationResult.Completed + } catch (failure: GameFailure) { + handleGameFailure(failure) + OperationResult.Failed(failure.message ?: "Game failed while saving", failure) } catch (failure: Exception) { OperationResult.Failed(failure.message ?: "Save failed", failure) } @@ -760,6 +766,36 @@ Resets server to host a given map, moves all clients to it (via reconnect) * SV_Frame. */ fun update(msec: Long) { + // Also covers Game callbacks reached through connection admission, timeouts and snapshots. + // Unwind these Host iterations before removing a failed Game; retry no partial work. + withGameFailureBoundary { updateFrame(msec) } + } + + private inline fun withGameFailureBoundary(action: () -> Unit) { + try { + action() + } catch (failure: GameFailure) { + handleGameFailure(failure) + } + } + + /** + * Contain a Game failure after execution unwinds: stop its Game and report the original stack. + * Foreign-Host failures propagate; cleanup failures escalate so enclosing Game boundaries cannot swallow them. + */ + private fun handleGameFailure(failure: GameFailure) { + if (failure.game.serverMain !== this) throw failure + try { + stopGame(failure.game, "Game failed: ${failure.message}\n") + } catch (cleanupFailure: Exception) { + failure.addSuppressed(cleanupFailure) + // Do not let an enclosing Game boundary retry or swallow failed recovery. + throw IllegalStateException("Could not recover Game ${failure.game.name}", failure) + } + Com.Printf(failure.stackTraceToString() + "\n") + } + + private fun updateFrame(msec: Long) { // if server is not active, do nothing // like when connected to another server if (games.isEmpty()) return @@ -804,9 +840,11 @@ Resets server to host a given map, moves all clients to it (via reconnect) replenishClientMoveBudgets() // let everything in the world think and move - for (game in games.values) { - game.SV_RunGameFrame(nextFrameTime.toLong()) + for (game in games.values.toList()) { + if (games[game.name] !== game) continue + withGameFailureBoundary { game.SV_RunGameFrame(nextFrameTime.toLong()) } } + if (games.isEmpty()) return // send messages back to the clients that had packets read this frame sendClientMessages() @@ -1242,6 +1280,10 @@ Resets server to host a given map, moves all clients to it (via reconnect) } fun handleClientMessages(cl: ClientNetworkInfo, clientMessages: Collection) { + withGameFailureBoundary { processClientMessages(cl, clientMessages) } + } + + private fun processClientMessages(cl: ClientNetworkInfo, clientMessages: Collection) { // only allow one move command var moveIssued = false From b7c6459efca183e0e1c6663c5e6f497cc494c0e8 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 09:22:39 +0100 Subject: [PATCH 37/47] Guarantee connection cleanup when disconnect callbacks fail --- .../jake2/server/HostClientAccountingTest.kt | 87 +++++++++++++++++++ .../main/kotlin/jake2/server/ServerHost.kt | 37 ++++---- 2 files changed, 106 insertions(+), 18 deletions(-) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt index 76d148c0..40178b24 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt @@ -127,6 +127,93 @@ class HostClientAccountingTest { assertEquals(ClientStates.CS_FREE, client.state) } + @Test + fun `failed disconnect callback still releases connection and is not repeated`() { + val client = bind(0, first) + val peer = bind(1, first) + client.state = ClientStates.CS_SPAWNED + client.download = byteArrayOf(1) + client.downloadsize = 1 + client.downloadcount = 1 + client.unreliable.add(jake2.qcommon.network.messages.server.StuffTextMessage("stale")) + val failure = IllegalStateException("broken callback") + var callbacks = 0 + first.gameExports = object : GameExports by first.gameExports { + override fun ClientDisconnect(ent: ServerEntity) { + callbacks++ + throw failure + } + } + assertSame(failure, assertThrows(IllegalStateException::class.java) { host.dropClient(client) }) + assertEquals(ClientStates.CS_ZOMBIE, client.state) + assertNull(client.download) + assertEquals(0, client.downloadsize) + assertEquals(0, client.downloadcount) + assertNull(client.edict) + assertTrue(client.unreliable.isEmpty()) + assertEquals(first.name, client.gameName, "Zombie routing remains until disconnect is delivered") + assertEquals(ClientStates.CS_CONNECTED, peer.state) + host.dropClient(client) + assertEquals(1, callbacks) + } + + @Test + fun `reliable overflow disconnects only offending connection and peers still receive messages`() { + val client = bind(0, first) + val peer = bind(1, first) + client.netchan.reliablePending.add( + jake2.qcommon.network.messages.server.StuffTextMessage("x".repeat(Defines.MAX_MSGLEN))) + peer.netchan.reliablePending.add(jake2.qcommon.network.messages.server.StuffTextMessage("peer")) + val previousSequence = peer.netchan.outgoing_sequence + host.sendClientMessages() + assertEquals(ClientStates.CS_ZOMBIE, client.state) + assertEquals(ClientStates.CS_CONNECTED, peer.state) + assertTrue(peer.netchan.outgoing_sequence > previousSequence) + assertSame(first, host.games[first.name]) + assertSame(second, host.games[second.name]) + } + + @Test + fun `rejected client packet cannot execute trailing commands`() { + val client = bind(0, first) + val peer = bind(1, first) + val invalid = object : jake2.qcommon.network.messages.client.ClientMessage( + jake2.qcommon.network.messages.client.ClientMessageType.CLC_BAD) { + override fun writeProperties(buffer: sizebuf_t) = Unit + override fun parse(buffer: sizebuf_t) = Unit + override fun getSize() = 1 + } + host.handleClientMessages(client, listOf(invalid, + jake2.qcommon.network.messages.client.UserInfoMessage("\\name\\must_not_apply"))) + assertEquals(ClientStates.CS_ZOMBIE, client.state) + assertEquals("", client.userinfo) + assertEquals(ClientStates.CS_CONNECTED, peer.state) + assertSame(first, host.games[first.name]) + } + + @Test + fun `console kick escalates gameplay failure to its Game and continues command buffer`() { + val client = bind(0, first) + val other = bind(1, second) + client.state = ClientStates.CS_SPAWNED + first.gameExports = object : GameExports by first.gameExports { + override fun ClientDisconnect(ent: ServerEntity) { first.error("disconnect failed") } + } + var continued = false + jake2.qcommon.exec.Cmd.AddCommand("test_after_kick") { continued = true } + try { + jake2.qcommon.exec.Cbuf.AddText("kick 0; test_after_kick") + jake2.qcommon.exec.Cbuf.Execute() + assertTrue(continued) + assertFalse(host.games.containsKey(first.name)) + assertEquals(ClientStates.CS_FREE, client.state) + assertSame(second, host.games[second.name]) + assertEquals(ClientStates.CS_CONNECTED, other.state) + } finally { + jake2.qcommon.exec.Cmd.RemoveCommand("test_after_kick") + } + } + @Test fun `snapshots use Game identity but Host send time and rate bucket`() { val client = bind(0, first) diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 089744e4..fb103bc3 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -1208,27 +1208,27 @@ Resets server to host a given map, moves all clients to it (via reconnect) } /** - * Called when the player is totally leaving the server, either willingly or - * unwillingly. This is NOT called if the entire server is quiting or - * crashing. + * End one connection, retaining its channel and routing for disconnect retransmission. + * Release connection resources even if gameplay fails; callers handle Game failures + * after unwinding their iteration. Repeated drops must not repeat gameplay callbacks. */ fun dropClient(client: ClientNetworkInfo) { - // add the disconnect - client.netchan.reliablePending.add(DisconnectMessage()) - - if (client.state == ClientStates.CS_SPAWNED) { - // call the prog function for removing a client - // this will remove the body, among other things - games[client.gameName]!!.gameExports.ClientDisconnect(client.edict) - } - - if (client.download != null) { + if (client.state == ClientStates.CS_FREE || client.state == ClientStates.CS_ZOMBIE) return + try { + client.netchan.reliablePending.add(DisconnectMessage()) + if (client.state == ClientStates.CS_SPAWNED) { + games[client.gameName]!!.gameExports.ClientDisconnect(client.edict) + } + } finally { client.download = null + client.downloadsize = 0 + client.downloadcount = 0 + client.unreliable.clear() + client.edict = null + client.lastmessage = realtime // start the zombie grace period on the Host clock + client.state = ClientStates.CS_ZOMBIE + client.name = "" } - - client.lastmessage = realtime // start the zombie grace period on the Host clock - client.state = ClientStates.CS_ZOMBIE // become free in a few seconds - client.name = "" } // endregion @@ -1355,6 +1355,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) } else { Com.Printf("SV_ReadClientMessage: unknown command: " + msg.type + "\n") dropClient(cl) + return // Never execute the remainder of a rejected packet. } } } @@ -1880,7 +1881,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) // print directly, because the dropped client won't get the // SV_BroadcastPrintf message client.queuePrint(Defines.PRINT_HIGH, "You were kicked from the game\n") - dropClient(client) + withGameFailureBoundary { dropClient(client) } } private fun printStatus() { From e052905c0cd561f8f72cdd8989fcc26fa9ba8e43 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 09:28:00 +0100 Subject: [PATCH 38/47] Close dedicated Host on startup and loop exit --- .../java/jake2/dedicated/Jake2Dedicated.java | 29 ++--- .../jake2/dedicated/DedicatedLifetimeTest.kt | 102 ++++++++++++++++++ 2 files changed, 117 insertions(+), 14 deletions(-) create mode 100644 dedicated/src/test/kotlin/jake2/dedicated/DedicatedLifetimeTest.kt diff --git a/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java b/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java index 442e4f02..dadbf559 100644 --- a/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java +++ b/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java @@ -7,7 +7,6 @@ import jake2.qcommon.exec.Cmd; import jake2.qcommon.exec.Cvar; import jake2.qcommon.longjmpException; -import jake2.qcommon.sys.Sys; import jake2.qcommon.sys.Timer; import jake2.qcommon.vfs.EngineFilesystemLifecycle; import jake2.server.ServerHost; @@ -20,7 +19,6 @@ public class Jake2Dedicated { - @SuppressWarnings("deprecation") public static void main(String[] args) { @@ -33,7 +31,6 @@ public static void main(String[] args) { if (argc > 1) { System.arraycopy(args, 0, c_args, 1, argc - 1); } - ServerHost serverMain = null; try { // prepare enough of the subsystems to handle @@ -59,24 +56,27 @@ public static void main(String[] args) { // init commands and vars Netchan.Netchan_Init(); - serverMain = new ServerHost(); //ok + runHost(args1); + } catch (longjmpException e) { + throw new IllegalStateException("Error during dedicated initialization", e); + } + } - // add + commands from command line - if (Cbuf.AddLateCommands(args1)) { + /** Own the Host through late-command startup and the loop, including exceptional exits. */ + static void runHost(List args) { + try (ServerHost host = new ServerHost()) { + if (Cbuf.AddLateCommands(args)) { Cbuf.AddText("dedicated_start\n"); Cbuf.Execute(); } - Com.Printf("====== Quake2 Initialized ======\n\n"); - - // save config when configuration is completed - - } catch (longjmpException e) { - Sys.Error("Error during initialization"); + runLoop(host); } + } + private static void runLoop(ServerHost host) { int oldtime = Timer.Milliseconds(); - while (true) { + while (!Thread.currentThread().isInterrupted()) { // find time spending rendering last frame int newtime = Timer.Milliseconds(); int time = newtime - oldtime; @@ -90,7 +90,8 @@ public static void main(String[] args) { Cbuf.Execute(); - serverMain.update(adjustedTime); + // A command can request orderly termination by interrupting the loop thread. + if (!Thread.currentThread().isInterrupted()) host.update(adjustedTime); } catch (longjmpException e) { Com.DPrintf("longjmp exception:" + e); diff --git a/dedicated/src/test/kotlin/jake2/dedicated/DedicatedLifetimeTest.kt b/dedicated/src/test/kotlin/jake2/dedicated/DedicatedLifetimeTest.kt new file mode 100644 index 00000000..8252d54b --- /dev/null +++ b/dedicated/src/test/kotlin/jake2/dedicated/DedicatedLifetimeTest.kt @@ -0,0 +1,102 @@ +package jake2.dedicated + +import jake2.qcommon.MainCommon +import jake2.qcommon.exec.Cbuf +import jake2.qcommon.exec.Cmd +import jake2.qcommon.exec.Cvar +import jake2.qcommon.exec.find +import jake2.qcommon.network.NET +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout + +@Timeout(10) +class DedicatedLifetimeTest { + @BeforeEach + fun setup() { + Cvar.getInstance().clear() + MainCommon.registerCvars(true) + Cmd.Init() + jake2.qcommon.network.Netchan.Netchan_Init() + Cvar.getInstance().find("port").forceSet(0) + } + + @AfterEach + fun cleanup() { + Thread.interrupted() + Cmd.RemoveCommand("test_start") + Cmd.RemoveCommand("test_end") + Cmd.RemoveCommand("test_drop") + Cbuf.Execute() + NET.configureServer(false) + Cvar.getInstance().clear() + } + + @Test + fun `late startup failure closes Host and preserves original exception`() { + val failure = IllegalStateException("startup failed") + Cmd.AddCommand("test_start") { + assertNotNull(Cmd.FindCommand("map")) + assertTrue(NET.configureServer(true)) + throw failure + } + assertSame(failure, assertThrows(IllegalStateException::class.java) { + Jake2Dedicated.runHost(listOf("Jake2", "+test_start")) + }) + assertReleased() + } + + @Test + fun `loop command failure closes Host without swallowing programming errors`() { + val failure = IllegalStateException("frame command failed") + Cmd.AddCommand("test_start") { assertTrue(NET.configureServer(true)) } + Cmd.AddCommand("test_end") { throw failure } + assertSame(failure, assertThrows(IllegalStateException::class.java) { + Jake2Dedicated.runHost(listOf("Jake2", "+test_start", "+wait", "+test_end")) + }) + assertReleased() + } + + @Test + fun `loop interruption closes Host and permits another dedicated lifetime`() { + var starts = 0 + Cmd.AddCommand("test_start") { + starts++ + assertNotNull(Cmd.FindCommand("map")) + assertTrue(NET.configureServer(true)) + } + Cmd.AddCommand("test_end") { Thread.currentThread().interrupt() } + repeat(2) { + Jake2Dedicated.runHost(listOf("Jake2", "+test_start", "+wait", "+test_end")) + assertTrue(Thread.interrupted(), "Loop must preserve interrupt status for its caller") + assertReleased() + } + assertEquals(2, starts) + } + + @Test + fun `legacy recoverable drop retains Host commands and continues loop`() { + Cmd.AddCommand("test_start") { assertTrue(NET.configureServer(true)) } + Cmd.AddCommand("test_drop") { + jake2.qcommon.Com.Error(jake2.qcommon.Defines.ERR_DROP, "recoverable test failure") + } + var continued = false + Cmd.AddCommand("test_end") { + continued = true + assertNotNull(Cmd.FindCommand("map")) + assertNull(NET.ip_sockets[jake2.qcommon.Defines.NS_SERVER]) + Thread.currentThread().interrupt() + } + Jake2Dedicated.runHost(listOf("Jake2", "+test_start", "+wait", "+test_drop", "+test_end")) + assertTrue(continued) + assertReleased() + } + + private fun assertReleased() { + assertNull(Cmd.FindCommand("map")) + assertNull(Cmd.FindCommand("gamemap")) + assertNull(NET.ip_sockets[jake2.qcommon.Defines.NS_SERVER]) + } +} From 8ed6a8e22c6c285c38cf59a681256baaae80c16a Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 09:55:37 +0100 Subject: [PATCH 39/47] Route collision failures through their owning Game --- .../jake2/server/GameContinuationTest.kt | 10 +- .../server/HostSessionPreparationTest.kt | 27 ++++ qcommon/src/main/java/jake2/qcommon/CM.java | 123 ++++++++++-------- .../jake2/qcommon/CollisionOwnershipTest.kt | 67 ++++++++++ .../java/jake2/server/GameImportsImpl.java | 2 +- 5 files changed, 172 insertions(+), 57 deletions(-) create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/CollisionOwnershipTest.kt diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt index 42b0ef33..81dcf2c3 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt @@ -201,15 +201,19 @@ class GameContinuationTest { assertEquals("testbox", host.games.getValue("default").mapName) } - @Test - fun `Game frame failure stops only its owner and other Game runs in the same tick`() { + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun `Game frame failure stops only its owner and other Game runs in the same tick`(collision: Boolean) { val first = game("default", "testbox") val second = game("other", "testbox2") val a = bind(0, first) val b = bind(1, second) var secondFrames = 0 first.gameExports = object : GameExports by first.gameExports { - override fun G_RunFrame() { first.configstring(-1, "invalid") } + override fun G_RunFrame() { + if (collision) first.cm.InlineModel("invalid") + else first.configstring(-1, "invalid") + } } second.gameExports = object : GameExports by second.gameExports { override fun G_RunFrame() { secondFrames++ } diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt index ba5a3894..b7d43a4f 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt @@ -654,6 +654,33 @@ class HostSessionPreparationTest { assertEquals(ServerHost.OperationResult.Completed, host.map("testbox")) } + @Test + fun `malformed BSP propagates Game failure without global client drop`() { + val content = root.resolve("content") + val map = content.resolve("baseq2/maps/broken.bsp") + Files.createDirectories(map.parent) + val bytes = Files.readAllBytes(Path.of("src/test/resources/baseq2/maps/testbox.bsp")) + java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.LITTLE_ENDIAN).putInt(8 + Defines.LUMP_TEXINFO * 8 + 4, 1) + Files.write(map, bytes) + EngineVfs.configure(VfsConfig(content, "baseq2", null, + true, false, false, emptyList(), setOf("pak"))) + val previous = jake2.qcommon.exec.Cmd.FindCommand("cl_drop") + var clientDropped = false + jake2.qcommon.exec.Cmd.AddCommand("cl_drop", true) { clientDropped = true } + try { + val result = host.map("broken") + assertInstanceOf(ServerHost.OperationResult.Failed::class.java, result) + assertInstanceOf(GameFailure::class.java, (result as ServerHost.OperationResult.Failed).cause, result.cause.stackTraceToString()) + assertTrue(result.reason.contains("funny lump size")) + assertFalse(clientDropped) + assertFalse(host.isRunning) + assertNotNull(jake2.qcommon.exec.Cmd.FindCommand("map")) + } finally { + jake2.qcommon.exec.Cmd.RemoveCommand("cl_drop") + previous?.let { jake2.qcommon.exec.Cmd.AddCommand("cl_drop", it.function) } + } + } + @Test fun `unreadable allocation metadata preserves running session`() { save(false) diff --git a/qcommon/src/main/java/jake2/qcommon/CM.java b/qcommon/src/main/java/jake2/qcommon/CM.java index 05dd818e..554d7758 100644 --- a/qcommon/src/main/java/jake2/qcommon/CM.java +++ b/qcommon/src/main/java/jake2/qcommon/CM.java @@ -38,10 +38,22 @@ import java.nio.ByteOrder; import java.nio.IntBuffer; import java.util.Arrays; +import java.util.Objects; +import java.util.function.BiFunction; @SuppressWarnings("deprecation") public class CM { + private final BiFunction failureFactory; + + /** Preserve the legacy client error policy until its execution boundary is migrated. */ public CM() { + this(CM::legacyFailure); + } + + /** Let the owner classify collision failures without introducing shutdown into collision code. */ + public CM(BiFunction failureFactory) { + this.failureFactory = Objects.requireNonNull(failureFactory); + for (int n = 0; n < Defines.MAX_MAP_BRUSHSIDES; n++) map_brushsides[n] = new cbrushside_t(); for (int n = 0; n < Defines.MAX_MAP_TEXINFO; n++) @@ -63,6 +75,11 @@ public CM() { } + private static RuntimeException legacyFailure(int severity, String message) { + Com.Error(severity, message); + return new IllegalStateException(message); // Defensive fallback if legacy dispatch ever returns. + } + public static class cnode_t { cplane_t plane; // ptr @@ -188,7 +205,7 @@ public static class carea_t { public cmodel_t CM_LoadMapFile(byte[] buf, String name, int[] checksum) { if (buf == null) - Com.Error(Defines.ERR_DROP, "Couldn't load " + name); + throw failureFactory.apply(Defines.ERR_DROP, "Couldn't load " + name); int length = buf.length; @@ -200,7 +217,7 @@ public cmodel_t CM_LoadMapFile(byte[] buf, String name, int[] checksum) { BspHeader header = new BspHeader(bbuf.slice()); if (header.version != Defines.BSPVERSION) - Com.Error(Defines.ERR_DROP, "CMod_LoadBrushModel: " + name + throw failureFactory.apply(Defines.ERR_DROP, "CMod_LoadBrushModel: " + name + " has wrong version number (" + header.version + " should be " + Defines.BSPVERSION + ")"); @@ -286,14 +303,14 @@ public void CMod_LoadSubmodels(BspLump l) { int i, j, count; if ((l.length % qfiles.dmodel_t.SIZE) != 0) - Com.Error(Defines.ERR_DROP, "CMod_LoadBmodel: funny lump size"); + throw failureFactory.apply(Defines.ERR_DROP, "CMod_LoadBmodel: funny lump size"); count = l.length / qfiles.dmodel_t.SIZE; if (count < 1) - Com.Error(Defines.ERR_DROP, "Map with no models"); + throw failureFactory.apply(Defines.ERR_DROP, "Map with no models"); if (count > Defines.MAX_MAP_MODELS) - Com.Error(Defines.ERR_DROP, "Map has too many models"); + throw failureFactory.apply(Defines.ERR_DROP, "Map has too many models"); Com.DPrintf(" numcmodels=" + count + "\n"); numcmodels = count; @@ -333,13 +350,13 @@ public void CMod_LoadSurfaces(BspLump l) { int i, count; if ((l.length % texinfo_t.SIZE) != 0) - Com.Error(Defines.ERR_DROP, "MOD_LoadBmodel: funny lump size"); + throw failureFactory.apply(Defines.ERR_DROP, "MOD_LoadBmodel: funny lump size"); count = l.length / texinfo_t.SIZE; if (count < 1) - Com.Error(Defines.ERR_DROP, "Map with no surfaces"); + throw failureFactory.apply(Defines.ERR_DROP, "Map with no surfaces"); if (count > Defines.MAX_MAP_TEXINFO) - Com.Error(Defines.ERR_DROP, "Map has too many surfaces"); + throw failureFactory.apply(Defines.ERR_DROP, "Map has too many surfaces"); numtexinfo = count; Com.DPrintf(" numtexinfo=" + count + "\n"); @@ -374,14 +391,14 @@ public void CMod_LoadNodes(BspLump l) { int i, j, count; if ((l.length % qfiles.dnode_t.SIZE) != 0) - Com.Error(Defines.ERR_DROP, "MOD_LoadBmodel: funny lump size:" + throw failureFactory.apply(Defines.ERR_DROP, "MOD_LoadBmodel: funny lump size:" + l.offset + "," + qfiles.dnode_t.SIZE); count = l.length / qfiles.dnode_t.SIZE; if (count < 1) - Com.Error(Defines.ERR_DROP, "Map has no nodes"); + throw failureFactory.apply(Defines.ERR_DROP, "Map has no nodes"); if (count > Defines.MAX_MAP_NODES) - Com.Error(Defines.ERR_DROP, "Map has too many nodes"); + throw failureFactory.apply(Defines.ERR_DROP, "Map has too many nodes"); numnodes = count; Com.DPrintf(" numnodes=" + count + "\n"); @@ -415,12 +432,12 @@ public void CMod_LoadBrushes(BspLump l) { int i, count; if ((l.length % qfiles.dbrush_t.SIZE) != 0) - Com.Error(Defines.ERR_DROP, "MOD_LoadBmodel: funny lump size"); + throw failureFactory.apply(Defines.ERR_DROP, "MOD_LoadBmodel: funny lump size"); count = l.length / qfiles.dbrush_t.SIZE; if (count > Defines.MAX_MAP_BRUSHES) - Com.Error(Defines.ERR_DROP, "Map has too many brushes"); + throw failureFactory.apply(Defines.ERR_DROP, "Map has too many brushes"); numbrushes = count; Com.DPrintf(" numbrushes=" + count + "\n"); @@ -452,16 +469,16 @@ public void CMod_LoadLeafs(BspLump l) { int count; if ((l.length % qfiles.dleaf_t.SIZE) != 0) - Com.Error(Defines.ERR_DROP, "MOD_LoadBmodel: funny lump size"); + throw failureFactory.apply(Defines.ERR_DROP, "MOD_LoadBmodel: funny lump size"); count = l.length / qfiles.dleaf_t.SIZE; if (count < 1) - Com.Error(Defines.ERR_DROP, "Map with no leafs"); + throw failureFactory.apply(Defines.ERR_DROP, "Map with no leafs"); // need to save space for box planes if (count > Defines.MAX_MAP_PLANES) - Com.Error(Defines.ERR_DROP, "Map has too many planes"); + throw failureFactory.apply(Defines.ERR_DROP, "Map has too many planes"); Com.DPrintf(" numleafes=" + count + "\n"); @@ -495,7 +512,7 @@ public void CMod_LoadLeafs(BspLump l) { Com.DPrintf(" numclusters=" + numclusters + "\n"); if (map_leafs[0].contents != Defines.CONTENTS_SOLID) - Com.Error(Defines.ERR_DROP, "Map leaf 0 is not CONTENTS_SOLID"); + throw failureFactory.apply(Defines.ERR_DROP, "Map leaf 0 is not CONTENTS_SOLID"); solidleaf = 0; emptyleaf = -1; @@ -508,7 +525,7 @@ public void CMod_LoadLeafs(BspLump l) { } if (emptyleaf == -1) - Com.Error(Defines.ERR_DROP, "Map does not have an empty leaf"); + throw failureFactory.apply(Defines.ERR_DROP, "Map does not have an empty leaf"); } /** Loads planes. */ @@ -521,16 +538,16 @@ public void CMod_LoadPlanes(BspLump l) { int bits; if ((l.length % qfiles.dplane_t.SIZE) != 0) - Com.Error(Defines.ERR_DROP, "MOD_LoadBmodel: funny lump size"); + throw failureFactory.apply(Defines.ERR_DROP, "MOD_LoadBmodel: funny lump size"); count = l.length / qfiles.dplane_t.SIZE; if (count < 1) - Com.Error(Defines.ERR_DROP, "Map with no planes"); + throw failureFactory.apply(Defines.ERR_DROP, "Map with no planes"); // need to save space for box planes if (count > Defines.MAX_MAP_PLANES) - Com.Error(Defines.ERR_DROP, "Map has too many planes"); + throw failureFactory.apply(Defines.ERR_DROP, "Map has too many planes"); Com.DPrintf(" numplanes=" + count + "\n"); @@ -572,18 +589,18 @@ public void CMod_LoadLeafBrushes(BspLump l) { Com.DPrintf("CMod_LoadLeafBrushes()\n"); if ((l.length % 2) != 0) - Com.Error(Defines.ERR_DROP, "MOD_LoadBmodel: funny lump size"); + throw failureFactory.apply(Defines.ERR_DROP, "MOD_LoadBmodel: funny lump size"); int count = l.length / 2; Com.DPrintf(" numbrushes=" + count + "\n"); if (count < 1) - Com.Error(Defines.ERR_DROP, "Map with no planes"); + throw failureFactory.apply(Defines.ERR_DROP, "Map with no planes"); // need to save space for box planes if (count > Defines.MAX_MAP_LEAFBRUSHES) - Com.Error(Defines.ERR_DROP, "Map has too many leafbrushes"); + throw failureFactory.apply(Defines.ERR_DROP, "Map has too many leafbrushes"); int[] out = map_leafbrushes; numleafbrushes = count; @@ -613,12 +630,12 @@ public void CMod_LoadBrushSides(BspLump l) { int num; if ((l.length % qfiles.dbrushside_t.SIZE) != 0) - Com.Error(Defines.ERR_DROP, "MOD_LoadBmodel: funny lump size"); + throw failureFactory.apply(Defines.ERR_DROP, "MOD_LoadBmodel: funny lump size"); count = l.length / qfiles.dbrushside_t.SIZE; // need to save space for box planes if (count > Defines.MAX_MAP_BRUSHSIDES) - Com.Error(Defines.ERR_DROP, "Map has too many planes"); + throw failureFactory.apply(Defines.ERR_DROP, "Map has too many planes"); numbrushsides = count; @@ -642,7 +659,7 @@ public void CMod_LoadBrushSides(BspLump l) { j = in.texinfo; if (j >= numtexinfo) - Com.Error(Defines.ERR_DROP, "Bad brushside texinfo"); + throw failureFactory.apply(Defines.ERR_DROP, "Bad brushside texinfo"); // java specific handling of -1 if (j == -1) @@ -665,12 +682,12 @@ public void CMod_LoadAreas(BspLump l) { int count; if ((l.length % qfiles.darea_t.SIZE) != 0) - Com.Error(Defines.ERR_DROP, "MOD_LoadBmodel: funny lump size"); + throw failureFactory.apply(Defines.ERR_DROP, "MOD_LoadBmodel: funny lump size"); count = l.length / qfiles.darea_t.SIZE; if (count > Defines.MAX_MAP_AREAS) - Com.Error(Defines.ERR_DROP, "Map has too many areas"); + throw failureFactory.apply(Defines.ERR_DROP, "Map has too many areas"); Com.DPrintf(" numareas=" + count + "\n"); numareas = count; @@ -705,11 +722,11 @@ public void CMod_LoadAreaPortals(BspLump l) { int count; if ((l.length % qfiles.dareaportal_t.SIZE) != 0) - Com.Error(Defines.ERR_DROP, "MOD_LoadBmodel: funny lump size"); + throw failureFactory.apply(Defines.ERR_DROP, "MOD_LoadBmodel: funny lump size"); count = l.length / qfiles.dareaportal_t.SIZE; if (count > Defines.MAX_MAP_AREAS) - Com.Error(Defines.ERR_DROP, "Map has too many areas"); + throw failureFactory.apply(Defines.ERR_DROP, "Map has too many areas"); numareaportals = count; Com.DPrintf(" numareaportals=" + count + "\n"); @@ -742,7 +759,7 @@ public void CMod_LoadVisibility(BspLump l) { Com.DPrintf(" numvisibility=" + numvisibility + "\n"); if (l.length > Defines.MAX_MAP_VISIBILITY) - Com.Error(Defines.ERR_DROP, "Map has too large visibility lump"); + throw failureFactory.apply(Defines.ERR_DROP, "Map has too large visibility lump"); if (l.length == 0) { Com.Warn("Map has no visibility lump; PVS/PHS culling disabled (all clusters treated as visible)"); @@ -751,7 +768,7 @@ public void CMod_LoadVisibility(BspLump l) { } if (l.length < Integer.BYTES) { - Com.Error(Defines.ERR_DROP, "Map has truncated visibility lump (" + l.length + " bytes)"); + throw failureFactory.apply(Defines.ERR_DROP, "Map has truncated visibility lump (" + l.length + " bytes)"); } System.arraycopy(cmod_base, l.offset, map_visibility, 0, l.length); @@ -761,17 +778,17 @@ public void CMod_LoadVisibility(BspLump l) { int visClusters = bb.getInt(); if (visClusters < 0 || visClusters > Defines.MAX_MAP_LEAFS) { - Com.Error(Defines.ERR_DROP, "Map has invalid visibility cluster count " + visClusters); + throw failureFactory.apply(Defines.ERR_DROP, "Map has invalid visibility cluster count " + visClusters); } long headerSize = Integer.BYTES + (long) visClusters * 2L * Integer.BYTES; if (headerSize > l.length) { - Com.Error(Defines.ERR_DROP, "Map has truncated visibility header: expected at least " + throw failureFactory.apply(Defines.ERR_DROP, "Map has truncated visibility header: expected at least " + headerSize + " bytes but lump has " + l.length); } if (visClusters < numclusters) { - Com.Error(Defines.ERR_DROP, "Map visibility cluster count " + visClusters + throw failureFactory.apply(Defines.ERR_DROP, "Map visibility cluster count " + visClusters + " is smaller than leaf cluster count " + numclusters); } @@ -779,7 +796,7 @@ public void CMod_LoadVisibility(BspLump l) { try { map_vis = new qfiles.dvis_t(bb); } catch (IllegalArgumentException ex) { - Com.Error(Defines.ERR_DROP, "Invalid visibility lump: " + ex.getMessage()); + throw failureFactory.apply(Defines.ERR_DROP, "Invalid visibility lump: " + ex.getMessage()); } int minDataOffset = (int) headerSize; @@ -790,7 +807,7 @@ public void CMod_LoadVisibility(BspLump l) { continue; } if (bitOffset < minDataOffset || bitOffset >= l.length) { - Com.Error(Defines.ERR_DROP, "Map has invalid visibility offset " + bitOffset + throw failureFactory.apply(Defines.ERR_DROP, "Map has invalid visibility offset " + bitOffset + " for cluster " + i); } } @@ -805,7 +822,7 @@ public void CMod_LoadEntityString(BspLump l) { numentitychars = l.length; if (l.length > Defines.MAX_MAP_ENTSTRING) - Com.Error(Defines.ERR_DROP, "Map has too large entity lump"); + throw failureFactory.apply(Defines.ERR_DROP, "Map has too large entity lump"); int x = 0; for (; x < l.length && cmod_base[x + l.offset] != 0; x++); @@ -819,12 +836,12 @@ public void CMod_LoadEntityString(BspLump l) { /** Returns the model with a given id "*" + */ public cmodel_t InlineModel(String name) { if (name == null || name.charAt(0) != '*') - Com.Error(Defines.ERR_DROP, "CM_InlineModel: bad name"); + throw failureFactory.apply(Defines.ERR_DROP, "CM_InlineModel: bad name"); int num = Lib.atoi(name.substring(1)); if (num < 1 || num >= numcmodels) - Com.Error(Defines.ERR_DROP, "CM_InlineModel: bad number"); + throw failureFactory.apply(Defines.ERR_DROP, "CM_InlineModel: bad number"); return map_cmodels[num]; } @@ -843,19 +860,19 @@ public String CM_EntityString() { public int CM_LeafContents(int leafnum) { if (leafnum < 0 || leafnum >= numleafs) - Com.Error(Defines.ERR_DROP, "CM_LeafContents: bad number"); + throw failureFactory.apply(Defines.ERR_DROP, "CM_LeafContents: bad number"); return map_leafs[leafnum].contents; } public int CM_LeafCluster(int leafnum) { if (leafnum < 0 || leafnum >= numleafs) - Com.Error(Defines.ERR_DROP, "CM_LeafCluster: bad number"); + throw failureFactory.apply(Defines.ERR_DROP, "CM_LeafCluster: bad number"); return map_leafs[leafnum].cluster; } public int CM_LeafArea(int leafnum) { if (leafnum < 0 || leafnum >= numleafs) - Com.Error(Defines.ERR_DROP, "CM_LeafArea: bad number"); + throw failureFactory.apply(Defines.ERR_DROP, "CM_LeafArea: bad number"); return map_leafs[leafnum].area; } @@ -887,7 +904,7 @@ public void CM_InitBoxHull() { || numleafbrushes + 1 > Defines.MAX_MAP_LEAFBRUSHES || numbrushsides + 6 > Defines.MAX_MAP_BRUSHSIDES || numplanes + 12 > Defines.MAX_MAP_PLANES) - Com.Error(Defines.ERR_DROP, "Not enough room for box tree"); + throw failureFactory.apply(Defines.ERR_DROP, "Not enough room for box tree"); box_brush = map_brushes[numbrushes]; box_brush.numsides = 6; @@ -994,11 +1011,11 @@ public int CM_PointLeafnum(float[] p) { private int leaf_count; private int leaf_maxcount; - private static int[] leaf_list; - private static float[] leaf_mins; - private static float[] leaf_maxs; + private int[] leaf_list; + private float[] leaf_mins; + private float[] leaf_maxs; - private static int leaf_topnode; + private int leaf_topnode; /** Recursively fills in a list of all the leafs touched. */ private void CM_BoxLeafnums_r(int nodenum) { @@ -1691,7 +1708,7 @@ public void FloodArea_r(carea_t area, int floodnum) { if (area.floodvalid == floodvalid) { if (area.floodnum == floodnum) return; - Com.Error(Defines.ERR_DROP, "FloodArea_r: reflooded"); + throw failureFactory.apply(Defines.ERR_DROP, "FloodArea_r: reflooded"); } area.floodnum = floodnum; @@ -1735,7 +1752,7 @@ public void FloodAreaConnections() { */ public void CM_SetAreaPortalState(int portalnum, boolean open) { if (portalnum > numareaportals) - Com.Error(Defines.ERR_DROP, "areaportal > numareaportals"); + throw failureFactory.apply(Defines.ERR_DROP, "areaportal > numareaportals"); portalopen[portalnum] = open; FloodAreaConnections(); @@ -1750,7 +1767,7 @@ public boolean CM_AreasConnected(int area1, int area2) { return true; if (area1 > numareas || area2 > numareas) - Com.Error(Defines.ERR_DROP, "area > numareas"); + throw failureFactory.apply(Defines.ERR_DROP, "area > numareas"); if (map_areas[area1].floodnum == map_areas[area2].floodnum) return true; @@ -1818,7 +1835,7 @@ public void CM_ReadPortalState(RandomAccessFile f) { try { f.readFully(buf); } catch (IOException e) { - Com.Error(Defines.ERR_FATAL, e.toString()); + throw failureFactory.apply(Defines.ERR_FATAL, e.toString()); } ByteBuffer bb = ByteBuffer.wrap(buf); diff --git a/qcommon/src/test/kotlin/jake2/qcommon/CollisionOwnershipTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/CollisionOwnershipTest.kt new file mode 100644 index 00000000..fd5f5932 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/CollisionOwnershipTest.kt @@ -0,0 +1,67 @@ +package jake2.qcommon + +import jake2.qcommon.exec.Cmd +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.util.concurrent.CyclicBarrier +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class CollisionOwnershipTest { + @Test + fun `owner policy propagates its exception`() { + val failure = IllegalArgumentException("owner failure") + val cm = CM { severity, message -> + assertEquals(Defines.ERR_DROP, severity) + assertTrue(message.contains("Couldn't load")) + failure + } + assertSame(failure, assertThrows(IllegalArgumentException::class.java) { + cm.CM_LoadMapFile(null, "missing", intArrayOf(0)) + }) + } + + @Test + fun `default policy preserves legacy drop dispatch`() { + val names = listOf("sv_shutdown", "cl_drop") + val previous = names.associateWith { Cmd.FindCommand(it) } + val called = mutableListOf() + try { + names.forEach { name -> Cmd.AddCommand(name, true) { called.add(name) } } + assertThrows(longjmpException::class.java) { CM().InlineModel("invalid") } + assertEquals(names, called) + } finally { + names.forEach { name -> + Cmd.RemoveCommand(name) + previous[name]?.let { Cmd.AddCommand(name, it.function) } + } + } + } + + @Test + fun `independent collision instances do not share box query scratch`() { + val worlds = List(2) { + CM().apply { + map_nodes[0].plane = cplane_t().apply { normal[0] = 1f; type = 0 } + map_nodes[0].children[0] = -1 + map_nodes[0].children[1] = -2 + map_cmodels[0].headnode = 0 + } + } + val start = CyclicBarrier(2) + Executors.newFixedThreadPool(2).use { executor -> + val tasks = worlds.mapIndexed { index, cm -> executor.submit { + val box = floatArrayOf(if (index == 0) 10f else -10f, 0f, 0f) + val leaves = IntArray(2) + val top = IntArray(1) + start.await(5, TimeUnit.SECONDS) + repeat(10000) { + assertEquals(1, cm.CM_BoxLeafnums(box, box, leaves, leaves.size, top)) + assertEquals(index, leaves[0]) + assertEquals(-1, top[0]) + } + } } + tasks.forEach { it.get(10, TimeUnit.SECONDS) } + } + } +} diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index 342337f4..0bd623bd 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -162,7 +162,7 @@ public GameImportsImpl(ServerHost serverMain, ChangeMapInfo changeMapInfo) { spawncount = Lib.rand(); - cm = new CM(); + cm = new CM((severity, message) -> new GameFailure(this, message)); clientEntityStates = new entity_state_t[serverMain.getClients().size() * Defines.UPDATE_BACKUP * 64]; for (int i = 0; i < clientEntityStates.length; i++) clientEntityStates[i] = new entity_state_t(); From 2e68aba720d8c8cdb0f8b8a11916359eafba90c5 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 11:23:10 +0100 Subject: [PATCH 40/47] Document query-local collision scratch follow-up --- qcommon/src/main/java/jake2/qcommon/CM.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/qcommon/src/main/java/jake2/qcommon/CM.java b/qcommon/src/main/java/jake2/qcommon/CM.java index 554d7758..27a48f14 100644 --- a/qcommon/src/main/java/jake2/qcommon/CM.java +++ b/qcommon/src/main/java/jake2/qcommon/CM.java @@ -1008,6 +1008,8 @@ public int CM_PointLeafnum(float[] p) { } + // TODO: Make CM_BoxLeafnums scratch state local to each query and pass it through traversal + // so queries are reentrant even on the same CM instance. private int leaf_count; private int leaf_maxcount; From 5274df3d63eecd91deb15aff3013d242e18933f8 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 11:25:19 +0100 Subject: [PATCH 41/47] Propagate low-level output failures without global teardown --- .../jake2/dedicated/DedicatedLifetimeTest.kt | 11 ++++ .../main/java/jake2/qcommon/network/NET.java | 3 +- .../main/java/jake2/qcommon/sizebuf_t.java | 6 +- .../kotlin/jake2/qcommon/OutputFailureTest.kt | 62 +++++++++++++++++++ 4 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt diff --git a/dedicated/src/test/kotlin/jake2/dedicated/DedicatedLifetimeTest.kt b/dedicated/src/test/kotlin/jake2/dedicated/DedicatedLifetimeTest.kt index 8252d54b..ed7f77cb 100644 --- a/dedicated/src/test/kotlin/jake2/dedicated/DedicatedLifetimeTest.kt +++ b/dedicated/src/test/kotlin/jake2/dedicated/DedicatedLifetimeTest.kt @@ -59,6 +59,17 @@ class DedicatedLifetimeTest { assertReleased() } + @Test + fun `output buffer failure unwinds the loop and closes Host`() { + Cmd.AddCommand("test_start") { assertTrue(NET.configureServer(true)) } + Cmd.AddCommand("test_end") { jake2.qcommon.sizebuf_t(1).writeInt(123) } + val failure = assertThrows(IllegalStateException::class.java) { + Jake2Dedicated.runHost(listOf("Jake2", "+test_start", "+wait", "+test_end")) + } + assertTrue(failure.message!!.contains("SZ_GetSpace")) + assertReleased() + } + @Test fun `loop interruption closes Host and permits another dedicated lifetime`() { var starts = 0 diff --git a/qcommon/src/main/java/jake2/qcommon/network/NET.java b/qcommon/src/main/java/jake2/qcommon/network/NET.java index 418727f6..8e585824 100644 --- a/qcommon/src/main/java/jake2/qcommon/network/NET.java +++ b/qcommon/src/main/java/jake2/qcommon/network/NET.java @@ -157,8 +157,7 @@ public static void SendPacket(int sock, int length, byte[] data, netadr_t to) { return; if (to.type != NetAddrType.NA_BROADCAST && to.type != NetAddrType.NA_IP) { - Com.Error(Defines.ERR_FATAL, "NET_SendPacket: bad address type"); - return; + throw new IllegalArgumentException("NET_SendPacket: unsupported address type " + to.type); } try { diff --git a/qcommon/src/main/java/jake2/qcommon/sizebuf_t.java b/qcommon/src/main/java/jake2/qcommon/sizebuf_t.java index 26bbe7ff..842491ca 100644 --- a/qcommon/src/main/java/jake2/qcommon/sizebuf_t.java +++ b/qcommon/src/main/java/jake2/qcommon/sizebuf_t.java @@ -51,17 +51,17 @@ public sizebuf_t(int length) { } /** - * Ask for the pointer using sizebuf_t.cursize (RST) + * Reserve output space. Impossible writes propagate to the caller without global teardown. */ private int GetSpace(int length) { int oldsize; if (cursize + length > maxsize) { if (!allowoverflow) - Com.Error(Defines.ERR_FATAL, "SZ_GetSpace: overflow without allowoverflow set"); + throw new IllegalStateException("SZ_GetSpace: overflow without allowoverflow set"); if (length > maxsize) - Com.Error(Defines.ERR_FATAL, "SZ_GetSpace: " + length + " is > full buffer size"); + throw new IllegalStateException("SZ_GetSpace: " + length + " is > full buffer size"); Com.Printf("SZ_GetSpace: overflow\n"); clear(); diff --git a/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt new file mode 100644 index 00000000..29b52246 --- /dev/null +++ b/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt @@ -0,0 +1,62 @@ +package jake2.qcommon + +import jake2.qcommon.network.NET +import jake2.qcommon.network.NetAddrType +import jake2.qcommon.network.netadr_t +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import java.net.DatagramSocket + +class OutputFailureTest { + @Test + fun `disallowed overflow preserves buffer until caller handles failure`() { + val buffer = sizebuf_t(4) + buffer.writeInt(123) + val saved = buffer.data.copyOf() + val failure = assertThrows(IllegalStateException::class.java) { buffer.writeByte(1) } + assertTrue(failure.message!!.contains("overflow without allowoverflow")) + assertEquals(4, buffer.cursize) + assertArrayEquals(saved, buffer.data) + assertFalse(buffer.overflowed) + buffer.clear() + buffer.writeInt(456) + assertEquals(456, buffer.readInt()) + } + + @Test + fun `permitted overflow restarts buffer but oversized single write still throws`() { + val buffer = sizebuf_t(4) + buffer.allowoverflow = true + buffer.writeInt(123) + buffer.writeShort(456) + assertTrue(buffer.overflowed) + assertEquals(2, buffer.cursize) + assertEquals(456, buffer.readShort()) + val saved = buffer.data.copyOf() + val failure = assertThrows(IllegalStateException::class.java) { buffer.writeBytes(ByteArray(5)) } + assertTrue(failure.message!!.contains("full buffer size")) + assertEquals(2, buffer.cursize) + assertArrayEquals(saved, buffer.data) + } + + @ParameterizedTest + @EnumSource(NetAddrType::class, names = ["NA_IPX", "NA_BROADCAST_IPX"]) + fun `unsupported destination propagates without closing the socket`(type: NetAddrType) { + val previous = NET.ip_sockets[Defines.NS_SERVER] + DatagramSocket(0).use { socket -> + NET.ip_sockets[Defines.NS_SERVER] = socket + try { + val failure = assertThrows(IllegalArgumentException::class.java) { + NET.SendPacket(Defines.NS_SERVER, 1, byteArrayOf(1), netadr_t().apply { this.type = type }) + } + assertTrue(failure.message!!.contains(type.name)) + assertSame(socket, NET.ip_sockets[Defines.NS_SERVER]) + assertFalse(socket.isClosed) + } finally { + NET.ip_sockets[Defines.NS_SERVER] = previous + } + } + } +} From 92c25d46f8f29247f10d6a19fde33a2eeec93db4 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 11:33:08 +0100 Subject: [PATCH 42/47] Order Host challenges independently of the global clock Challenge eviction needs issuance order, not elapsed time. Global clock resets or wrap can make newer entries appear older, while requests issued within one tick can receive identical timestamps. Use a Host-owned counter to order new challenges. Repeated requests retain their existing token and position; session stop clears both the cache and counter. Wire protocol behavior is unchanged. Document the address-challenge handshake and its limited protection against blind source-IP spoofing. Validate eviction after global-clock reversal and existing-token reuse through local UDP tests. --- .../jake2/server/HostClientAccountingTest.kt | 52 +++++++++++++++++++ .../main/java/jake2/server/challenge_t.java | 5 +- .../main/kotlin/jake2/server/ServerHost.kt | 36 ++++++++----- 3 files changed, 77 insertions(+), 16 deletions(-) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt index 40178b24..60a3f8cf 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt @@ -258,6 +258,58 @@ class HostClientAccountingTest { assertEquals(1800, b.commandMsec) } + @Test + fun `challenge eviction follows issuance order despite global clock reversal`() { + val previousTime = Globals.curtime + val loopback = java.net.InetAddress.getByName("127.0.0.1") + java.net.DatagramSocket(0, loopback).use { receiver -> + receiver.soTimeout = 2000 + fun reply(): String { + val packet = java.net.DatagramPacket(ByteArray(2048), 2048) + receiver.receive(packet) + return String(packet.data, 4, packet.length - 4).trim() + } + val address = netadr_t().apply { + type = NetAddrType.NA_IP + ip = loopback.address + port = receiver.localPort + } + cvars.find("port").forceSet(0) + try { + assertTrue(NET.configureServer(true)) + Globals.curtime = 1000 + host.handleConnectionlessPacket(address, "getchallenge") + val issued = reply() + val challenge = issued.substringAfterLast(' ') + NET.configureServer(false) + // No UDP socket while filling synthetic remote addresses: no external packets. + Globals.curtime = -1000 + repeat(Defines.MAX_CHALLENGES - 1) { index -> + val remote = netadr_t().apply { + type = NetAddrType.NA_IP + ip = byteArrayOf(192.toByte(), 0, (index / 256).toByte(), (index % 256).toByte()) + } + host.handleConnectionlessPacket(remote, "getchallenge") + } + assertTrue(NET.configureServer(true)) + host.handleConnectionlessPacket(address, "getchallenge") + assertEquals(issued, reply(), "Repeated requests retain the existing challenge") + NET.configureServer(false) + host.handleConnectionlessPacket(netadr_t().apply { + type = NetAddrType.NA_IP + ip = byteArrayOf(198.toByte(), 51, 100, 1) + }, "getchallenge") + assertTrue(NET.configureServer(true)) + host.handleConnectionlessPacket(address, + "connect ${Defines.PROTOCOL_VERSION} 7 $challenge \\name\\tester") + assertTrue(reply().contains("No challenge for address")) + } finally { + Globals.curtime = previousTime + NET.configureServer(false) + } + } + } + @Test fun `remote reconnect uses Host acceptance time across Game clock changes`() { // Loopback transport with a nonlocal port exercises remote admission without a UDP socket. diff --git a/server/src/main/java/jake2/server/challenge_t.java b/server/src/main/java/jake2/server/challenge_t.java index 4b7bda83..70c05c0e 100644 --- a/server/src/main/java/jake2/server/challenge_t.java +++ b/server/src/main/java/jake2/server/challenge_t.java @@ -24,9 +24,10 @@ of the License, or (at your option) any later version. import jake2.qcommon.network.netadr_t; +/** One entry in the Host's bounded connection-challenge cache; see ServerHost.getChallenge. */ public class challenge_t { - //mem netadr_t adr = new netadr_t(); int challenge; - int time; + // Host-local eviction order, not a timestamp or wire value; zero denotes an unused entry. + long issuedOrder; } diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index fb103bc3..2b9c14d3 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -51,6 +51,7 @@ class ServerHost : AutoCloseable { val games: MutableMap = HashMap() private val challenges = Array(Defines.MAX_CHALLENGES) { challenge_t() } + private var challengeOrder = 0L var clients: MutableList = mutableListOf() private set @@ -316,6 +317,7 @@ class ServerHost : AutoCloseable { paused.forceSet(false) Cvar.getInstance().find("mapname").forceSet("") for (i in challenges.indices) challenges[i] = challenge_t() + challengeOrder = 0 // Ordinary queued commands may start the next session (killserver; gamemap ...). Cbuf.ClearDeferred() NET.configureServer(false) @@ -973,41 +975,47 @@ Resets server to host a given map, moves all clients to it (via reconnect) } /** - * Returns a challenge number that can be used in a subsequent - * client_connect command. We do this to prevent denial of service attacks - * that flood the server with invalid connection IPs. With a challenge, they - * must give a valid IP address. + * Issue or reuse the token for the connectionless getchallenge -> challenge -> connect handshake. + * A remote client echoes the token in its connect request; [connectClient] checks it against + * the cached source IP (UDP port is ignored). Local connections bypass that check. + * + * This makes blind source-IP spoofing harder: the requester normally needs to receive the + * reply before it can connect. The legacy 15-bit random token is not authentication or a + * general denial-of-service defence; it can be guessed and does not prevent request floods. + * + * The bounded Host cache has no time-based expiry. Repeated requests reuse the token without + * refreshing its position. New addresses take an unused entry or evict the earliest-issued + * entry. A Host-owned counter orders issuance even within one clock tick and across global + * clock resets/wrap. Session stop clears the cache and counter; this order is never sent. */ private fun getChallenge(from: netadr_t): Int { var oldest = 0 - var oldestTime = 0x7fffffff + var oldestOrder = Long.MAX_VALUE var i: Int i = 0 while (i < Defines.MAX_CHALLENGES) { if (from.CompareBaseAdr(challenges[i].adr)) break - if (challenges[i].time < oldestTime) { - oldestTime = challenges[i].time + if (challenges[i].issuedOrder < oldestOrder) { + oldestOrder = challenges[i].issuedOrder oldest = i } i++ } if (i == Defines.MAX_CHALLENGES) { - // overwrite the oldest + // Unused entries have order zero and are selected before issued entries. challenges[oldest].challenge = Lib.rand().toInt() and 0x7fff challenges[oldest].adr = from - challenges[oldest].time = Globals.curtime + // Eviction needs issuance order, not wall time or Game simulation time. + challenges[oldest].issuedOrder = ++challengeOrder i = oldest } - // send it back return challenges[i].challenge } - /** - * A connection request that did not come from the master. - */ + /** Validate a direct connect request, including its remote-address challenge, before admission. */ private fun connectClient(args: List, adr: netadr_t) { Com.DPrintf("SVC_DirectConnect ()\n") @@ -1029,7 +1037,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) // force the IP key/value pair so the game can filter based on ip userinfo = Info.Info_SetValueForKey(userinfo, "ip", adr.toString()) - // see if the challenge is valid + // Match the source IP and echoed token before allocating/reusing a client slot. var j: Int if (!adr.IsLocalAddress()) { j = 0 From c9d94129ca351f62eadb2a357bd6a964c88b27cc Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 11:45:08 +0100 Subject: [PATCH 43/47] Remove unused Game realtime and frame timestamp plumbing --- .../java/jake2/server/GameImportsTest.java | 4 ++-- .../jake2/server/HostClientAccountingTest.kt | 18 +++++++++--------- .../java/jake2/server/GameImportsImpl.java | 12 ++---------- .../src/main/kotlin/jake2/server/ServerHost.kt | 13 +++---------- 4 files changed, 16 insertions(+), 31 deletions(-) diff --git a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java index 4def69d0..8cb7d4d7 100644 --- a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java +++ b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java @@ -58,7 +58,7 @@ public void runEmptyInstance() { manager = new ServerHost(); GameImportsImpl testInstance = manager.createGameInstance(new ChangeMapInfo("testbox", false, false), null); - testInstance.SV_RunGameFrame(100); + testInstance.runFrame(); Assertions.assertEquals(1, testInstance.frameNum); } @@ -80,7 +80,7 @@ public void gamesKeepIndependentMapStateAndFrameCounters() { first.configstrings[jake2.qcommon.Defines.CS_NAME] = "changed first map"; first.baselines[1].modelindex = 7; first.state = ServerStates.SS_GAME; - first.SV_RunGameFrame(100); + first.runFrame(); Assertions.assertEquals(1, first.frameNum); Assertions.assertEquals(0, second.frameNum); diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt index 60a3f8cf..8e00e547 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt @@ -82,16 +82,16 @@ class HostClientAccountingTest { } @Test - fun `timeout survives Game clock reset and expires at Host boundary`() { + fun `timeout survives Game frame reset and expires at Host boundary`() { val client = bind(0, first) host.timeout.forceSet(0.5f) - first.realtime = -10000 + first.frameNum = 10000 host.update(500) assertEquals(ClientStates.CS_CONNECTED, client.state) assertEquals(100L, client.lastmessage) // Simulate transfer to a newly allocated Game with a different clock. client.gameName = second.name - second.realtime = 0 + second.frameNum = 0 host.update(1) assertEquals(ClientStates.CS_FREE, client.state) } @@ -122,7 +122,7 @@ class HostClientAccountingTest { assertEquals(500L, client.lastmessage) host.update(500) assertEquals(ClientStates.CS_ZOMBIE, client.state) - first.realtime = -10000 + first.frameNum = 10000 host.update(1) assertEquals(ClientStates.CS_FREE, client.state) } @@ -218,7 +218,7 @@ class HostClientAccountingTest { fun `snapshots use Game identity but Host send time and rate bucket`() { val client = bind(0, first) client.state = ClientStates.CS_SPAWNED - first.realtime = -9000 + first.frameNum = 9000 host.sendClientMessages() val snapshot = first.frameNum assertEquals(100L, client.frames[snapshot and Defines.UPDATE_MASK].senttime) @@ -227,7 +227,7 @@ class HostClientAccountingTest { client.state = ClientStates.CS_CONNECTED host.update(100) - first.realtime = 0 + first.frameNum = 0 val move = MoveMessage(false, snapshot, usercmd_t(), usercmd_t(), usercmd_t(), 0) host.handleClientMessages(client, listOf(move)) client.state = ClientStates.CS_SPAWNED @@ -311,14 +311,14 @@ class HostClientAccountingTest { } @Test - fun `remote reconnect uses Host acceptance time across Game clock changes`() { + fun `remote reconnect uses Host acceptance time across Game frame changes`() { // Loopback transport with a nonlocal port exercises remote admission without a UDP socket. val address = netadr_t().apply { port = 12345 } host.handleConnectionlessPacket(address, "getchallenge") val reply = NET.receiveNetworkPacket(null, null, NET.loopbacks[Defines.NS_CLIENT], false)!!.connectionlessMessage val challenge = reply.trim().substringAfterLast(' ') val connect = "connect ${Defines.PROTOCOL_VERSION} 7 $challenge \\name\\tester" - first.realtime = -9000 + first.frameNum = 9000 host.handleConnectionlessPacket(address, connect) val client = host.clients[0] assertEquals(ClientStates.CS_CONNECTED, client.state) @@ -326,7 +326,7 @@ class HostClientAccountingTest { drainPackets() cvars.find("sv_reconnect_limit").forceSet(0.5f) host.update(499) - first.realtime = 50000 + first.frameNum = 50000 host.handleConnectionlessPacket(address, connect) assertEquals(100L, client.lastconnect, "Reconnect before the boundary must be rejected") host.update(1) diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index 0bd623bd..3adb4898 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -75,7 +75,6 @@ public class GameImportsImpl implements GameImports { final String[] configstrings = new String[Defines.MAX_CONFIGSTRINGS]; final entity_state_t[] baselines = new entity_state_t[Defines.MAX_EDICTS]; - public int realtime; // always increasing, no clamping, etc // todo: remove public int spawncount; // incremented each server start @@ -863,10 +862,8 @@ void saveGame(String slot) { new ServerSaveFiles(EngineWriteRoot.path()).copy("current", slot); } - /** - * SV_RunGameFrame. - */ - void SV_RunGameFrame(long fixedtime) { + /** Advance this Game by one simulation tick; scheduling belongs to the Host. */ + void runFrame() { // we always need to bump framenum, even if we // don't run the world, otherwise the delta @@ -880,11 +877,6 @@ void SV_RunGameFrame(long fixedtime) { gameExports.G_RunFrame(); - // never get more than one tic behind - if (fixedtime < realtime) { - realtime = (int) fixedtime; - } - } /** diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index 2b9c14d3..effcd8a7 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -803,9 +803,6 @@ Resets server to host a given map, moves all clients to it (via reconnect) if (games.isEmpty()) return realtime += msec - for (game in games.values) { - game.realtime += msec.toInt() - } // keep the random time dependent Lib.rand() @@ -819,14 +816,10 @@ Resets server to host a given map, moves all clients to it (via reconnect) if (games.isEmpty()) return // identify if we need to update the game or it's too early (sleep or skip this update). - // fixedtime - the time of the next scheduled game update. var delay = nextFrameTime - realtime if (delay > 0) { - // never let the time get too far off - if (delay > 100) { // how is it even possible? - for (game in games.values) game.realtime = (nextFrameTime - 100) - delay = 100 - } + // Bound the dedicated sleep while waiting for the next scheduled tick. + delay = delay.coerceAtMost(100) NET.Sleep(delay.toInt()) return } @@ -844,7 +837,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) // let everything in the world think and move for (game in games.values.toList()) { if (games[game.name] !== game) continue - withGameFailureBoundary { game.SV_RunGameFrame(nextFrameTime.toLong()) } + withGameFailureBoundary { game.runFrame() } } if (games.isEmpty()) return From 759ce0ca397603c3a2345c23f23c836536ed057e Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 11:55:04 +0100 Subject: [PATCH 44/47] Share Game pause policy and report Game-owned map identity --- .../jake2/server/HostClientAccountingTest.kt | 33 +++++++++++++++++++ .../java/jake2/server/GameImportsImpl.java | 11 +++++-- .../main/kotlin/jake2/server/ServerHost.kt | 2 +- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt index 8e00e547..6b25da2f 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt @@ -104,6 +104,8 @@ class HostClientAccountingTest { assertNotEquals(first.name, client.gameName) assertEquals("testbox2", host.games.getValue(client.gameName).mapName) assertSame(second, host.games[second.name]) + assertEquals("testbox2", Info.Info_ValueForKey(host.games.getValue(client.gameName).serverInfo(), "mapname")) + assertEquals("testbox", Info.Info_ValueForKey(second.serverInfo(), "mapname")) assertEquals(100L, client.lastmessage) assertEquals(100L, client.lastconnect) host.timeout.forceSet(0.5f) @@ -214,6 +216,37 @@ class HostClientAccountingTest { } } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource( + "false,false,1,true", "true,false,1,false", "false,true,1,false", "false,false,2,false") + fun `simulation and movement share effective Game pause policy`( + coop: Boolean, deathmatch: Boolean, slots: Int, expectedPaused: Boolean + ) { + cvars.find("maxclients").forceSet(slots) + host.resetClients() + first.findCvar("coop", Boolean::class.java).forceSet(coop) + first.findCvar("deathmatch", Boolean::class.java).forceSet(deathmatch) + val client = bind(0, first) + client.state = ClientStates.CS_SPAWNED + var frames = 0 + var moves = 0 + first.gameExports = object : GameExports by first.gameExports { + override fun G_RunFrame() { frames++ } + override fun ClientThink(ent: ServerEntity, cmd: usercmd_t) { moves++ } + } + val move = MoveMessage(false, -1, usercmd_t(), usercmd_t(), usercmd_t(), 0).apply { valid = true } + cvars.find("paused").forceSet(true) // Direct console request, bypassing menu filtering. + first.runFrame() + host.handleClientMessages(client, listOf(move)) + assertEquals(if (expectedPaused) 0 else 1, frames) + assertEquals(frames, moves) + cvars.find("paused").forceSet(false) + first.runFrame() + host.handleClientMessages(client, listOf(move)) + assertEquals(if (expectedPaused) 1 else 2, frames) + assertEquals(frames, moves) + } + @Test fun `snapshots use Game identity but Host send time and rate bucket`() { val client = bind(0, first) diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index 3adb4898..a398d4b9 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -247,9 +247,16 @@ private GameExports createGameModInstance() { public boolean isCoop() { return coop.getTypedValue(); } - /** Overlay this Game's mode and rules on the remaining shared server-info settings. */ + /** Effective pause policy shared by simulation and client movement. */ + boolean isPaused() { + return serverMain.isPaused() && !isDeathmatch() && !isCoop() + && serverMain.getClients().size() <= 1; + } + + /** Overlay this Game's map, mode and rules on the remaining shared server-info settings. */ public String serverInfo() { String info = Cvar.getInstance().Serverinfo(); + info = Info.Info_SetValueForKey(info, "mapname", mapName); info = Info.Info_SetValueForKey(info, "deathmatch", deathmatch.getRawString()); info = Info.Info_SetValueForKey(info, "coop", coop.getRawString()); for (String rule : List.of("dmflags", "fraglimit", "timelimit")) { @@ -871,7 +878,7 @@ void runFrame() { // has the "current" frame frameNum++; - if (serverMain.isPaused() && !isDeathmatch() && !isCoop() && serverMain.getClients().size() <= 1) { + if (isPaused()) { return; } diff --git a/server/src/main/kotlin/jake2/server/ServerHost.kt b/server/src/main/kotlin/jake2/server/ServerHost.kt index effcd8a7..7d3677b7 100644 --- a/server/src/main/kotlin/jake2/server/ServerHost.kt +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -1332,7 +1332,7 @@ Resets server to host a given map, moves all clients to it (via reconnect) return } - if (!paused.typedValue) { + if (!games[cl.gameName]!!.isPaused) { var droppedPackets = cl.netchan.dropped if (droppedPackets < 20) { //if (droppedPackets > 2) From 497f48b8f4c8822ec477d9efbcbe1ebaf8f37419 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 12:06:09 +0100 Subject: [PATCH 45/47] Avoid inherited map names when Game server-info replacement fails --- .../jake2/server/HostClientAccountingTest.kt | 37 +++++++++++++++++++ .../java/jake2/server/GameImportsImpl.java | 3 +- .../kotlin/jake2/server/GameModeCvarsTest.kt | 8 ++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt index 6b25da2f..1b3f2fed 100644 --- a/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt @@ -343,6 +343,43 @@ class HostClientAccountingTest { } } + @Test + fun `UDP status reply reports default Games map instead of global map`() { + cvars.find("mapname").forceSet("another-game-map") + cvars.find("port").forceSet(0) + val loopback = java.net.InetAddress.getByName("127.0.0.1") + java.net.DatagramSocket(0, loopback).use { receiver -> + receiver.soTimeout = 2000 + assertTrue(NET.configureServer(true)) + try { + val request = byteArrayOf(-1, -1, -1, -1) + "status".toByteArray() + receiver.send(java.net.DatagramPacket(request, request.size, loopback, + NET.ip_sockets[Defines.NS_SERVER]!!.localPort)) + val response = java.net.DatagramPacket(ByteArray(2048), 2048) + // The UDP receive path is nonblocking; allow delivery before pumping it again. + val deadline = System.nanoTime() + 2_000_000_000L + receiver.soTimeout = 20 + while (true) { + host.readPackets() + try { + receiver.receive(response) + break + } catch (waiting: java.net.SocketTimeoutException) { + if (System.nanoTime() >= deadline) throw waiting + } + } + assertEquals(-1, java.nio.ByteBuffer.wrap(response.data).int) + val text = String(response.data, 4, response.length - 4) + assertTrue(text.startsWith("print\n")) + val info = text.substringAfter('\n').substringBefore('\n') + assertEquals(first.mapName, Info.Info_ValueForKey(info, "mapname")) + assertFalse(text.contains("another-game-map")) + } finally { + NET.configureServer(false) + } + } + } + @Test fun `remote reconnect uses Host acceptance time across Game frame changes`() { // Loopback transport with a nonlocal port exercises remote admission without a UDP socket. diff --git a/server/src/main/java/jake2/server/GameImportsImpl.java b/server/src/main/java/jake2/server/GameImportsImpl.java index a398d4b9..316e3fda 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -256,7 +256,8 @@ boolean isPaused() { /** Overlay this Game's map, mode and rules on the remaining shared server-info settings. */ public String serverInfo() { String info = Cvar.getInstance().Serverinfo(); - info = Info.Info_SetValueForKey(info, "mapname", mapName); + // Never retain another Game's compatibility value if this value cannot fit. + info = Info.Info_SetValueForKey(Info.Info_RemoveKey(info, "mapname"), "mapname", mapName); info = Info.Info_SetValueForKey(info, "deathmatch", deathmatch.getRawString()); info = Info.Info_SetValueForKey(info, "coop", coop.getRawString()); for (String rule : List.of("dmflags", "fraglimit", "timelimit")) { diff --git a/server/src/test/kotlin/jake2/server/GameModeCvarsTest.kt b/server/src/test/kotlin/jake2/server/GameModeCvarsTest.kt index 3e5c0684..3a6f3ebc 100644 --- a/server/src/test/kotlin/jake2/server/GameModeCvarsTest.kt +++ b/server/src/test/kotlin/jake2/server/GameModeCvarsTest.kt @@ -44,6 +44,14 @@ class GameModeCvarsTest { state = ServerStates.SS_PIC } + @Test + fun `unrepresentable map name cannot fall back to another Games map`() { + cvars.find("mapname").forceSet("other-map") + val oversized = GameImportsImpl(host, ChangeMapInfo("x".repeat(64) + ".pcx", false, false)) + assertEquals("", jake2.qcommon.Info.Info_ValueForKey(oversized.serverInfo(), "mapname")) + assertEquals("other-map", cvars.find("mapname").typedValue) + } + @Test fun `local rule edits stay isolated and validate without changing defaults`() { val first = game().apply { name = "default" } From 1bf70a91b89fc3f79b37798b68c5de2499d904e1 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 12:06:09 +0100 Subject: [PATCH 46/47] Isolate opt-in rendered game tests by application process --- cake/cake-fullgame/build.gradle.kts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cake/cake-fullgame/build.gradle.kts b/cake/cake-fullgame/build.gradle.kts index 0ced98f3..8d6fc600 100644 --- a/cake/cake-fullgame/build.gradle.kts +++ b/cake/cake-fullgame/build.gradle.kts @@ -17,3 +17,11 @@ dependencies { runtimeOnly(project(":game")) testImplementation(project(":game")) } + +// Real-application tests bootstrap process-wide engine state and replace user.home. +// Keep each opt-in test class in its own application process. +tasks.test { + if (providers.environmentVariable("CAKE_HOST_GAME_TESTS").orNull == "1") { + forkEvery = 1 + } +} From b52ce6ba93801ea02ebe62b19421507742fac0a7 Mon Sep 17 00:00:00 2001 From: Daniil Bubnov Date: Tue, 27 Oct 2026 12:27:44 +0100 Subject: [PATCH 47/47] Bump version to 1.2.5 after Host/Game cleanup Consolidate legacy SV_* responsibilities into ServerHost and Game ownership. Unify session preparation, save/load outcomes, and failure cleanup; fix level revisits and component persistence. Contain Game failures, guarantee client cleanup, and dispose dedicated Hosts reliably. Isolate Game rules, collision scratch state, pause policy, and map reporting; remove obsolete timing state. Validate rendered save/load, multiplayer networking, and dedicated stop/restart. Execution-context and multi-Game persistence isolation remain deferred. --- build.gradle | 2 +- .../main/kotlin/org/demoth/cake/lwjgl3/Lwjgl3ViewerLauncher.kt | 2 +- .../main/kotlin/org/demoth/cake/lwjgl3/CakeDesktopLauncher.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index aab2b297..57fc8683 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ subprojects { apply plugin: 'jacoco' group = 'org.demoth' - version = '1.2.4' + version = '1.2.5' ext.appName = 'cake' kotlin { diff --git a/cake/cake-viewer/src/main/kotlin/org/demoth/cake/lwjgl3/Lwjgl3ViewerLauncher.kt b/cake/cake-viewer/src/main/kotlin/org/demoth/cake/lwjgl3/Lwjgl3ViewerLauncher.kt index cab32b57..a017c64b 100644 --- a/cake/cake-viewer/src/main/kotlin/org/demoth/cake/lwjgl3/Lwjgl3ViewerLauncher.kt +++ b/cake/cake-viewer/src/main/kotlin/org/demoth/cake/lwjgl3/Lwjgl3ViewerLauncher.kt @@ -29,7 +29,7 @@ private fun createApplication(args: Array): Lwjgl3Application { } private fun createConfiguration() = Lwjgl3ApplicationConfiguration().apply { - setTitle("Cake Viewer 1.2.4") + setTitle("Cake Viewer 1.2.5") setOpenGLEmulation(Lwjgl3ApplicationConfiguration.GLEmulation.GL32, 3, 2) useVsync(true) //// Limits FPS to the refresh rate of the currently active monitor. diff --git a/cake/desktop-common/src/main/kotlin/org/demoth/cake/lwjgl3/CakeDesktopLauncher.kt b/cake/desktop-common/src/main/kotlin/org/demoth/cake/lwjgl3/CakeDesktopLauncher.kt index ce81b405..d363d2b4 100644 --- a/cake/desktop-common/src/main/kotlin/org/demoth/cake/lwjgl3/CakeDesktopLauncher.kt +++ b/cake/desktop-common/src/main/kotlin/org/demoth/cake/lwjgl3/CakeDesktopLauncher.kt @@ -39,7 +39,7 @@ object CakeDesktopLauncher { val displayMode = modes.firstOrNull { it.toCakeMode() == selected && it.bitsPerPixel == desktop.bitsPerPixel } ?: modes.firstOrNull { it.toCakeMode() == selected } ?: desktop return Lwjgl3ApplicationConfiguration().apply { - setTitle("Cake Engine v1.2.4") + setTitle("Cake Engine v1.2.5") setInitialVisible(!videoMode.hidden) setOpenGLEmulation(Lwjgl3ApplicationConfiguration.GLEmulation.GL32, 3, 2) setBackBufferConfig(