mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:40:43 +00:00
Trading in lakes 🚤 (#3653)
## Description: - Widened port placement and warship spawn/patrol checks from `isOcean`/`isOceanShore` to `isWater`/`isShore`, so ports can be built on lake shores and ships can operate on lakes, we discussed it here: <img width="996" height="423" alt="image" src="https://github.com/user-attachments/assets/acf1e970-9631-4848-a0ed-6d0470616e1d" /> - Filtered `tradingPorts()` by water component so ports only attempt trades with reachable ports - prevents silent path-not-found failures across disconnected water bodies - Applied the same water component filter when a captured trade ship reroutes to its new owner's nearest port - Removed the `WaterManager` fallback that force-marked isolated water-nuked-tiles as ocean (no longer needed since lakes are now navigable) - Added a check to prevent nations from building ports on water bodies that aren't accessible to other players ## 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 --------- Co-authored-by: Evan <evanpelle@gmail.com>
This commit is contained in:
@@ -137,7 +137,7 @@ export class UnitLayer implements Layer {
|
||||
|
||||
clickRef = this.game.ref(cell.x, cell.y);
|
||||
}
|
||||
if (!this.game.isOcean(clickRef)) return;
|
||||
if (!this.game.isWater(clickRef)) return;
|
||||
|
||||
if (this.selectedUnit) {
|
||||
this.eventBus.emit(
|
||||
@@ -169,13 +169,13 @@ export class UnitLayer implements Layer {
|
||||
const clickRef = this.game.ref(cell.x, cell.y);
|
||||
if (this.game.inSpawnPhase()) {
|
||||
// No Radial Menu during spawn phase, only spawn point selection
|
||||
if (!this.game.isOcean(clickRef)) {
|
||||
if (!this.game.isWater(clickRef)) {
|
||||
this.eventBus.emit(new MouseUpEvent(event.x, event.y));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.game.isOcean(clickRef)) {
|
||||
if (!this.game.isWater(clickRef)) {
|
||||
// No warship to find because no Ocean tile, open Radial Menu
|
||||
this.eventBus.emit(new ContextMenuEvent(event.x, event.y));
|
||||
return;
|
||||
|
||||
@@ -97,10 +97,22 @@ export class PortExecution implements Execution {
|
||||
// It's a probability list, so if an element appears twice it's because it's
|
||||
// twice more likely to be picked later.
|
||||
tradingPorts(): Unit[] {
|
||||
const sourceComponents = new Set<number>();
|
||||
for (const neighbor of this.mg.neighbors(this.port!.tile())) {
|
||||
if (!this.mg.isWater(neighbor)) continue;
|
||||
const comp = this.mg.getWaterComponent(neighbor);
|
||||
if (comp !== null) sourceComponents.add(comp);
|
||||
}
|
||||
const ports = this.mg
|
||||
.players()
|
||||
.filter((p) => p !== this.port!.owner() && p.canTrade(this.port!.owner()))
|
||||
.flatMap((p) => p.units(UnitType.Port))
|
||||
.filter((p) => {
|
||||
for (const comp of sourceComponents) {
|
||||
if (this.mg.hasWaterComponent(p.tile(), comp)) return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.sort((p1, p2) => {
|
||||
return (
|
||||
this.mg.manhattanDist(this.port!.tile(), p1.tile()) -
|
||||
|
||||
@@ -90,13 +90,16 @@ export class TradeShipExecution implements Execution {
|
||||
this.wasCaptured &&
|
||||
(tradeShipOwner !== dstPortOwner || !this._dstPort.isActive())
|
||||
) {
|
||||
const myComponent = this.mg.getWaterComponent(curTile);
|
||||
const nearestPort = findClosestBy(
|
||||
tradeShipOwner.units(UnitType.Port),
|
||||
(port) => this.mg.manhattanDist(port.tile(), curTile),
|
||||
(port) =>
|
||||
port.isActive() &&
|
||||
!port.isMarkedForDeletion() &&
|
||||
!port.isUnderConstruction(),
|
||||
!port.isUnderConstruction() &&
|
||||
myComponent !== null &&
|
||||
this.mg.hasWaterComponent(port.tile(), myComponent),
|
||||
);
|
||||
if (nearestPort === null) {
|
||||
this.tradeShip.delete(false);
|
||||
|
||||
@@ -254,7 +254,7 @@ export class WarshipExecution implements Execution {
|
||||
}
|
||||
const tile = this.mg.ref(x, y);
|
||||
if (
|
||||
!this.mg.isOcean(tile) ||
|
||||
!this.mg.isWater(tile) ||
|
||||
(!allowShoreline && this.mg.isShoreline(tile))
|
||||
) {
|
||||
attempts++;
|
||||
|
||||
@@ -90,6 +90,7 @@ export class NationStructureBehavior {
|
||||
cluster: Cluster | null;
|
||||
weight: number;
|
||||
}> | null = null;
|
||||
private _sharedWaterComponents: Set<number> | null = null;
|
||||
|
||||
constructor(
|
||||
private random: PseudoRandom,
|
||||
@@ -107,7 +108,8 @@ export class NationStructureBehavior {
|
||||
Math.floor(this.player.numTilesOwned() / TILES_PER_CITY_EQUIVALENT),
|
||||
)
|
||||
: this.player.unitsOwned(UnitType.City);
|
||||
const hasCoastalTiles = this.hasCoastalTiles();
|
||||
this._sharedWaterComponents = this.sharedWaterComponents();
|
||||
const hasCoastalTiles = this._sharedWaterComponents !== null;
|
||||
|
||||
// Build order for non-city structures (priority order)
|
||||
const buildOrder: UnitType[] = [
|
||||
@@ -165,11 +167,37 @@ export class NationStructureBehavior {
|
||||
return false;
|
||||
}
|
||||
|
||||
private hasCoastalTiles(): boolean {
|
||||
/**
|
||||
* Returns the set of water components shared with at least one other player,
|
||||
* or null if there are none.
|
||||
*/
|
||||
private sharedWaterComponents(): Set<number> | null {
|
||||
// Collect all water-component IDs reachable from this player's coast.
|
||||
const playerComponents = new Set<number>();
|
||||
for (const tile of this.player.borderTiles()) {
|
||||
if (this.game.isOceanShore(tile)) return true;
|
||||
if (!this.game.isShore(tile)) continue;
|
||||
for (const neighbor of this.game.neighbors(tile)) {
|
||||
if (!this.game.isWater(neighbor)) continue;
|
||||
const comp = this.game.getWaterComponent(neighbor);
|
||||
if (comp !== null) playerComponents.add(comp);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
if (playerComponents.size === 0) return null;
|
||||
|
||||
// Keep only components that at least one other player also touches.
|
||||
const shared = new Set<number>();
|
||||
for (const other of this.game.players()) {
|
||||
if (other === this.player) continue;
|
||||
for (const tile of other.borderTiles()) {
|
||||
if (!this.game.isShore(tile)) continue;
|
||||
for (const neighbor of this.game.neighbors(tile)) {
|
||||
if (!this.game.isWater(neighbor)) continue;
|
||||
const comp = this.game.getWaterComponent(neighbor);
|
||||
if (comp !== null && playerComponents.has(comp)) shared.add(comp);
|
||||
}
|
||||
}
|
||||
}
|
||||
return shared.size > 0 ? shared : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -471,10 +499,19 @@ export class NationStructureBehavior {
|
||||
return bestTile;
|
||||
}
|
||||
|
||||
/** Samples shore tiles adjacent to water reachable by another player (=> trading possible) */
|
||||
private randCoastalTileArray(numTiles: number): TileRef[] {
|
||||
const tiles = Array.from(this.player.borderTiles()).filter((t) =>
|
||||
this.game.isOceanShore(t),
|
||||
);
|
||||
const shared = this._sharedWaterComponents;
|
||||
const tiles = Array.from(this.player.borderTiles()).filter((t) => {
|
||||
if (!this.game.isShore(t)) return false;
|
||||
if (shared === null) return false;
|
||||
for (const neighbor of this.game.neighbors(t)) {
|
||||
if (!this.game.isWater(neighbor)) continue;
|
||||
const comp = this.game.getWaterComponent(neighbor);
|
||||
if (comp !== null && shared.has(comp)) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return Array.from(this.arraySampler(tiles, numTiles));
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ export class NationWarshipBehavior {
|
||||
}
|
||||
const tile = this.game.ref(randX, randY);
|
||||
// Sanity check
|
||||
if (!this.game.isOcean(tile)) {
|
||||
if (!this.game.isWater(tile)) {
|
||||
continue;
|
||||
}
|
||||
return tile;
|
||||
|
||||
@@ -109,15 +109,15 @@ export class AiAttackBehavior {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have any ocean shore tiles to launch from
|
||||
const oceanShore = Array.from(this.player.borderTiles()).filter((t) =>
|
||||
this.game.isOceanShore(t),
|
||||
// Check if we have any shore tiles to launch from
|
||||
const shore = Array.from(this.player.borderTiles()).filter((t) =>
|
||||
this.game.isShore(t),
|
||||
);
|
||||
if (oceanShore.length === 0) {
|
||||
if (shore.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const src = this.random.randElement(oceanShore);
|
||||
const src = this.random.randElement(shore);
|
||||
|
||||
// First look for high-interest targets (unowned or bot-owned). Mainly relevant for earlygame
|
||||
let dst = this.findRandomBoatTarget(src, borderingEnemies, true);
|
||||
@@ -574,11 +574,11 @@ export class AiAttackBehavior {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we have any ocean shore tiles to launch from
|
||||
const hasOceanShore = Array.from(this.player.borderTiles()).some((t) =>
|
||||
this.game.isOceanShore(t),
|
||||
// Check if we have any shore tiles to launch from
|
||||
const hasShore = Array.from(this.player.borderTiles()).some((t) =>
|
||||
this.game.isShore(t),
|
||||
);
|
||||
if (!hasOceanShore) return null;
|
||||
if (!hasShore) return null;
|
||||
|
||||
const filteredPlayers = this.game.players().filter((p) => {
|
||||
if (p === this.player) return false;
|
||||
@@ -615,10 +615,10 @@ export class AiAttackBehavior {
|
||||
const closest = closestTwoTiles(
|
||||
this.game,
|
||||
Array.from(this.player.borderTiles()).filter((t) =>
|
||||
this.game.isOceanShore(t),
|
||||
this.game.isShore(t),
|
||||
),
|
||||
Array.from(entry.player.borderTiles()).filter((t) =>
|
||||
this.game.isOceanShore(t),
|
||||
this.game.isShore(t),
|
||||
),
|
||||
);
|
||||
if (closest === null) continue;
|
||||
@@ -786,10 +786,8 @@ export class AiAttackBehavior {
|
||||
private sendBoatAttack(target: Player) {
|
||||
const closest = closestTwoTiles(
|
||||
this.game,
|
||||
Array.from(this.player.borderTiles()).filter((t) =>
|
||||
this.game.isOceanShore(t),
|
||||
),
|
||||
Array.from(target.borderTiles()).filter((t) => this.game.isOceanShore(t)),
|
||||
Array.from(this.player.borderTiles()).filter((t) => this.game.isShore(t)),
|
||||
Array.from(target.borderTiles()).filter((t) => this.game.isShore(t)),
|
||||
);
|
||||
if (closest === null) {
|
||||
return;
|
||||
|
||||
@@ -1222,7 +1222,7 @@ export class PlayerImpl implements Player {
|
||||
manhattanDistFN(tile, this.mg.config().radiusPortSpawn()),
|
||||
),
|
||||
)
|
||||
.filter((t) => this.mg.owner(t) === this && this.mg.isOceanShore(t))
|
||||
.filter((t) => this.mg.owner(t) === this && this.mg.isShore(t))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
this.mg.manhattanDist(a, tile) - this.mg.manhattanDist(b, tile),
|
||||
@@ -1239,14 +1239,19 @@ export class PlayerImpl implements Player {
|
||||
}
|
||||
|
||||
warshipSpawn(tile: TileRef): TileRef | false {
|
||||
if (!this.mg.isOcean(tile)) {
|
||||
if (!this.mg.isWater(tile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tileComponent = this.mg.getWaterComponent(tile);
|
||||
const bestPort = findClosestBy(
|
||||
this.units(UnitType.Port),
|
||||
(port) => this.mg.manhattanDist(port.tile(), tile),
|
||||
(port) => port.isActive() && !port.isUnderConstruction(),
|
||||
(port) =>
|
||||
port.isActive() &&
|
||||
!port.isUnderConstruction() &&
|
||||
tileComponent !== null &&
|
||||
this.mg.hasWaterComponent(port.tile(), tileComponent),
|
||||
);
|
||||
|
||||
return bestPort?.tile() ?? false;
|
||||
|
||||
@@ -216,14 +216,6 @@ export class WaterManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
// If no converted tile is adjacent to existing ocean (e.g. all-land map),
|
||||
// mark all converted tiles as ocean so they're navigable for ports/boats.
|
||||
if (oceanQueue.length === 0) {
|
||||
for (const tile of converted) {
|
||||
map.setOcean(tile);
|
||||
oceanQueue.push(tile);
|
||||
}
|
||||
}
|
||||
let oHead = 0;
|
||||
while (oHead < oceanQueue.length) {
|
||||
const tile = oceanQueue[oHead++];
|
||||
|
||||
@@ -180,42 +180,6 @@ describe("Water Nukes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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", {
|
||||
|
||||
Reference in New Issue
Block a user