diff --git a/engine/src/main/battlecode/world/LiveMap.java b/engine/src/main/battlecode/world/LiveMap.java index 2b3450ae..2827d323 100644 --- a/engine/src/main/battlecode/world/LiveMap.java +++ b/engine/src/main/battlecode/world/LiveMap.java @@ -432,6 +432,9 @@ public void assertIsValid() throws Exception { throw new RuntimeException("MAP HEIGHT BENEATH GameConstants.MAP_MIN_HEIGHT"); } + // Validate map symmetry + assertSymmetryValid(); + int initialBodyCountTeamA = 0; int initialBodyCountTeamB = 0; @@ -510,6 +513,138 @@ public void assertIsValid() throws Exception { } } + /** + * Validates that the map's terrain and initial bodies match the declared symmetry. + * Throws on the first error found. + */ + private void assertSymmetryValid() { + MapSymmetry sym = this.symmetry; + + // Check terrain arrays + for (int x = 0; x < width; x++) { + for (int y = 0; y < height; y++) { + MapLocation loc = new MapLocation(x, y); + int curIdx = locationToIndex(loc); + + int symX = symmetricX(x, sym); + int symY = symmetricY(y, sym); + MapLocation symLoc = new MapLocation(symX, symY); + int symIdx = locationToIndex(symLoc); + + if (wallArray[curIdx] != wallArray[symIdx]) { + throw new RuntimeException("Wall mismatch for " + sym + " symmetry at (" + x + "," + y + ") vs (" + symX + "," + symY + ")"); + } + if (dirtArray[curIdx] != dirtArray[symIdx]) { + throw new RuntimeException("Dirt mismatch for " + sym + " symmetry at (" + x + "," + y + ") vs (" + symX + "," + symY + ")"); + } + if (cheeseMineArray[curIdx] != cheeseMineArray[symIdx]) { + throw new RuntimeException("Cheese mine mismatch for " + sym + " symmetry at (" + x + "," + y + ") vs (" + symX + "," + symY + ")"); + } + if (cheeseArray[curIdx] != cheeseArray[symIdx]) { + throw new RuntimeException("Cheese mismatch for " + sym + " symmetry at (" + x + "," + y + "): " + cheeseArray[curIdx] + " vs " + cheeseArray[symIdx]); + } + } + } + + // Check initial bodies (rat kings and cats) + Map robotsByLoc = new HashMap<>(); + for (RobotInfo r : initialBodies) { + robotsByLoc.put(r.location, r); + } + + for (RobotInfo robot : initialBodies) { + // Account for unit size when computing symmetric location + int unitSize = robot.type.getSize(); + MapLocation symLoc = new MapLocation( + symmetricX(robot.location.x, sym, unitSize), + symmetricY(robot.location.y, sym, unitSize) + ); + RobotInfo symRobot = robotsByLoc.get(symLoc); + + if (symRobot == null) { + throw new RuntimeException("No robot at symmetric location " + symLoc + " for " + robot.type + " at " + robot.location); + } + if (robot.type != symRobot.type) { + throw new RuntimeException("Robot type mismatch: " + robot.type + " at " + robot.location + " vs " + symRobot.type + " at " + symLoc); + } + if (!areSymmetricTeams(robot.team, symRobot.team)) { + throw new RuntimeException("Robot team mismatch: " + robot.team + " at " + robot.location + " vs " + symRobot.team + " at " + symLoc); + } + } + } + + /** + * Helper method to compute symmetric x coordinate for a given symmetry type. + * For terrain tiles (size 1). + */ + private int symmetricX(int x, MapSymmetry symmetry) { + return symmetricX(x, symmetry, 1); + } + + /** + * Helper method to compute symmetric y coordinate for a given symmetry type. + * For terrain tiles (size 1). + */ + private int symmetricY(int y, MapSymmetry symmetry) { + return symmetricY(y, symmetry, 1); + } + + /** + * Helper method to compute symmetric x coordinate for a given symmetry type, + * accounting for unit size. + * - Odd-sized units (RAT_KING=3): location is center, use standard formula + * - Even-sized units (CAT=2): location is bottom-left corner, offset by (size-1) + */ + private int symmetricX(int x, MapSymmetry symmetry, int unitSize) { + switch (symmetry) { + case HORIZONTAL: + return x; + case VERTICAL: + case ROTATIONAL: + if (unitSize % 2 == 0) { + // Even size: location is bottom-left corner, need offset + return width - unitSize - x; + } else { + // Odd size: location is center, standard formula + return width - 1 - x; + } + default: + throw new IllegalArgumentException("Unknown symmetry type: " + symmetry); + } + } + + /** + * Helper method to compute symmetric y coordinate for a given symmetry type, + * accounting for unit size. + */ + private int symmetricY(int y, MapSymmetry symmetry, int unitSize) { + switch (symmetry) { + case VERTICAL: + return y; + case HORIZONTAL: + case ROTATIONAL: + if (unitSize % 2 == 0) { + // Even size: location is bottom-left corner, need offset + return height - unitSize - y; + } else { + // Odd size: location is center, standard formula + return height - 1 - y; + } + default: + throw new IllegalArgumentException("Unknown symmetry type: " + symmetry); + } + } + + /** + * Helper method to check if two teams are symmetric to each other. + * Rat kings: A <-> B, Cats: NEUTRAL <-> NEUTRAL + */ + private boolean areSymmetricTeams(Team a, Team b) { + if (a == Team.A) return b == Team.B; + if (a == Team.B) return b == Team.A; + return a == Team.NEUTRAL && b == Team.NEUTRAL; + } + // private boolean isTeamNumber(int team) { // return team == 1 || team == 2; // } diff --git a/engine/src/main/battlecode/world/MapBuilder.java b/engine/src/main/battlecode/world/MapBuilder.java index e7321fd5..f0ad45a8 100644 --- a/engine/src/main/battlecode/world/MapBuilder.java +++ b/engine/src/main/battlecode/world/MapBuilder.java @@ -69,9 +69,25 @@ private int locationToIndex(int x, int y) { // return loc.x + loc.y * width; // } - // public void setWall(int x, int y, boolean value) { - // this.wallArray[locationToIndex(x, y)] = value; - // } + public void setWall(int x, int y, boolean value) { + this.wallArray[locationToIndex(x, y)] = value; + } + + public void setDirt(int x, int y, boolean value) { + this.dirtArray[locationToIndex(x, y)] = value; + } + + public void setCheeseMine(int x, int y, boolean value) { + this.cheeseMineArray[locationToIndex(x, y)] = value; + } + + public void setCheese(int x, int y, int value) { + this.cheeseArray[locationToIndex(x, y)] = value; + } + + public void addBody(RobotInfo body) { + this.bodies.add(body); + } // public void setCloud(int x, int y, boolean value) { // this.cloudArray[locationToIndex(x, y)] = value; diff --git a/engine/src/test/battlecode/world/LiveMapTest.java b/engine/src/test/battlecode/world/LiveMapTest.java new file mode 100644 index 00000000..91cb5282 --- /dev/null +++ b/engine/src/test/battlecode/world/LiveMapTest.java @@ -0,0 +1,406 @@ +package battlecode.world; + +import battlecode.common.*; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.*; + +/** + * Unit tests for LiveMap symmetry validation. + */ +public class LiveMapTest { + + /** + * Helper to create a basic valid symmetric map with rat kings. + */ + private MapBuilder createBasicMapBuilder(int width, int height, MapSymmetry symmetry) { + MapBuilder builder = new MapBuilder("test", width, height, 0, 0, 1337); + builder.setSymmetry(symmetry); + + // Add symmetric rat kings (required for valid map) + int symX = symmetry == MapSymmetry.HORIZONTAL ? 5 : width - 1 - 5; + int symY = symmetry == MapSymmetry.VERTICAL ? 5 : height - 1 - 5; + + builder.addBody(new RobotInfo(1, Team.A, UnitType.RAT_KING, 600, + new MapLocation(5, 5), Direction.NORTH, 1, 0, null)); + builder.addBody(new RobotInfo(2, Team.B, UnitType.RAT_KING, 600, + new MapLocation(symX, symY), Direction.NORTH, 1, 0, null)); + + return builder; + } + + // ==================== MAP DIMENSION TESTS ==================== + + @Test + public void testMapWidthExceedsMaxFails() { + // MAP_MAX_WIDTH = 60, so 61 should fail + MapBuilder builder = new MapBuilder("test", 61, 20, 0, 0, 1337); + builder.setSymmetry(MapSymmetry.ROTATIONAL); + addSymmetricRatKings(builder, 61, 20, MapSymmetry.ROTATIONAL); + + LiveMap map = builder.build(); + + Exception exception = assertThrows(RuntimeException.class, () -> { + map.assertIsValid(); + }); + + assertTrue(exception.getMessage().contains("MAP WIDTH EXCEEDS")); + } + + @Test + public void testMapWidthBeneathMinFails() { + // MAP_MIN_WIDTH = 20, so 19 should fail + MapBuilder builder = new MapBuilder("test", 19, 20, 0, 0, 1337); + builder.setSymmetry(MapSymmetry.ROTATIONAL); + addSymmetricRatKings(builder, 19, 20, MapSymmetry.ROTATIONAL); + + LiveMap map = builder.build(); + + Exception exception = assertThrows(RuntimeException.class, () -> { + map.assertIsValid(); + }); + + assertTrue(exception.getMessage().contains("MAP WIDTH BENEATH")); + } + + @Test + public void testMapHeightExceedsMaxFails() { + // MAP_MAX_HEIGHT = 60, so 61 should fail + MapBuilder builder = new MapBuilder("test", 20, 61, 0, 0, 1337); + builder.setSymmetry(MapSymmetry.ROTATIONAL); + addSymmetricRatKings(builder, 20, 61, MapSymmetry.ROTATIONAL); + + LiveMap map = builder.build(); + + Exception exception = assertThrows(RuntimeException.class, () -> { + map.assertIsValid(); + }); + + assertTrue(exception.getMessage().contains("MAP HEIGHT EXCEEDS")); + } + + @Test + public void testMapHeightBeneathMinFails() { + // MAP_MIN_HEIGHT = 20, so 19 should fail + MapBuilder builder = new MapBuilder("test", 20, 19, 0, 0, 1337); + builder.setSymmetry(MapSymmetry.ROTATIONAL); + addSymmetricRatKings(builder, 20, 19, MapSymmetry.ROTATIONAL); + + LiveMap map = builder.build(); + + Exception exception = assertThrows(RuntimeException.class, () -> { + map.assertIsValid(); + }); + + assertTrue(exception.getMessage().contains("MAP HEIGHT BENEATH")); + } + + @Test + public void testMapAtMinDimensionsPass() throws Exception { + // Both at minimum (20x20) should pass + MapBuilder builder = new MapBuilder("test", 20, 20, 0, 0, 1337); + builder.setSymmetry(MapSymmetry.ROTATIONAL); + addSymmetricRatKings(builder, 20, 20, MapSymmetry.ROTATIONAL); + + LiveMap map = builder.build(); + map.assertIsValid(); // Should not throw + } + + @Test + public void testMapAtMaxDimensionsPass() throws Exception { + // Both at maximum (60x60) should pass + MapBuilder builder = new MapBuilder("test", 60, 60, 0, 0, 1337); + builder.setSymmetry(MapSymmetry.ROTATIONAL); + addSymmetricRatKings(builder, 60, 60, MapSymmetry.ROTATIONAL); + + LiveMap map = builder.build(); + map.assertIsValid(); // Should not throw + } + + /** + * Helper to add symmetric rat kings to a map. + */ + private void addSymmetricRatKings(MapBuilder builder, int width, int height, MapSymmetry symmetry) { + int x = 5; + int y = 5; + int symX = (symmetry == MapSymmetry.HORIZONTAL) ? x : width - 1 - x; + int symY = (symmetry == MapSymmetry.VERTICAL) ? y : height - 1 - y; + + builder.addBody(new RobotInfo(1, Team.A, UnitType.RAT_KING, 600, + new MapLocation(x, y), Direction.NORTH, 1, 0, null)); + builder.addBody(new RobotInfo(2, Team.B, UnitType.RAT_KING, 600, + new MapLocation(symX, symY), Direction.NORTH, 1, 0, null)); + } + + // ==================== VALID SYMMETRY TESTS ==================== + + @Test + public void testValidRotationalSymmetry() throws Exception { + MapBuilder builder = createBasicMapBuilder(20, 20, MapSymmetry.ROTATIONAL); + + // Add symmetric walls + builder.setSymmetricWalls(2, 3, true); + + LiveMap map = builder.build(); + map.assertIsValid(); // Should not throw + } + + @Test + public void testValidHorizontalSymmetry() throws Exception { + MapBuilder builder = createBasicMapBuilder(20, 20, MapSymmetry.HORIZONTAL); + + // Add symmetric walls (horizontal: x stays same, y flips) + builder.setWall(5, 2, true); + builder.setWall(5, 17, true); + + LiveMap map = builder.build(); + map.assertIsValid(); // Should not throw + } + + @Test + public void testValidVerticalSymmetry() throws Exception { + MapBuilder builder = createBasicMapBuilder(20, 20, MapSymmetry.VERTICAL); + + // Add symmetric walls (vertical: y stays same, x flips) + builder.setWall(2, 5, true); + builder.setWall(17, 5, true); + + LiveMap map = builder.build(); + map.assertIsValid(); // Should not throw + } + + // ==================== INVALID SYMMETRY TESTS ==================== + + @Test + public void testAsymmetricWallsFail() { + MapBuilder builder = createBasicMapBuilder(20, 20, MapSymmetry.ROTATIONAL); + + // Add asymmetric wall (only on one side) + builder.setWall(2, 3, true); + // Don't add the symmetric wall at (17, 16) + + LiveMap map = builder.build(); + + Exception exception = assertThrows(RuntimeException.class, () -> { + map.assertIsValid(); + }); + + assertTrue(exception.getMessage().contains("symmetry")); + } + + @Test + public void testAsymmetricDirtFails() { + MapBuilder builder = createBasicMapBuilder(20, 20, MapSymmetry.ROTATIONAL); + + // Add asymmetric dirt + builder.setDirt(4, 4, true); + // Don't add the symmetric dirt + + LiveMap map = builder.build(); + + Exception exception = assertThrows(RuntimeException.class, () -> { + map.assertIsValid(); + }); + + assertTrue(exception.getMessage().contains("symmetry")); + } + + @Test + public void testAsymmetricCheeseFails() { + MapBuilder builder = createBasicMapBuilder(20, 20, MapSymmetry.ROTATIONAL); + + // Add asymmetric cheese amounts + builder.setCheese(3, 3, 100); + // Don't add matching cheese on other side + + LiveMap map = builder.build(); + + Exception exception = assertThrows(RuntimeException.class, () -> { + map.assertIsValid(); + }); + + assertTrue(exception.getMessage().contains("symmetry")); + } + + @Test + public void testAsymmetricRatKingsFail() { + MapBuilder builder = new MapBuilder("test", 20, 20, 0, 0, 1337); + builder.setSymmetry(MapSymmetry.ROTATIONAL); + + // Add rat kings in non-symmetric positions + builder.addBody(new RobotInfo(1, Team.A, UnitType.RAT_KING, 600, + new MapLocation(5, 5), Direction.NORTH, 1, 0, null)); + builder.addBody(new RobotInfo(2, Team.B, UnitType.RAT_KING, 600, + new MapLocation(10, 10), Direction.NORTH, 1, 0, null)); // Wrong position for rotational + + LiveMap map = builder.build(); + + Exception exception = assertThrows(RuntimeException.class, () -> { + map.assertIsValid(); + }); + + assertTrue(exception.getMessage().contains("No robot at symmetric location")); + } + + @Test + public void testAsymmetricCatsFail() { + MapBuilder builder = createBasicMapBuilder(20, 20, MapSymmetry.ROTATIONAL); + + // Add cat in non-symmetric position + // For CAT (size 2), symmetric of [8, 8] is [10, 10] + builder.addBody(new RobotInfo(3, Team.NEUTRAL, UnitType.CAT, 4000, + new MapLocation(8, 8), Direction.NORTH, 1, 0, null)); + // No matching cat at the correct symmetric position (10, 10) + + LiveMap map = builder.build(); + + Exception exception = assertThrows(RuntimeException.class, () -> { + map.assertIsValid(); + }); + + assertTrue(exception.getMessage().contains("No robot at symmetric location")); + } + + @Test + public void testSymmetricCatsPass() throws Exception { + MapBuilder builder = createBasicMapBuilder(20, 20, MapSymmetry.ROTATIONAL); + + // Add cats in symmetric positions + // For CAT (size 2, even), symmetric position = [width - 2 - x, height - 2 - y] + // Cat at [8, 8] -> symmetric at [20 - 2 - 8, 20 - 2 - 8] = [10, 10] + builder.addBody(new RobotInfo(3, Team.NEUTRAL, UnitType.CAT, 4000, + new MapLocation(8, 8), Direction.NORTH, 1, 0, null)); + builder.addBody(new RobotInfo(4, Team.NEUTRAL, UnitType.CAT, 4000, + new MapLocation(10, 10), Direction.NORTH, 1, 0, null)); + + LiveMap map = builder.build(); + map.assertIsValid(); // Should not throw + } + + @Test + public void testWrongDeclaredSymmetryFails() { + MapBuilder builder = new MapBuilder("test", 20, 20, 0, 0, 1337); + // Declare ROTATIONAL but build for HORIZONTAL + builder.setSymmetry(MapSymmetry.ROTATIONAL); + + // Add rat kings symmetric for HORIZONTAL only (x same, y flips) + builder.addBody(new RobotInfo(1, Team.A, UnitType.RAT_KING, 600, + new MapLocation(5, 5), Direction.NORTH, 1, 0, null)); + builder.addBody(new RobotInfo(2, Team.B, UnitType.RAT_KING, 600, + new MapLocation(5, 14), Direction.NORTH, 1, 0, null)); // Horizontal symmetric, not rotational + + // Add walls that are horizontally symmetric but not rotationally + builder.setWall(3, 2, true); + builder.setWall(3, 17, true); // Horizontal symmetric + + LiveMap map = builder.build(); + + Exception exception = assertThrows(RuntimeException.class, () -> { + map.assertIsValid(); + }); + + // Should fail on wall mismatch since walls are horizontally symmetric but not rotationally + assertTrue(exception.getMessage().contains("mismatch") || exception.getMessage().contains("No robot")); + } + + // ==================== DIAGNOSTIC TEST FOR ACTUAL MAPS ==================== + + /** + * Diagnostic test that loads all actual map files and reports which ones + * pass or fail symmetry validation. This test never fails - it just reports. + */ + @Test + public void diagnoseAllMaps() throws IOException { + File mapsDir = new File("maps"); + if (!mapsDir.exists()) mapsDir = new File("../maps"); + if (!mapsDir.exists()) mapsDir = new File("../../maps"); + + if (!mapsDir.exists()) { + System.out.println("Could not find maps directory, skipping diagnostic test"); + return; + } + + File[] mapFiles = mapsDir.listFiles((dir, name) -> name.endsWith(".map26")); + + if (mapFiles == null || mapFiles.length == 0) { + System.out.println("No .map26 files found in " + mapsDir.getAbsolutePath()); + return; + } + + List passed = new ArrayList<>(); + List failed = new ArrayList<>(); + + final File finalMapsDir = mapsDir; + for (File mapFile : mapFiles) { + String mapName = mapFile.getName().replace(".map26", ""); + try { + LiveMap map = GameMapIO.loadMap(mapName, finalMapsDir, false); + map.assertIsValid(); + passed.add(mapName); + } catch (Exception e) { + failed.add(mapName + ": " + e.getMessage()); + } + } + + System.out.println("\n========== MAP SYMMETRY DIAGNOSTIC =========="); + System.out.println("Maps directory: " + mapsDir.getAbsolutePath()); + System.out.println("Total maps: " + (passed.size() + failed.size())); + System.out.println("PASSED: " + passed.size()); + System.out.println("FAILED: " + failed.size()); + + if (!failed.isEmpty()) { + System.out.println("\n--- Failed Maps ---"); + for (String failure : failed) { + System.out.println(" " + failure); + } + } + + if (!passed.isEmpty()) { + System.out.println("\n--- Passed Maps ---"); + for (String success : passed) { + System.out.println(" " + success); + } + } + } + + /** + * Debug a single map to see where robots actually are. + */ + @Test + public void debugSingleMapRobots() throws IOException { + File mapsDir = new File("maps"); + if (!mapsDir.exists()) mapsDir = new File("../maps"); + if (!mapsDir.exists()) mapsDir = new File("../../maps"); + + String mapName = "corridorofdoomanddespair"; // Change this to test different maps + + try { + LiveMap map = GameMapIO.loadMap(mapName, mapsDir, false); + MapSymmetry declared = map.getSymmetry(); + int width = map.getWidth(); + int height = map.getHeight(); + + System.out.println("\n========== DEBUG: " + mapName + " =========="); + System.out.println("Size: " + width + "x" + height); + System.out.println("Declared symmetry: " + declared); + System.out.println("\nInitial bodies:"); + + RobotInfo[] bodies = map.getInitialBodies(); + for (RobotInfo r : bodies) { + int expectedSymX = (declared == MapSymmetry.HORIZONTAL) ? r.location.x : width - 1 - r.location.x; + int expectedSymY = (declared == MapSymmetry.VERTICAL) ? r.location.y : height - 1 - r.location.y; + + System.out.println(" " + r.type + " " + r.team + " at " + r.location + + " -> expected symmetric at [" + expectedSymX + ", " + expectedSymY + "]"); + } + System.out.println("===========================================\n"); + } catch (Exception e) { + System.out.println("Error: " + e.getMessage()); + } + } +}