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