diff --git a/eslint.config.js b/eslint.config.js
index 65bf69bd7..7df719abe 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -99,10 +99,7 @@ export default [
"function-call-argument-newline": ["error", "consistent"],
"max-depth": ["error", { max: 5 }],
"max-len": ["error", { code: 120 }],
- "max-lines": [
- "error",
- { max: 676, skipBlankLines: true, skipComments: true },
- ],
+ "max-lines": ["error", { max: 677, skipBlankLines: true, skipComments: true }],
"max-lines-per-function": ["error", { max: 561 }],
"no-loss-of-precision": "error",
"no-multi-spaces": "error",
diff --git a/resources/sprites/trainCarriage.png b/resources/sprites/trainCarriage.png
index 0040e6f89..46da0f7be 100644
Binary files a/resources/sprites/trainCarriage.png and b/resources/sprites/trainCarriage.png differ
diff --git a/resources/sprites/trainCarriageLoaded.png b/resources/sprites/trainCarriageLoaded.png
index c4ddc111a..350f6d2d6 100644
Binary files a/resources/sprites/trainCarriageLoaded.png and b/resources/sprites/trainCarriageLoaded.png differ
diff --git a/resources/sprites/trainEngine.png b/resources/sprites/trainEngine.png
index 6d7974938..d883c8b5c 100644
Binary files a/resources/sprites/trainEngine.png and b/resources/sprites/trainEngine.png differ
diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts
index c3a859171..483b89b28 100644
--- a/src/client/TerritoryPatternsModal.ts
+++ b/src/client/TerritoryPatternsModal.ts
@@ -66,6 +66,10 @@ export class TerritoryPatternsModal extends LitElement {
}
async onUserMe(userMeResponse: UserMeResponse | null) {
+ if (userMeResponse === null) {
+ this.userSettings.setSelectedPattern(undefined);
+ this.selectedPattern = undefined;
+ }
this.patterns = await patterns(userMeResponse);
this.me = userMeResponse;
this.requestUpdate();
@@ -143,7 +147,7 @@ export class TerritoryPatternsModal extends LitElement {
@mouseleave=${() => this.handleMouseLeave()}
>
- ${translateText(`territory_patterns.pattern.${pattern.name}`)}
+ ${translatePatternName("territory_patterns.pattern", pattern.name)}
boolean;
};
@customElement("events-display")
@@ -205,7 +206,8 @@ export class EventsDisplay extends LitElement implements Layer {
let remainingEvents = this.events.filter((event) => {
if (this.game === undefined) return;
const shouldKeep =
- this.game.ticks() - event.createdAt < (event.duration ?? 600);
+ this.game.ticks() - event.createdAt < (event.duration ?? 600) &&
+ !event.shouldDelete?.(this.game);
if (!shouldKeep && event.onDelete) {
event.onDelete();
}
@@ -468,18 +470,13 @@ export class EventsDisplay extends LitElement implements Layer {
},
],
createdAt: this.game.ticks(),
- description: translateText("events_display.request_alliance", {
- name: requestor.name(),
- }),
- duration: 150,
- focusID: update.requestorID,
- highlight: true,
- onDelete: () =>
- this.eventBus?.emit(
- new SendAllianceReplyIntentEvent(requestor, recipient, false),
- ),
priority: 0,
- type: MessageType.ALLIANCE_REQUEST,
+ duration: this.game.config().allianceRequestDuration() - 20, // 2 second buffer
+ shouldDelete: (game) => {
+ // Recipient sent a separate request, so they became allied without the recipient responding.
+ return requestor.isAlliedWith(recipient);
+ },
+ focusID: update.requestorID,
});
}
diff --git a/src/core/configuration/ColorAllocator.ts b/src/core/configuration/ColorAllocator.ts
index 9c80ea4d5..a95e26159 100644
--- a/src/core/configuration/ColorAllocator.ts
+++ b/src/core/configuration/ColorAllocator.ts
@@ -49,7 +49,7 @@ export class ColorAllocator {
case ColoredTeams.Bot:
return botTeamColors;
default:
- throw new Error(`Unknown team color: ${team}`);
+ return [this.assignColor(team)];
}
}
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index 24780747b..4e32ca37c 100644
--- a/src/core/configuration/Config.ts
+++ b/src/core/configuration/Config.ts
@@ -121,6 +121,7 @@ export type Config = {
shellLifetime(): number;
boatMaxNumber(): number;
allianceDuration(): Tick;
+ allianceRequestDuration(): Tick;
allianceRequestCooldown(): Tick;
temporaryEmbargoDuration(): Tick;
targetDuration(): Tick;
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index 6a3eb2404..9f6eb2375 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -532,6 +532,9 @@ export class DefaultConfig implements Config {
targetCooldown(): Tick {
return 15 * 10;
}
+ allianceRequestDuration(): Tick {
+ return 20 * 10;
+ }
allianceRequestCooldown(): Tick {
return 30 * 10;
}
diff --git a/src/core/execution/CityExecution.ts b/src/core/execution/CityExecution.ts
index 863ba716e..69d39977d 100644
--- a/src/core/execution/CityExecution.ts
+++ b/src/core/execution/CityExecution.ts
@@ -52,7 +52,6 @@ export class CityExecution implements Execution {
this.city.tile(),
this.mg.config().trainStationMaxRange(),
UnitType.Factory,
- this.player.id(),
);
if (nearbyFactory) {
this.mg.addExecution(new TrainStationExecution(this.city));
diff --git a/src/core/execution/PortExecution.ts b/src/core/execution/PortExecution.ts
index 9395564f4..7dcff805d 100644
--- a/src/core/execution/PortExecution.ts
+++ b/src/core/execution/PortExecution.ts
@@ -97,7 +97,6 @@ export class PortExecution implements Execution {
this.port.tile(),
this.mg.config().trainStationMaxRange(),
UnitType.Factory,
- this.player.id(),
);
if (nearbyFactory) {
this.mg.addExecution(new TrainStationExecution(this.port));
diff --git a/src/core/execution/alliance/AllianceRequestExecution.ts b/src/core/execution/alliance/AllianceRequestExecution.ts
index ee2e929a3..9ae7cbdfb 100644
--- a/src/core/execution/alliance/AllianceRequestExecution.ts
+++ b/src/core/execution/alliance/AllianceRequestExecution.ts
@@ -1,8 +1,15 @@
-import { Execution, Game, Player, PlayerID } from "../../game/Game";
+import {
+ AllianceRequest,
+ Execution,
+ Game,
+ Player,
+ PlayerID,
+} from "../../game/Game";
export class AllianceRequestExecution implements Execution {
+ private req: AllianceRequest | null = null;
private active = true;
- private recipient: Player | null = null;
+ private mg: Game | undefined;
constructor(
private readonly requestor: Player,
@@ -10,29 +17,50 @@ export class AllianceRequestExecution implements Execution {
) {}
init(mg: Game, ticks: number): void {
+ this.mg = mg;
if (!mg.hasPlayer(this.recipientID)) {
console.warn(
`AllianceRequestExecution recipient ${this.recipientID} not found`,
);
- this.active = false;
return;
}
- this.recipient = mg.player(this.recipientID);
+ const recipient = mg.player(this.recipientID);
+
+ if (!this.requestor.canSendAllianceRequest(recipient)) {
+ console.warn("cannot send alliance request");
+ this.active = false;
+ } else {
+ const incoming = recipient
+ .outgoingAllianceRequests()
+ .find((r) => r.recipient() === this.requestor);
+ if (incoming) {
+ // If the recipient already has pending alliance request,
+ // then accept it instead of creating a new one.
+ this.active = false;
+ incoming.accept();
+ } else {
+ this.req = this.requestor.createAllianceRequest(recipient);
+ }
+ }
}
tick(ticks: number): void {
- if (this.recipient === null) {
- throw new Error("Not initialized");
+ if (
+ this.req?.status() === "accepted" ||
+ this.req?.status() === "rejected"
+ ) {
+ this.active = false;
+ return;
}
- if (this.requestor.isFriendly(this.recipient)) {
- console.warn("already allied");
- } else if (!this.requestor.canSendAllianceRequest(this.recipient)) {
- console.warn("recent or pending alliance request");
- } else {
- this.requestor.createAllianceRequest(this.recipient);
+ if (this.mg === undefined) throw new Error("Not initialized");
+ if (
+ this.mg.ticks() - (this.req?.createdAt() ?? 0) >
+ this.mg.config().allianceRequestDuration()
+ ) {
+ this.req?.reject();
+ this.active = false;
}
- this.active = false;
}
isActive(): boolean {
diff --git a/src/core/game/AllianceRequestImpl.ts b/src/core/game/AllianceRequestImpl.ts
index e26c8df9b..23d717add 100644
--- a/src/core/game/AllianceRequestImpl.ts
+++ b/src/core/game/AllianceRequestImpl.ts
@@ -3,6 +3,8 @@ import { GameImpl } from "./GameImpl";
import { AllianceRequestUpdate, GameUpdateType } from "./GameUpdates";
export class AllianceRequestImpl implements AllianceRequest {
+ private status_: "pending" | "accepted" | "rejected" = "pending";
+
constructor(
private readonly requestor_: Player,
private readonly recipient_: Player,
@@ -10,6 +12,10 @@ export class AllianceRequestImpl implements AllianceRequest {
private readonly game: GameImpl,
) {}
+ status(): "pending" | "accepted" | "rejected" {
+ return this.status_;
+ }
+
requestor(): Player {
return this.requestor_;
}
@@ -23,9 +29,11 @@ export class AllianceRequestImpl implements AllianceRequest {
}
accept(): void {
+ this.status_ = "accepted";
this.game.acceptAllianceRequest(this);
}
reject(): void {
+ this.status_ = "rejected";
this.game.rejectAllianceRequest(this);
}
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index 3dcd4af83..70434288d 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -352,6 +352,7 @@ export type AllianceRequest = {
requestor(): Player;
recipient(): Player;
createdAt(): Tick;
+ status(): "pending" | "accepted" | "rejected";
};
export type Alliance = {
@@ -670,7 +671,7 @@ export type Game = {
tile: TileRef,
searchRange: number,
type: UnitType,
- playerId: PlayerID,
+ playerId?: PlayerID,
): boolean;
nearbyUnits(
tile: TileRef,
diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts
index e0c4aa776..dca0dcee7 100644
--- a/src/core/game/GameImpl.ts
+++ b/src/core/game/GameImpl.ts
@@ -753,7 +753,7 @@ export class GameImpl implements Game {
tile: TileRef,
searchRange: number,
type: UnitType,
- playerId: PlayerID,
+ playerId?: PlayerID,
) {
return this.unitGrid.hasUnitNearby(tile, searchRange, type, playerId);
}
diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts
index 2851e99b3..db931ebcf 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -396,9 +396,9 @@ export class PlayerImpl implements Player {
return false;
}
- const hasPending =
- this.incomingAllianceRequests().some((ar) => ar.requestor() === other) ||
- this.outgoingAllianceRequests().some((ar) => ar.recipient() === other);
+ const hasPending = this.outgoingAllianceRequests().some(
+ (ar) => ar.recipient() === other,
+ );
if (hasPending) {
return false;
@@ -555,6 +555,9 @@ export class PlayerImpl implements Player {
}
canSendEmoji(recipient: Player | typeof AllPlayers): boolean {
+ if (recipient === this) {
+ return false;
+ }
const recipientID =
recipient === AllPlayers ? AllPlayers : recipient.smallID();
const prevMsgs = this.outgoingEmojis_.filter(
diff --git a/src/core/game/UnitGrid.ts b/src/core/game/UnitGrid.ts
index 0a965eaa0..b790c2a82 100644
--- a/src/core/game/UnitGrid.ts
+++ b/src/core/game/UnitGrid.ts
@@ -170,12 +170,28 @@ export class UnitGrid {
return nearby;
}
+ private unitIsInRange(
+ unit: Unit | UnitView,
+ tile: TileRef,
+ rangeSquared: number,
+ playerId?: PlayerID,
+ ): boolean {
+ if (!unit.isActive()) {
+ return false;
+ }
+ if (playerId !== undefined && unit.owner().id() !== playerId) {
+ return false;
+ }
+ const distSquared = this.squaredDistanceFromTile(unit, tile);
+ return distSquared <= rangeSquared;
+ }
+
// Return true if it finds an owned specific unit in range
hasUnitNearby(
tile: TileRef,
searchRange: number,
type: UnitType,
- playerId: PlayerID,
+ playerId?: PlayerID,
): boolean {
const { startGridX, endGridX, startGridY, endGridY } = this.getCellsInRange(
tile,
@@ -187,11 +203,8 @@ export class UnitGrid {
const unitSet = this.grid[cy][cx].get(type);
if (unitSet === undefined) continue;
for (const unit of unitSet) {
- if (unit.owner().id() === playerId && unit.isActive()) {
- const distSquared = this.squaredDistanceFromTile(unit, tile);
- if (distSquared <= rangeSquared) {
- return true;
- }
+ if (this.unitIsInRange(unit, tile, rangeSquared, playerId)) {
+ return true;
}
}
}
diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts
index 66538895d..ef2e57456 100644
--- a/src/server/MapPlaylist.ts
+++ b/src/server/MapPlaylist.ts
@@ -46,7 +46,6 @@ const frequency: Partial> = {
SouthAmerica: 5,
StraitOfGibraltar: 5,
World: 8,
- Yenisei: 6,
};
type MapWithMode = {
diff --git a/tests/AllianceRequestExecution.test.ts b/tests/AllianceRequestExecution.test.ts
new file mode 100644
index 000000000..b6df1e657
--- /dev/null
+++ b/tests/AllianceRequestExecution.test.ts
@@ -0,0 +1,77 @@
+import { Game, Player, PlayerType } from "../src/core/game/Game";
+import { playerInfo, setup } from "./util/Setup";
+import { AllianceRequestExecution } from "../src/core/execution/alliance/AllianceRequestExecution";
+import { AllianceRequestReplyExecution } from "../src/core/execution/alliance/AllianceRequestReplyExecution";
+
+let game: Game;
+let player1: Player;
+let player2: Player;
+
+describe("AllianceRequestExecution", () => {
+ beforeEach(async () => {
+ game = await setup(
+ "plains",
+ {
+ infiniteGold: true,
+ instantBuild: true,
+ infiniteTroops: true,
+ },
+ [
+ playerInfo("player1", PlayerType.Human),
+ playerInfo("player2", PlayerType.Human),
+ playerInfo("player3", PlayerType.FakeHuman),
+ ],
+ );
+
+ player1 = game.player("player1");
+ player1.conquer(game.ref(0, 0));
+
+ player2 = game.player("player2");
+ player2.conquer(game.ref(0, 1));
+
+ while (game.inSpawnPhase()) {
+ game.executeNextTick();
+ }
+ });
+
+ test("Can create alliance by replying", () => {
+ game.addExecution(new AllianceRequestExecution(player1, player2.id()));
+ game.executeNextTick();
+
+ game.addExecution(
+ new AllianceRequestReplyExecution(player1.id(), player2, true),
+ );
+ game.executeNextTick();
+ game.executeNextTick();
+
+ expect(player1.isAlliedWith(player2)).toBeTruthy();
+ expect(player2.isAlliedWith(player1)).toBeTruthy();
+ });
+
+ test("Can create alliance by sending alliance request back", () => {
+ game.addExecution(new AllianceRequestExecution(player1, player2.id()));
+ game.executeNextTick();
+
+ game.addExecution(new AllianceRequestExecution(player2, player1.id()));
+ game.executeNextTick();
+
+ expect(player1.isAlliedWith(player2)).toBeTruthy();
+ expect(player2.isAlliedWith(player1)).toBeTruthy();
+ });
+
+ test("Alliance request expires", () => {
+ game.config().allianceRequestDuration = () => 5;
+ game.addExecution(new AllianceRequestExecution(player1, player2.id()));
+ game.executeNextTick();
+
+ expect(player1.outgoingAllianceRequests()).toHaveLength(1);
+
+ for (let i = 0; i < 6; i++) {
+ game.executeNextTick();
+ }
+
+ expect(player1.outgoingAllianceRequests()).toHaveLength(0);
+ expect(player1.isAlliedWith(player2)).toBeFalsy();
+ expect(player2.isAlliedWith(player1)).toBeFalsy();
+ });
+});