diff --git a/.gitignore b/.gitignore
index d0ae912..3f66ecb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,7 @@ logs/
*.log
MegaMek-patched.jar
patches/out/
+webswing-dist/
+slim-jre/
+slim-data/
+slim-home/
diff --git a/harness/src/spike/HostForHuman.java b/harness/src/spike/HostForHuman.java
new file mode 100644
index 0000000..67470b4
--- /dev/null
+++ b/harness/src/spike/HostForHuman.java
@@ -0,0 +1,113 @@
+package spike;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+import megamek.client.bot.princess.Princess;
+import megamek.common.Player;
+import megamek.common.compute.Compute;
+import megamek.common.game.IGame;
+import megamek.common.loaders.MekSummaryCache;
+import megamek.common.scenario.Scenario;
+import megamek.common.scenario.ScenarioLoader;
+import megamek.server.Server;
+import megamek.server.totalWarfare.TWGameManager;
+
+/**
+ * Hosts a MegaMek game with Princess on one side and an empty slot for a human.
+ *
+ *
Used to drive the Webswing spike: this process owns the server and the bot, and a
+ * real MegaMek Swing client - rendered into a browser by Webswing - logs into the
+ * remaining faction.
+ *
+ *
The ready-up ordering is the fiddly part. Bots never ready themselves out of the
+ * lounge, and {@code checkReady()} ignores ghost players. So readying the bot before the
+ * human has connected would satisfy "all non-ghost players are done" and start the game
+ * without them. The bot is therefore held until its opponent's slot stops being a ghost.
+ *
+ *
Usage: {@code HostForHuman [scenario] [port]}
+ */
+public final class HostForHuman {
+
+ private static final String LOCALHOST = "127.0.0.1";
+
+ public static void main(String[] args) throws Exception {
+ String scenarioPath = args.length > 0
+ ? args[0]
+ : "data/scenarios/TrainingScenarios/1-FirstRun.mms";
+ int port = args.length > 1 ? Integer.parseInt(args[1]) : 8850;
+
+ Compute.d6();
+ MekSummaryCache cache = MekSummaryCache.getInstance();
+ while (!cache.isInitialized()) {
+ Thread.sleep(250L);
+ }
+
+ ScenarioLoader loader = new ScenarioLoader(new File(scenarioPath));
+ Scenario scenario = loader.load();
+ IGame game = scenario.createGame();
+
+ TWGameManager gameManager = new TWGameManager();
+ Server server = new Server("", port, gameManager);
+ server.setGame(game);
+ scenario.applyDamage(gameManager);
+ gameManager.calculatePlayerInitialCounts();
+
+ List players = new ArrayList<>(game.getPlayersList());
+ if (players.size() < 2) {
+ throw new IllegalStateException("scenario needs at least 2 factions");
+ }
+ Player humanSlot = players.get(0);
+ List botSlots = players.subList(1, players.size());
+
+ System.out.println("=================================================");
+ System.out.printf(" scenario : %s%n", scenarioPath);
+ System.out.printf(" port : %d%n", port);
+ System.out.printf(" HUMAN : %s <-- log in as this name%n", humanSlot.getName());
+ for (Player p : botSlots) {
+ System.out.printf(" bot : %s (Princess)%n", p.getName());
+ }
+ System.out.println("=================================================");
+
+ List bots = new ArrayList<>();
+ for (Player p : botSlots) {
+ Princess bot = new Princess(p.getName(), LOCALHOST, port);
+ bot.startPrecognition();
+ bot.connect();
+ bots.add(bot);
+ }
+
+ for (Princess bot : bots) {
+ long deadline = System.currentTimeMillis() + 30_000L;
+ while (System.currentTimeMillis() < deadline
+ && (bot.getLocalPlayer() == null
+ || bot.getGame().getEntitiesOwnedBy(bot.getLocalPlayer()) == 0)) {
+ Thread.sleep(100L);
+ }
+ }
+ System.out.println("[host] bots connected; waiting for a human to claim "
+ + humanSlot.getName() + " ...");
+
+ // Hold the bots in the lounge until the human slot is actually occupied.
+ boolean announced = false;
+ while (true) {
+ Player current = server.getGame().getPlayer(humanSlot.getId());
+ if (current != null && !current.isGhost()) {
+ if (!announced) {
+ System.out.println("[host] human connected -> readying bots. "
+ + "Click 'Done' in the browser to start the game.");
+ for (Princess bot : bots) {
+ bot.sendDone(true);
+ }
+ announced = true;
+ }
+ } else if (announced) {
+ // Human dropped back out; re-arm so a reconnect works.
+ System.out.println("[host] human disconnected; waiting again ...");
+ announced = false;
+ }
+ Thread.sleep(1000L);
+ }
+ }
+}
diff --git a/spike-webswing/start.sh b/spike-webswing/start.sh
new file mode 100755
index 0000000..54b4286
--- /dev/null
+++ b/spike-webswing/start.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+# Bring up the Webswing spike: MegaMek server + Princess bot + browser-rendered client.
+#
+# 1. MegaMek Server + Princess (TraineeB) on :8850, TraineeA slot left open
+# 2. Webswing on :8080 serving the real MegaMek Swing client at /megamek,
+# launched with -client -player_name TraineeA so it claims that slot
+#
+# Then open http://localhost:8080/megamek/
+set -euo pipefail
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+JAVA="$ROOT/jdk-21.0.12+8/bin/java"
+MM="$ROOT/MegaMek-0.51.00"
+WS="$ROOT/webswing-dist"
+SCENARIO="${1:-data/scenarios/TrainingScenarios/1-FirstRun.mms}"
+
+mkdir -p "$ROOT/results" "$WS/logs"
+
+echo "starting MegaMek host (server + Princess)..."
+( cd "$MM" && nohup "$JAVA" -Xmx1g \
+ -Dlog4j2.configurationFile="$ROOT/harness/log4j2-quiet.xml" \
+ --add-opens java.base/java.util=ALL-UNNAMED \
+ --add-opens java.base/java.util.concurrent=ALL-UNNAMED \
+ -cp "$ROOT/harness/out:$ROOT/MegaMek-patched.jar:$(ls "$MM"/lib/*.jar | tr '\n' ':')" \
+ spike.HostForHuman "$SCENARIO" 8850 > "$ROOT/results/host.log" 2>&1 & )
+
+echo "starting Webswing..."
+( cd "$WS" && env -u DISPLAY -u XAUTHORITY nohup "$JAVA" -server -Xmx2G -Xms256m \
+ --add-modules=java.desktop \
+ --add-exports=java.desktop/sun.awt=ALL-UNNAMED \
+ --add-exports=java.desktop/sun.awt.dnd=ALL-UNNAMED \
+ --add-exports=java.desktop/sun.awt.datatransfer=ALL-UNNAMED \
+ --add-exports=java.desktop/sun.awt.image=ALL-UNNAMED \
+ --add-exports=java.desktop/sun.java2d=ALL-UNNAMED \
+ --add-exports=java.desktop/sun.java2d.pipe=ALL-UNNAMED \
+ --add-exports=java.desktop/sun.java2d.loops=ALL-UNNAMED \
+ --add-exports=java.desktop/sun.font=ALL-UNNAMED \
+ --add-exports=java.desktop/sun.print=ALL-UNNAMED \
+ --add-exports=java.desktop/java.awt.peer=ALL-UNNAMED \
+ --add-exports=java.desktop/java.awt.dnd=ALL-UNNAMED \
+ --add-exports=java.base/sun.nio.cs=ALL-UNNAMED \
+ --add-opens=java.desktop/sun.awt.image=ALL-UNNAMED \
+ -Dwebswing.websocketMessageSizeLimit=1024000 \
+ -jar webswing-server-26.4.5.war \
+ -h 0.0.0.0 -j jetty.properties -c webswing.config -pf webswing.properties \
+ > logs/ws.log 2>&1 & )
+
+echo
+echo " open http://localhost:8080/megamek/"
+echo " host log: $ROOT/results/host.log"
+echo " webswing log: $WS/logs/ws.log"
+echo " stop: pkill -f HostForHuman; pkill -f webswing-server"
diff --git a/spike-webswing/webswing.config b/spike-webswing/webswing.config
new file mode 100644
index 0000000..b008d38
--- /dev/null
+++ b/spike-webswing/webswing.config
@@ -0,0 +1,33 @@
+{
+ "/" : {
+ "path" : "/",
+ "security" : {
+ "module" : "NONE"
+ }
+ },
+ "/megamek" : {
+ "path" : "/megamek",
+ "name" : "MegaMek",
+ "security" : {
+ "module" : "NONE"
+ },
+ "swingConfig" : {
+ "launcherType" : "Desktop",
+ "launcherConfig" : {
+ "mainClass" : "megamek.MegaMek",
+ "args" : "-client -server localhost -port 8850 -player_name TraineeA"
+ },
+ "homeDir" : "/home/jmm/Code/mm-spike/MegaMek-0.51.00",
+ "jreExecutable" : "/home/jmm/Code/mm-spike/jdk-21.0.12+8/bin/java",
+ "classPathEntries" : [ "MegaMek.jar", "lib/*" ],
+ "vmArgs" : "-Xmx2G -Dsentry.dsn= --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.util.concurrent=ALL-UNNAMED",
+ "directdraw" : true,
+ "isolatedFs" : false,
+ "sessionTimeout" : 3600,
+ "swingSessionTimeout" : 3600,
+ "timeoutIfInactive" : false,
+ "allowLocalClipboard" : true,
+ "debug" : false
+ }
+ }
+}