mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-18 14:22:44 +00:00
## Description: Adds a new `waterNukes` game config option that causes nuclear detonations to convert land tiles into water instead of just leaving fallout. When enabled, nuked land tiles are batched and converted to water each tick, with full terrain metadata updates including: - Ocean bit propagation from adjacent ocean tiles (BFS flood fill) - Magnitude recomputation via BFS from remaining coastlines - Shoreline bit fix-up in a 2-ring neighborhood around converted tiles - Minimap terrain sync (majority-rule downsampling) - Throttled water navigation graph rebuild (every 20 ticks) for ship pathfinding - Ship executions detect graph rebuilds and refresh their pathfinders - TransportShips auto-retreat if their destination becomes water - Water nuke craters use a smoothed angular noise ring with a bounding-box scan instead of the regular per-tile random coin flip with BFS, producing clean blob-shaped craters without scattered land pixels that players would have to boat to individually The `TerrainLayer` now incrementally repaints tiles that changed terrain type, and tile update packets encode the terrain byte alongside tile state so clients can reflect water conversions in real time. When `waterNukes` is disabled, behavior is unchanged (fallout only). Includes a new test suite (WaterNukes.test.ts) covering the conversion pipeline, ocean propagation, magnitude recalculation, shoreline updates, and minimap sync. Also adds a new public game modifier for the special rotation. ### The only problem A bit of lag on impact. But otherwise it works great and is fun. Maybe needs some followup improvements if it gets merged. I think its very cool in baikal / four islands team games. Chip away the territory of your opponents. Its also fun to turn The Box / Alps into a water map (its actually possible to boat-trade then) ### Media Video does not show the updated craters https://github.com/user-attachments/assets/aed8bf08-0e94-4484-b997-4de11ae313d9 Updated craters (no tiny islands after impact): <img width="1920" height="1080" alt="image" src="https://github.com/user-attachments/assets/e896870b-bc9d-493d-8bc8-b3a5427d69d3" /> <img width="1472" height="920" alt="image" src="https://github.com/user-attachments/assets/677065aa-0159-48cd-af44-a91b0f57adfc" /> <img width="1296" height="892" alt="image" src="https://github.com/user-attachments/assets/886ffaba-541f-4e46-97c6-ce963f632fe0" /> ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin
247 lines
8.6 KiB
TypeScript
247 lines
8.6 KiB
TypeScript
import { NukeExecution } from "../../src/core/execution/NukeExecution";
|
|
import { SpawnExecution } from "../../src/core/execution/SpawnExecution";
|
|
import {
|
|
Game,
|
|
Player,
|
|
PlayerInfo,
|
|
PlayerType,
|
|
UnitType,
|
|
} from "../../src/core/game/Game";
|
|
import { TileRef } from "../../src/core/game/GameMap";
|
|
import { GameID } from "../../src/core/Schemas";
|
|
import { setup } from "../util/Setup";
|
|
import { constructionExecution } from "../util/utils";
|
|
|
|
const gameID: GameID = "game_id";
|
|
|
|
function launchNukeAt(game: Game, player: Player, target: TileRef): void {
|
|
game.addExecution(new NukeExecution(UnitType.AtomBomb, player, target, null));
|
|
// init + build
|
|
game.executeNextTick();
|
|
game.executeNextTick();
|
|
}
|
|
|
|
function tickUntilNukeLands(game: Game, maxTicks = 50): void {
|
|
for (let i = 0; i < maxTicks; i++) {
|
|
game.executeNextTick();
|
|
}
|
|
}
|
|
|
|
describe("Water Nukes", () => {
|
|
let game: Game;
|
|
let player: Player;
|
|
|
|
describe("when waterNukes is enabled", () => {
|
|
beforeEach(async () => {
|
|
game = await setup("plains", {
|
|
infiniteGold: true,
|
|
instantBuild: true,
|
|
waterNukes: true,
|
|
});
|
|
const info = new PlayerInfo("p", PlayerType.Human, null, "p");
|
|
game.addPlayer(info);
|
|
game.addExecution(new SpawnExecution(gameID, info, game.ref(1, 1)));
|
|
while (game.inSpawnPhase()) game.executeNextTick();
|
|
player = game.player(info.id);
|
|
|
|
// Build a missile silo
|
|
constructionExecution(game, player, 1, 1, UnitType.MissileSilo);
|
|
});
|
|
|
|
test("nuke converts land tiles to water instead of fallout", () => {
|
|
const target = game.ref(10, 10);
|
|
// Confirm target is land before nuke
|
|
expect(game.isLand(target)).toBe(true);
|
|
|
|
launchNukeAt(game, player, target);
|
|
tickUntilNukeLands(game);
|
|
|
|
// Target should now be water, not land
|
|
expect(game.isLand(target)).toBe(false);
|
|
expect(game.isWater(target)).toBe(true);
|
|
// Should NOT have fallout
|
|
expect(game.hasFallout(target)).toBe(false);
|
|
});
|
|
|
|
test("converted tiles get shoreline bits updated", () => {
|
|
const target = game.ref(10, 10);
|
|
launchNukeAt(game, player, target);
|
|
tickUntilNukeLands(game);
|
|
|
|
// With nukeMagnitudes { inner: 1, outer: 1 }, the target and its
|
|
// cardinal neighbors (dist² <= 1) are all converted to water.
|
|
// Shoreline tiles are the land tiles just outside the blast radius.
|
|
const x = game.x(target);
|
|
const y = game.y(target);
|
|
|
|
// 2 tiles away should still be land and now be shoreline
|
|
const outerNeighbors: TileRef[] = [];
|
|
if (game.isValidCoord(x - 2, y)) outerNeighbors.push(game.ref(x - 2, y));
|
|
if (game.isValidCoord(x + 2, y)) outerNeighbors.push(game.ref(x + 2, y));
|
|
if (game.isValidCoord(x, y - 2)) outerNeighbors.push(game.ref(x, y - 2));
|
|
if (game.isValidCoord(x, y + 2)) outerNeighbors.push(game.ref(x, y + 2));
|
|
|
|
for (const n of outerNeighbors) {
|
|
expect(game.isLand(n)).toBe(true);
|
|
expect(game.isShoreline(n)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("queueWaterConversion skips tiles conquered before flush", () => {
|
|
// Pick an unowned land tile and queue it for water conversion directly
|
|
const target = game.ref(10, 10);
|
|
expect(game.isLand(target)).toBe(true);
|
|
expect(game.hasOwner(target)).toBe(false);
|
|
|
|
// Queue the tile for water conversion (simulates nuke queueing)
|
|
game.queueWaterConversion(target);
|
|
|
|
// Another actor conquers the tile before the tick flushes the queue
|
|
player.conquer(target);
|
|
expect(game.hasOwner(target)).toBe(true);
|
|
|
|
// Flush: the pending conversion should be skipped because the tile is now owned
|
|
game.executeNextTick();
|
|
|
|
// Tile should remain land and owned
|
|
expect(game.isLand(target)).toBe(true);
|
|
expect(game.hasOwner(target)).toBe(true);
|
|
expect(game.isWater(target)).toBe(false);
|
|
});
|
|
|
|
test("waterGraphVersion increments after water conversion", async () => {
|
|
// Need a game with nav mesh enabled for graph rebuilds
|
|
const navGame = await setup("plains", {
|
|
infiniteGold: true,
|
|
instantBuild: true,
|
|
waterNukes: true,
|
|
disableNavMesh: false,
|
|
});
|
|
const info2 = new PlayerInfo("p2", PlayerType.Human, null, "p2");
|
|
navGame.addPlayer(info2);
|
|
navGame.addExecution(
|
|
new SpawnExecution(gameID, info2, navGame.ref(1, 1)),
|
|
);
|
|
while (navGame.inSpawnPhase()) navGame.executeNextTick();
|
|
const player2 = navGame.player(info2.id);
|
|
constructionExecution(navGame, player2, 1, 1, UnitType.MissileSilo);
|
|
|
|
const versionBefore = navGame.waterGraphVersion();
|
|
|
|
// Launch multiple nukes in a cluster to ensure enough tiles convert
|
|
// for at least one minimap tile to flip (need >= 3 of 4 source tiles)
|
|
const target = navGame.ref(50, 50);
|
|
navGame.addExecution(
|
|
new NukeExecution(UnitType.AtomBomb, player2, target, null),
|
|
);
|
|
// Tick enough for nuke to land + graph rebuild throttle (20 ticks)
|
|
for (let i = 0; i < 80; i++) navGame.executeNextTick();
|
|
|
|
expect(navGame.waterGraphVersion()).toBeGreaterThan(versionBefore);
|
|
});
|
|
});
|
|
|
|
describe("when waterNukes is disabled (default)", () => {
|
|
beforeEach(async () => {
|
|
game = await setup("plains", {
|
|
infiniteGold: true,
|
|
instantBuild: true,
|
|
waterNukes: false,
|
|
});
|
|
const info = new PlayerInfo("p", PlayerType.Human, null, "p");
|
|
game.addPlayer(info);
|
|
game.addExecution(new SpawnExecution(gameID, info, game.ref(1, 1)));
|
|
while (game.inSpawnPhase()) game.executeNextTick();
|
|
player = game.player(info.id);
|
|
|
|
constructionExecution(game, player, 1, 1, UnitType.MissileSilo);
|
|
});
|
|
|
|
test("nuke applies fallout instead of converting to water", () => {
|
|
const target = game.ref(10, 10);
|
|
expect(game.isLand(target)).toBe(true);
|
|
|
|
launchNukeAt(game, player, target);
|
|
tickUntilNukeLands(game);
|
|
|
|
// Should remain land with fallout
|
|
expect(game.isLand(target)).toBe(true);
|
|
expect(game.hasFallout(target)).toBe(true);
|
|
});
|
|
|
|
test("waterGraphVersion does not change", () => {
|
|
const versionBefore = game.waterGraphVersion();
|
|
const target = game.ref(10, 10);
|
|
|
|
launchNukeAt(game, player, target);
|
|
tickUntilNukeLands(game);
|
|
|
|
expect(game.waterGraphVersion()).toBe(versionBefore);
|
|
});
|
|
});
|
|
|
|
describe("all-land map (no pre-existing ocean)", () => {
|
|
test("nuke-created water gets ocean bit so ports can be built", async () => {
|
|
game = await setup("plains", {
|
|
infiniteGold: true,
|
|
instantBuild: true,
|
|
waterNukes: true,
|
|
});
|
|
const info = new PlayerInfo("p", PlayerType.Human, null, "p");
|
|
game.addPlayer(info);
|
|
game.addExecution(new SpawnExecution(gameID, info, game.ref(1, 1)));
|
|
while (game.inSpawnPhase()) game.executeNextTick();
|
|
player = game.player(info.id);
|
|
constructionExecution(game, player, 1, 1, UnitType.MissileSilo);
|
|
|
|
const target = game.ref(10, 10);
|
|
|
|
// Verify no ocean exists anywhere near the target before the nuke
|
|
expect(game.isLand(target)).toBe(true);
|
|
|
|
launchNukeAt(game, player, target);
|
|
tickUntilNukeLands(game);
|
|
|
|
// The converted tile should be ocean (not just lake water)
|
|
expect(game.isWater(target)).toBe(true);
|
|
expect(game.isOcean(target)).toBe(true);
|
|
|
|
// Neighboring land tiles should be ocean-shore (required for port placement)
|
|
const x = game.x(target);
|
|
const y = game.y(target);
|
|
const shoreCandidate = game.ref(x + 2, y);
|
|
if (game.isLand(shoreCandidate)) {
|
|
expect(game.isOceanShore(shoreCandidate)).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("updateTile terrain byte round-trip", () => {
|
|
test("terrain byte is packed and unpacked correctly", async () => {
|
|
game = await setup("plains", {
|
|
infiniteGold: true,
|
|
instantBuild: true,
|
|
waterNukes: true,
|
|
});
|
|
const info = new PlayerInfo("p", PlayerType.Human, null, "p");
|
|
game.addPlayer(info);
|
|
game.addExecution(new SpawnExecution(gameID, info, game.ref(1, 1)));
|
|
while (game.inSpawnPhase()) game.executeNextTick();
|
|
player = game.player(info.id);
|
|
constructionExecution(game, player, 1, 1, UnitType.MissileSilo);
|
|
|
|
const target = game.ref(10, 10);
|
|
const terrainBefore = game.terrainByte(target);
|
|
expect(game.isLand(target)).toBe(true);
|
|
|
|
launchNukeAt(game, player, target);
|
|
tickUntilNukeLands(game);
|
|
|
|
const terrainAfter = game.terrainByte(target);
|
|
// Terrain should have changed (was land, now water)
|
|
expect(terrainAfter).not.toBe(terrainBefore);
|
|
expect(game.isWater(target)).toBe(true);
|
|
});
|
|
});
|
|
});
|