mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-01 06:02:07 +00:00
Merge branch 'main' into local-attack
This commit is contained in:
@@ -401,7 +401,11 @@ export class AttackExecution implements Execution {
|
||||
} else {
|
||||
for (const neighbor of this.mg.neighbors(tile)) {
|
||||
const no = this.mg.owner(neighbor);
|
||||
if (no.isPlayer() && no !== this.target) {
|
||||
if (
|
||||
no.isPlayer() &&
|
||||
no !== this.target &&
|
||||
!no.isFriendly(this.target)
|
||||
) {
|
||||
this.mg.player(no.id()).conquer(tile);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export class BotExecution implements Execution {
|
||||
}
|
||||
|
||||
if (this.neighborsTerraNullius) {
|
||||
if (this.bot.sharesBorderWith(this.mg.terraNullius())) {
|
||||
if (this.bot.neighbors().some((n) => !n.isPlayer())) {
|
||||
this.attackBehavior.sendAttack(this.mg.terraNullius());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -72,13 +72,7 @@ export class Executor {
|
||||
case "spawn":
|
||||
return new SpawnExecution(this.gameID, player.info(), intent.tile);
|
||||
case "boat":
|
||||
return new TransportShipExecution(
|
||||
player,
|
||||
intent.targetID,
|
||||
intent.dst,
|
||||
intent.troops,
|
||||
intent.src,
|
||||
);
|
||||
return new TransportShipExecution(player, intent.dst, intent.troops);
|
||||
case "allianceRequest":
|
||||
return new AllianceRequestExecution(player, intent.recipient);
|
||||
case "allianceRequestReply":
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
isStructureType,
|
||||
MessageType,
|
||||
Player,
|
||||
StructureTypes,
|
||||
TerraNullius,
|
||||
TrajectoryTile,
|
||||
Unit,
|
||||
@@ -16,7 +15,7 @@ import { ParabolaUniversalPathFinder } from "../pathfinding/PathFinder.Parabola"
|
||||
import { PathStatus } from "../pathfinding/types";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { NukeType } from "../StatsSchemas";
|
||||
import { computeNukeBlastCounts } from "./Util";
|
||||
import { listNukeBreakAlliance } from "./Util";
|
||||
|
||||
const SPRITE_RADIUS = 16;
|
||||
|
||||
@@ -85,36 +84,22 @@ export class NukeExecution implements Execution {
|
||||
}
|
||||
|
||||
const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type());
|
||||
const threshold = this.mg.config().nukeAllianceBreakThreshold();
|
||||
|
||||
// Use shared utility to compute weighted tile counts per player
|
||||
const blastCounts = computeNukeBlastCounts({
|
||||
gm: this.mg,
|
||||
const playersToBreakAllianceWith = listNukeBreakAlliance({
|
||||
game: this.mg,
|
||||
targetTile: this.dst,
|
||||
magnitude,
|
||||
allySmallIds: new Set(this.player.allies().map((a) => a.smallID())),
|
||||
threshold: this.mg.config().nukeAllianceBreakThreshold(),
|
||||
});
|
||||
|
||||
// Collect all players that should have alliance broken:
|
||||
// either exceeds tile threshold OR has a structure in blast radius
|
||||
const playersToBreakAllianceWith = new Set<number>();
|
||||
|
||||
for (const [playerSmallId, totalWeight] of blastCounts) {
|
||||
if (totalWeight > threshold) {
|
||||
playersToBreakAllianceWith.add(playerSmallId);
|
||||
// Automatically reject incoming alliance requests.
|
||||
for (const incoming of this.player.incomingAllianceRequests()) {
|
||||
if (playersToBreakAllianceWith.has(incoming.requestor().smallID())) {
|
||||
incoming.reject();
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if any allied structures would be destroyed
|
||||
this.mg
|
||||
.nearbyUnits(this.dst, magnitude.outer, [...StructureTypes])
|
||||
.filter(
|
||||
({ unit }) =>
|
||||
unit.owner().isPlayer() && this.player.isAlliedWith(unit.owner()),
|
||||
)
|
||||
.forEach(({ unit }) =>
|
||||
playersToBreakAllianceWith.add(unit.owner().smallID()),
|
||||
);
|
||||
|
||||
for (const playerSmallId of playersToBreakAllianceWith) {
|
||||
const attackedPlayer = this.mg.playerBySmallID(playerSmallId);
|
||||
if (!attackedPlayer.isPlayer()) {
|
||||
@@ -123,11 +108,12 @@ export class NukeExecution implements Execution {
|
||||
|
||||
// Resolves exploit of alliance breaking in which a pending alliance request
|
||||
// was accepted in the middle of a missile attack.
|
||||
const allianceRequest = attackedPlayer
|
||||
const outgoingAllianceRequest = attackedPlayer
|
||||
.incomingAllianceRequests()
|
||||
.find((ar) => ar.requestor() === this.player);
|
||||
if (allianceRequest) {
|
||||
allianceRequest.reject();
|
||||
if (outgoingAllianceRequest) {
|
||||
outgoingAllianceRequest.reject();
|
||||
continue;
|
||||
}
|
||||
|
||||
const alliance = this.player.allianceWith(attackedPlayer);
|
||||
|
||||
@@ -42,6 +42,11 @@ export class SpawnExecution implements Execution {
|
||||
player = this.mg.addPlayer(this.playerInfo);
|
||||
}
|
||||
|
||||
// Security: If random spawn is enabled, prevent players from re-rolling their spawn location
|
||||
if (this.mg.config().isRandomSpawn() && player.hasSpawned()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tile ??= this.randomSpawnLand();
|
||||
|
||||
if (this.tile === undefined) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Game,
|
||||
MessageType,
|
||||
Player,
|
||||
PlayerID,
|
||||
TerraNullius,
|
||||
Unit,
|
||||
UnitType,
|
||||
@@ -16,33 +15,28 @@ import { PathStatus, SteppingPathFinder } from "../pathfinding/types";
|
||||
import { AttackExecution } from "./AttackExecution";
|
||||
|
||||
const malusForRetreat = 25;
|
||||
|
||||
export class TransportShipExecution implements Execution {
|
||||
private lastMove: number;
|
||||
private active = true;
|
||||
|
||||
// TODO: make this configurable
|
||||
private ticksPerMove = 1;
|
||||
|
||||
private active = true;
|
||||
private lastMove: number;
|
||||
|
||||
private mg: Game;
|
||||
private target: Player | TerraNullius;
|
||||
|
||||
// TODO make private
|
||||
public path: TileRef[];
|
||||
private dst: TileRef | null;
|
||||
|
||||
private boat: Unit;
|
||||
|
||||
private pathFinder: SteppingPathFinder<TileRef>;
|
||||
|
||||
private dst: TileRef | null;
|
||||
private src: TileRef | null;
|
||||
private boat: Unit;
|
||||
|
||||
private originalOwner: Player;
|
||||
|
||||
constructor(
|
||||
private attacker: Player,
|
||||
private targetID: PlayerID | null,
|
||||
private ref: TileRef,
|
||||
private startTroops: number,
|
||||
private src: TileRef | null,
|
||||
private troops: number,
|
||||
) {
|
||||
this.originalOwner = this.attacker;
|
||||
}
|
||||
@@ -52,24 +46,15 @@ export class TransportShipExecution implements Execution {
|
||||
}
|
||||
|
||||
init(mg: Game, ticks: number) {
|
||||
if (this.targetID !== null && !mg.hasPlayer(this.targetID)) {
|
||||
console.warn(`TransportShipExecution: target ${this.targetID} not found`);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
if (!mg.isValidRef(this.ref)) {
|
||||
console.warn(`TransportShipExecution: ref ${this.ref} not valid`);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
if (this.src !== null && !mg.isValidRef(this.src)) {
|
||||
console.warn(`TransportShipExecution: src ${this.src} not valid`);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastMove = ticks;
|
||||
this.mg = mg;
|
||||
this.target = mg.owner(this.ref);
|
||||
this.pathFinder = PathFinding.Water(mg);
|
||||
|
||||
if (
|
||||
@@ -87,78 +72,51 @@ export class TransportShipExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.targetID === null ||
|
||||
this.targetID === this.mg.terraNullius().id()
|
||||
) {
|
||||
this.target = mg.terraNullius();
|
||||
} else {
|
||||
this.target = mg.player(this.targetID);
|
||||
}
|
||||
if (this.target.isPlayer() && !this.attacker.canAttackPlayer(this.target)) {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.startTroops ??= this.mg
|
||||
this.troops ??= this.mg
|
||||
.config()
|
||||
.boatAttackAmount(this.attacker, this.target);
|
||||
|
||||
this.startTroops = Math.min(this.startTroops, this.attacker.troops());
|
||||
this.troops = Math.min(this.troops, this.attacker.troops());
|
||||
|
||||
this.dst = targetTransportTile(this.mg, this.ref);
|
||||
|
||||
if (this.dst === null) {
|
||||
console.warn(
|
||||
`${this.attacker} cannot send ship to ${this.target}, cannot find attack tile`,
|
||||
`${this.attacker} cannot send ship to ${this.target}, cannot find target tile`,
|
||||
);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const closestTileSrc = this.attacker.canBuild(
|
||||
UnitType.TransportShip,
|
||||
this.dst,
|
||||
);
|
||||
if (closestTileSrc === false) {
|
||||
console.warn(`can't build transport ship`);
|
||||
const src = this.attacker.canBuild(UnitType.TransportShip, this.dst);
|
||||
|
||||
if (src === false) {
|
||||
console.warn(
|
||||
`${this.attacker} cannot send ship to ${this.target}, cannot find start tile`,
|
||||
);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.src === null) {
|
||||
// Only update the src if it's not already set
|
||||
// because we assume that the src is set to the best spawn tile
|
||||
this.src = closestTileSrc;
|
||||
} else {
|
||||
if (
|
||||
this.mg.owner(this.src) !== this.attacker ||
|
||||
!this.mg.isShore(this.src)
|
||||
) {
|
||||
console.warn(
|
||||
`src is not a shore tile or not owned by: ${this.attacker.name()}`,
|
||||
);
|
||||
this.src = closestTileSrc;
|
||||
}
|
||||
}
|
||||
this.src = src;
|
||||
|
||||
this.boat = this.attacker.buildUnit(UnitType.TransportShip, this.src, {
|
||||
troops: this.startTroops,
|
||||
troops: this.troops,
|
||||
targetTile: this.dst,
|
||||
});
|
||||
|
||||
if (this.dst !== null) {
|
||||
this.boat.setTargetTile(this.dst);
|
||||
} else {
|
||||
this.boat.setTargetTile(undefined);
|
||||
}
|
||||
|
||||
// Notify the target player about the incoming naval invasion
|
||||
if (this.targetID && this.targetID !== mg.terraNullius().id()) {
|
||||
if (this.target.id() !== mg.terraNullius().id()) {
|
||||
mg.displayIncomingUnit(
|
||||
this.boat.id(),
|
||||
// TODO TranslateText
|
||||
`Naval invasion incoming from ${this.attacker.displayName()}`,
|
||||
`Naval invasion incoming from ${this.attacker.displayName()} (${renderTroops(this.boat.troops())})`,
|
||||
MessageType.NAVAL_INVASION_INBOUND,
|
||||
this.targetID,
|
||||
this.target.id(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -259,7 +217,7 @@ export class TransportShipExecution implements Execution {
|
||||
new AttackExecution(
|
||||
this.boat.troops(),
|
||||
this.attacker,
|
||||
this.targetID,
|
||||
this.target.id(),
|
||||
this.dst,
|
||||
false,
|
||||
),
|
||||
@@ -283,7 +241,7 @@ export class TransportShipExecution implements Execution {
|
||||
const map = this.mg.map();
|
||||
const boatTile = this.boat.tile();
|
||||
console.warn(
|
||||
`TransportShip path not found: boat@(${map.x(boatTile)},${map.y(boatTile)}) -> dst@(${map.x(this.dst)},${map.y(this.dst)}), attacker=${this.attacker.id()}, target=${this.targetID}`,
|
||||
`TransportShip path not found: boat@(${map.x(boatTile)},${map.y(boatTile)}) -> dst@(${map.x(this.dst)},${map.y(this.dst)}), attacker=${this.attacker.id()}, target=${this.target.id()}`,
|
||||
);
|
||||
this.attacker.addTroops(this.boat.troops());
|
||||
this.boat.delete(false);
|
||||
|
||||
@@ -36,7 +36,7 @@ export function computeNukeBlastCounts(
|
||||
}
|
||||
|
||||
export interface NukeAllianceCheckParams {
|
||||
game: GameView;
|
||||
game: Game | GameView;
|
||||
targetTile: TileRef;
|
||||
magnitude: NukeMagnitude;
|
||||
allySmallIds: Set<number>;
|
||||
@@ -93,6 +93,40 @@ export function wouldNukeBreakAlliance(
|
||||
return result;
|
||||
}
|
||||
|
||||
// Same as wouldNukeBreakAlliance(), but takes time to find every player
|
||||
// that would be "angered" from this nuke.
|
||||
// This includes unallied players!
|
||||
export function listNukeBreakAlliance(
|
||||
params: NukeAllianceCheckParams,
|
||||
): Set<number> {
|
||||
const { game, targetTile, magnitude, threshold } = params;
|
||||
|
||||
// Collect all players that should have alliance broken:
|
||||
// either exceeds tile threshold OR has a structure in blast radius
|
||||
const playersToBreakAllianceWith = new Set<number>();
|
||||
|
||||
// compute tile breakage threshold
|
||||
const blastCounts = computeNukeBlastCounts({
|
||||
gm: game,
|
||||
targetTile,
|
||||
magnitude,
|
||||
});
|
||||
for (const [playerSmallId, totalWeight] of blastCounts) {
|
||||
if (totalWeight > threshold) {
|
||||
playersToBreakAllianceWith.add(playerSmallId);
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if any allied structures would be destroyed
|
||||
game
|
||||
.nearbyUnits(targetTile, magnitude.outer, [...StructureTypes])
|
||||
.forEach(({ unit }) =>
|
||||
playersToBreakAllianceWith.add(unit.owner().smallID()),
|
||||
);
|
||||
|
||||
return playersToBreakAllianceWith;
|
||||
}
|
||||
|
||||
export function getSpawnTiles(gm: GameMap, tile: TileRef): TileRef[] {
|
||||
return Array.from(gm.bfs(tile, euclDistFN(tile, 4, true))).filter(
|
||||
(t) => !gm.hasOwner(t) && gm.isLand(t),
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Execution, Game, Player, PlayerID } from "../../game/Game";
|
||||
import {
|
||||
Execution,
|
||||
Game,
|
||||
MessageType,
|
||||
Player,
|
||||
PlayerID,
|
||||
UnitType,
|
||||
} from "../../game/Game";
|
||||
|
||||
export class AllianceRequestReplyExecution implements Execution {
|
||||
private active = true;
|
||||
@@ -10,6 +17,57 @@ export class AllianceRequestReplyExecution implements Execution {
|
||||
private accept: boolean,
|
||||
) {}
|
||||
|
||||
private cancelNukesBetweenAlliedPlayers(
|
||||
mg: Game,
|
||||
p1: Player,
|
||||
p2: Player,
|
||||
): void {
|
||||
const neutralized = new Map<Player, number>();
|
||||
|
||||
const players = [p1, p2];
|
||||
|
||||
for (const launcher of players) {
|
||||
for (const unit of launcher.units(
|
||||
UnitType.AtomBomb,
|
||||
UnitType.HydrogenBomb,
|
||||
)) {
|
||||
if (!unit.isActive() || unit.reachedTarget()) continue;
|
||||
|
||||
const targetTile = unit.targetTile();
|
||||
if (!targetTile) continue;
|
||||
|
||||
const targetOwner = mg.owner(targetTile);
|
||||
if (!targetOwner.isPlayer()) continue;
|
||||
|
||||
const other = launcher === p1 ? p2 : p1;
|
||||
if (targetOwner !== other) continue;
|
||||
|
||||
unit.delete(false);
|
||||
neutralized.set(launcher, (neutralized.get(launcher) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [launcher, count] of neutralized) {
|
||||
const other = launcher === p1 ? p2 : p1;
|
||||
|
||||
mg.displayMessage(
|
||||
"events_display.alliance_nukes_destroyed_outgoing",
|
||||
MessageType.ALLIANCE_ACCEPTED,
|
||||
launcher.id(),
|
||||
undefined,
|
||||
{ name: other.displayName(), count },
|
||||
);
|
||||
|
||||
mg.displayMessage(
|
||||
"events_display.alliance_nukes_destroyed_incoming",
|
||||
MessageType.ALLIANCE_ACCEPTED,
|
||||
other.id(),
|
||||
undefined,
|
||||
{ name: launcher.displayName(), count },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
if (!mg.hasPlayer(this.requestorID)) {
|
||||
console.warn(
|
||||
@@ -33,6 +91,12 @@ export class AllianceRequestReplyExecution implements Execution {
|
||||
request.accept();
|
||||
this.requestor.updateRelation(this.recipient, 100);
|
||||
this.recipient.updateRelation(this.requestor, 100);
|
||||
|
||||
this.cancelNukesBetweenAlliedPlayers(
|
||||
mg,
|
||||
this.requestor,
|
||||
this.recipient,
|
||||
);
|
||||
} else {
|
||||
request.reject();
|
||||
}
|
||||
|
||||
@@ -240,13 +240,13 @@ export class NationAllianceBehavior {
|
||||
const { difficulty } = this.game.config().gameConfig();
|
||||
switch (difficulty) {
|
||||
case Difficulty.Easy:
|
||||
return false; // 0% chance to reject on easy
|
||||
return this.random.nextInt(0, 100) < 25; // 25% chance to reject on easy
|
||||
case Difficulty.Medium:
|
||||
return this.random.nextInt(0, 100) < 20; // 20% chance to reject on medium
|
||||
return this.random.nextInt(0, 100) < 50; // 50% chance to reject on medium
|
||||
case Difficulty.Hard:
|
||||
return this.random.nextInt(0, 100) < 40; // 40% chance to reject on hard
|
||||
return this.random.nextInt(0, 100) < 75; // 75% chance to reject on hard
|
||||
case Difficulty.Impossible:
|
||||
return this.random.nextInt(0, 100) < 60; // 60% chance to reject on impossible
|
||||
return true; // 100% chance to reject on impossible
|
||||
default:
|
||||
assertNever(difficulty);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Player,
|
||||
PlayerType,
|
||||
Relation,
|
||||
Team,
|
||||
Tick,
|
||||
} from "../../game/Game";
|
||||
import { PseudoRandom } from "../../PseudoRandom";
|
||||
@@ -55,6 +54,8 @@ export class NationEmojiBehavior {
|
||||
) {}
|
||||
|
||||
maybeSendCasualEmoji() {
|
||||
if (this.gameOver) return;
|
||||
|
||||
this.checkOverwhelmedByAttacks();
|
||||
this.checkVerySmallAttack();
|
||||
this.congratulateWinner();
|
||||
@@ -107,60 +108,23 @@ export class NationEmojiBehavior {
|
||||
|
||||
// Check if game is over - send congratulations
|
||||
private congratulateWinner(): void {
|
||||
if (this.gameOver) return;
|
||||
const winner = this.game.getWinner();
|
||||
if (winner === null) return;
|
||||
|
||||
this.gameOver = true;
|
||||
|
||||
const percentToWin = this.game.config().percentageTilesOwnedToWin();
|
||||
const numTilesWithoutFallout =
|
||||
this.game.numLandTiles() - this.game.numTilesWithFallout();
|
||||
const isTeamGame =
|
||||
this.game.config().gameConfig().gameMode === GameMode.Team;
|
||||
|
||||
if (isTeamGame) {
|
||||
// Team game: all nations congratulate if another team won
|
||||
const teamToTiles = new Map<Team, number>();
|
||||
for (const player of this.game.players()) {
|
||||
const team = player.team();
|
||||
if (team === null) continue;
|
||||
teamToTiles.set(
|
||||
team,
|
||||
(teamToTiles.get(team) ?? 0) + player.numTilesOwned(),
|
||||
);
|
||||
}
|
||||
|
||||
const sorted = Array.from(teamToTiles.entries()).sort(
|
||||
(a, b) => b[1] - a[1],
|
||||
);
|
||||
if (sorted.length === 0) return;
|
||||
|
||||
const [winningTeam, winningTiles] = sorted[0];
|
||||
const winningPercent = (winningTiles / numTilesWithoutFallout) * 100;
|
||||
if (winningPercent < percentToWin) return;
|
||||
|
||||
this.gameOver = true;
|
||||
|
||||
// Don't congratulate if it's our own team
|
||||
if (winningTeam === this.player.team()) return;
|
||||
if (winner === this.player.team()) return;
|
||||
|
||||
this.sendEmoji(AllPlayers, EMOJI_CONGRATULATE);
|
||||
} else {
|
||||
// FFA game: The largest nation congratulates if a human player won
|
||||
const sorted = this.game
|
||||
.players()
|
||||
.sort((a, b) => b.numTilesOwned() - a.numTilesOwned());
|
||||
|
||||
if (sorted.length === 0) return;
|
||||
|
||||
const firstPlace = sorted[0];
|
||||
|
||||
// Check if first place has won (crossed the win threshold)
|
||||
const firstPlacePercent =
|
||||
(firstPlace.numTilesOwned() / numTilesWithoutFallout) * 100;
|
||||
if (firstPlacePercent < percentToWin) return;
|
||||
|
||||
this.gameOver = true;
|
||||
|
||||
// Only send if first place is a human
|
||||
if (firstPlace.type() !== PlayerType.Human) return;
|
||||
if (typeof winner === "string") return; // It's a team, not a player
|
||||
|
||||
// Only the largest nation sends the congratulation
|
||||
const largestNation = this.game
|
||||
@@ -169,13 +133,12 @@ export class NationEmojiBehavior {
|
||||
.sort((a, b) => b.numTilesOwned() - a.numTilesOwned())[0];
|
||||
if (largestNation !== this.player) return;
|
||||
|
||||
this.sendEmoji(firstPlace, EMOJI_CONGRATULATE);
|
||||
this.sendEmoji(winner, EMOJI_CONGRATULATE);
|
||||
}
|
||||
}
|
||||
|
||||
// Brag with our crown
|
||||
private brag(): void {
|
||||
if (this.gameOver) return;
|
||||
if (!this.random.chance(300)) return;
|
||||
|
||||
const sorted = this.game
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import {
|
||||
Difficulty,
|
||||
Game,
|
||||
GameMode,
|
||||
HumansVsNations,
|
||||
Player,
|
||||
PlayerID,
|
||||
PlayerType,
|
||||
Relation,
|
||||
TerraNullius,
|
||||
UnitType,
|
||||
} from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import { canBuildTransportShip } from "../../game/TransportShipUtils";
|
||||
@@ -16,6 +19,7 @@ import {
|
||||
calculateBoundingBoxCenter,
|
||||
} from "../../Util";
|
||||
import { AttackExecution } from "../AttackExecution";
|
||||
import { DonateTroopsExecution } from "../DonateTroopExecution";
|
||||
import { NationAllianceBehavior } from "../nation/NationAllianceBehavior";
|
||||
import {
|
||||
EMOJI_ASSIST_ACCEPT,
|
||||
@@ -94,6 +98,16 @@ export class AiAttackBehavior {
|
||||
|
||||
private attackWithRandomBoat(borderingEnemies: Player[] = []) {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
|
||||
// Check if we've already sent out the maximum number of transport ships
|
||||
if (
|
||||
this.player.unitCount(UnitType.TransportShip) >=
|
||||
this.game.config().boatMaxNumber()
|
||||
) {
|
||||
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),
|
||||
);
|
||||
@@ -114,13 +128,7 @@ export class AiAttackBehavior {
|
||||
}
|
||||
|
||||
this.game.addExecution(
|
||||
new TransportShipExecution(
|
||||
this.player,
|
||||
this.game.owner(dst).id(),
|
||||
dst,
|
||||
this.player.troops() / 5,
|
||||
null,
|
||||
),
|
||||
new TransportShipExecution(this.player, dst, this.player.troops() / 5),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -315,6 +323,8 @@ export class AiAttackBehavior {
|
||||
return false;
|
||||
};
|
||||
|
||||
const donate = (): boolean => this.donateTroops();
|
||||
|
||||
// Return strategies in order based on difficulty
|
||||
// Easy nations get the dumbest order, impossible nations get the smartest order
|
||||
switch (difficulty) {
|
||||
@@ -323,13 +333,13 @@ export class AiAttackBehavior {
|
||||
return [nuked, bots, retaliate, assist, betray, hated, weakest];
|
||||
case Difficulty.Medium:
|
||||
// prettier-ignore
|
||||
return [bots, nuked, retaliate, assist, betray, hated, afk, traitor, weakest, island];
|
||||
return [bots, nuked, retaliate, assist, betray, hated, afk, traitor, weakest, island, donate];
|
||||
case Difficulty.Hard:
|
||||
// prettier-ignore
|
||||
return [bots, retaliate, assist, betray, nuked, traitor, afk, hated, veryWeak, victim, weakest, island];
|
||||
return [bots, retaliate, assist, betray, nuked, traitor, afk, hated, veryWeak, victim, weakest, island, donate];
|
||||
case Difficulty.Impossible:
|
||||
// prettier-ignore
|
||||
return [retaliate, bots, veryWeak, assist, traitor, afk, betray, victim, nuked, hated, weakest, island];
|
||||
return [retaliate, bots, veryWeak, assist, traitor, afk, betray, victim, nuked, hated, weakest, island, donate];
|
||||
default:
|
||||
assertNever(difficulty);
|
||||
}
|
||||
@@ -525,54 +535,67 @@ export class AiAttackBehavior {
|
||||
}
|
||||
|
||||
private findNearestIslandEnemy(): Player | null {
|
||||
const myBorder = this.player.borderTiles();
|
||||
if (myBorder.size === 0) return null;
|
||||
// Check if we've already sent out the maximum number of transport ships
|
||||
if (
|
||||
this.player.unitCount(UnitType.TransportShip) >=
|
||||
this.game.config().boatMaxNumber()
|
||||
) {
|
||||
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),
|
||||
);
|
||||
if (!hasOceanShore) return null;
|
||||
|
||||
const filteredPlayers = this.game.players().filter((p) => {
|
||||
if (p === this.player) return false;
|
||||
if (!p.isAlive()) return false;
|
||||
if (p.borderTiles().size === 0) return false;
|
||||
if (this.player.isFriendly(p)) return false;
|
||||
// Don't spam boats into players with more troops
|
||||
return p.troops() < this.player.troops();
|
||||
});
|
||||
|
||||
if (filteredPlayers.length > 0) {
|
||||
const playerCenter = this.getPlayerCenter(this.player);
|
||||
if (filteredPlayers.length === 0) return null;
|
||||
|
||||
const sortedPlayers = filteredPlayers
|
||||
.map((filteredPlayer) => {
|
||||
const filteredPlayerCenter = this.getPlayerCenter(filteredPlayer);
|
||||
const playerCenter = this.getPlayerCenter(this.player);
|
||||
|
||||
const playerCenterTile = this.game.ref(
|
||||
playerCenter.x,
|
||||
playerCenter.y,
|
||||
);
|
||||
const filteredPlayerCenterTile = this.game.ref(
|
||||
filteredPlayerCenter.x,
|
||||
filteredPlayerCenter.y,
|
||||
);
|
||||
const sortedPlayers = filteredPlayers
|
||||
.map((filteredPlayer) => {
|
||||
const filteredPlayerCenter = this.getPlayerCenter(filteredPlayer);
|
||||
|
||||
const distance = this.game.manhattanDist(
|
||||
playerCenterTile,
|
||||
filteredPlayerCenterTile,
|
||||
);
|
||||
return { player: filteredPlayer, distance };
|
||||
})
|
||||
.sort((a, b) => a.distance - b.distance); // Sort by distance (ascending)
|
||||
const playerCenterTile = this.game.ref(playerCenter.x, playerCenter.y);
|
||||
const filteredPlayerCenterTile = this.game.ref(
|
||||
filteredPlayerCenter.x,
|
||||
filteredPlayerCenter.y,
|
||||
);
|
||||
|
||||
// Select the nearest or second-nearest enemy (So our boat doesn't always run into the same warship, if there is one)
|
||||
let selectedEnemy: Player | null;
|
||||
if (sortedPlayers.length > 1 && this.random.chance(2)) {
|
||||
selectedEnemy = sortedPlayers[1].player;
|
||||
} else {
|
||||
selectedEnemy = sortedPlayers[0].player;
|
||||
}
|
||||
const distance = this.game.manhattanDist(
|
||||
playerCenterTile,
|
||||
filteredPlayerCenterTile,
|
||||
);
|
||||
return { player: filteredPlayer, distance };
|
||||
})
|
||||
.sort((a, b) => a.distance - b.distance); // Sort by distance (ascending)
|
||||
|
||||
if (selectedEnemy !== null) {
|
||||
return selectedEnemy;
|
||||
// Try players in order of distance until we find one reachable by boat
|
||||
for (const entry of sortedPlayers) {
|
||||
const closest = closestTwoTiles(
|
||||
this.game,
|
||||
Array.from(this.player.borderTiles()).filter((t) =>
|
||||
this.game.isOceanShore(t),
|
||||
),
|
||||
Array.from(entry.player.borderTiles()).filter((t) =>
|
||||
this.game.isOceanShore(t),
|
||||
),
|
||||
);
|
||||
if (closest === null) continue;
|
||||
|
||||
if (canBuildTransportShip(this.game, this.player, closest.y)) {
|
||||
return entry.player;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -652,12 +675,14 @@ export class AiAttackBehavior {
|
||||
}
|
||||
|
||||
shouldAttack(other: Player | TerraNullius): boolean {
|
||||
// Always attack Terra Nullius, non-humans and traitors (or if we are a bot)
|
||||
if (
|
||||
// Always attack Terra Nullius, non-humans and traitors
|
||||
other.isPlayer() === false ||
|
||||
other.type() !== PlayerType.Human ||
|
||||
other.isTraitor() ||
|
||||
this.player.type() === PlayerType.Bot
|
||||
// Always attack if we are a bot or in an HvN game
|
||||
this.player.type() === PlayerType.Bot ||
|
||||
this.game.config().gameConfig().playerTeams === HumansVsNations
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -724,6 +749,10 @@ export class AiAttackBehavior {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canBuildTransportShip(this.game, this.player, closest.y)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let troops;
|
||||
if (target.type() === PlayerType.Bot) {
|
||||
troops = this.calculateBotAttackTroops(target, this.player.troops() / 5);
|
||||
@@ -741,13 +770,7 @@ export class AiAttackBehavior {
|
||||
}
|
||||
|
||||
this.game.addExecution(
|
||||
new TransportShipExecution(
|
||||
this.player,
|
||||
target.id(),
|
||||
closest.y,
|
||||
troops,
|
||||
null,
|
||||
),
|
||||
new TransportShipExecution(this.player, closest.y, troops),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -771,4 +794,99 @@ export class AiAttackBehavior {
|
||||
this.botAttackTroopsSent += troops;
|
||||
return troops;
|
||||
}
|
||||
|
||||
private donateTroops(): boolean {
|
||||
// Only donate in team games
|
||||
if (this.game.config().gameConfig().gameMode !== GameMode.Team) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if donating troops is allowed
|
||||
if (this.game.config().donateTroops() === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't donate if the game has a winner
|
||||
if (this.game.getWinner() !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip donating based on difficulty
|
||||
const { difficulty } = this.game.config().gameConfig();
|
||||
switch (difficulty) {
|
||||
case Difficulty.Easy:
|
||||
// Easy nations don't donate
|
||||
return false;
|
||||
case Difficulty.Medium:
|
||||
// Medium nations donate 25% of the time
|
||||
if (!this.random.chance(4)) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case Difficulty.Hard:
|
||||
// Hard nations donate 50% of the time
|
||||
if (!this.random.chance(2)) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case Difficulty.Impossible:
|
||||
// Impossible nations always try to donate
|
||||
break;
|
||||
default:
|
||||
assertNever(difficulty);
|
||||
}
|
||||
|
||||
// Find teammates who are currently in combat
|
||||
const teammates = this.game
|
||||
.players()
|
||||
.filter((p) => this.player.isOnSameTeam(p))
|
||||
.filter(
|
||||
(p) => p.incomingAttacks().length > 0 || p.outgoingAttacks().length > 0,
|
||||
);
|
||||
|
||||
if (teammates.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find teammate with lowest troop percentage (troops / maxTroops)
|
||||
const teammatesWithTroopPercentage = teammates
|
||||
.map((teammate) => {
|
||||
const maxTroops = this.game.config().maxTroops(teammate);
|
||||
const troopPercentage = teammate.troops() / Math.max(maxTroops, 1);
|
||||
return { teammate, troopPercentage };
|
||||
})
|
||||
.sort((a, b) => a.troopPercentage - b.troopPercentage);
|
||||
|
||||
// Try to donate to teammates in order of lowest troop percentage
|
||||
let selectedTeammate: Player | null = null;
|
||||
for (const entry of teammatesWithTroopPercentage) {
|
||||
if (this.player.canDonateTroops(entry.teammate)) {
|
||||
selectedTeammate = entry.teammate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedTeammate === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Donate a portion of our troops (keeping reserve)
|
||||
const maxTroops = this.game.config().maxTroops(this.player);
|
||||
const troopsToKeep = maxTroops * this.reserveRatio;
|
||||
const availableTroops = this.player.troops() - troopsToKeep;
|
||||
|
||||
if (availableTroops < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.game.addExecution(
|
||||
new DonateTroopsExecution(
|
||||
this.player,
|
||||
selectedTeammate.id(),
|
||||
availableTroops,
|
||||
),
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user