From 71a4cd465047360b2da32d14c2e8e82f6daa82c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 17:35:33 -0400 Subject: [PATCH] Store patches as diffs; drop multi-tenancy patch from the built jar Two changes, both prompted by review. Patches are now unified diffs in patches/patchset/ rather than copied source files. A copied file silently discards upstream changes to that file when rebasing onto a new MegaMek release; a diff either applies or fails loudly. build-patched-jar.sh copies only the touched files into a scratch dir, applies the patchset with -p1 there, compiles just those, and injects the classes into a copy of the release jar. The MegaMek source and release trees are never mutated, so the same patches apply to a fresh checkout. The multi-tenancy patch is no longer built. It is archived as a diff under patches/archive/multitenancy/ with a note on why: the architecture settled on one game per container, so the Server.getServerInstance() static never bites, and the patch was insufficient anyway. Verified the built jar now differs from stock MegaMek.jar in exactly one entry: entries: stock=4458 patched=4458 differing: megamek/client/ui/clientGUI/audio/Sound.class Server.class and AbstractGameManager.class are byte-identical to stock. Co-Authored-By: Claude Opus 5 --- README.md | 21 + patches/archive/multitenancy/README.md | 21 + .../archive/multitenancy/multitenancy.patch | 98 ++ patches/build-patched-jar.sh | 71 +- ...d-guard-optional-master-gain-control.patch | 28 + .../megamek/server/AbstractGameManager.java | 316 ---- patches/src/megamek/server/Server.java | 1556 ----------------- .../client/ui/clientGUI/audio/Sound.java | 0 8 files changed, 221 insertions(+), 1890 deletions(-) create mode 100644 patches/archive/multitenancy/README.md create mode 100644 patches/archive/multitenancy/multitenancy.patch create mode 100644 patches/patchset/0001-sound-guard-optional-master-gain-control.patch delete mode 100644 patches/src/megamek/server/AbstractGameManager.java delete mode 100644 patches/src/megamek/server/Server.java rename patches/{ => work/megamek}/src/megamek/client/ui/clientGUI/audio/Sound.java (100%) diff --git a/README.md b/README.md index 43daac6..05f3831 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,27 @@ ATProto-backed campaign game. using only public API, so tracking new MegaMek releases stays a recompile rather than a rebase. +## Patches + +MegaMek patches live in `patches/patchset/` as **unified diffs**, applied with +`-p1` against a pristine source tree. Never as copied source files: a copied file +silently discards upstream changes to that file on the next release, whereas a +diff conflicts loudly. + +`patches/build-patched-jar.sh` copies only the touched files out of +`megamek-0.51.0/` into a scratch dir, applies the patchset there, compiles just +those files, and injects the classes into a copy of `MegaMek.jar`. The MegaMek +source and release trees are never modified. (Injection rather than classpath +shadowing is required because `MegaMek.jar` is sealed.) + +Currently applied - verified to be the *only* difference from the stock jar: + +- `0001-sound-guard-optional-master-gain-control.patch` - MegaMek assumes the + optional `MASTER_GAIN` Clip control exists; under Webswing's audio mixer it + does not, and the client dies at startup with an uncaught NPE. Upstreamable. + +`patches/archive/` holds patches that were tried and dropped; see its README. + ## Layout - `harness/src/bench/` - benchmark + diagnostic harness diff --git a/patches/archive/multitenancy/README.md b/patches/archive/multitenancy/README.md new file mode 100644 index 0000000..a4ab45f --- /dev/null +++ b/patches/archive/multitenancy/README.md @@ -0,0 +1,21 @@ +# Archived: multi-tenancy patch (NOT applied) + +These are kept for reference only. They are **not** compiled into +`MegaMek-patched.jar`. + +They gave `AbstractGameManager` a reference to its owning `Server` instead of +reaching for the static `Server.getServerInstance()`, which points at the most +recently constructed Server and silently misroutes packets once a second game +exists in the same JVM. + +Dropped because: + +1. The architecture settled on **one game per container** (Fargate task), so two + Servers never share a JVM and the static is harmless. +2. The patch was **necessary but not sufficient** anyway - patched multi-game + runs still showed cross-game interference (round counters regressing, and a + 2-game run silently wedging). Other `getServerInstance()` callers remain in + `Compute`, `WeaponHandler`, `ACWeapon` and `TWGameManager`. + +To reproduce the density experiment, restore these to `patches/src/` and add +them back to `patches/build-patched-jar.sh`. diff --git a/patches/archive/multitenancy/multitenancy.patch b/patches/archive/multitenancy/multitenancy.patch new file mode 100644 index 0000000..4535f8f --- /dev/null +++ b/patches/archive/multitenancy/multitenancy.patch @@ -0,0 +1,98 @@ +--- a/megamek/src/megamek/server/Server.java ++++ b/megamek/src/megamek/server/Server.java +@@ -455,6 +455,14 @@ + connector.start(); + + serverInstance = this; ++ ++ // MULTI-TENANCY PATCH: give the manager a direct reference to its own server. ++ // The static serverInstance still exists (nothing else is disturbed), but a ++ // manager that knows its owner no longer has to guess via the static, which ++ // always points at the most recently constructed Server. ++ if (gameManager instanceof AbstractGameManager abstractGameManager) { ++ abstractGameManager.setOwningServer(this); ++ } + } + + public IGameManager getGameManager() { +--- a/megamek/src/megamek/server/AbstractGameManager.java ++++ b/megamek/src/megamek/server/AbstractGameManager.java +@@ -48,6 +48,27 @@ + public abstract class AbstractGameManager implements IGameManager { + private static final MMLogger logger = MMLogger.create(AbstractGameManager.class); + ++ /** ++ * MULTI-TENANCY PATCH: the Server that owns this manager. ++ * ++ *

Previously every method below reached for {@link Server#getServerInstance()}, ++ * a static pointing at the most recently constructed Server. With one game per JVM ++ * that is always the right answer. With two, the older game silently starts ++ * transmitting to the newer game's connections and both wedge. ++ * ++ *

Falls back to the static when unset so any construction path that does not go ++ * through the Server constructor behaves exactly as before. ++ */ ++ private Server owningServer; ++ ++ void setOwningServer(Server server) { ++ this.owningServer = server; ++ } ++ ++ protected Server server() { ++ return owningServer != null ? owningServer : Server.getServerInstance(); ++ } ++ + protected final GameManagerPacketHelper packetHelper = new GameManagerPacketHelper(this); + protected final GameManagerSaveHelper saveHandler = new GameManagerSaveHelper(this); + protected final AutosaveService autoSaveService = new AutosaveService(this); +@@ -60,7 +81,7 @@ + */ + @Override + public void send(Packet packet) { +- Server.getServerInstance().send(packet); ++ server().send(packet); + } + + /** +@@ -70,7 +91,7 @@ + */ + @Override + public void send(int connId, Packet p) { +- Server.getServerInstance().send(connId, p); ++ server().send(connId, p); + } + + @Override +@@ -203,7 +224,7 @@ + * @see #transmitAllPlayerUpdates() + */ + protected void transmitPlayerUpdate(Player player) { +- Server.getServerInstance().transmitPlayerUpdate(player); ++ server().transmitPlayerUpdate(player); + } + + /** +@@ -241,19 +262,19 @@ + } + + public void sendChat(String origin, String message) { +- Server.getServerInstance().sendChat(origin, message); ++ server().sendChat(origin, message); + } + + public void sendChat(int connId, String origin, String message) { +- Server.getServerInstance().sendChat(connId, origin, message); ++ server().sendChat(connId, origin, message); + } + + public void sendServerChat(String message) { +- Server.getServerInstance().sendServerChat(message); ++ server().sendServerChat(message); + } + + public void sendServerChat(int connId, String message) { +- Server.getServerInstance().sendServerChat(connId, message); ++ server().sendServerChat(connId, message); + } + + /** diff --git a/patches/build-patched-jar.sh b/patches/build-patched-jar.sh index ab6e45b..37d2c5f 100755 --- a/patches/build-patched-jar.sh +++ b/patches/build-patched-jar.sh @@ -1,38 +1,73 @@ #!/usr/bin/env bash -# Build a patched MegaMek.jar carrying the multi-tenancy fix. +# Build MegaMek-patched.jar by applying patches/patchset/*.patch to a pristine +# MegaMek source tree, compiling only the touched files, and injecting the +# resulting classes into a copy of the release jar. # -# MegaMek.jar sets "Sealed: true", so a replacement megamek.server.* class cannot -# simply be placed earlier on the classpath - the JVM refuses to load a sealed -# package from a second codebase. Injecting the recompiled classes back into a -# copy of the jar keeps the seal intact. +# Patches are stored as unified diffs, not as copied source files. A copied file +# silently discards any upstream change to that file on the next MegaMek release; +# a diff either applies cleanly or fails loudly, which is the behaviour we want +# when tracking upstream. # -# The pristine release tree is never modified; output is MegaMek-patched.jar. +# Nothing here mutates the MegaMek source or release tree - the sources are +# copied to a scratch dir first. Output is MegaMek-patched.jar. +# +# MegaMek.jar sets "Sealed: true", so patched classes cannot simply be placed +# earlier on the classpath; the JVM refuses to load a sealed package from a +# second codebase. Injecting them back into a copy of the jar keeps the seal +# intact. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" MM_HOME="${MM_HOME:-$ROOT/MegaMek-0.51.00}" +MM_SRC="${MM_SRC:-$ROOT/megamek-0.51.0}" JAVA_HOME="${JAVA_HOME:-$ROOT/jdk-21.0.12+8}" +[ -d "$MM_SRC" ] || { echo "ERROR: MegaMek source tree missing at $MM_SRC (run scripts/fetch-deps.sh)" >&2; exit 1; } +command -v patch >/dev/null || { echo "ERROR: 'patch' not found" >&2; exit 1; } + CP="$MM_HOME/MegaMek.jar" for jar in "$MM_HOME"/lib/*.jar; do CP="$CP:$jar"; done +WORK="$ROOT/patches/work" OUT="$ROOT/patches/out" -rm -rf "$OUT" -mkdir -p "$OUT" - -echo "compiling patched sources..." -"$JAVA_HOME/bin/javac" \ - -nowarn \ - -classpath "$CP" \ - -d "$OUT" \ - "$ROOT"/patches/src/megamek/server/Server.java \ - "$ROOT"/patches/src/megamek/server/AbstractGameManager.java \ - "$ROOT"/patches/src/megamek/client/ui/clientGUI/audio/Sound.java +rm -rf "$WORK" "$OUT" +mkdir -p "$WORK" "$OUT" + +shopt -s nullglob +PATCHES=( "$ROOT"/patches/patchset/*.patch ) +shopt -u nullglob +if [ ${#PATCHES[@]} -eq 0 ]; then + echo "No patches in patches/patchset/ - nothing to do." + exit 0 +fi + +# Copy only the files each patch touches, preserving their tree layout, so the +# patches apply with -p1 exactly as they would against a fresh checkout. +TOUCHED=() +for p in "${PATCHES[@]}"; do + while read -r rel; do + [ -z "$rel" ] && continue + src="$MM_SRC/$rel" + [ -f "$src" ] || { echo "ERROR: $rel not found in $MM_SRC (patch stale?)" >&2; exit 1; } + mkdir -p "$WORK/$(dirname "$rel")" + cp "$src" "$WORK/$rel" + TOUCHED+=( "$WORK/$rel" ) + done < <(grep '^+++ b/' "$p" | sed 's|^+++ b/||') +done + +for p in "${PATCHES[@]}"; do + echo "applying $(basename "$p")" + patch -p1 -d "$WORK" --forward --silent < "$p" \ + || { echo "ERROR: failed to apply $(basename "$p")" >&2; exit 1; } +done + +echo "compiling ${#TOUCHED[@]} patched file(s)..." +"$JAVA_HOME/bin/javac" -nowarn -classpath "$CP" -d "$OUT" "${TOUCHED[@]}" PATCHED_JAR="$ROOT/MegaMek-patched.jar" cp "$MM_HOME/MegaMek.jar" "$PATCHED_JAR" -echo "injecting $(find "$OUT" -name '*.class' | wc -l) recompiled classes..." +echo "injecting $(find "$OUT" -name '*.class' | wc -l) recompiled class(es)..." "$JAVA_HOME/bin/jar" --update --file "$PATCHED_JAR" -C "$OUT" megamek echo "built -> $PATCHED_JAR" diff --git a/patches/patchset/0001-sound-guard-optional-master-gain-control.patch b/patches/patchset/0001-sound-guard-optional-master-gain-control.patch new file mode 100644 index 0000000..82e1db9 --- /dev/null +++ b/patches/patchset/0001-sound-guard-optional-master-gain-control.patch @@ -0,0 +1,28 @@ +--- a/megamek/src/megamek/client/ui/clientGUI/audio/Sound.java ++++ b/megamek/src/megamek/client/ui/clientGUI/audio/Sound.java +@@ -67,8 +67,24 @@ + } + + if (clip != null) { ++ // MASTER_GAIN is optional. A Clip implementation is free not to support ++ // it, and getControl() then returns null - so this must be checked rather ++ // than assumed. Webswing's audio mixer is one such implementation, and ++ // without this guard MegaMek dies at startup with an uncaught NPE before ++ // the client is usable at all. ++ if (!clip.isControlSupported(FloatControl.Type.MASTER_GAIN)) { ++ return; ++ } + FloatControl gainControl = (FloatControl) clip.getControl(FloatControl.Type.MASTER_GAIN); +- gainControl.setValue(20f * (float) Math.log10(volume)); ++ if (gainControl == null) { ++ return; ++ } ++ // log10(0) is -Infinity; clamp to the control's own floor instead. ++ float gain = (volume <= 0f) ++ ? gainControl.getMinimum() ++ : 20f * (float) Math.log10(volume); ++ gainControl.setValue(Math.max(gainControl.getMinimum(), ++ Math.min(gainControl.getMaximum(), gain))); + } + } + } diff --git a/patches/src/megamek/server/AbstractGameManager.java b/patches/src/megamek/server/AbstractGameManager.java deleted file mode 100644 index f7ec30d..0000000 --- a/patches/src/megamek/server/AbstractGameManager.java +++ /dev/null @@ -1,316 +0,0 @@ -/* - * Copyright (C) 2024-2025 The MegaMek Team. All Rights Reserved. - * - * This file is part of MegaMek. - * - * MegaMek is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License (GPL), - * version 3 or (at your option) any later version, - * as published by the Free Software Foundation. - * - * MegaMek 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. - * - * A copy of the GPL should have been included with this project; - * if not, see . - * - * NOTICE: The MegaMek organization is a non-profit group of volunteers - * creating free software for the BattleTech community. - * - * MechWarrior, BattleMech, `Mech and AeroTech are registered trademarks - * of The Topps Company, Inc. All Rights Reserved. - * - * Catalyst Game Labs and the Catalyst Game Labs logo are trademarks of - * InMediaRes Productions, LLC. - * - * MechWarrior Copyright Microsoft Corporation. MegaMek was created under - * Microsoft's "Game Content Usage Rules" - * and it is not endorsed by or - * affiliated with Microsoft. - */ - -package megamek.server; - -import megamek.common.Player; -import megamek.common.enums.GamePhase; -import megamek.common.game.IGame; -import megamek.common.net.enums.PacketCommand; -import megamek.common.net.packets.InvalidPacketDataException; -import megamek.common.net.packets.Packet; -import megamek.common.options.OptionsConstants; -import megamek.common.preference.PreferenceManager; -import megamek.common.util.StringUtil; -import megamek.logging.MMLogger; -import megamek.server.trigger.TriggerSituation; - -public abstract class AbstractGameManager implements IGameManager { - private static final MMLogger logger = MMLogger.create(AbstractGameManager.class); - - /** - * MULTI-TENANCY PATCH: the Server that owns this manager. - * - *

Previously every method below reached for {@link Server#getServerInstance()}, - * a static pointing at the most recently constructed Server. With one game per JVM - * that is always the right answer. With two, the older game silently starts - * transmitting to the newer game's connections and both wedge. - * - *

Falls back to the static when unset so any construction path that does not go - * through the Server constructor behaves exactly as before. - */ - private Server owningServer; - - void setOwningServer(Server server) { - this.owningServer = server; - } - - protected Server server() { - return owningServer != null ? owningServer : Server.getServerInstance(); - } - - protected final GameManagerPacketHelper packetHelper = new GameManagerPacketHelper(this); - protected final GameManagerSaveHelper saveHandler = new GameManagerSaveHelper(this); - protected final AutosaveService autoSaveService = new AutosaveService(this); - protected final GameManagerScriptedEventHelper scriptedEventHelper = new GameManagerScriptedEventHelper(this); - - /** - * Sends the given packet to all connections (all connected Clients = players). - * - * @see Server#send(Packet) - */ - @Override - public void send(Packet packet) { - server().send(packet); - } - - /** - * Sends the given packet to the given connection (= player ID). - * - * @see Server#send(int, Packet) - */ - @Override - public void send(int connId, Packet p) { - server().send(connId, p); - } - - @Override - public void handlePacket(int connId, Packet packet) { - if (packet.command() == PacketCommand.PLAYER_READY) { - try { - receivePlayerDone(packet, connId); - if (getGame().getPlayer(connId) != null) { - send(packetHelper.createPlayerDonePacket(connId)); - checkReady(); - } - } catch (InvalidPacketDataException e) { - logger.error("Invalid packet data:", e); - } - } - } - - /** - * Ends this phase and moves on to the next. - */ - protected abstract void endCurrentPhase(); - - /** - * Do anything we need to work through the current phase, such as give a turn to the first player to play. - */ - protected abstract void executeCurrentPhase(); - - /** - * Prepares for the game's current phase. This typically involves resetting the states of units in the game and - * making sure the clients have the information they need for the new phase. - */ - protected abstract void prepareForCurrentPhase(); - - /** - * Switches to the given new Phase and preforms preparation, checks if it should be skipped and executes it. - */ - public final void changePhase(GamePhase newPhase) { - if (getGame().getPhase().isExchange() || getGame().getPhase().isStartingScenario()) { - scriptedEventHelper.processScriptedEvents(TriggerSituation.GAME_START); - } - if (newPhase.isInitiative()) { - scriptedEventHelper.processScriptedEvents(TriggerSituation.ROUND_START); - } - if (getGame().getPhase().isEnd()) { - // the endreport phase cannot be used here as it may be skipped - scriptedEventHelper.processScriptedEvents(TriggerSituation.ROUND_END); - } - scriptedEventHelper.processScriptedEvents(TriggerSituation.PHASE_END); - - getGame().setLastPhase(getGame().getPhase()); - getGame().setPhase(newPhase); - - scriptedEventHelper.processScriptedEvents(TriggerSituation.PHASE_START); - if (getGame().getPhase().isVictory()) { - scriptedEventHelper.processScriptedEvents(TriggerSituation.GAME_END); - } - prepareForCurrentPhase(); - - if (getGame().shouldSkipCurrentPhase()) { - endCurrentPhase(); - } else { - // tell the players about the new phase - sendPhaseChange(); - executeCurrentPhase(); - } - } - - protected void sendPhaseChange() { - send(packetHelper.createPhaseChangePacket()); - } - - /** - * Called when a player declares that they are "done". By default, this method advances to the next phase, if - *
- * - all non-ghost, non-observer players are done, - *
- * - the present phase does not use turns (e.g. if it's a report phase), and - *
- * - we are not in an empty lobby (= no units at all). - *
- * In other circumstances, ending the current phase is triggered elsewhere. Note that specifically, ghost players - * are not checked for their status here so the game can advance through non-turn (report) phases even with ghost - * players. - */ - protected void checkReady() { - for (Player player : getGame().getPlayersList()) { - if (!player.isGhost() && !player.isObserver() && !player.isDone()) { - return; - } - } - - if (!getGame().getPhase().usesTurns() && !isEmptyLobby()) { - endCurrentPhase(); - } - } - - /** - * Sends out the player ready stats for all players to all connections - */ - protected void transmitAllPlayerDones() { - getGame().getPlayersList().forEach(player -> send(packetHelper.createPlayerDonePacket(player.getId()))); - } - - /** - * @return True when the game is in the lobby phase and is empty (no units present). - */ - protected boolean isEmptyLobby() { - return getGame().getPhase().isLounge() && getGame().getInGameObjects().isEmpty(); - } - - /** - * Sets a player's ready status as received from the Client. This method does not perform any follow-up actions. - */ - private void receivePlayerDone(Packet packet, int connIndex) throws InvalidPacketDataException { - boolean ready = packet.getBooleanValue(0); - Player player = getGame().getPlayer(connIndex); - if (null != player) { - player.setDone(ready); - } else { - logger.error("Tried to set done status of non-existent player!"); - } - } - - /** - * Sends out the player object to all players. Private info of the given player is redacted before being sent to - * other players. - * - * @param player The player whose information is to be shared - * - * @see #transmitAllPlayerUpdates() - */ - protected void transmitPlayerUpdate(Player player) { - server().transmitPlayerUpdate(player); - } - - /** - * Shares all player objects with all players. Private info is redacted before being sent to other players. - * - * @see #transmitPlayerUpdate(Player) - */ - public void transmitAllPlayerUpdates() { - getGame().getPlayersList().forEach(this::transmitPlayerUpdate); - } - - /** - * Performs an automatic save (does not check the autosave settings - the autosave will simply be done). Depending - * on the settings, the "autosave" filename is appended with a timestamp and/or a chat message is sent announcing - * the autosave. - */ - public void autoSave() { - String fileName = "autosave"; - - if (PreferenceManager.getClientPreferences().stampFilenames()) { - fileName = StringUtil.addDateTimeStamp(fileName); - } - - saveGame(fileName, getGame().getOptions().booleanOption(OptionsConstants.BASE_AUTOSAVE_MSG)); - } - - @Override - public void saveGame(String fileName, boolean sendChat) { - saveHandler.saveGame(fileName, sendChat); - } - - @Override - public void sendSaveGame(int connId, String sFile, String sLocalPath) { - saveHandler.sendSaveGame(connId, sFile, sLocalPath); - } - - public void sendChat(String origin, String message) { - server().sendChat(origin, message); - } - - public void sendChat(int connId, String origin, String message) { - server().sendChat(connId, origin, message); - } - - public void sendServerChat(String message) { - server().sendServerChat(message); - } - - public void sendServerChat(int connId, String message) { - server().sendServerChat(connId, message); - } - - /** - * Sends the current list of player turns as stored in the game's turn list to the Clients. - * - * @see IGame#getTurnsList() - */ - public void sendCurrentTurns() { - send(packetHelper.createTurnListPacket()); - } - - /** - * Increment's the server's game round and send it to all the clients - */ - public void incrementAndSendGameRound() { - getGame().incrementCurrentRound(); - send(packetHelper.createCurrentRoundNumberPacket()); - } - - public GameManagerPacketHelper getPacketHelper() { - return packetHelper; - } - - public AutosaveService getAutoSaveService() { - return autoSaveService; - } - - /** - * Sends out a notification message indicating that a ghost player's turn may be skipped with the /skip command. - * - * @param ghost the Player who is ghosted. This value must not be null. - */ - protected void sendGhostSkipMessage(Player ghost) { - String message = String.format( - "Player '%s' is disconnected. You may skip their current turn with the /skip command.", - ghost.getName()); - sendServerChat(message); - } -} diff --git a/patches/src/megamek/server/Server.java b/patches/src/megamek/server/Server.java deleted file mode 100644 index c89a3d8..0000000 --- a/patches/src/megamek/server/Server.java +++ /dev/null @@ -1,1556 +0,0 @@ -/* - * Copyright (c) 2000-2005 - Ben Mazur (bmazur@sev.org) - * Copyright (c) 2013 - Edward Cullen (eddy@obsessedcomputers.co.uk) - * Copyright (C) 2002-2025 The MegaMek Team. All Rights Reserved. - * - * This file is part of MegaMek. - * - * MegaMek is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License (GPL), - * version 3 or (at your option) any later version, - * as published by the Free Software Foundation. - * - * MegaMek 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. - * - * A copy of the GPL should have been included with this project; - * if not, see . - * - * NOTICE: The MegaMek organization is a non-profit group of volunteers - * creating free software for the BattleTech community. - * - * MechWarrior, BattleMech, `Mech and AeroTech are registered trademarks - * of The Topps Company, Inc. All Rights Reserved. - * - * Catalyst Game Labs and the Catalyst Game Labs logo are trademarks of - * InMediaRes Productions, LLC. - * - * MechWarrior Copyright Microsoft Corporation. MegaMek was created under - * Microsoft's "Game Content Usage Rules" - * and it is not endorsed by or - * affiliated with Microsoft. - */ -package megamek.server; - -import java.io.BufferedReader; -import java.io.DataOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.net.HttpURLConnection; -import java.net.InetAddress; -import java.net.ServerSocket; -import java.net.Socket; -import java.net.URL; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.*; -import java.util.Map.Entry; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.locks.ReentrantLock; -import java.util.function.Consumer; -import java.util.zip.GZIPInputStream; - -import com.thoughtworks.xstream.XStream; -import megamek.MMConstants; -import megamek.SuiteConstants; -import megamek.Version; -import megamek.client.ui.util.PlayerColour; -import megamek.codeUtilities.StringUtility; -import megamek.common.Player; -import megamek.common.annotations.Nullable; -import megamek.common.board.Board; -import megamek.common.commandLine.AbstractCommandLineParser.ParseException; -import megamek.common.game.Game; -import megamek.common.game.IGame; -import megamek.common.icons.Camouflage; -import megamek.common.loaders.MapSettings; -import megamek.common.net.connections.AbstractConnection; -import megamek.common.net.enums.PacketCommand; -import megamek.common.net.events.DisconnectedEvent; -import megamek.common.net.events.PacketReceivedEvent; -import megamek.common.net.factories.ConnectionFactory; -import megamek.common.net.listeners.ConnectionListener; -import megamek.common.net.packets.InvalidPacketDataException; -import megamek.common.net.packets.Packet; -import megamek.common.options.OptionsConstants; -import megamek.common.preference.PreferenceManager; -import megamek.common.rolls.Roll; -import megamek.common.util.EmailService; -import megamek.common.util.SerializationHelper; -import megamek.logging.MMLogger; -import megamek.server.commands.ServerCommand; - -/** - * @author Ben Mazur - */ -public class Server implements Runnable { - // server setup - private final String password; - - private final IGameManager gameManager; - - private final String metaServerUrl; - - private final ServerSocket serverSocket; - - private final String messageOfTheDay; - - private final EmailService mailer; - - private static final MMLogger LOGGER = MMLogger.create(Server.class); - - private final ReentrantLock GAME_LOCK = new ReentrantLock(); - - public static class ReceivedPacket { - private int connectionId; - private Packet packet; - - public ReceivedPacket(int cid, Packet p) { - setPacket(p); - setConnectionId(cid); - } - - public int getConnectionId() { - return connectionId; - } - - public void setConnectionId(int connectionId) { - this.connectionId = connectionId; - } - - public Packet getPacket() { - return packet; - } - - public void setPacket(Packet packet) { - this.packet = packet; - } - } - - private class PacketPump implements Runnable { - boolean shouldStop; - - PacketPump() { - shouldStop = false; - } - - void signalEnd() { - shouldStop = true; - } - - @Override - public void run() { - while (!shouldStop) { - while (!packetQueue.isEmpty()) { - ReceivedPacket rp = packetQueue.poll(); - synchronized (serverLock) { - handle(rp.getConnectionId(), rp.getPacket()); - } - } - - try { - synchronized (packetQueue) { - packetQueue.wait(); - } - } catch (InterruptedException ignored) { - // If we are interrupted, just keep going, generally this happens after we are signalled to stop. - } - } - } - } - - // commands - private final Map commandsHash = new ConcurrentHashMap<>(); - - // game info - private final List connections = new CopyOnWriteArrayList<>(); - - private final Map connectionHandlers = new ConcurrentHashMap<>(); - - private final ConcurrentLinkedQueue packetQueue = new ConcurrentLinkedQueue<>(); - - private final boolean dedicated; - - private final List connectionsPending = new CopyOnWriteArrayList<>(); - - private final Map connectionIds = new ConcurrentHashMap<>(); - - private int connectionCounter; - - // listens for and connects players - private Thread connector; - - private final PacketPump packetPump; - private Thread packetPumpThread; - - private final Timer watchdogTimer = new Timer("Watchdog Timer"); - - private static Server serverInstance = null; - - private String serverAccessKey = null; - - private Timer serverBrowserUpdateTimer = null; - - /** - * Used to ensure only one thread at a time is accessing this particular instance of the server. - */ - private final Object serverLock = new Object(); - - public static final String ORIGIN = "***Server"; - - // Easter eggs. Happy April Fool's Day!! - private static final String DUNE_CALL = "They tried and failed?"; - - private static final String DUNE_RESPONSE = "They tried and died!"; - - private static final String STAR_WARS_CALL = "I'd just as soon kiss a Wookiee."; - - private static final String STAR_WARS_RESPONSE = "I can arrange that!"; - - private static final String INVADER_ZIM_CALL = "What does the G stand for?"; - - private static final String INVADER_ZIM_RESPONSE = "I don't know."; - - private static final String WARGAMES_CALL = "Shall we play a game?"; - - private static final String WARGAMES_RESPONSE = "Let's play global thermonuclear war."; - - public static final int SERVER_CONN = Integer.MIN_VALUE; - - private final ConnectionListener connectionListener = new ConnectionListener() { - - private boolean isPaused = false; - private final List pausedWaitingList = new ArrayList<>(); - - /** - * Called when it is sensed that a connection has terminated. - */ - @Override - public void disconnected(DisconnectedEvent e) { - AbstractConnection conn = e.getConnection(); - - // write something in the log - LOGGER.info("s: connect {} disconnected.", conn.getId()); - - connections.remove(conn); - synchronized (serverLock) { - connectionsPending.remove(conn); - connectionIds.remove(conn.getId()); - ConnectionHandler ch = connectionHandlers.get(conn.getId()); - if (ch != null) { - ch.signalStop(); - connectionHandlers.remove(conn.getId()); - } - } - - // if there's a player for this connection, remove it too - Player player = getPlayer(conn.getId()); - if (null != player) { - Server.this.disconnected(player); - } - } - - @Override - public void packetReceived(PacketReceivedEvent e) { - ReceivedPacket rp = new ReceivedPacket(e.getConnection().getId(), e.getPacket()); - switch (e.getPacket().command()) { - case CLIENT_FEEDBACK_REQUEST: - // Handled CFR packets specially - gameManager.handleCfrPacket(rp); - break; - case CLOSE_CONNECTION: - case CLIENT_NAME: - case CLIENT_VERSIONS: - case CHAT: - // Some packets should be handled immediately - handle(rp.getConnectionId(), rp.getPacket()); - break; - case PAUSE: - if (!isPaused) { - LOGGER.info("Pause packet received - pausing packet handling"); - sendServerChat("Game is paused."); - } - isPaused = true; - break; - case UNPAUSE: - if (isPaused) { - LOGGER.info("Unpause packet received - resuming packet handling"); - sendServerChat("Game is resumed."); - } - isPaused = false; - synchronized (packetQueue) { - packetQueue.addAll(pausedWaitingList); - packetQueue.notifyAll(); - } - pausedWaitingList.clear(); - break; - default: - if (isPaused) { - pausedWaitingList.add(rp); - } else { - synchronized (packetQueue) { - packetQueue.add(rp); - packetQueue.notifyAll(); - } - } - break; - } - } - }; - - /** - * @param serverAddress IP or Domain Name Of Server - * - * @return valid hostName - * - * @throws ParseException for null or empty serverAddress - */ - public static String validateServerAddress(String serverAddress) throws ParseException { - if ((serverAddress == null) || serverAddress.isBlank()) { - String message = "serverAddress must not be null or empty"; - LOGGER.error(message); - throw new ParseException(message); - } else { - return serverAddress.trim(); - } - } - - /** - * @param playerName throw ParseException if null or empty - * - * @return valid playerName - */ - public static String validatePlayerName(String playerName) throws ParseException { - if (playerName == null) { - String message = "playerName must not be null"; - LOGGER.error(message); - throw new ParseException(message); - } else if (playerName.isBlank()) { - String message = "playerName must not be empty string"; - LOGGER.error(message); - throw new ParseException(message); - } else { - return playerName.trim(); - } - } - - /** - * @param password Password to set for server. - * - * @return valid password or null if no password or password is blank string - */ - public static @Nullable String validatePassword(@Nullable String password) { - return StringUtility.isNullOrBlank(password) ? null : password.trim(); - } - - /** - * Checks a String against the server password - * - * @param password The password provided by the user. - * - * @return true if the user-supplied data matches the server password or no password is set. - */ - public boolean passwordMatches(Object password) { - return StringUtility.isNullOrBlank(this.password) || this.password.equals(password); - } - - /** - * @param port if outside the established range of #{@see MMConstants#MIN_PORT} and #{@see MMConstants#MAX_PORT} - * return #{@see MMConstants#DEFAULT_PORT} or the passed in port. - * - * @return valid port number - */ - public static int validatePort(int port) { - if (port < MMConstants.MIN_PORT || port > MMConstants.MAX_PORT) { - return MMConstants.DEFAULT_PORT; - } - - return port; - } - - public Server(@Nullable String password, int port, IGameManager gameManager) throws IOException { - this(password, port, gameManager, false, "", null, false); - } - - public Server(@Nullable String password, int port, IGameManager gameManager, boolean registerWithServerBrowser, - @Nullable String metaServerUrl) throws IOException { - this(password, port, gameManager, registerWithServerBrowser, metaServerUrl, null, false); - } - - /** - * Construct a new GameHost and begin listening for incoming clients. - * - * @param password the String that is set as a password - * @param port the int value that specifies the port that is used - * @param gameManager the {@link IGameManager} instance for this server instance. - * @param registerWithServerBrowser a boolean indicating whether we should register with the master - * server browser on ... - * @param mailer an email service instance to use for sending round reports. - * @param dedicated set to true if this server is started from a GUI-less context - */ - public Server(@Nullable String password, int port, IGameManager gameManager, boolean registerWithServerBrowser, - @Nullable String metaServerUrl, @Nullable EmailService mailer, boolean dedicated) throws IOException { - this.metaServerUrl = StringUtility.isNullOrBlank(metaServerUrl) ? null : metaServerUrl; - this.password = StringUtility.isNullOrBlank(password) ? null : password; - this.gameManager = gameManager; - this.mailer = mailer; - this.dedicated = dedicated; - - // initialize server socket - serverSocket = new ServerSocket(port); - - messageOfTheDay = createMOTD(); - - // display server start text - LOGGER.info("s: starting a new server..."); - - try { - String host = InetAddress.getLocalHost().getHostName(); - LOGGER.info("s: hostname = '{}' port = {}", host, serverSocket.getLocalPort()); - - InetAddress[] addresses = InetAddress.getAllByName(host); - for (InetAddress address : addresses) { - LOGGER.info("s: hosting on address = {}", address.getHostAddress()); - } - } catch (Exception ignored) { - } - - if (password != null) { - LOGGER.info("s: password is set (redacted)"); - } - - for (ServerCommand command : gameManager.getCommandList(this)) { - registerCommand(command); - } - - packetPump = new PacketPump(); - packetPumpThread = new Thread(packetPump, "Packet Pump"); - packetPumpThread.start(); - - if (registerWithServerBrowser) { - if (!StringUtility.isNullOrBlank(metaServerUrl)) { - final TimerTask register = new TimerTask() { - @Override - public void run() { - registerWithServerBrowser(true, Server.getServerInstance().metaServerUrl); - } - }; - serverBrowserUpdateTimer = new Timer("Server Browser Register Timer", true); - serverBrowserUpdateTimer.schedule(register, 1, 40000); - } else { - LOGGER.error("Invalid URL for server browser {}", this.metaServerUrl); - } - } - - // Fully initialized, now accept connections - connector = new Thread(this, "Connection Listener"); - connector.start(); - - serverInstance = this; - - // MULTI-TENANCY PATCH: give the manager a direct reference to its own server. - // The static serverInstance still exists (nothing else is disturbed), but a - // manager that knows its owner no longer has to guess via the static, which - // always points at the most recently constructed Server. - if (gameManager instanceof AbstractGameManager abstractGameManager) { - abstractGameManager.setOwningServer(this); - } - } - - public IGameManager getGameManager() { - return gameManager; - } - - /** - * Sets the game for this server. Restores any transient fields, and sets all players as ghosts. This should only be - * called during server initialization before any players have connected. - */ - public void setGame(IGame g) { - gameManager.setGame(g); - } - - public IGame getGame() { - return gameManager.getGame(); - } - - public EmailService getEmailService() { - return mailer; - } - - /** - * Make a default message o' the day containing the version string, and if it was found, the build timestamp - */ - private String createMOTD() { - return "Welcome to MegaMek. Server is running version " + SuiteConstants.VERSION; - } - - /** - * @return true if the server has a password - */ - public boolean isPassworded() { - return password != null; - } - - /** - * @return true if the password matches - */ - public boolean isPassword(Object guess) { - return password.equals(guess); - } - - /** - * Registers a new command in the server command table - */ - private void registerCommand(ServerCommand command) { - commandsHash.put(command.getName(), command); - } - - /** - * Returns the command associated with the specified name - */ - public ServerCommand getCommand(String name) { - return commandsHash.get(name); - } - - /** - * @return true run from a GUI-less context - */ - public boolean getDedicated() { - return dedicated; - } - - /** - * Shuts down the server. - */ - public void die() { - watchdogTimer.cancel(); - - // kill thread accepting new connections - connector = null; - packetPump.signalEnd(); - packetPumpThread.interrupt(); - packetPumpThread = null; - - // close socket - try { - serverSocket.close(); - } catch (Exception ignored) { - } - - // kill pending connections - connectionsPending.forEach(AbstractConnection::close); - connectionsPending.clear(); - - // Send "kill" commands to all connections - // This WILL handle the connection end on both sides - send(new Packet(PacketCommand.CLOSE_CONNECTION)); - connectionIds.clear(); - - // Shutdown Email - if (mailer != null) { - mailer.shutdown(); - } - - // Unregister Server Browser Setup - if (serverBrowserUpdateTimer != null) { - serverBrowserUpdateTimer.cancel(); - } - - if ((metaServerUrl != null) && (!metaServerUrl.isBlank())) { - registerWithServerBrowser(false, metaServerUrl); - } - } - - /** - * Returns an enumeration of all the command names - */ - public Collection getAllCommandNames() { - return commandsHash.keySet(); - } - - /** - * Sent when a client attempts to connect. - */ - void clientVersionCheck(int cn) { - sendToPending(cn, new Packet(PacketCommand.SERVER_VERSION_CHECK)); - } - - /** - * Returns a free connection id. - */ - public int getFreeConnectionId() { - while ((getPendingConnection(connectionCounter) != null) || - (getConnection(connectionCounter) != null) || - (getPlayer(connectionCounter) != null)) { - connectionCounter++; - } - return connectionCounter; - } - - /** - * Returns a free entity id. Perhaps this should be in Game instead. - */ - @Deprecated(since = "0.51.0", forRemoval = true) - public int getFreeEntityId() { - return getGame().getNextEntityId(); - } - - /** - * Allow the player to set whatever parameters he is able to - */ - private void receivePlayerInfo(Packet packet, int connId) throws InvalidPacketDataException { - Player player = packet.getPlayer(0); - - if (player == null) { - return; - } - - Player gamePlayer = getGame().getPlayer(connId); - if (null != gamePlayer) { - gamePlayer.setColour(player.getColour()); - gamePlayer.setStartingPos(player.getStartingPos()); - gamePlayer.setStartWidth(player.getStartWidth()); - gamePlayer.setStartOffset(player.getStartOffset()); - gamePlayer.setStartingAnyNWx(player.getStartingAnyNWx()); - gamePlayer.setStartingAnyNWy(player.getStartingAnyNWy()); - gamePlayer.setStartingAnySEx(player.getStartingAnySEx()); - gamePlayer.setStartingAnySEy(player.getStartingAnySEy()); - gamePlayer.setTeam(player.getTeam()); - gamePlayer.setCamouflage(player.getCamouflage().clone()); - gamePlayer.setNbrMFConventional(player.getNbrMFConventional()); - gamePlayer.setNbrMFCommand(player.getNbrMFCommand()); - gamePlayer.setNbrMFVibra(player.getNbrMFVibra()); - gamePlayer.setNbrMFActive(player.getNbrMFActive()); - gamePlayer.setNbrMFInferno(player.getNbrMFInferno()); - gamePlayer.setNbrMFEMP(player.getNbrMFEMP()); - if (gamePlayer.getConstantInitBonus() != player.getConstantInitBonus()) { - sendServerChat("Player " + - gamePlayer.getName() + - " changed their initiative bonus from " + - gamePlayer.getConstantInitBonus() + - " to " + - player.getConstantInitBonus() + - '.'); - } - gamePlayer.setConstantInitBonus(player.getConstantInitBonus()); - gamePlayer.setEmail(player.getEmail()); - gamePlayer.setGroundObjectsToPlace(new ArrayList<>(player.getGroundObjectsToPlace())); - } - } - - /** - * Correct a duplicate player name - * - * @param oldName the String old player name, that is a duplicate - * - * @return the String new player name - */ - private String correctDupeName(String oldName) { - for (Player player : getGame().getPlayersList()) { - if (player.getName().equals(oldName)) { - // We need to correct it. - String newName = oldName; - int dupNum; - try { - dupNum = Integer.parseInt(oldName.substring(oldName.lastIndexOf('.') + 1)); - dupNum++; - newName = oldName.substring(0, oldName.lastIndexOf('.')); - } catch (Exception e) { - // If this fails, we don't care much. Just assume it's the first time for this name. - dupNum = 2; - } - newName = newName.concat(".").concat(Integer.toString(dupNum)); - return correctDupeName(newName); - } - } - return oldName; - } - - private boolean receivePlayerVersion(Packet packet, int connId) throws InvalidPacketDataException { - final Version version = (Version) packet.getObject(0); - - if (!SuiteConstants.VERSION.is(version)) { - final String message = String.format("Client/Server Version Mismatch -- Client: %s, Server: %s", - version, - SuiteConstants.VERSION); - LOGGER.error(message); - - final Player player = getPlayer(connId); - sendServerChat(String.format("For %s, Server reports:%s%s", - ((player == null) ? "unknown player" : player.getName()), - System.lineSeparator(), - message)); - return false; - } - - return true; - } - - /** - * Receives a player name, sent from a pending connection, and connects that connection. - */ - private void receivePlayerName(Packet packet, int connId) throws InvalidPacketDataException { - final AbstractConnection conn = getPendingConnection(connId); - String name = packet.getStringValue(0); - boolean isBot = packet.getBooleanValue(1); - boolean returning = false; - String message; - - // this had better be from a pending connection - if (conn == null) { - LOGGER.warn("Got a client name from a non-pending connection"); - return; - } - - // check if they're connecting with the same name as a ghost player - for (Player player : getGame().getPlayersList()) { - if (player.getName().equals(name) && player.isGhost()) { - returning = true; - player.setGhost(false); - player.setBot(isBot); - // switch id - connId = player.getId(); - conn.setId(connId); - } - } - - if (!returning) { - // Check to avoid duplicate names... - name = correctDupeName(name); - sendToPending(connId, new Packet(PacketCommand.SERVER_CORRECT_NAME, name)); - } - - // right, switch the connection into the "active" bin - connectionsPending.remove(conn); - connections.add(conn); - connectionIds.put(conn.getId(), conn); - - // add and validate the player info - if (!returning) { - addNewPlayer(connId, name, isBot); - } - - // if it is not the lounge phase, this player becomes an observer - Player player = getPlayer(connId); - if (!getGame().getPhase().isLounge() && (null != player) && (getGame().getEntitiesOwnedBy(player) < 1)) { - player.setObserver(true); - } - - // send the player the messageOfTheDay - sendServerChat(connId, messageOfTheDay); - - // send info that the player has connected - transmitPlayerConnect(player); - - // tell them their local playerId - send(connId, new Packet(PacketCommand.LOCAL_PN, connId)); - - // send current game info - sendCurrentInfo(connId); - - final boolean showIPAddressesInChat = PreferenceManager.getClientPreferences().getShowIPAddressesInChat(); - - try { - InetAddress[] addresses = InetAddress.getAllByName(InetAddress.getLocalHost().getHostName()); - for (InetAddress address : addresses) { - message = String.format("s: Machine IP %s", address.getHostAddress()); - LOGGER.info(message); - - if (showIPAddressesInChat) { - sendServerChat(connId, message); - } - } - } catch (Exception ignored) { - } - - message = String.format("s: Listening on port %d", serverSocket.getLocalPort()); - LOGGER.info(message); - - if (showIPAddressesInChat) { - // Send the port we're listening on. Only useful for the player on the server machine to check. - sendServerChat(connId, message); - } - - // Get the player *again*, because they may have disconnected. - player = getPlayer(connId); - if (null != player) { - String who = String.format("%s connected from %s", player.getName(), getClient(connId).getInetAddress()); - message = String.format("s: player #%d, %s", connId, who); - LOGGER.info(message); - - if (showIPAddressesInChat) { - sendServerChat(who); - } - } // Found the player - } - - /** - * Sends a player the info they need to look at the current phase. This is triggered when a player first connects to - * the server. - */ - public void sendCurrentInfo(int connId) { - transmitPlayerConnect(getClient(connId)); - gameManager.sendCurrentInfo(connId); - } - - /** - * Adds a new player to the game - */ - private Player addNewPlayer(int connId, String name, boolean isBot) { - int team = getTeam(isBot); - Player newPlayer = new Player(connId, name); - newPlayer.setBot(isBot); - PlayerColour colour = newPlayer.getColour(); - final PlayerColour[] colours = PlayerColour.values(); - - for (Player player : getGame().getPlayersList()) { - if (player.getId() == newPlayer.getId()) { - continue; - } - - if ((player.getColour() == colour) && (colours.length > (colour.ordinal() + 1))) { - colour = colours[colour.ordinal() + 1]; - } - } - - newPlayer.setColour(colour); - newPlayer.setCamouflage(new Camouflage(Camouflage.COLOUR_CAMOUFLAGE, colour.name())); - newPlayer.setTeam(Math.min(team, 5)); - getGame().addPlayer(connId, newPlayer); - validatePlayerInfo(connId); - return newPlayer; - } - - private int getTeam(boolean isBot) { - int team = Player.TEAM_UNASSIGNED; - - if (getGame().getPhase().isLounge()) { - team = Player.TEAM_NONE; - final var gOpts = getGame().getOptions(); - if (isBot || !gOpts.booleanOption(OptionsConstants.BASE_SET_DEFAULT_TEAM_1)) { - for (Player p : getGame().getPlayersList()) { - if (p.getTeam() > team) { - team = p.getTeam(); - } - } - team++; - } else { - team = 1; - } - - } - - return team; - } - - /** - * Validates the player info. - */ - public void validatePlayerInfo(int playerId) { - final Player player = getPlayer(playerId); - - if (player != null) { - // TODO : check for duplicate or reserved names - - // Colour Assignment - final PlayerColour[] playerColours = PlayerColour.values(); - boolean allUsed = true; - Set colourUtilization = new HashSet<>(); - for (Player otherPlayer : getGame().getPlayersList()) { - if (otherPlayer.getId() != playerId) { - colourUtilization.add(otherPlayer.getColour()); - } else { - allUsed = false; - } - } - - if (!allUsed && colourUtilization.contains(player.getColour())) { - for (PlayerColour colour : playerColours) { - if (!colourUtilization.contains(colour)) { - player.setColour(colour); - break; - } - } - } - } - } - - /** - * Called when it's been determined that an actual player disconnected. Notifies the other players and does the - * appropriate housekeeping. - */ - void disconnected(Player player) { - // Only do this if nobody is trying to save a game currently - GAME_LOCK.lock(); - try { - gameManager.disconnect(player); - } finally { - GAME_LOCK.unlock(); - } - } - - public void resetGame() { - gameManager.resetGame(); - } - - /** - * send a packet to the connection tells it load a locally saved game - * - * @param connId The int connection id to send to - * @param sFile The String filename to use - */ - public void sendLoadGame(int connId, String sFile) { - String sFinalFile = sFile; - if (!sFinalFile.endsWith(MMConstants.SAVE_FILE_EXT) && !sFinalFile.endsWith(MMConstants.SAVE_FILE_GZ_EXT)) { - sFinalFile = sFile + MMConstants.SAVE_FILE_EXT; - } - if (!sFinalFile.endsWith(".gz")) { - sFinalFile = sFinalFile + ".gz"; - } - send(connId, new Packet(PacketCommand.LOAD_SAVEGAME, sFinalFile)); - } - - /** - * load the game - * - * @param f The File to load - * - * @return A boolean value whether the loading was successful - */ - public boolean loadGame(File f) { - return loadGame(f, true); - } - - /** - * load the game - * - * @param f The File to load - * @param sendInfo Determines whether the connections should be updated with current info. This may be false if some - * reconnection remapping needs to be done first. - * - * @return A boolean value whether the loading was successful - */ - public boolean loadGame(File f, boolean sendInfo) { - String message = String.format("s: Loading saved game file '%s'", f.getAbsolutePath()); - LOGGER.info(message); - - Game newGame; - try (InputStream is = new FileInputStream(f)) { - InputStream gzi; - - if (f.getName().toLowerCase().endsWith(".gz")) { - gzi = new GZIPInputStream(is); - } else { - gzi = is; - } - - XStream xStream = SerializationHelper.getLoadSaveGameXStream(); - newGame = (Game) xStream.fromXML(gzi); - } catch (Exception e) { - message = String.format("Unable to load file: %s", f); - LOGGER.error(e, message); - return false; - } - - setGame(newGame); - - // Saves created before mapName tracking was added land here with Board.BOARD_NAME_UNNAMED. - // Reconstruct the name from the saved MapSettings so the Ruler title and other UI surfaces - // identify the map. Only fills in when the board has the placeholder; saves with an explicit - // name keep theirs. - backfillBoardNameFromMapSettings(newGame); - - if (!sendInfo) { - return true; - } - - // update all the clients with the new game info - for (AbstractConnection conn : connections) { - sendCurrentInfo(conn.getId()); - } - return true; - } - - /** - * If the deserialized game's board has the placeholder map name (saves predate the name fix), - * reconstructs a display name from the saved MapSettings' selected board file names. The actual - * board hex data is unchanged; only the displayable name is filled in. - */ - private static void backfillBoardNameFromMapSettings(Game game) { - Board board = game.getBoard(); - if (board == null || !Board.BOARD_NAME_UNNAMED.equals(board.getBoardName())) { - return; - } - MapSettings ms = game.getMapSettings(); - if (ms == null) { - return; - } - List selected = ms.getBoardsSelectedVector(); - if (selected == null || selected.isEmpty()) { - return; - } - LinkedHashSet names = new LinkedHashSet<>(); - for (String raw : selected) { - if (raw == null) { - continue; - } - String name = raw; - if (name.startsWith(Board.BOARD_REQUEST_ROTATION)) { - name = name.substring(Board.BOARD_REQUEST_ROTATION.length()); - } - // Skip placeholders that don't correspond to an on-disk file - if (MapSettings.BOARD_GENERATED.equals(name) - || MapSettings.BOARD_RANDOM.equals(name) - || name.startsWith(MapSettings.BOARD_SURPRISE)) { - continue; - } - if (!name.isBlank()) { - names.add(name); - } - } - if (names.isEmpty()) { - return; - } - String reconstructed = names.size() == 1 ? names.iterator().next() : String.join(" + ", names); - board.setMapName(reconstructed); - } - - public void saveGame(String fileName) { - GAME_LOCK.lock(); - try { - gameManager.saveGame(fileName); - } finally { - GAME_LOCK.unlock(); - } - } - - public void sendSaveGame(int connId, String fileName, String localPath) { - GAME_LOCK.lock(); - try { - gameManager.sendSaveGame(connId, fileName, localPath); - } finally { - GAME_LOCK.unlock(); - } - } - - /** - * When the load command is used, there is a list of already connected players which have assigned names and player - * id numbers with the id numbers matching the connection numbers. When a new game is loaded, this mapping may need - * to be updated. This method takes a map of player names to their current ids, and uses the list of players to - * figure out what the current ids should change to. - * - * @param nameToIdMap This maps a player name to the current connection ID - * @param idToNameMap This maps a current conn ID to a player name, and is just the inverse mapping from - * nameToIdMap - */ - public void remapConnIds(Map nameToIdMap, Map idToNameMap) { - // Keeps track of connections without Ids - List unassignedConnections = new ArrayList<>(); - - // Keep track of which ids are used - Set usedPlayerIds = new HashSet<>(); - Set currentPlayerNames = new HashSet<>(); - - for (Player p : getGame().getPlayersList()) { - currentPlayerNames.add(p.getName()); - } - - // Map the old connection ID to new value - Map connIdRemapping = new HashMap<>(); - for (Player p : getGame().getPlayersList()) { - // Check to see if this player was already connected - Integer oldId = nameToIdMap.get(p.getName()); - if ((oldId != null) && (oldId != p.getId())) { - connIdRemapping.put(oldId, p.getId()); - } - - // If the old and new Ids match, make sure we remove ghost status - if ((oldId != null) && (oldId == p.getId())) { - p.setGhost(false); - } - - // Check to see if this player's ID is taken - String oldName = idToNameMap.get(p.getId()); - if ((oldName != null) && !oldName.equals(p.getName())) { - // If this name doesn't belong to a current player, unassign it - if (!currentPlayerNames.contains(oldName)) { - unassignedConnections.add(connectionIds.get(p.getId())); - // Make sure we don't add this to unassigned connections twice - connectionIds.remove(p.getId()); - } - // If it does belong to a current player, it'll get handled - // when that player comes up - } - // Keep track of what Ids are used - usedPlayerIds.add(p.getId()); - } - - // Remap old connection Ids to new ones - for (Entry currentConnect : connIdRemapping.entrySet()) { - Integer newId = currentConnect.getValue(); - AbstractConnection conn = connectionIds.get(currentConnect.getKey()); - conn.setId(newId); - - // If this ID is used, make sure we reassign that connection - if (connectionIds.containsKey(newId)) { - unassignedConnections.add(connectionIds.get(newId)); - } - // Map the new ID - connectionIds.put(newId, conn); - - getGame().getPlayer(newId).setGhost(false); - send(newId, new Packet(PacketCommand.LOCAL_PN, newId)); - } - - // It's possible we have players not in the saved game, add 'em - for (AbstractConnection conn : unassignedConnections) { - int newId = 0; - - while (usedPlayerIds.contains(newId)) { - newId++; - } - - String name = idToNameMap.get(conn.getId()); - conn.setId(newId); - Player newPlayer = addNewPlayer(newId, name, false); - newPlayer.setObserver(true); - connectionIds.put(newId, conn); - send(newId, new Packet(PacketCommand.LOCAL_PN, newId)); - } - - // Ensure all clients are up-to-date on player info - transmitAllPlayerUpdates(); - } - - /** - * Shortcut to game.getPlayer(id) - */ - public Player getPlayer(int id) { - return getGame().getPlayer(id); - } - - /** - * a shorter name for getConnection() - */ - private AbstractConnection getClient(int connId) { - return getConnection(connId); - } - - /** - * Executes the process on each active connection. - * - * @param process The process to execute. - */ - public void forEachConnection(Consumer process) { - connections.forEach(process); - } - - /** - * Returns a connection, indexed by id - */ - public AbstractConnection getConnection(int connId) { - return connectionIds.get(connId); - } - - /** - * Returns a pending connection - */ - public @Nullable AbstractConnection getPendingConnection(int connId) { - for (AbstractConnection conn : connectionsPending) { - if (conn.getId() == connId) { - return conn; - } - } - return null; - } - - /** - * Sends out all player info to the specified connection - */ - private void transmitPlayerConnect(AbstractConnection connection) { - for (Player player : getGame().getPlayersList()) { - var connectionId = connection.getId(); - connection.send(createPlayerConnectPacket(player, player.getId() != connectionId)); - } - } - - /** - * Sends out player info to all connections - */ - private void transmitPlayerConnect(Player player) { - for (var connection : connections) { - var playerId = player.getId(); - connection.send(createPlayerConnectPacket(player, playerId != connection.getId())); - } - } - - /** - * Creates a packet informing that the player has connected - */ - private Packet createPlayerConnectPacket(Player player, boolean isPrivate) { - var playerId = player.getId(); - var destPlayer = player; - if (isPrivate) { - // Sending the player's data to another player's - // connection, need to redact any private data - destPlayer = player.copy(); - destPlayer.redactPrivateData(); - } - return new Packet(PacketCommand.PLAYER_ADD, playerId, destPlayer); - } - - /** - * Sends out player info updates for a player to all connections - */ - void transmitPlayerUpdate(Player player) { - for (var connection : connections) { - var playerId = player.getId(); - var destPlayer = player; - - if (playerId != connection.getId()) { - // Sending the player's data to another player's - // connection, need to redact any private data - destPlayer = player.copy(); - destPlayer.redactPrivateData(); - } - connection.send(new Packet(PacketCommand.PLAYER_UPDATE, playerId, destPlayer)); - } - } - - /** - * Sends out the player info updates for all players to all connections - */ - private void transmitAllPlayerUpdates() { - for (Player player : getGame().getPlayersList()) { - transmitPlayerUpdate(player); - } - } - - /** - * Player can request its own change of team - * - * @param teamID target team id - * @param player player requesting the change - */ - public void requestTeamChangeForPlayer(int teamID, Player player) { - gameManager.requestTeamChangeForPlayer(teamID, player); - } - - public void requestGameMaster(Player player) { - gameManager.requestGameMaster(player); - } - - public static String formatChatMessage(String origin, String message) { - return origin + ": " + message; - } - - /** - * Transmits a chat message to all players - */ - public void sendChat(int connId, String origin, String message) { - send(connId, new Packet(PacketCommand.CHAT, formatChatMessage(origin, message))); - } - - /** - * Transmits a chat message to all players - */ - public void sendChat(String origin, String message) { - send(new Packet(PacketCommand.CHAT, formatChatMessage(origin, message))); - } - - public void sendServerChat(int connId, String message) { - sendChat(connId, ORIGIN, message); - } - - public void sendServerChat(String message) { - sendChat(ORIGIN, message); - } - - /** - * Sends the given packet to all connections (all connected Clients = players). - */ - void send(Packet packet) { - connections.stream().filter(Objects::nonNull).forEach(connection -> connection.send(packet)); - } - - /** - * Sends the given packet to the given connection (= player ID) if it is not null. Does nothing otherwise. - */ - public void send(int connId, Packet packet) { - AbstractConnection connection = getClient(connId); - - if (connection != null) { - connection.send(packet); - } - } - - /** - * Send a packet to a pending connection - */ - private void sendToPending(int connId, Packet packet) { - AbstractConnection pendingConn = getPendingConnection(connId); - - if (pendingConn != null) { - pendingConn.send(packet); - } - // What should we do if we've lost this client? - // For now, nothing. - } - - /** - * Process an in-game command - */ - private void processCommand(int connId, String commandString) { - // all tokens are read as strings; if they're numbers, string-ize 'em. - // replaced the tokenizer with the split function. - String[] args = commandString.split("\\s+"); - - // figure out which command this is - String commandName = args[0].substring(1); - - // process it - ServerCommand command = getCommand(commandName); - if (command != null) { - command.run(connId, args); - } else { - sendServerChat(connId, "Command not recognized. Type /help for a list of commands."); - } - } - - /** - * Process a packet from a connection. - * - * @param connId - the int ID the connection that received the packet. - * @param packet - the Packet to be processed. - */ - protected void handle(int connId, Packet packet) { - Player player = getGame().getPlayer(connId); - - // Check player. Please note, the connection may be pending. - if ((null == player) && (null == getPendingConnection(connId))) { - String message = String.format("Server does not recognize player at connection %d", connId); - LOGGER.error(message); - return; - } - - if (packet == null) { - LOGGER.error("Got null packet"); - return; - } - - // act on it - try { - switch (packet.command()) { - case CLIENT_VERSIONS: - final boolean valid = receivePlayerVersion(packet, connId); - if (valid) { - sendToPending(connId, new Packet(PacketCommand.SERVER_GREETING)); - } else { - sendToPending(connId, new Packet(PacketCommand.ILLEGAL_CLIENT_VERSION, SuiteConstants.VERSION)); - getPendingConnection(connId).close(); - } - break; - case CLOSE_CONNECTION: - // We have a client going down! - AbstractConnection c = getConnection(connId); - if (c != null) { - c.close(); - } - break; - case CLIENT_NAME: - receivePlayerName(packet, connId); - break; - case PLAYER_UPDATE: - receivePlayerInfo(packet, connId); - validatePlayerInfo(connId); - transmitPlayerUpdate(getPlayer(connId)); - break; - case CHAT: - String chat = packet.getStringValue(0); - if (chat.startsWith("/")) { - processCommand(connId, chat); - } else if (packet.data().length > 1) { - connId = packet.getIntValue(1); - if (connId == Player.PLAYER_NONE) { - sendServerChat(chat); - } else { - sendServerChat(connId, chat); - } - } else { - if (player != null) { - sendChat(player.getName(), chat); - } - } - - // Easter eggs. Happy April Fool's Day!! - if (DUNE_CALL.equalsIgnoreCase(chat)) { - sendServerChat(DUNE_RESPONSE); - } else if (STAR_WARS_CALL.equalsIgnoreCase(chat)) { - sendServerChat(STAR_WARS_RESPONSE); - } else if (INVADER_ZIM_CALL.equalsIgnoreCase(chat)) { - sendServerChat(INVADER_ZIM_RESPONSE); - } else if (WARGAMES_CALL.equalsIgnoreCase(chat)) { - wargamesResponse(); - } - - break; - case LOAD_GAME: - try { - sendServerChat(getPlayer(connId).getName() + " loaded a new game."); - setGame((Game) packet.getObject(0)); - for (AbstractConnection conn : connections) { - sendCurrentInfo(conn.getId()); - } - } catch (Exception e) { - LOGGER.error(e, "Error loading save game sent from client"); - } - break; - default: - // We don't want to change the game state while other threads may be in the game - GAME_LOCK.lock(); - try { - gameManager.handlePacket(connId, packet); - } finally { - GAME_LOCK.unlock(); - } - } - } catch (InvalidPacketDataException e) { - LOGGER.error("Invalid packet data:", e); - } - } - - private void wargamesResponse() { - sendServerChat(WARGAMES_RESPONSE); - } - - /** - * Listen for incoming clients. - */ - @Override - public void run() { - String message; - Thread currentThread = Thread.currentThread(); - LOGGER.info("s: listening for clients..."); - while (connector == currentThread) { - try { - Socket s = serverSocket.accept(); - synchronized (serverLock) { - int id = getFreeConnectionId(); - message = String.format("s: accepting player connection #%d...", id); - LOGGER.info(message); - - AbstractConnection c = ConnectionFactory.getInstance().createServerConnection(s, id); - c.addConnectionListener(connectionListener); - c.open(); - connectionsPending.add(c); - ConnectionHandler ch = new ConnectionHandler(c); - Thread newConnThread = new Thread(ch, "Connection " + id); - newConnThread.start(); - connectionHandlers.put(id, ch); - - clientVersionCheck(id); - ConnectionWatchdog w = new ConnectionWatchdog(this, id); - watchdogTimer.schedule(w, 1000, 500); - } - } catch (Exception ignored) { - - } - } - } - - /** - * @return a String representing the hostname - */ - public String getHost() { - try { - return InetAddress.getLocalHost().getHostName(); - } catch (Exception ex) { - LOGGER.error(ex, "Get Host exception"); - return ""; - } - } - - /** - * @return the int this server is listening on - */ - public int getPort() { - return serverSocket.getLocalPort(); - } - - /** - * @return the current server instance. This may be null if a server has not been started - */ - public static @Nullable Server getServerInstance() { - return serverInstance; - } - - private void registerWithServerBrowser(boolean register, String urlString) { - try { - URL url = new URL(urlString); - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - conn.setDoOutput(true); - conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); - - OutputStream os = conn.getOutputStream(); - DataOutputStream dos = new DataOutputStream(os); - StringBuilder content = new StringBuilder("port=" + - URLEncoder.encode(Integer.toString(serverSocket.getLocalPort()), - StandardCharsets.UTF_8)); - if (register) { - for (AbstractConnection abstractConnection : connections) { - content.append("&players[]=").append(getPlayer(abstractConnection.getId()).getName()); - } - - if (!getGame().getPhase().isLounge() && !getGame().getPhase().isUnknown()) { - content.append("&close=yes"); - } - - content.append("&version=").append(SuiteConstants.VERSION); - - if (isPassworded()) { - content.append("&pw=yes"); - } - } else { - content.append("&delete=yes"); - } - - if (serverAccessKey != null) { - content.append("&key=").append(serverAccessKey); - } - - dos.writeBytes(content.toString()); - dos.flush(); - - InputStream is = conn.getInputStream(); - InputStreamReader isr = new InputStreamReader(is); - BufferedReader br = new BufferedReader(isr); - String line; - if (conn.getResponseCode() == 200) { - while ((line = br.readLine()) != null) { - if (serverAccessKey == null) { - serverAccessKey = line; - } - } - } - } catch (Exception ignored) { - - } - } - - /** - * Adds a roll report to the GameManager's current pending report list. - * - * @param roll The roll to add - */ - public void reportRoll(Roll roll) { - gameManager.addReport(getGame().getNewReport(1230).addRoll(roll)); - } -} diff --git a/patches/src/megamek/client/ui/clientGUI/audio/Sound.java b/patches/work/megamek/src/megamek/client/ui/clientGUI/audio/Sound.java similarity index 100% rename from patches/src/megamek/client/ui/clientGUI/audio/Sound.java rename to patches/work/megamek/src/megamek/client/ui/clientGUI/audio/Sound.java -- 2.51.2