diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index 01a4a5de0..bfd991c4a 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -41,9 +41,8 @@ export async function handlePurchase(priceId: string) { }, body: JSON.stringify({ priceId: priceId, - - successUrl: `${window.location.href}purchase-success`, - cancelUrl: `${window.location.href}purchase-cancel`, + successUrl: `${window.location.origin}#purchase-completed=true`, + cancelUrl: `${window.location.origin}#purchase-completed=false`, }), }, ); diff --git a/src/client/DarkModeButton.ts b/src/client/DarkModeButton.ts index 65071408f..5284ca323 100644 --- a/src/client/DarkModeButton.ts +++ b/src/client/DarkModeButton.ts @@ -11,6 +11,21 @@ export class DarkModeButton extends LitElement { return this; } + connectedCallback() { + super.connectedCallback(); + window.addEventListener("dark-mode-changed", this.handleDarkModeChanged); + } + + disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener("dark-mode-changed", this.handleDarkModeChanged); + } + + private handleDarkModeChanged = (e: Event) => { + const event = e as CustomEvent<{ darkMode: boolean }>; + this.darkMode = event.detail.darkMode; + }; + toggleDarkMode() { this.userSettings.toggleDarkMode(); this.darkMode = this.userSettings.darkMode(); diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 07212ef6b..8b5e256e3 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -47,7 +47,7 @@ export class HostLobbyModal extends LitElement { @state() private copySuccess = false; @state() private clients: ClientInfo[] = []; @state() private useRandomMap: boolean = false; - @state() private disabledUnits: UnitType[] = [UnitType.Factory]; + @state() private disabledUnits: UnitType[] = []; @state() private lobbyCreatorClientID: string = ""; @state() private lobbyIdVisible: boolean = true; diff --git a/src/client/Main.ts b/src/client/Main.ts index 732a58c12..97cca212c 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -423,8 +423,25 @@ class Client { private handleHash() { const { hash } = window.location; + + const alertAndStrip = (message: string) => { + alert(message); + history.replaceState( + null, + "", + window.location.pathname + window.location.search, + ); + }; + if (hash.startsWith("#")) { const params = new URLSearchParams(hash.slice(1)); + if (params.get("purchase-completed") === "true") { + alertAndStrip("purchase succeeded"); + return; + } else if (params.get("purchase-completed") === "false") { + alertAndStrip("purchase failed"); + return; + } const lobbyId = params.get("join"); if (lobbyId && ID.safeParse(lobbyId).success) { this.joinModal.open(lobbyId); @@ -475,6 +492,7 @@ class Client { "territory-patterns-modal", "language-modal", "news-modal", + "flag-input-modal", ].forEach((tag) => { const modal = document.querySelector(tag) as HTMLElement & { close?: () => void; diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index d819a0ccf..0ed5ff179 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -43,7 +43,7 @@ export class SinglePlayerModal extends LitElement { @state() private gameMode: GameMode = GameMode.FFA; @state() private teamCount: TeamCountConfig = 2; - @state() private disabledUnits: UnitType[] = [UnitType.Factory]; + @state() private disabledUnits: UnitType[] = []; private userSettings: UserSettings = new UserSettings(); diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 14f79c190..d55f644ea 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -95,6 +95,14 @@ export class UserSettingModal extends LitElement { document.documentElement.classList.remove("dark"); } + this.dispatchEvent( + new CustomEvent("dark-mode-changed", { + detail: { darkMode: enabled }, + bubbles: true, + composed: true, + }), + ); + console.log("🌙 Dark Mode:", enabled ? "ON" : "OFF"); } diff --git a/src/client/components/baseComponents/Modal.ts b/src/client/components/baseComponents/Modal.ts index 0841b4ba4..ae58d9078 100644 --- a/src/client/components/baseComponents/Modal.ts +++ b/src/client/components/baseComponents/Modal.ts @@ -26,7 +26,6 @@ export class OModal extends LitElement { } .c-modal__wrapper { - background: #23232382; border-radius: 8px; min-width: 340px; max-width: 860px; @@ -62,6 +61,7 @@ export class OModal extends LitElement { } .c-modal__content { + background: #23232382; position: relative; color: #fff; padding: 1.4rem; diff --git a/src/client/index.html b/src/client/index.html index 0892848f8..33b280951 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -442,10 +442,6 @@ document.documentElement.classList.remove("preload"); }); }); - window.addEventListener("beforeunload", function (e) { - e.preventDefault(); - e.returnValue = "Are you sure you want to leave?"; - }); diff --git a/src/core/execution/BotExecution.ts b/src/core/execution/BotExecution.ts index 5915b6223..ddd635cc8 100644 --- a/src/core/execution/BotExecution.ts +++ b/src/core/execution/BotExecution.ts @@ -57,6 +57,7 @@ export class BotExecution implements Execution { } this.behavior.handleAllianceRequests(); + this.behavior.handleAllianceExtensionRequests(); this.maybeAttack(); } diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index 828631757..0f41f7e86 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -155,6 +155,7 @@ export class FakeHumanExecution implements Execution { this.updateRelationsFromEmbargos(); this.behavior.handleAllianceRequests(); + this.behavior.handleAllianceExtensionRequests(); this.handleUnits(); this.handleEmbargoesToHostileNations(); this.maybeAttack(); diff --git a/src/core/execution/alliance/AllianceExtensionExecution.ts b/src/core/execution/alliance/AllianceExtensionExecution.ts index 2469d9038..df24a9fa8 100644 --- a/src/core/execution/alliance/AllianceExtensionExecution.ts +++ b/src/core/execution/alliance/AllianceExtensionExecution.ts @@ -39,7 +39,7 @@ export class AllianceExtensionExecution implements Execution { // Mark this player's intent to extend alliance.addExtensionRequest(this.from); - if (alliance.canExtend()) { + if (alliance.bothAgreedToExtend()) { alliance.extend(); mg.displayMessage( diff --git a/src/core/execution/utils/BotBehavior.ts b/src/core/execution/utils/BotBehavior.ts index 4525c4ad9..3cf85c249 100644 --- a/src/core/execution/utils/BotBehavior.ts +++ b/src/core/execution/utils/BotBehavior.ts @@ -9,6 +9,7 @@ import { } from "../../game/Game"; import { PseudoRandom } from "../../PseudoRandom"; import { flattenedEmojiTable } from "../../Util"; +import { AllianceExtensionExecution } from "../alliance/AllianceExtensionExecution"; import { AttackExecution } from "../AttackExecution"; import { EmojiExecution } from "../EmojiExecution"; @@ -37,6 +38,28 @@ export class BotBehavior { } } + handleAllianceExtensionRequests() { + for (const alliance of this.player.alliances()) { + // Alliance expiration tracked by Events Panel, only human ally can click Request to Renew + // Skip if no expiration yet/ ally didn't request extension yet/ bot already agreed to extend + if (!alliance.onlyOneAgreedToExtend()) continue; + + // Nation is either Friendly or Neutral as an ally. Bot has no attitude + // If Friendly or Bot, always agree to extend. If Neutral, have random chance decide + const human = alliance.other(this.player); + if ( + this.player.type() === PlayerType.FakeHuman && + this.player.relation(human) === Relation.Neutral + ) { + if (!this.random.chance(1.5)) continue; + } + + this.game.addExecution( + new AllianceExtensionExecution(this.player, human.id()), + ); + } + } + private emoji(player: Player, emoji: number) { if (player.type() !== PlayerType.Human) return; this.game.addExecution(new EmojiExecution(this.player, player.id(), emoji)); diff --git a/src/core/game/AllianceImpl.ts b/src/core/game/AllianceImpl.ts index 6d2782595..fa74ca766 100644 --- a/src/core/game/AllianceImpl.ts +++ b/src/core/game/AllianceImpl.ts @@ -47,12 +47,21 @@ export class AllianceImpl implements MutableAlliance { } } - canExtend(): boolean { + bothAgreedToExtend(): boolean { return ( this.extensionRequestedRequestor_ && this.extensionRequestedRecipient_ ); } + onlyOneAgreedToExtend(): boolean { + // Requestor / Recipient of the original alliance request, not of the extension request + // False if: no expiration or neither requested extension yet (both false), or both agreed to extend (both true) + // True if: one requested extension, other didn't yet or actively ignored (one true, one false) + return ( + this.extensionRequestedRequestor_ !== this.extensionRequestedRecipient_ + ); + } + public id(): number { return this.id_; } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 478d16781..1c2bc8065 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -364,10 +364,11 @@ export interface Alliance { export interface MutableAlliance extends Alliance { expire(): void; other(player: Player): Player; - canExtend(): boolean; + bothAgreedToExtend(): boolean; addExtensionRequest(player: Player): void; id(): number; extend(): void; + onlyOneAgreedToExtend(): boolean; } export class PlayerInfo { diff --git a/src/core/game/TrainStation.ts b/src/core/game/TrainStation.ts index a0e78fa01..8eb3bacee 100644 --- a/src/core/game/TrainStation.ts +++ b/src/core/game/TrainStation.ts @@ -229,10 +229,7 @@ export class Cluster { availableForTrade(player: Player): Set { const tradingStations = new Set(); for (const station of this.stations) { - if ( - station.unit.owner() === player || - station.unit.owner().isFriendly(player) - ) { + if (station.tradeAvailable(player)) { tradingStations.add(station); } } diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 5b68292b1..db55d2045 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -8,7 +8,6 @@ import { GameType, Quads, Trios, - UnitType, } from "../core/game/Game"; import { PseudoRandom } from "../core/PseudoRandom"; import { GameConfig, TeamCountConfig } from "../core/Schemas"; @@ -89,7 +88,7 @@ export class MapPlaylist { gameMode: mode, playerTeams, bots: 400, - disabledUnits: [UnitType.Train, UnitType.Factory], + disabledUnits: [], } satisfies GameConfig; } diff --git a/tests/AllianceExtensionExecution.test.ts b/tests/AllianceExtensionExecution.test.ts index 8e4cbcebb..6ae6340ed 100644 --- a/tests/AllianceExtensionExecution.test.ts +++ b/tests/AllianceExtensionExecution.test.ts @@ -7,6 +7,7 @@ import { playerInfo, setup } from "./util/Setup"; let game: Game; let player1: Player; let player2: Player; +let player3: Player; describe("AllianceExtensionExecution", () => { beforeEach(async () => { @@ -20,18 +21,20 @@ describe("AllianceExtensionExecution", () => { [ playerInfo("player1", PlayerType.Human), playerInfo("player2", PlayerType.Human), + playerInfo("player3", PlayerType.FakeHuman), ], ); player1 = game.player("player1"); player2 = game.player("player2"); + player3 = game.player("player3"); while (game.inSpawnPhase()) { game.executeNextTick(); } }); - test("Successfully extends existing alliance", () => { + test("Successfully extends existing alliance between Humans", () => { jest.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true); jest.spyOn(player2, "isAlive").mockReturnValue(true); jest.spyOn(player1, "isAlive").mockReturnValue(true); @@ -51,8 +54,8 @@ describe("AllianceExtensionExecution", () => { const allianceBefore = player1.allianceWith(player2)!; const allianceSpy = jest.spyOn(allianceBefore, "extend"); - const expirationBefore = - allianceBefore.createdAt() + game.config().allianceDuration(); + + const expirationBefore = allianceBefore.expiresAt(); game.addExecution(new AllianceExtensionExecution(player1, player2.id())); game.executeNextTick(); @@ -64,10 +67,9 @@ describe("AllianceExtensionExecution", () => { expect(allianceAfter.id()).toBe(allianceBefore.id()); - const expirationAfter = - allianceAfter.createdAt() + game.config().allianceDuration(); + const expirationAfter = allianceAfter.expiresAt(); - expect(expirationAfter).toBeGreaterThanOrEqual(expirationBefore); + expect(expirationAfter).toBeGreaterThan(expirationBefore); expect(allianceSpy).toHaveBeenCalledTimes(1); }); @@ -78,4 +80,43 @@ describe("AllianceExtensionExecution", () => { expect(player1.allianceWith(player2)).toBeFalsy(); expect(player2.allianceWith(player1)).toBeFalsy(); }); + + test("Successfully extends existing alliance between Human and non-Human", () => { + //test of handleAllianceExtensions is done in BotBehavior tests + jest.spyOn(player1, "canSendAllianceRequest").mockReturnValue(true); + jest.spyOn(player3, "isAlive").mockReturnValue(true); + jest.spyOn(player1, "isAlive").mockReturnValue(true); + + game.addExecution(new AllianceRequestExecution(player1, player3.id())); + game.executeNextTick(); + game.executeNextTick(); + + game.addExecution( + new AllianceRequestReplyExecution(player1.id(), player3, true), + ); + game.executeNextTick(); + game.executeNextTick(); + + expect(player1.allianceWith(player3)).toBeTruthy(); + expect(player3.allianceWith(player1)).toBeTruthy(); + + const allianceBefore = player1.allianceWith(player3)!; + const allianceSpy = jest.spyOn(allianceBefore, "extend"); + const expirationBefore = allianceBefore.expiresAt(); + + game.addExecution(new AllianceExtensionExecution(player1, player3.id())); + game.executeNextTick(); + expect(allianceSpy).toHaveBeenCalledTimes(0); // both players must agree to extend + game.addExecution(new AllianceExtensionExecution(player3, player1.id())); + game.executeNextTick(); + + const allianceAfter = player1.allianceWith(player3)!; + + expect(allianceAfter.id()).toBe(allianceBefore.id()); + + const expirationAfter = allianceAfter.expiresAt(); + + expect(expirationAfter).toBeGreaterThan(expirationBefore); + expect(allianceSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/BotBehavior.test.ts b/tests/BotBehavior.test.ts index 51084b509..71b14ac0b 100644 --- a/tests/BotBehavior.test.ts +++ b/tests/BotBehavior.test.ts @@ -1,3 +1,4 @@ +import { AllianceExtensionExecution } from "../src/core/execution/alliance/AllianceExtensionExecution"; import { BotBehavior } from "../src/core/execution/utils/BotBehavior"; import { AllianceRequest, @@ -5,6 +6,7 @@ import { Player, PlayerInfo, PlayerType, + Relation, Tick, } from "../src/core/game/Game"; import { PseudoRandom } from "../src/core/PseudoRandom"; @@ -149,3 +151,79 @@ describe("BotBehavior.handleAllianceRequests", () => { expect(request.reject).toHaveBeenCalled(); }); }); + +describe("BotBehavior.handleAllianceExtensionRequests", () => { + let mockGame: any; + let mockPlayer: any; + let mockAlliance: any; + let mockHuman: any; + let mockRandom: any; + let botBehavior: BotBehavior; + + beforeEach(() => { + mockGame = { addExecution: jest.fn() }; + mockHuman = { id: jest.fn(() => "human_id") }; + mockAlliance = { + onlyOneAgreedToExtend: jest.fn(() => true), + other: jest.fn(() => mockHuman), + }; + mockRandom = { chance: jest.fn() }; + + mockPlayer = { + alliances: jest.fn(() => [mockAlliance]), + relation: jest.fn(), + id: jest.fn(() => "bot_id"), + type: jest.fn(() => PlayerType.FakeHuman), + }; + + botBehavior = new BotBehavior( + mockRandom, + mockGame, + mockPlayer, + 0.5, + 0.5, + 0.2, + ); + }); + + it("should NOT request extension if onlyOneAgreedToExtend is false (no expiration yet or both already agreed)", () => { + mockAlliance.onlyOneAgreedToExtend.mockReturnValue(false); + botBehavior.handleAllianceExtensionRequests(); + expect(mockGame.addExecution).not.toHaveBeenCalled(); + }); + + it("should always extend if type Bot", () => { + mockPlayer.type.mockReturnValue(PlayerType.Bot); + botBehavior.handleAllianceExtensionRequests(); + expect(mockGame.addExecution).toHaveBeenCalledTimes(1); + expect(mockGame.addExecution.mock.calls[0][0]).toBeInstanceOf( + AllianceExtensionExecution, + ); + }); + + it("should always extend if Nation and relation is Friendly", () => { + mockPlayer.relation.mockReturnValue(Relation.Friendly); + botBehavior.handleAllianceExtensionRequests(); + expect(mockGame.addExecution).toHaveBeenCalledTimes(1); + expect(mockGame.addExecution.mock.calls[0][0]).toBeInstanceOf( + AllianceExtensionExecution, + ); + }); + + it("should extend if Nation, relation is Neutral and random chance is true", () => { + mockPlayer.relation.mockReturnValue(Relation.Neutral); + mockRandom.chance.mockReturnValue(true); + botBehavior.handleAllianceExtensionRequests(); + expect(mockGame.addExecution).toHaveBeenCalledTimes(1); + expect(mockGame.addExecution.mock.calls[0][0]).toBeInstanceOf( + AllianceExtensionExecution, + ); + }); + + it("should NOT extend if Nation, relation is Neutral and random chance is false", () => { + mockPlayer.relation.mockReturnValue(Relation.Neutral); + mockRandom.chance.mockReturnValue(false); + botBehavior.handleAllianceExtensionRequests(); + expect(mockGame.addExecution).not.toHaveBeenCalled(); + }); +});