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(); + }); +});