allow alliance extension Fixes #491 (#1314)

## Description:

About 30s before an alliance is about to expire, both players receive a
prompt to extend the alliance. If both players agree the alliance is
extended.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
- [x] I understand that submitting code with bugs that could have been
caught through manual testing blocks releases and new features for all
contributors

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
This commit is contained in:
evanpelle
2025-07-02 15:25:20 -07:00
committed by GitHub
parent cfabdfebc7
commit 5b5ac7bfca
19 changed files with 358 additions and 19 deletions
+6 -1
View File
@@ -432,7 +432,12 @@
},
"events_display": {
"retreating": "retreating",
"boat": "Boat"
"boat": "Boat",
"about_to_expire": "Your alliance with {name} is about to expire!",
"renew_alliance": "Request to renew",
"focus": "Focus",
"alliance_renewed": "Your alliance with {name} has been renewed",
"ignore": "Ignore"
},
"unit_info_modal": {
"structure_info": "Structure Info",
+17
View File
@@ -67,6 +67,10 @@ export class SendAllianceReplyIntentEvent implements GameEvent {
) {}
}
export class SendAllianceExtensionIntentEvent implements GameEvent {
constructor(public readonly recipient: PlayerView) {}
}
export class SendSpawnIntentEvent implements GameEvent {
constructor(public readonly cell: Cell) {}
}
@@ -194,6 +198,9 @@ export class Transport {
this.eventBus.on(SendAllianceReplyIntentEvent, (e) =>
this.onAllianceRequestReplyUIEvent(e),
);
this.eventBus.on(SendAllianceExtensionIntentEvent, (e) =>
this.onSendAllianceExtensionIntent(e),
);
this.eventBus.on(SendBreakAllianceIntentEvent, (e) =>
this.onBreakAllianceRequestUIEvent(e),
);
@@ -419,6 +426,16 @@ export class Transport {
});
}
private onSendAllianceExtensionIntent(
event: SendAllianceExtensionIntentEvent,
) {
this.sendIntent({
type: "allianceExtension",
clientID: this.lobbyConfig.clientID,
recipient: event.recipient.id(),
});
}
private onSendSpawnIntentEvent(event: SendSpawnIntentEvent) {
this.sendIntent({
type: "spawn",
+1
View File
@@ -141,6 +141,7 @@ export function getMessageTypeClasses(type: MessageType): string {
case MessageType.SAM_MISS:
case MessageType.ALLIANCE_EXPIRED:
case MessageType.NAVAL_INVASION_INBOUND:
case MessageType.RENEW_ALLIANCE:
return severityColors["warn"];
case MessageType.CHAT:
case MessageType.ALLIANCE_REQUEST:
+64 -2
View File
@@ -32,6 +32,7 @@ import {
import {
CancelAttackIntentEvent,
CancelBoatIntentEvent,
SendAllianceExtensionIntentEvent,
SendAllianceReplyIntentEvent,
} from "../../Transport";
import { Layer } from "./Layer";
@@ -45,7 +46,6 @@ import {
GoToUnitEvent,
} from "./Leaderboard";
import { UserSettings } from "../../../core/game/UserSettings";
import { getMessageTypeClasses, translateText } from "../../Utils";
interface GameEvent {
@@ -73,9 +73,11 @@ export class EventsDisplay extends LitElement implements Layer {
public eventBus: EventBus;
public game: GameView;
private userSettings: UserSettings = new UserSettings();
private active: boolean = false;
private events: GameEvent[] = [];
// allianceID -> last checked at tick
private alliancesCheckedAt = new Map<number, Tick>();
@state() private incomingAttacks: AttackUpdate[] = [];
@state() private outgoingAttacks: AttackUpdate[] = [];
@state() private outgoingLandAttacks: AttackUpdate[] = [];
@@ -182,6 +184,8 @@ export class EventsDisplay extends LitElement implements Layer {
return;
}
this.checkForAllianceExpirations();
const updates = this.game.updatesSinceLastTick();
if (updates) {
for (const [ut, fn] of this.updateMap) {
@@ -235,6 +239,64 @@ export class EventsDisplay extends LitElement implements Layer {
}
}
private checkForAllianceExpirations() {
const myPlayer = this.game.myPlayer();
if (!myPlayer) return;
for (const alliance of myPlayer.alliances()) {
if (
alliance.expiresAt >
this.game.ticks() + this.game.config().allianceExtensionPromptOffset()
) {
continue;
}
if (
(this.alliancesCheckedAt.get(alliance.id) ?? 0) >=
this.game.ticks() - this.game.config().allianceExtensionPromptOffset()
) {
// We've already displayed a message for this alliance.
continue;
}
this.alliancesCheckedAt.set(alliance.id, this.game.ticks());
const other = this.game.player(alliance.other) as PlayerView;
this.addEvent({
description: translateText("events_display.about_to_expire", {
name: other.name(),
}),
type: MessageType.RENEW_ALLIANCE,
duration: this.game.config().allianceExtensionPromptOffset() - 3 * 10, // 3 second buffer
buttons: [
{
text: translateText("events_display.focus"),
className: "btn-gray",
action: () => this.eventBus.emit(new GoToPlayerEvent(other)),
preventClose: true,
},
{
text: translateText("events_display.renew_alliance", {
name: other.name(),
}),
className: "btn",
action: () =>
this.eventBus.emit(new SendAllianceExtensionIntentEvent(other)),
},
{
text: translateText("events_display.ignore"),
className: "btn-info",
action: () => {},
},
],
highlight: true,
createdAt: this.game.ticks(),
focusID: other.smallID(),
});
}
}
private addEvent(event: GameEvent) {
this.events = [...this.events, event];
if (this._hidden === true) {
+3 -5
View File
@@ -176,11 +176,9 @@ export class PlayerPanel extends LitElement implements Layer {
if (myPlayer !== null && myPlayer.isAlive()) {
this.actions = await myPlayer.actions(this.tile);
if (this.actions?.interaction?.allianceCreatedAtTick !== undefined) {
const createdAt = this.actions.interaction.allianceCreatedAtTick;
const durationTicks = this.g.config().allianceDuration();
const expiryTick = createdAt + durationTicks;
const remainingTicks = expiryTick - this.g.ticks();
if (this.actions?.interaction?.allianceExpiresAt !== undefined) {
const expiresAt = this.actions.interaction.allianceExpiresAt;
const remainingTicks = expiresAt - this.g.ticks();
if (remainingTicks > 0) {
const remainingSeconds = Math.max(
+2 -1
View File
@@ -196,12 +196,13 @@ export class GameRunner {
};
const alliance = player.allianceWith(other as Player);
if (alliance) {
actions.interaction.allianceCreatedAtTick = alliance.createdAt();
actions.interaction.allianceExpiresAt = alliance.expiresAt();
}
}
return actions;
}
public playerProfile(playerID: number): PlayerProfile {
const player = this.game.playerBySmallID(playerID);
if (!player.isPlayer()) {
+10
View File
@@ -25,6 +25,7 @@ export type Intent =
| CancelBoatIntent
| AllianceRequestIntent
| AllianceRequestReplyIntent
| AllianceExtensionIntent
| BreakAllianceIntent
| TargetPlayerIntent
| EmojiIntent
@@ -67,6 +68,9 @@ export type QuickChatIntent = z.infer<typeof QuickChatIntentSchema>;
export type MarkDisconnectedIntent = z.infer<
typeof MarkDisconnectedIntentSchema
>;
export type AllianceExtensionIntent = z.infer<
typeof AllianceExtensionIntentSchema
>;
export type Turn = z.infer<typeof TurnSchema>;
export type GameConfig = z.infer<typeof GameConfigSchema>;
@@ -213,6 +217,11 @@ const BaseIntentSchema = z.object({
clientID: ID,
});
export const AllianceExtensionIntentSchema = BaseIntentSchema.extend({
type: z.literal("allianceExtension"),
recipient: ID,
});
export const AttackIntentSchema = BaseIntentSchema.extend({
type: z.literal("attack"),
targetID: ID.nullable(),
@@ -354,6 +363,7 @@ const IntentSchema = z.discriminatedUnion("type", [
EmbargoIntentSchema,
MoveWarshipIntentSchema,
QuickChatIntentSchema,
AllianceExtensionIntentSchema,
]);
//
+1
View File
@@ -158,6 +158,7 @@ export interface Config {
nukeDeathFactor(humans: number, tilesOwned: number): number;
structureMinDist(): number;
isReplay(): boolean;
allianceExtensionPromptOffset(): number;
}
export interface Theme {
+4
View File
@@ -839,4 +839,8 @@ export class DefaultConfig implements Config {
defensePostTargettingRange(): number {
return 75;
}
allianceExtensionPromptOffset(): number {
return 300; // 30 seconds before expiration
}
}
+5
View File
@@ -2,6 +2,7 @@ import { Execution, Game } from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { ClientID, GameID, Intent, Turn } from "../Schemas";
import { simpleHash } from "../Util";
import { AllianceExtensionExecution } from "./alliance/AllianceExtensionExecution";
import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution";
import { AllianceRequestReplyExecution } from "./alliance/AllianceRequestReplyExecution";
import { BreakAllianceExecution } from "./alliance/BreakAllianceExecution";
@@ -111,6 +112,10 @@ export class Executor {
this.mg.ref(intent.x, intent.y),
intent.unit,
);
case "allianceExtension": {
return new AllianceExtensionExecution(player, intent.recipient);
}
case "upgrade_structure":
return new UpgradeStructureExecution(player, intent.unitId);
case "create_station":
+1 -4
View File
@@ -72,10 +72,7 @@ export class PlayerExecution implements Execution {
const alliances = Array.from(this.player.alliances());
for (const alliance of alliances) {
if (
this.mg.ticks() - alliance.createdAt() >
this.mg.config().allianceDuration()
) {
if (alliance.expiresAt() <= this.mg.ticks()) {
alliance.expire();
}
}
@@ -0,0 +1,61 @@
import {
Execution,
Game,
MessageType,
Player,
PlayerID,
} from "../../game/Game";
export class AllianceExtensionExecution implements Execution {
constructor(
private readonly from: Player,
private readonly toID: PlayerID,
) {}
init(mg: Game, ticks: number): void {
if (!mg.hasPlayer(this.toID)) {
console.warn(
`[AllianceExtensionExecution] Player ${this.toID} not found`,
);
return;
}
const to = mg.player(this.toID);
const alliance = this.from.allianceWith(to);
if (!alliance) {
console.warn(
`[AllianceExtensionExecution] No alliance to extend between ${this.from.id()} and ${this.toID}`,
);
return;
}
// Mark this player's intent to extend
alliance.addExtensionRequest(this.from);
if (alliance.canExtend()) {
alliance.extend();
mg.displayMessage(
"alliance.renewed",
MessageType.ALLIANCE_ACCEPTED,
this.from.id(),
);
mg.displayMessage(
"alliance.renewed",
MessageType.ALLIANCE_ACCEPTED,
this.toID,
);
}
}
tick(ticks: number): void {
// No-op
}
isActive(): boolean {
return false;
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
+39 -3
View File
@@ -1,12 +1,20 @@
import { Game, MutableAlliance, Player, Tick } from "./Game";
export class AllianceImpl implements MutableAlliance {
private extensionRequestedRequestor_: boolean = false;
private extensionRequestedRecipient_: boolean = false;
private expiresAt_: Tick;
constructor(
private readonly mg: Game,
readonly requestor_: Player,
readonly recipient_: Player,
readonly createdAtTick_: Tick,
) {}
private readonly createdAt_: Tick,
private readonly id_: number,
) {
this.expiresAt_ = createdAt_ + mg.config().allianceDuration();
}
other(player: Player): Player {
if (this.requestor_ === player) {
@@ -24,10 +32,38 @@ export class AllianceImpl implements MutableAlliance {
}
createdAt(): Tick {
return this.createdAtTick_;
return this.createdAt_;
}
expire(): void {
this.mg.expireAlliance(this);
}
addExtensionRequest(player: Player): void {
if (this.requestor_ === player) {
this.extensionRequestedRequestor_ = true;
} else if (this.recipient_ === player) {
this.extensionRequestedRecipient_ = true;
}
}
canExtend(): boolean {
return (
this.extensionRequestedRequestor_ && this.extensionRequestedRecipient_
);
}
public id(): number {
return this.id_;
}
extend(): void {
this.extensionRequestedRequestor_ = false;
this.extensionRequestedRecipient_ = false;
this.expiresAt_ = this.mg.ticks() + this.mg.config().allianceDuration();
}
expiresAt(): Tick {
return this.expiresAt_;
}
}
+13 -2
View File
@@ -339,12 +339,17 @@ export interface Alliance {
requestor(): Player;
recipient(): Player;
createdAt(): Tick;
expiresAt(): Tick;
other(player: Player): Player;
}
export interface MutableAlliance extends Alliance {
expire(): void;
other(player: Player): Player;
canExtend(): boolean;
addExtensionRequest(player: Player): void;
id(): number;
extend(): void;
}
export class PlayerInfo {
@@ -536,6 +541,7 @@ export interface Player {
incomingAllianceRequests(): AllianceRequest[];
outgoingAllianceRequests(): AllianceRequest[];
alliances(): MutableAlliance[];
expiredAlliances(): Alliance[];
allies(): Player[];
isAlliedWith(other: Player): boolean;
allianceWith(other: Player): MutableAlliance | null;
@@ -591,7 +597,6 @@ export interface Player {
}
export interface Game extends GameMap {
expireAlliance(alliance: Alliance);
// Map & Dimensions
isOnMap(cell: Cell): boolean;
width(): number;
@@ -613,6 +618,10 @@ export interface Game extends GameMap {
teams(): Team[];
// Alliances
alliances(): MutableAlliance[];
expireAlliance(alliance: Alliance): void;
// Game State
ticks(): Tick;
inSpawnPhase(): boolean;
@@ -694,7 +703,7 @@ export interface PlayerInteraction {
canTarget: boolean;
canDonate: boolean;
canEmbargo: boolean;
allianceCreatedAtTick?: Tick;
allianceExpiresAt?: Tick;
}
export interface EmojiMessage {
@@ -729,6 +738,7 @@ export enum MessageType {
SENT_TROOPS_TO_PLAYER,
RECEIVED_TROOPS_FROM_PLAYER,
CHAT,
RENEW_ALLIANCE,
}
// Message categories used for filtering events in the EventsDisplay
@@ -759,6 +769,7 @@ export const MESSAGE_TYPE_CATEGORIES: Record<MessageType, MessageCategory> = {
[MessageType.ALLIANCE_REQUEST]: MessageCategory.ALLIANCE,
[MessageType.ALLIANCE_BROKEN]: MessageCategory.ALLIANCE,
[MessageType.ALLIANCE_EXPIRED]: MessageCategory.ALLIANCE,
[MessageType.RENEW_ALLIANCE]: MessageCategory.ALLIANCE,
[MessageType.SENT_GOLD_TO_PLAYER]: MessageCategory.TRADE,
[MessageType.RECEIVED_GOLD_FROM_PLAYER]: MessageCategory.TRADE,
[MessageType.RECEIVED_GOLD_FROM_TRADE]: MessageCategory.TRADE,
+18
View File
@@ -15,6 +15,7 @@ import {
GameMode,
GameUpdates,
MessageType,
MutableAlliance,
Nation,
Player,
PlayerID,
@@ -77,6 +78,9 @@ export class GameImpl implements Game {
private botTeam: Team = ColoredTeams.Bot;
private _railNetwork: RailNetwork = createRailNetwork(this);
// Used to assign unique IDs to each new alliance
private nextAllianceID: number = 0;
constructor(
private _humans: PlayerInfo[],
private _nations: Nation[],
@@ -146,6 +150,11 @@ export class GameImpl implements Game {
owner(ref: TileRef): Player | TerraNullius {
return this.playerBySmallID(this.ownerID(ref));
}
alliances(): MutableAlliance[] {
return this.alliances_;
}
playerBySmallID(id: number): Player | TerraNullius {
if (id === 0) {
return this.terraNullius();
@@ -231,11 +240,20 @@ export class GameImpl implements Game {
const requestor = request.requestor();
const recipient = request.recipient();
const existing = requestor.allianceWith(recipient);
if (existing) {
throw new Error(
`cannot accept alliance request, already allied with ${recipient.name()}`,
);
}
// Create and register the new alliance
const alliance = new AllianceImpl(
this,
requestor as PlayerImpl,
recipient as PlayerImpl,
this._ticks,
this.nextAllianceID++,
);
this.alliances_.push(alliance);
(request.requestor() as PlayerImpl).pastOutgoingAllianceRequests.push(
+16
View File
@@ -36,6 +36,7 @@ export enum GameUpdateType {
AllianceRequestReply,
BrokeAlliance,
AllianceExpired,
AllianceExtension,
TargetPlayer,
Emoji,
Win,
@@ -60,6 +61,7 @@ export type GameUpdate =
| WinUpdate
| HashUpdate
| UnitIncomingUpdate
| AllianceExtensionUpdate
| BonusEventUpdate
| RailroadUpdate;
@@ -155,10 +157,18 @@ export interface PlayerUpdate {
outgoingAttacks: AttackUpdate[];
incomingAttacks: AttackUpdate[];
outgoingAllianceRequests: PlayerID[];
alliances: AllianceView[];
hasSpawned: boolean;
betrayals?: bigint;
}
export interface AllianceView {
id: number;
other: PlayerID;
createdAt: Tick;
expiresAt: Tick;
}
export interface AllianceRequestUpdate {
type: GameUpdateType.AllianceRequest;
requestorID: number;
@@ -184,6 +194,12 @@ export interface AllianceExpiredUpdate {
player2ID: number;
}
export interface AllianceExtensionUpdate {
type: GameUpdateType.AllianceExtension;
playerID: number;
allianceID: number;
}
export interface TargetPlayerUpdate {
type: GameUpdateType.TargetPlayer;
playerID: number;
+5
View File
@@ -24,6 +24,7 @@ import {
} from "./Game";
import { GameMap, TileRef, TileUpdate } from "./GameMap";
import {
AllianceView,
AttackUpdate,
GameUpdateType,
GameUpdateViewData,
@@ -291,6 +292,10 @@ export class PlayerView {
return this.data.outgoingAllianceRequests.some((id) => other.id() === id);
}
alliances(): AllianceView[] {
return this.data.alliances;
}
hasEmbargoAgainst(other: PlayerView): boolean {
return this.data.embargoes.has(other.id());
}
+20 -1
View File
@@ -42,7 +42,12 @@ import {
} from "./Game";
import { GameImpl } from "./GameImpl";
import { andFN, manhattanDistFN, TileRef } from "./GameMap";
import { AttackUpdate, GameUpdateType, PlayerUpdate } from "./GameUpdates";
import {
AllianceView,
AttackUpdate,
GameUpdateType,
PlayerUpdate,
} from "./GameUpdates";
import {
bestShoreDeploymentSource,
canBuildTransportShip,
@@ -85,6 +90,7 @@ export class PlayerImpl implements Player {
private _displayName: string;
public pastOutgoingAllianceRequests: AllianceRequest[] = [];
private _expiredAlliances: Alliance[] = [];
private targets_: Target[] = [];
@@ -166,6 +172,15 @@ export class PlayerImpl implements Player {
} satisfies AttackUpdate;
}),
outgoingAllianceRequests: outgoingAllianceRequests,
alliances: this.alliances().map(
(a) =>
({
id: a.id(),
other: a.other(this).id(),
createdAt: a.createdAt(),
expiresAt: a.expiresAt(),
}) satisfies AllianceView,
),
hasSpawned: this.hasSpawned(),
betrayals: stats?.betrayals,
};
@@ -342,6 +357,10 @@ export class PlayerImpl implements Player {
);
}
expiredAlliances(): Alliance[] {
return [...this._expiredAlliances];
}
allies(): Player[] {
return this.alliances().map((a) => a.other(this));
}
+72
View File
@@ -0,0 +1,72 @@
import { AllianceExtensionExecution } from "../src/core/execution/alliance/AllianceExtensionExecution";
import { AllianceRequestExecution } from "../src/core/execution/alliance/AllianceRequestExecution";
import { AllianceRequestReplyExecution } from "../src/core/execution/alliance/AllianceRequestReplyExecution";
import { Game, Player, PlayerType } from "../src/core/game/Game";
import { playerInfo, setup } from "./util/Setup";
let game: Game;
let player1: Player;
let player2: Player;
describe("AllianceExtensionExecution", () => {
beforeEach(async () => {
game = await setup(
"ocean_and_land",
{
infiniteGold: true,
instantBuild: true,
infiniteTroops: true,
},
[
playerInfo("player1", PlayerType.Human),
playerInfo("player2", PlayerType.Human),
],
);
player1 = game.player("player1");
player2 = game.player("player2");
while (game.inSpawnPhase()) {
game.executeNextTick();
}
});
test("Successfully extends existing alliance", () => {
game.addExecution(new AllianceRequestExecution(player1, player2.id()));
game.executeNextTick();
game.executeNextTick();
game.addExecution(
new AllianceRequestReplyExecution(player1.id(), player2, true),
);
game.executeNextTick();
game.executeNextTick();
expect(player1.allianceWith(player2)).toBeTruthy();
expect(player2.allianceWith(player1)).toBeTruthy();
const allianceBefore = player1.allianceWith(player2)!;
const expirationBefore =
allianceBefore.createdAt() + game.config().allianceDuration();
game.addExecution(new AllianceExtensionExecution(player1, player2.id()));
game.executeNextTick();
const allianceAfter = player1.allianceWith(player2)!;
expect(allianceAfter.id()).toBe(allianceBefore.id());
const expirationAfter =
allianceAfter.createdAt() + game.config().allianceDuration();
expect(expirationAfter).toBeGreaterThanOrEqual(expirationBefore);
});
test("Fails gracefully if no alliance exists", () => {
game.addExecution(new AllianceExtensionExecution(player1, player2.id()));
game.executeNextTick();
expect(player1.allianceWith(player2)).toBeFalsy();
expect(player2.allianceWith(player1)).toBeFalsy();
});
});