diff --git a/build.gradle b/build.gradle index aab2b297d..57fc8683f 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-fullgame/build.gradle.kts b/cake/cake-fullgame/build.gradle.kts index 0ced98f3f..8d6fc6007 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 + } +} 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 503b9411e..1e5557d36 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,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: ServerHost? = null + private set private var starting = false private var ownerThread: Thread? = null @@ -62,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) @@ -128,18 +129,14 @@ class EmbeddedCakeHost( server = null starting = false try { - manager?.SV_Shutdown("$reason\n", false) + manager?.stop("$reason\n", false) } finally { try { 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 @@ -151,8 +148,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/java/jake2/server/GameImportsTest.java b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java index 901333667..8cb7d4d7f 100644 --- a/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java +++ b/cake/cake-fullgame/src/test/java/jake2/server/GameImportsTest.java @@ -1,12 +1,17 @@ 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; +import jake2.qcommon.ServerEntity; import jake2.qcommon.exec.Cvar; import jake2.qcommon.MainCommon; import jake2.qcommon.Globals; import jake2.qcommon.ServerStates; +import jake2.qcommon.Defines; +import jake2.qcommon.network.messages.server.*; import jake2.qcommon.vfs.EngineVfs; import jake2.qcommon.vfs.EngineWriteRoot; import jake2.qcommon.vfs.VfsConfig; @@ -25,7 +30,7 @@ public class GameImportsTest { @TempDir Path writeRoot; private Path previousWriteRoot; - private SV_MAIN manager; + private ServerHost manager; private ServerStates previousServerState; @BeforeEach @@ -50,18 +55,374 @@ 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); + testInstance.runFrame(); - Assertions.assertEquals(1, testInstance.sv.framenum); + Assertions.assertEquals(1, testInstance.frameNum); + } + + @Test + public void gamesKeepIndependentMapStateAndFrameCounters() { + manager = new ServerHost(); + 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.runFrame(); + + 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 + public void resourceUpdatesStayWithTheirGameAndOnlySendAfterLoading() { + manager = new ServerHost(); + 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); + 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 spatialQueriesAndClearingStayWithTheirGame() { + 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); + 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 clientSnapshotsKeepIndependentGameHistory() { + 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); + 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 queuedSoundsKeepTheirPositionsAndStayWithinTheirGame() { + manager = new ServerHost(); + 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 levelInitializationTransfersPersistentStateAndKeepsEventsGameLocal() { + 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); + Assertions.assertEquals(2, firstGameplay.level.framenum); + firstGameplay.game.serverflags = 7; + manager.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 ServerHost(); + 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; + manager.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 hostProtocolDispatchAndShutdownUseTheirOwnGameRegistry() { + 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(); + 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.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.executeClientCommand(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 (ServerHost other = new ServerHost()) { + 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 saveCommandResolvesOnlyDefaultGameWithoutReregistration() throws Exception { + manager = new ServerHost(); + 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.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"); + Assertions.assertFalse(store.hasMapCommand("idle")); } @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/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt b/cake/cake-fullgame/src/test/kotlin/jake2/server/CakeHostApplicationTest.kt index f2a6f6983..ca03815ef 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 5b0a2a552..906e65cf2 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 ecc7820ac..0dc259aff 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 b53e418f1..77f76ee49 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 @@ -53,7 +54,18 @@ class EmbeddedCakeHostTest { cvars.clear() } - private fun game() = SV_MAIN.games.getValue("default").gameExports as GameExportsImpl + @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 fun `missing download receives failure for fresh and resumed requests`() { @@ -69,8 +81,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!!.executeClientCommand(client, command) }, downloadManager = CakeDownloadManager(CakeDownloadCache(cache), cvars), ) @@ -95,7 +107,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 }) } @@ -114,6 +126,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 +134,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") @@ -152,7 +165,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/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 000000000..81dcf2c3f --- /dev/null +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/GameContinuationTest.kt @@ -0,0 +1,399 @@ +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 +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 + 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 `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") + 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 `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) + } + + @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() { + if (collision) first.cm.InlineModel("invalid") + else 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") + 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) + } + + @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.OperationResult.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") + 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"]) + picture.findCvar("coop", Boolean::class.java).forceSet(true) + finish(client, picture) + host.update(100) + assertEquals("testbox2", host.games.getValue("default").mapName) + } +} 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 000000000..1b3f2fed6 --- /dev/null +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostClientAccountingTest.kt @@ -0,0 +1,407 @@ +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 frame reset and expires at Host boundary`() { + val client = bind(0, first) + host.timeout.forceSet(0.5f) + 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.frameNum = 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("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) + 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.frameNum = 10000 + host.update(1) + 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") + } + } + + @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) + client.state = ClientStates.CS_SPAWNED + first.frameNum = 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.frameNum = 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 `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 `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. + 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.frameNum = 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.frameNum = 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/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 000000000..b7d43a4fa --- /dev/null +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/HostSessionPreparationTest.kt @@ -0,0 +1,694 @@ +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()) + } + + @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) + } + + @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 `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) + val original = host.games.getValue("default") + original.configstrings[Defines.CS_NAME] = "Saved level name" + original.SV_WriteLevelFile() + ServerSaveFiles(root).copy("current", "slot") + 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")) + assertInstanceOf(ServerHost.OperationResult.Failed::class.java, host.saveGame("slot")) + 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.OperationResult.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.OperationResult.Completed, 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.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.OperationResult.Completed, 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) + } + + @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.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) + 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.OperationResult.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.OperationResult.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.OperationResult.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.OperationResult.Failed::class.java, host.changeMap("testbox")) + assertTrue(host.games.isEmpty()) + assertTrue(host.clients.isEmpty()) + assertFalse(globalShutdown) + } + cvars.find("port").forceSet(0) + 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) + } + } + + @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() + } + + @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") + } + } + + @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") + } + } + + @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 `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) + 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.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 new file mode 100644 index 000000000..8ba391515 --- /dev/null +++ b/cake/cake-fullgame/src/test/kotlin/jake2/server/TransitionArchiveTest.kt @@ -0,0 +1,128 @@ +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 + +class TransitionArchiveTest { + @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 + + @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 + assertInstanceOf(ServerHost.OperationResult.Rejected::class.java, host.changeMap("testbox2")) + 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/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 cab32b575..a017c64b7 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/core/src/main/kotlin/org/demoth/cake/Cake.kt b/cake/core/src/main/kotlin/org/demoth/cake/Cake.kt index d8cf2afa3..d4394d278 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/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 ce81b405b..d363d2b4f 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( diff --git a/client/src/main/java/jake2/client/CL.java b/client/src/main/java/jake2/client/CL.java index 7e4eb03d6..fee8a7c53 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 8711dc014..dadbf5591 100644 --- a/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java +++ b/dedicated/src/main/java/jake2/dedicated/Jake2Dedicated.java @@ -7,11 +7,9 @@ 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.JakeServer; -import jake2.server.SV_MAIN; +import jake2.server.ServerHost; import java.util.Arrays; import java.util.List; @@ -21,7 +19,6 @@ public class Jake2Dedicated { - @SuppressWarnings("deprecation") public static void main(String[] args) { @@ -34,7 +31,6 @@ public static void main(String[] args) { if (argc > 1) { System.arraycopy(args, 0, c_args, 1, argc - 1); } - JakeServer serverMain = null; try { // prepare enough of the subsystems to handle @@ -60,24 +56,27 @@ public static void main(String[] args) { // init commands and vars Netchan.Netchan_Init(); - serverMain = new SV_MAIN(); //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; @@ -91,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 000000000..ed7f77cbc --- /dev/null +++ b/dedicated/src/test/kotlin/jake2/dedicated/DedicatedLifetimeTest.kt @@ -0,0 +1,113 @@ +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 `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 + 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]) + } +} diff --git a/game/src/main/java/jake2/game/GameCvars.java b/game/src/main/java/jake2/game/GameCvars.java index 641102332..dfb1f9cf3 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 931829169..2ccc4fb9a 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 { @@ -57,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", @@ -1592,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); } } @@ -1613,7 +1611,7 @@ public void readGameLocals(String filename) { } catch (Exception e) { - e.printStackTrace(); + throw new IllegalStateException("Failed to restore gameplay save " + filename, e); } } @@ -1635,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); } } @@ -1645,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); @@ -1680,7 +1686,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); } } @@ -1689,12 +1695,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 fbcce861f..000000000 --- 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 5fbd9bd7e..969eec40a 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/game/src/main/kotlin/jake2/game/EntityComponentsSnapshot.kt b/game/src/main/kotlin/jake2/game/EntityComponentsSnapshot.kt new file mode 100644 index 000000000..7590d3e76 --- /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 1a12acbdb..7b1440169 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 a67d96762..4dc8d3f6c 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 000000000..cbde378db --- /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/qcommon/src/main/java/jake2/qcommon/CM.java b/qcommon/src/main/java/jake2/qcommon/CM.java index 05dd818e1..27a48f140 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; @@ -991,14 +1008,16 @@ 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; - 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 +1710,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 +1754,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 +1769,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 +1837,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/main/java/jake2/qcommon/GameExports.java b/qcommon/src/main/java/jake2/qcommon/GameExports.java index 166bde417..91c07a4d5 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/qcommon/src/main/java/jake2/qcommon/entity_state_t.java b/qcommon/src/main/java/jake2/qcommon/entity_state_t.java index 89a517d66..f0914d80c 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/qcommon/src/main/java/jake2/qcommon/network/NET.java b/qcommon/src/main/java/jake2/qcommon/network/NET.java index 8b1dbb50b..8e585824a 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 { @@ -228,10 +227,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/qcommon/src/main/java/jake2/qcommon/sizebuf_t.java b/qcommon/src/main/java/jake2/qcommon/sizebuf_t.java index 26bbe7ffb..842491ca5 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/CollisionOwnershipTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/CollisionOwnershipTest.kt new file mode 100644 index 000000000..fd5f5932b --- /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/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt b/qcommon/src/test/kotlin/jake2/qcommon/OutputFailureTest.kt new file mode 100644 index 000000000..29b52246b --- /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 + } + } + } +} diff --git a/server/src/main/java/jake2/server/ClientNetworkInfo.java b/server/src/main/java/jake2/server/ClientNetworkInfo.java index 55c0e4fad..d4eff93b9 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; @@ -84,10 +85,17 @@ 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 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 788d1330d..316e3fda9 100644 --- a/server/src/main/java/jake2/server/GameImportsImpl.java +++ b/server/src/main/java/jake2/server/GameImportsImpl.java @@ -24,49 +24,57 @@ 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.CvarTypes; 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; +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.network.messages.server.SoundMessage; import jake2.qcommon.util.Lib; -import jake2.qcommon.util.Vargs; +import jake2.qcommon.util.Math3D; import jake2.server.save.ServerLevelJsonStore; import jake2.server.save.ServerSaveJsonStore; +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; +import java.util.Collection; import java.util.List; 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. - Also serves as the holder of game state from the server side - - todo make singleton (same as game exports) -*/ -@SuppressWarnings("deprecation") +/** Owns one server-side game instance and exposes engine services to its gameplay module. */ public class GameImportsImpl implements GameImports { String name; - final JakeServer serverMain; + final ServerHost 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 public int spawncount; // incremented each server start @@ -74,86 +82,249 @@ public class GameImportsImpl implements GameImports { public String firstmap = ""; String mapcmd = ""; // ie: *intro.cin+base - - SV_WORLD world; - + 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; + 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]; + + // 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; CM cm; - SV_ENTS sv_ents; - - SV_GAME sv_game; + // 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; + private final cvar_t configuredAirAcceleration; final float airAcceleration; private final PMove.PmoveProcessor pmoveProcessor = PMove.newLegacyProcessor(); - public GameImportsImpl(JakeServer serverMain, ChangeMapInfo changeMapInfo) { + public GameImportsImpl(ServerHost serverMain, ChangeMapInfo changeMapInfo) { this(serverMain, changeMapInfo, null); } - GameImportsImpl(JakeServer serverMain, ChangeMapInfo changeMapInfo, GameImportsImpl previous) { + GameImportsImpl(ServerHost 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); - spawncount = Lib.rand(); - - world = new SV_WORLD(); + // 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); + + 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)"); + + 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()); + } + } - cm = new CM(); - sv_ents = new SV_ENTS(this, serverMain.getClients().size() * Defines.UPDATE_BACKUP * 64); + spawncount = Lib.rand(); - sv_game = new SV_GAME(this); + 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(); // 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]); } // archive server state to be used in savegame mapcmd = changeMapInfo.levelString; + nextMap = changeMapInfo.nextServer; if (changeMapInfo.isLoadgame) { 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() - ? configuredAirAcceleration : 0f; - sv.configstrings[Defines.CS_AIRACCEL] = Float.toString(airAcceleration); + airAcceleration = isDeathmatch() + ? configuredAirAcceleration.getTypedValue() : 0f; + configstrings[Defines.CS_AIRACCEL] = Float.toString(airAcceleration); // clear physics interaction links // Q: is it required? the instance was just created - SV_WORLD.SV_ClearWorld(this); + clearWorld(); - SV_InitOperatorCommands(); } + /** Allocate the gameplay module after engine services and latched settings are ready. */ + static GameImportsImpl create(ServerHost 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) { + throw new IllegalStateException("Could not initialise game subsystem", e); + } + } + + public boolean isDeathmatch() { return deathmatch.getTypedValue(); } + + public boolean isCoop() { return coop.getTypedValue(); } + + /** 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(); + // 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")) { + info = Info.Info_SetValueForKey(info, rule, localCvars.find(rule).getRawString()); + } + return info; + } + + /** 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 && !isCoop())) { + 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(); + } + + /** 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()) { + // 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 + 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 * @@ -166,43 +337,24 @@ 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(); 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); } } - /** - * 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) { - serverMain.SV_BroadcastPrintf(printlevel, s, name); + serverMain.broadcastPrint(printlevel, s, name); } @Override @@ -212,28 +364,116 @@ 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()) + throw new GameFailure(this, "cprintf to a non-client"); + } + + if (ent != null) + serverMain.getClients().get(n - 1).queuePrint(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 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) { + + if (volume < 0 || volume > 1.0) + throw new GameFailure(this, "SV_StartSound: volume = " + volume); + + if (attenuation < 0 || attenuation > 4) + 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) + throw new GameFailure(this, "SV_StartSound: timeofs = " + timeofs); - SV_SEND.SV_StartSound(origin, ent, channel, soundinedex, volume, - attenuation, timeofs, this); + 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); } /* @@ -244,50 +484,124 @@ 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) + throw new GameFailure(this, "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 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 */ @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); + linkentity(ent); + } } /* collision detection */ @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 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 @@ -307,19 +621,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 @@ -356,290 +822,361 @@ public void AddCommandString(String text) { @Override public int getPointContents(float[] p) { - return SV_WORLD.SV_PointContents(p, this); - } - - private void SV_Savegame_f(List args) { - - if (sv.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 (Cvar.getInstance().find("deathmatch", Boolean.class).getTypedValue()) { - Com.Printf("Can't savegame in a deathmatch\n"); - return; + 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; + } - 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; - } + // 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. Multi-Game persistence is explicitly deferred; save targets the default Game. + /** 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 - try { - SV_WriteServerFile(false); - } - catch (Exception e) { - Com.Printf("IOError in SV_WriteServerFile: " + e); - } - - // copy it off - SV_CopySaveGame("current", saveGame); - Com.Printf("Done.\n"); + SV_WriteServerFile(false); + new ServerSaveFiles(EngineWriteRoot.path()).copy("current", slot); } - /* - ================== - SV_Kick_f + /** Advance this Game by one simulation tick; scheduling belongs to the Host. */ + void runFrame() { - 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; + // we always need to bump framenum, even if we + // don't run the world, otherwise the delta + // compression can get confused when a client + // has the "current" frame + frameNum++; - 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"); - SV_MAIN.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"); + if (isPaused()) { return; } - Com.Printf("map : " + sv.name + "\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])); + gameExports.G_RunFrame(); - 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(" "); + /** + * Sends text to all active clients + */ + void SV_BroadcastCommand(String s) { - Com.Printf("%7i ", new Vargs().add(realtime - cl.lastmessage)); + //fixme: check if server is running + if (state == ServerStates.SS_DEAD) + return; - String s = cl.netchan.remote_address.toString(); - Com.Printf(s); - l = 22 - s.length(); - for (j = 0; j < l; j++) - Com.Printf(" "); + for (ClientNetworkInfo client : serverMain.getClients()) { + if (client.state == ClientStates.CS_FREE || client.state == ClientStates.CS_ZOMBIE) + continue; + client.netchan.reliablePending.add(new StuffTextMessage(s)); + } + } - Com.Printf("%5i", new Vargs().add(cl.netchan.qport)); + /** 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); + } - Com.Printf("\n"); - i++; - } - Com.Printf("\n"); + private static void clearLink(link_t l) { + l.prev = l.next = l; } - /* - ================== - SV_ConSay_f - ================== - */ - private void SV_ConSay_f(List args) { - if (args.size() < 2) - return; + private static void removeLink(link_t l) { + l.next.prev = l.prev; + l.prev.next = l.next; + } - // char[1024]; - String text = "console: "; - String p = getArguments(args); + 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; + } - if (p.charAt(0) == '"') { - p = p.substring(1, p.length() - 1); + 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; + } - text += p; - - for (ClientNetworkInfo client: serverMain.getClients()) { - if (client.state != ClientStates.CS_SPAWNED) - continue; - SV_SEND.SV_ClientPrintf(client, Defines.PRINT_CHAT, text + "\n"); + 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]); } - /* - ================== - SV_Heartbeat_f - ================== - */ - private void SV_Heartbeat_f(List agrs) { + + 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) + throw new GameFailure(this, "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); } - /* - =========== - 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()); + 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; + } } - /* - =========== - 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; + 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; + } } - ClientNetworkInfo client = SV_SetPlayer(args); - if (client == null) - return; + } - Com.Printf("userinfo\n"); - Com.Printf("--------\n"); - Info.Print(client.userinfo); + /** 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; - /** - * Sets sv_client and sv_player to the player with idnum Cmd.Argv(1) - */ - private ClientNetworkInfo SV_SetPlayer(List args) { + for (i = 1; i < max && configstrings[start + i] != null; i++) + if (val.equals(configstrings[start + i])) + return i; - if (args.size() < 2) - return null; + if (i == max) + throw new GameFailure(this, "*Index: overflow"); - String idOrName = args.get(1); + configstring(start + i, val); - // 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; - } + return i; + } - 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; - } + /** Build the entity baselines used to compress client updates. */ + void createBaselines() { + for (int entnum = 1; entnum < gameExports.getNumEdicts(); entnum++) { + ServerEntity svent = gameExports.getEdict(entnum); - // check for a name match - for (ClientNetworkInfo cl: serverMain.getClients()) { - if (ClientStates.CS_FREE == cl.state) + if (!svent.inuse) + continue; + if (0 == svent.s.modelindex && 0 == svent.s.sound + && 0 == svent.s.effects) continue; - if (idOrName.equals(cl.name)) { - return cl; - } - } - Com.Printf("Userid " + idOrName + " is not on the server\n"); - return null; + svent.s.index = entnum; + + // take current state as baseline + Math3D.VectorCopy(svent.s.origin, svent.s.old_origin); + baselines[entnum].set(svent.s); + } } - /** - * SV_RunGameFrame. - */ - void SV_RunGameFrame(long fixedtime) { + /** Restore archived level state, settling revisited levels before clients reconnect. */ + void restorePreviousGame() { - // we always need to bump framenum, even if we - // don't run the world, otherwise the delta - // compression can get confused when a client - // has the "current" frame - sv.framenum++; + // get configstrings and areaportals + // then read game entities + readLevelFile(); - if (SV_MAIN.sv_paused != null && SV_MAIN.sv_paused.getTypedValue() && serverMain.getClients().size() <= 1) { - return; + 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(); + } } + } - gameExports.G_RunFrame(); + boolean isPreviousLevel() { + if (isDeathmatch()) + return false; - // never get more than one tic behind - if (fixedtime < realtime) { - realtime = (int) fixedtime; - } + String name = EngineWriteRoot.resolve("save/current/" + mapName + ".sav.json").toString(); + + return new File(name).exists(); } - /** - * Sends text to all active clients - */ - 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"); - //fixme: check if server is running - if (sv.state == ServerStates.SS_DEAD) - return; + try { + ServerLevelJsonStore store = ServerLevelJsonStore.forWriteDir(EngineWriteRoot.pathString()); + store.applyLevelState(store.readLevelState("current", mapName), configstrings, cm); + } catch (Exception e) { + throw new IllegalStateException("Failed to restore server level " + mapName, e); + } - for (ClientNetworkInfo client : serverMain.getClients()) { - if (client.state == ClientStates.CS_FREE || client.state == ClientStates.CS_ZOMBIE) - continue; - client.netchan.reliablePending.add(new StuffTextMessage(s)); + String name = EngineWriteRoot.resolve("save/current/" + mapName + ".sav.json").toString(); + 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]; + } } } @@ -649,14 +1186,13 @@ 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"); - e.printStackTrace(); + throw new IllegalStateException("Failed to write current level state for " + mapName, e); } - String name = EngineWriteRoot.resolve("save/current/" + sv.name + ".sav.json").toString(); + String name = EngineWriteRoot.resolve("save/current/" + mapName + ".sav.json").toString(); gameExports.WriteLevel(name); } @@ -679,9 +1215,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); @@ -695,15 +1231,12 @@ 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 -> { 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 -> @@ -765,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 @@ -806,6 +1339,311 @@ 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); + + } + + /** + * 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) + throw new GameFailure(this, "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/JakeServer.java b/server/src/main/java/jake2/server/JakeServer.java deleted file mode 100644 index 1b24987a7..000000000 --- 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_CCMDS.java b/server/src/main/java/jake2/server/SV_CCMDS.java deleted file mode 100644 index c77e1506d..000000000 --- a/server/src/main/java/jake2/server/SV_CCMDS.java +++ /dev/null @@ -1,241 +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.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; -import java.io.IOException; -import java.io.RandomAccessFile; -import java.nio.file.Path; -import java.util.List; - -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(); - } - - /** - * 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. */ - 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_ENTS.java b/server/src/main/java/jake2/server/SV_ENTS.java deleted file mode 100644 index ed7bfe02b..000000000 --- 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.sv.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)) { - // 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.sv.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.sv.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_GAME.java b/server/src/main/java/jake2/server/SV_GAME.java deleted file mode 100644 index df9f92e2b..000000000 --- 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.sv.configstrings[index] = val; - - if (gameImports.sv.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.sv.configstrings[start + i] != null; i++) - if (val.equals(gameImports.sv.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.sv.baselines[entnum].set(svent.s); - } - } - - /** - * SV_CheckForSavegame. - */ - void restorePreviousGame(server_t sv) { - - // get configstrings and areaportals - // then read game enitites - SV_CCMDS.SV_ReadLevelFile(sv.name, gameImports); - - if (!sv.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 - for (int i = 0; i < 100; i++) { - gameImports.gameExports.G_RunFrame(); - } - } - } - - boolean isPreviousLevel(server_t sv) { - 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(); - - 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 deleted file mode 100644 index 6c78779d0..000000000 --- a/server/src/main/java/jake2/server/SV_MAIN.java +++ /dev/null @@ -1,1505 +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.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.netadr_t; -import jake2.qcommon.util.Lib; -import jake2.server.save.ServerSaveJsonStore; - -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; - -@SuppressWarnings("deprecation") -public class SV_MAIN implements JakeServer, 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(); - private final ServerCommands gameCommands = new ServerCommands(); - - @Override - public void registerGameCommand(String name, jake2.qcommon.exec.Command command) { - gameCommands.add(name, true, command); - } - - 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(); - } - } - - @Override - public List getClients() { - return clients; - } - - private static final int MAX_STRINGCMDS = 8; - - public static 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 - 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()); - } - - - /** - * 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 - - private List clients = new ArrayList<>(); - - private long realtime = 0; - private int frameNum = 0; - private int fixedtime = 1000; - public static int gameCounter = 0; - - static { - for (int i = 0; i < Defines.MAX_MASTERS; i++) { - master_adr[i] = new netadr_t(); - } - - } - - static cvar_t sv_paused; - - static cvar_t sv_timedemo; - - static cvar_t sv_enforcetime; - - static cvar_t timeout; // seconds without any message - - static cvar_t zombietime; // seconds to sink messages after - // disconnect - - static cvar_t allow_download; - - static cvar_t allow_download_players; - - static cvar_t allow_download_models; - - static cvar_t allow_download_sounds; - - static cvar_t allow_download_maps; - - static cvar_t sv_airaccelerate; - - static cvar_t sv_showclamp; - - static cvar_t hostname; - - static cvar_t public_server; // should heartbeats be sent - - static 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 = SV_MAIN.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").sv.name + " " + 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.sv.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_MAIN.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_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. - */ - 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()) { - clearEntityStateEvents(game.gameExports); - } - } - - 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.sv.state == ServerStates.SS_CINEMATIC || game.sv.state == ServerStates.SS_DEMO || game.sv.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; - - SV_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. - */ - static 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 - * =================== - */ - static 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_MAIN.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); - } - } - } - - static void SV_ClientThink(ClientNetworkInfo cl, usercmd_t cmd) { - cl.commandMsec -= cmd.msec & 0xFF; - - if (cl.commandMsec < 0 && SV_MAIN.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. - */ - static 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); - } - - } - - private static 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.sv.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 * SV_MAIN.timeout.getTypedValue()); - int zombiepoint = (int) (game.realtime - 1000 * SV_MAIN.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).sv.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 - */ - @Override - public void SV_Shutdown(String finalmsg, boolean reconnect) { - try { - if (!games.isEmpty()) { - if (!clients.isEmpty()) SV_FinalMessage(finalmsg, 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; - 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() { - - // 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(); - } - - 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 callbacks only after initialization succeeds. Game operator commands - // are registered separately when a map starts. - 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", SV_CCMDS::VM_Mem_f); - 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) { - 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 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; - } - - /** - * 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) { - 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; - } - - SV_CopySaveGame(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 - SV_WipeSavegame("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.sv.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); - SV_CopySaveGame("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 - - 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(); - } - - gameImports = createGameInstance(changeMapInfo, gameImports); - - 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.sv.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.sv.name, gameImports.cm.CM_EntityString(), changeMapInfo.spawnPoint); - - if (gameImports.sv_game.isPreviousLevel(gameImports.sv)) { - // 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); - } 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.sv_game.SV_CreateBaseline(); - - - // Level restoration may replace configstrings, including saves from before air movement was published. - gameImports.sv.configstrings[CS_AIRACCEL] = Float.toString(gameImports.airAcceleration); - - // all precaches are complete - gameImports.sv.state = changeMapInfo.state; - Globals.server_state = gameImports.sv.state; - - - // set serverinfo variable - Cvar.getInstance().find("mapname", String.class).forceSet(gameImports.sv.name); - Com.Println("mapname: " + gameImports.sv.name); - - 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.sv.state = ServerStates.SS_DEAD; // don't save current level when changing - - SV_WipeSavegame("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(); - } - -} 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 78bb47b3e..000000000 --- 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.sv_ents.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.sv.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 deleted file mode 100644 index 4e83071d0..000000000 --- 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.sv.state == ServerStates.SS_CINEMATIC || gameImports.sv.state == ServerStates.SS_PIC) - clientIndex = -1; - else - clientIndex = client.edict.index - 1; - - - client.netchan.reliablePending.add( - new ServerDataMessage( - Defines.PROTOCOL_VERSION, - gameImports.spawncount, - gameImports.sv.isDemo, - gamedir, - clientIndex, - gameImports.sv.configstrings[Defines.CS_NAME] - )); - // - // game server - // - if (gameImports.sv.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.sv.configstrings[start] != null && gameImports.sv.configstrings[start].length() != 0) { - final ConfigStringMessage config = new ConfigStringMessage(start, gameImports.sv.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.sv.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.sv.state == ServerStates.SS_GAME - || (gameImports.sv.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/SV_WORLD.java b/server/src/main/java/jake2/server/SV_WORLD.java deleted file mode 100644 index 87ed2ac6a..000000000 --- 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.sv.models[1].mins, gameImports.sv.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.sv.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.sv.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.sv.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 diff --git a/server/src/main/java/jake2/server/ServerCommands.java b/server/src/main/java/jake2/server/ServerCommands.java index 1b0e3e1d6..f405d9678 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) 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 7aecb3170..000000000 --- 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/main/java/jake2/server/challenge_t.java b/server/src/main/java/jake2/server/challenge_t.java index 4b7bda831..70c05c0ee 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/java/jake2/server/client_frame_t.java b/server/src/main/java/jake2/server/client_frame_t.java index 7654f0d65..9ca7e7a0f 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/java/jake2/server/save/ServerSaveFiles.java b/server/src/main/java/jake2/server/save/ServerSaveFiles.java new file mode 100644 index 000000000..8b814e0fc --- /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/main/java/jake2/server/server_t.java b/server/src/main/java/jake2/server/server_t.java deleted file mode 100644 index cd26f3de8..000000000 --- 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/main/kotlin/jake2/server/GameFailure.kt b/server/src/main/kotlin/jake2/server/GameFailure.kt new file mode 100644 index 000000000..2a109e612 --- /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/SavePreflight.kt b/server/src/main/kotlin/jake2/server/SavePreflight.kt new file mode 100644 index 000000000..f3deecb27 --- /dev/null +++ b/server/src/main/kotlin/jake2/server/SavePreflight.kt @@ -0,0 +1,76 @@ +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) { + // 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" } + require(server.configstrings.size == Defines.MAX_CONFIGSTRINGS) { "Incomplete configstrings: $name" } + } + return PreparedSave(target, capacity) + } + + private fun readEnvelope(path: Path, version: Int = 1): Map<*, *> = Files.newInputStream(path).use { + val value = SaveJson.read(it, Map::class.java) + require(value["schemaVersion"] == version) { "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 new file mode 100644 index 000000000..7d3677b72 --- /dev/null +++ b/server/src/main/kotlin/jake2/server/ServerHost.kt @@ -0,0 +1,2048 @@ +/* + * 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. +// former SV_MAIN +package jake2.server + +import jake2.qcommon.* +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.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. */ +class ServerHost : AutoCloseable { + // region Host state and command dispatch + + private val commands = ServerCommands() + private val pendingContinuations = linkedMapOf() + val isRunning: Boolean + get() = games.isNotEmpty() + + @JvmField + val games: MutableMap = HashMap() + + private val challenges = Array(Defines.MAX_CHALLENGES) { challenge_t() } + private var challengeOrder = 0L + + var clients: MutableList = mutableListOf() + private set + + private var realtime: Long = 0 + private var frameNum = 0 + private var nextFrameTime = 1000 + private var gameCounter = 0 + + val isPaused: Boolean + get() = paused.typedValue + + private val paused: cvar_t + + private val enforceMoveTime: cvar_t + + @JvmField + val timeout: cvar_t // seconds without any message + + private val zombieTimeout: cvar_t // seconds to sink messages after + + private val allowDownload: cvar_t + + private val allowPlayerDownloads: cvar_t + + private val allowModelDownloads: cvar_t + + private val allowSoundDownloads: cvar_t + + private val allowMapDownloads: cvar_t + + private val hostname: cvar_t + + private val reconnectLimit: cvar_t // minimum seconds between connect + + 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 { _, game, _ -> Info.Print(game.serverInfo()) }, + StringCmdMessage.DOWNLOAD to ServerUserCommand { args, _, client -> beginDownload(args, client) }, + StringCmdMessage.NEXT_DOWNLOAD to ServerUserCommand { _, _, client -> sendNextDownloadChunk(client) }, + ) + + private fun interface ServerUserCommand { + fun execute(args: List, game: GameImportsImpl, client: ClientNetworkInfo) + } + + // endregion + + // region Initialization and lifecycle + + /** + * 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( + "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" + ) + + // 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") + + 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() + + // 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)) + + commands.add("killserver") { + stop("Server was killed.\n", 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" + } + stop(reason + "\n", args.size > 2 && args[2].toBoolean()) + } + + commands.add("listmaps") { + val bytes = EngineVfs.loadBytes("maps.lst") + if (bytes == null) { + Com.Printf("Could not read maps.lst\n") + 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")) + } + } + } + + /** Typed entrypoint for an embedded host; uses the same map path as the console. */ + fun startMap(mapName: String): Boolean { + val result = map(mapName) + reportTransition("map", result) + return result == OperationResult.Completed + } + + /** Release this manager's console hooks after ending its last session. */ + override fun close() { + commands.use { + stop("Server stopped\n", false) + } + } + + /** End the whole server session, retaining Host commands for a subsequent start. */ + fun stop(finalmsg: String?, reconnect: Boolean) { + try { + if (games.isNotEmpty()) { + if (clients.isNotEmpty()) sendFinalMessage(finalmsg, reconnect) + Com.Printf("==== ShutdownGame ====\n") + } + } finally { + games.clear() + pendingContinuations.clear() + clients.clear() + publishLegacyServerState(ServerStates.SS_DEAD) + realtime = 0 + frameNum = 0 + nextFrameTime = 100 + 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) + // Keep outgoing final messages available to the local client. + NET.clearLoopback(Defines.NS_SERVER) + } + } + + /** + * 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 + */ + fun resetClients() { + sendFinalMessage("Server restarted", true) + val max = Cvar.getInstance().find("maxclients").typedValue + + // Clear all clients + clients = MutableList(max) { + ClientNetworkInfo().apply { lastcmd = usercmd_t() } + } + } + + /** 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 = 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 + } + check(NET.configureServer(multiplayer)) { "Could not open the server UDP port" } + if (savedMaxClients == null || clients.size != savedMaxClients) resetClients() + } + + /** + * @return if the game is multiplayer or not + */ + 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() + + 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.typedValue) { + if (!coop.typedValue) deathmatch.forceSet(true) + } + + // 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 + } + + /** Bridge for client loading state and cvar latching until shared-global cleanup. */ + private fun publishLegacyServerState(state: ServerStates) { + Globals.server_state = state + } + + // endregion + + // region Game routing and map/save transitions + + 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 + + fun allocateGameName(): String { + return "jake2_${gameCounter++}" + } + + /** + * SV_InitGame. + * + * A brand new game has been started. + */ + fun createGameInstance(changeMapInfo: ChangeMapInfo, previous: GameImportsImpl?): GameImportsImpl { + return GameImportsImpl.create(this, changeMapInfo, previous) + } + + 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. */ + fun loadGame(args: List) { + if (args.size != 2) { + Com.Printf("USAGE: load \n") + return + } + Com.Printf("Loading game...\n") + reportTransition("load", loadGame(args[1])) + } + + /** Preflight preserves the live session; failures after copying begins leave an idle Host. */ + fun loadGame(slot: String): OperationResult { + val saveGame = try { + ServerSaveJsonStore.normalizeSlot(slot) + } catch (_: IllegalArgumentException) { + return OperationResult.Rejected("Bad save name") + } + if (saveGame == "current") return OperationResult.Rejected("Can't load from 'current'") + + val prepared = try { + SavePreflight(EngineWriteRoot.path()).inspect(saveGame) + } catch (e: Exception) { + return OperationResult.Rejected(e.message ?: "Invalid save") + } + val savedMaxClients = prepared.maxClients + if (games.keys.any { it != "default" } && clients.size != savedMaxClients) { + return OperationResult.Rejected("Can't change saved client capacity while other Games are running") + } + + 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 } + OperationResult.Completed + } catch (failure: Exception) { + recoverFailedTransition(failure) + OperationResult.Failed(failure.message ?: "Save restoration failed", failure) + } + } + + private fun recoverFailedTransition(failure: Exception) { + try { + stop("Server transition failed\n", false) + } catch (cleanupFailure: Exception) { + // stop clears Host state in finally even if notifying clients fails. + failure.addSuppressed(cleanupFailure) + } + } + + /* +================== +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 [client]\n") + return + } + val clientIndex = if (args.size > 2 && args[2].isNotBlank()) args[2].toIntOrNull() else 0 + if (clientIndex == null) { + reportTransition("gamemap", OperationResult.Rejected("Invalid client index")) + return + } + reportTransition("gamemap", changeMap(args[1], clientIndex)) + } + + fun changeMap(level: String, clientIndex: Int = 0): OperationResult = + transitionMap(level, clientIndex, newSession = false) + + fun map(level: String): OperationResult = transitionMap(level, 0, newSession = true) + + 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" } + if (clientIndex != 0) { + require(games.containsKey(clients[clientIndex - 1].gameName)) { "Client has no running Game" } + } + parsed + } catch (failure: Exception) { + return OperationResult.Rejected(failure.message ?: "Invalid map request") + } + + // 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: 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}") + } + } + + 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) + OperationResult.Completed + } catch (failure: Exception) { + recoverFailedTransition(failure) + OperationResult.Failed(failure.message ?: "Map transition failed", failure) + } + } + + 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: OperationResult) { + when (result) { + OperationResult.Completed -> Unit + is OperationResult.Rejected -> Com.Printf("$operation rejected: ${result.reason}\n") + is OperationResult.Failed -> Com.Printf("$operation failed: ${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 + // 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 + } + + // copy off the level to the autosave slot + if (!Globals.dedicated.typedValue) { + 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["default"] = game + } + } + + /** + * SV_Map + */ + fun spawnServerInstance(changeMapInfo: ChangeMapInfo, clientIndex: Int): GameImportsImpl { + 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) + + Cmd.ExecuteFunction("loading") // for local system + + 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")) + } + } + + sendClientMessages() + + // precache and static commands can be issued during + // map initialization + publishLegacyServerState(gameImports.state) + + gameImports.initializeLevel(changeMapInfo, previous) + publishLegacyServerState(gameImports.state) + + // set serverinfo variable + Cvar.getInstance().find("mapname").forceSet(gameImports.mapName) + Com.Println("mapname: " + gameImports.mapName) + + if (changeMapInfo.state == ServerStates.SS_GAME) Cbuf.CopyToDefer() + + for (client in transitionClients) { + if (client.state == ClientStates.CS_CONNECTED) { + client.netchan.reliablePending.add(StuffTextMessage("reconnect")) + } + } + 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 map(args: List) { + if (args.size != 2) { + Com.Printf("usage: map \n") + return + } + reportTransition("map", map(args[1])) + } + + /** Console syntax stays here; Game owns save eligibility and serialization. */ + private fun saveGame(args: List) { + if (args.size != 2) { + Com.Printf("USAGE: save \n") + return + } + 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: GameFailure) { + handleGameFailure(failure) + OperationResult.Failed(failure.message ?: "Game failed while saving", failure) + } catch (failure: Exception) { + OperationResult.Failed(failure.message ?: "Save failed", failure) + } + } + + // endregion + + // region Frame scheduling and client time accounting + + /** + * 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 + + realtime += msec + + // keep the random time dependent + Lib.rand() + + // check timeouts + checkTimeouts() + + // 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). + var delay = nextFrameTime - realtime + if (delay > 0) { + // Bound the dedicated sleep while waiting for the next scheduled tick. + delay = delay.coerceAtMost(100) + NET.Sleep(delay.toInt()) + return + } + + // move autonomous things around if enough time has passed + frameNum++ + nextFrameTime = 100 * frameNum + + // update ping based on the last known frame from all clients + updateClientPings() + + // give the clients some timeslices + replenishClientMoveBudgets() + + // let everything in the world think and move + for (game in games.values.toList()) { + if (games[game.name] !== game) continue + withGameFailureBoundary { game.runFrame() } + } + if (games.isEmpty()) return + + // send messages back to the clients that had packets read this frame + sendClientMessages() + + // send a heartbeat to the master if needed + + // clear teleport flags, etc for next frame + for (game in games.values) { + game.clearEntityStateEvents() + } + } + + /** + * If a packet has not been received from a client for the configured timeout + * 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 + * necessary. + */ + fun checkTimeouts() { + for (cl in clients) { + if (cl.state == ClientStates.CS_FREE) continue + + 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 + continue + } + if ((cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) && cl.lastmessage < droppoint) { + broadcastPrint(Defines.PRINT_HIGH, cl.name + " timed out\n", cl.gameName) + dropClient(cl) + cl.state = ClientStates.CS_FREE // don't bother with zombie state + } + } + } + + /** + * Updates the cl.ping variables. + */ + fun updateClientPings() { + 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 replenishClientMoveBudgets() { + // why *1111 + if ((frameNum and 15) != 0) return + + for (cl in clients) { + if (cl.state == ClientStates.CS_FREE) continue + + cl.commandMsec = 1800 // 1600 + some slop + } + } + + // endregion + + // region Connection admission and disconnection + + /** + * Builds the string that is sent as heartbeats and status replies. + */ + private fun buildStatusResponse(): String { + var status = (games["default"]?.serverInfo() ?: 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 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]) + + 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" + } + + return info + } + + /** + * 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 oldestOrder = Long.MAX_VALUE + + var i: Int + i = 0 + while (i < Defines.MAX_CHALLENGES) { + if (from.CompareBaseAdr(challenges[i].adr)) break + if (challenges[i].issuedOrder < oldestOrder) { + oldestOrder = challenges[i].issuedOrder + oldest = i + } + i++ + } + + if (i == Defines.MAX_CHALLENGES) { + // Unused entries have order zero and are selected before issued entries. + challenges[oldest].challenge = Lib.rand().toInt() and 0x7fff + challenges[oldest].adr = from + // Eviction needs issuance order, not wall time or Game simulation time. + challenges[oldest].issuedOrder = ++challengeOrder + i = oldest + } + + return challenges[i].challenge + } + + /** 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") + + val version = if (args.size >= 2) Lib.atoi(args[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[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()) + + // Match the source IP and echoed token before allocating/reusing a client slot. + 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) < (reconnectLimit.typedValue * 1000)) { + Com.DPrintf("$adr:reconnect rejected : too soon\n") + return + } + Com.Printf("$adr:reconnect\n") + + initializeClientConnection(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 + } + initializeClientConnection(index, challenge, userinfo, adr, qport) + } + + /** + * Initializes player structures after successfull connection. + */ + 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[i] + val gameImports: GameImportsImpl = games["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 + updateClientUserinfo(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 = realtime // don't timeout + client.lastconnect = 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 handleConnectionlessPacket(from: netadr_t, messageLine: String?) { + val args = Cmd.TokenizeString(messageLine, false) + + if (args.isEmpty()) { + Com.Printf("empty connectionless packet: $messageLine") + return + } + + val c = args[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" + buildStatusResponse() + ) + + 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") + } + } + + /** + * 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) { + 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 = "" + } + } + + // endregion + + // region Incoming packets and client commands + + /** + * 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 + ) + + if (networkPacket == null) break + + if (networkPacket.isConnectionless) { + handleConnectionlessPacket(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 = realtime // don't timeout + val body = networkPacket.parseBodyFromClient(cl.netchan.incoming_sequence) + if (body == null) dropClient(cl) + else handleClientMessages(cl, body) + } + } + break + } + } + } + + 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 + 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 = + (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 + } + } + + 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 (!games[cl.gameName]!!.isPaused) { + 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) + return // Never execute the remainder of a rejected packet. + } + } + } + + 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") + + // 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 + * + * 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 sendInitialGameData(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").typedValue + + // send the serverdata + // send full levelname + 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( + 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 + ) + ) + ) + } + } + + private fun sendConfigstrings( + args: List, + 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[1]) else 0 + if (spawnCount != gameImports.spawncount) { + Com.Printf("SV_Configstrings_f from different level\n") + sendInitialGameData(gameImports, client) + return + } + + var start = if (args.size >= 3) Lib.atoi(args[2]) else 0 + + // write a packet full of data + var currentReliableSize = + client.netchan.reliablePending.sumOf { it.size } + while (currentReliableSize < Defines.MAX_MSGLEN / 2 && start < Defines.MAX_CONFIGSTRINGS) { + if (gameImports.configstrings[start] != null && gameImports.configstrings[start].isNotEmpty()) { + val config = ConfigStringMessage(start, gameImports.configstrings[start]) + currentReliableSize += config.size + client.netchan.reliablePending.add(config) + } + start++ + } + + // send next command + val nextCmd: String = if (start == Defines.MAX_CONFIGSTRINGS) { + String.format("cmd %s %d 0", StringCmdMessage.BASELINES, gameImports.spawncount) + } else { + String.format("cmd %s %d %d", StringCmdMessage.CONFIG_STRINGS, gameImports.spawncount, start) + } + client.netchan.reliablePending.add(StuffTextMessage(nextCmd)) + } + + private fun sendBaselines(args: List, 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[1]) else 0 + if (spawnCount != gameImports.spawncount) { + Com.Printf("SV_Baselines_f from different level\n") + sendInitialGameData(gameImports, client) + return + } + + // todo: validate argument + var start = if (args.size >= 3) Lib.atoi(args[2]) else 0 + + // write a packet full of data + var currentReliableSize = + 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.size + client.netchan.reliablePending.add(spawn) + } + start++ + } + + // send next command + val nextCmd: String = if (start == Defines.MAX_EDICTS) { + // finished sending baselines + String.format("%s %d", StringCmdMessage.PRECACHE, gameImports.spawncount) + } else { + // continue from where we finished + String.format("cmd %s %d %d", StringCmdMessage.BASELINES, gameImports.spawncount, start) + } + client.netchan.reliablePending.add(StuffTextMessage(nextCmd)) + } + + 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[1]) else 0 + if (spawnCount != gameImports.spawncount) { + Com.Printf("SV_Begin_f from different level\n") + sendInitialGameData(gameImports, client) + return + } + + client.state = ClientStates.CS_SPAWNED + + // call the jake2.game begin function + gameImports.gameExports.ClientBegin(client.edict) + + Cbuf.InsertFromDefer() + } + + 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 result = continueGame(previous, nextMap) + reportTransition("Continuation for ${previous.name}", result) + if (result is OperationResult.Failed) break // Recovery invalidated the whole queued batch. + } + } + + private fun continueGame(previous: GameImportsImpl, nextMap: String): OperationResult { + val target = try { + if (nextMap.isEmpty()) null else prepareMapTarget(nextMap) + } catch (failure: Exception) { + return OperationResult.Rejected(failure.message ?: "Invalid continuation target") + } + return try { + val routedClients = clients.filter { it.gameName == previous.name } + if (target == null) { + stopGame(previous, "Game sequence ended.\n") + return OperationResult.Completed + } + // 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") + } + OperationResult.Completed + } catch (failure: Exception) { + recoverFailedTransition(failure) + OperationResult.Failed(failure.message ?: "Continuation failed", failure) + } + } + + /* + * ================== 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") + val nextMap = gameImports.takeNextMap() ?: return + pendingContinuations[gameImports] = nextMap + } + + // endregion + + // region Downloads + + private fun sendNextDownloadChunk(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 + } + } + + private fun beginDownload( + args: List, + client: ClientNetworkInfo + ) { + var offset = 0 + + if (args.size < 2) return + + 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("..") + || !allowDownload.typedValue // leading dot is no good + || name[0] == '.' // leading slash bad as well, must be + // in subdir + || name[0] == '/' // next up, skin check + || (name.startsWith("players/") && !allowPlayerDownloads.typedValue) // now + // models + || (name.startsWith("models/") && !allowModelDownloads.typedValue) // now + // sounds + || (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 + || 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 + } + + sendNextDownloadChunk(client) + Com.DPrintf("Downloading " + name + " to " + client.name + "\n") + } + + // endregion + + // region Outgoing messages and rate limiting + + fun sendClientMessages() { + // send a message to each connected client + + 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 + ) + } + } + } + + /** + * 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 + * + * + * 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 + + 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 + } + + /** + * 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, + recipients: List = clients + ) { + 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 recipients) { + if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) cl.netchan.transmit(msgs) + } + for (cl in recipients) { + if (cl.state == ClientStates.CS_CONNECTED || cl.state == ClientStates.CS_SPAWNED) cl.netchan.transmit(msgs) + } + } + + /** 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.frames[gameImports.frameNum and Defines.UPDATE_MASK].senttime = realtime + client.netchan.transmit(unreliable) + + // record the size for rate estimation + client.message_size[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") + return + } + val client = findClient(args) ?: return + + 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") + withGameFailureBoundary { dropClient(client) } + } + + private fun printStatus() { + // 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[i] + if (ClientStates.CS_FREE == cl.state) continue + + Com.Printf(i.toString().padStart(3) + " ") + Com.Printf(cl.edict.getClient().getPlayerState().stats[Defines.STAT_FRAGS].toString().padStart(5) + " ") + + 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)) + 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)) + + Com.Printf("\n") + } + Com.Printf("\n") + } + + private fun say(args: List) { + if (args.size < 2) return + + // char[1024]; + var text = "console: " + var p = Cmd.getArguments(args) + + if (p.isNotEmpty() && p[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") + } + } + + private fun printServerInfo() { + Com.Printf("Server info settings:\n") + Info.Print(games["default"]?.serverInfo() ?: Cvar.getInstance().Serverinfo()) + } + + private fun dumpUser(args: List) { + if (args.size != 2) { + Com.Printf("Usage: info \n") + return + } + val client = findClient(args) ?: 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 findClient(args: List): ClientNetworkInfo? { + if (args.size < 2) return null + + val idOrName = args[1] + + // numeric values are just slot numbers + // fixme: player name cannot start with a number? + 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[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 + } + + // endregion + + companion object { + // Recreating an embedded manager must reuse the same choice declaration. + 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 isRconAuthorized(args: List): Boolean { + val rconPassword = Cvar.getInstance().find("rcon_password").typedValue + if (rconPassword.isEmpty()) return false + + 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 executeRemoteCommand(args: List, from: netadr_t?) { + val rconIsValid: Boolean = isRconAuthorized(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( + 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 f5b089060..f19fd19c6 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); @@ -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 { @@ -153,6 +155,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]); } } diff --git a/server/src/test/java/jake2/server/GameCvarInitializationTest.java b/server/src/test/java/jake2/server/GameCvarInitializationTest.java index eb0e87fbc..a7851fd16 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(); @@ -28,13 +28,13 @@ 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); + cvar_t deathmatch = imports.findCvar("deathmatch", Boolean.class); assertEquals(1, maxclients.getTypedValue()); - assertEquals(0.5f, SV_MAIN.timeout.getTypedValue()); - assertSame(deathmatch, imports.findCvar("deathmatch", Boolean.class)); + assertEquals(0.5f, server.timeout.getTypedValue()); + 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 { @@ -62,7 +65,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); @@ -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(); @@ -98,7 +101,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 +135,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 +162,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( @@ -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(); @@ -189,7 +193,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)); 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 000000000..e16278335 --- /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); + } +} 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 000000000..3a6f3ebc8 --- /dev/null +++ b/server/src/test/kotlin/jake2/server/GameModeCvarsTest.kt @@ -0,0 +1,172 @@ +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 `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" } + 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) + 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) + } +} 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 000000000..8793b6d99 --- /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) + } +}