Merge v25 into main

This commit is contained in:
Scott Anderson
2025-08-06 22:43:14 -04:00
18 changed files with 211 additions and 25 deletions
+2 -3
View File
@@ -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`,
}),
},
);
+15
View File
@@ -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();
+1 -1
View File
@@ -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;
+18
View File
@@ -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;
+1 -1
View File
@@ -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();
+8
View File
@@ -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");
}
@@ -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;
-4
View File
@@ -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?";
});
</script>
<!-- Playwire ads -->
+1
View File
@@ -57,6 +57,7 @@ export class BotExecution implements Execution {
}
this.behavior.handleAllianceRequests();
this.behavior.handleAllianceExtensionRequests();
this.maybeAttack();
}
+1
View File
@@ -155,6 +155,7 @@ export class FakeHumanExecution implements Execution {
this.updateRelationsFromEmbargos();
this.behavior.handleAllianceRequests();
this.behavior.handleAllianceExtensionRequests();
this.handleUnits();
this.handleEmbargoesToHostileNations();
this.maybeAttack();
@@ -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(
+23
View File
@@ -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));
+10 -1
View File
@@ -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_;
}
+2 -1
View File
@@ -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 {
+1 -4
View File
@@ -229,10 +229,7 @@ export class Cluster {
availableForTrade(player: Player): Set<TrainStation> {
const tradingStations = new Set<TrainStation>();
for (const station of this.stations) {
if (
station.unit.owner() === player ||
station.unit.owner().isFriendly(player)
) {
if (station.tradeAvailable(player)) {
tradingStations.add(station);
}
}
+1 -2
View File
@@ -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;
}
+47 -6
View File
@@ -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);
});
});
+78
View File
@@ -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();
});
});