diff --git a/resources/lang/en.json b/resources/lang/en.json index b83369383..1c91794c6 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -402,6 +402,7 @@ "d_troops": "Defending troops", "a_troops": "Attacking troops", "gold": "Gold", + "productivity": "Productivity", "ports": "Ports", "cities": "Cities", "hospitals": "Hospitals", @@ -467,5 +468,21 @@ }, "heads_up_message": { "choose_spawn": "Choose a starting location" + }, + "buttons": { + "focus": "Focus", + "accept": "Accept", + "reject": "Reject", + "propose_renewal": "Propose to renew", + "i_want_to_renew": "I want to renew" + }, + "alliance": { + "requested": "{name} requests an alliance!", + "renewed": "Alliance successfully renewed.", + "request_accepted": "{name} accepted your alliance request", + "request_rejected": "{name} rejected your alliance request", + "about_to_expire": "Your alliance with {name} is about to expire", + "expired": "Your alliance with {name} expired", + "no_alliance_to_extend": "No alliance to extend." } } diff --git a/resources/lang/nl.json b/resources/lang/nl.json index 270537185..7d5f2cab1 100644 --- a/resources/lang/nl.json +++ b/resources/lang/nl.json @@ -409,5 +409,21 @@ "stop_trade": "Stop handel", "yes": "Ja", "no": "Nee" + }, + "buttons": { + "focus": "Focus", + "accept": "Accepteren", + "reject": "Weigeren", + "propose_renewal": "Verlenging voorstellen", + "i_want_to_renew": "Ik wil verlengen" + }, + "alliance": { + "requested": "{name} vraagt om een alliantie!", + "renewed": "Alliantie succesvol verlengd.", + "request_accepted": "{name} heeft je alliantieverzoek geaccepteerd", + "request_rejected": "{name} heeft je alliantieverzoek geweigerd", + "about_to_expire": "Je alliantie met {name} staat op het punt te verlopen", + "expired": "Je alliantie met {name} is verlopen", + "no_alliance_to_extend": "Er is geen alliantie om te verlengen." } } diff --git a/src/client/NewsModal.ts b/src/client/NewsModal.ts index 81db0b235..c8057e752 100644 --- a/src/client/NewsModal.ts +++ b/src/client/NewsModal.ts @@ -51,33 +51,26 @@ export class NewsModal extends LitElement {

- This test version introduces a new building: - Hospitals. Each hospital restores some of - your troop losses from both offensive and defensive combat. - The restored troops are displayed next to your population - growth count in your control panel. + This test version introduces a new mechanic: + Investment.

- The first hospital provides a - 10% reduction in combat casualties. Each - additional hospital reduces losses by - 75% of the previous reduction. + A new Investment Slider lets you dedicate a + portion of your nation's gold to productivity growth. Gold + spent on investment is subtracted before any other expenses.

- -

These effects stack cumulatively.

- For a full list of changes, join the - - Discord . + The more you invest, the faster your + worker productivity increases—boosting your + long-term gold income. Productivity grows gradually and + compounds over time, meaning consistent investment can lead to + a powerful economic advantage. +

+

+ Nuclear strikes now reduce productivity + proportionally to the number of tiles you lose. This creates + longer-term economic damage beyond just troop and worker + losses.

diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 4fea1e7f3..c0091f106 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -63,6 +63,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) {} } @@ -141,6 +145,9 @@ export class CancelBoatIntentEvent implements GameEvent { export class SendSetTargetTroopRatioEvent implements GameEvent { constructor(public readonly ratio: number) {} } +export class SendSetInvestmentRateEvent implements GameEvent { + constructor(public readonly rate: number) {} +} export class SendWinnerEvent implements GameEvent { constructor( @@ -193,6 +200,9 @@ export class Transport { this.eventBus.on(SendBreakAllianceIntentEvent, (e) => this.onBreakAllianceRequestUIEvent(e), ); + this.eventBus.on(SendAllianceExtensionIntentEvent, (e) => + this.onSendAllianceExtensionIntent(e), + ); this.eventBus.on(SendSpawnIntentEvent, (e) => this.onSendSpawnIntentEvent(e), ); @@ -217,6 +227,10 @@ export class Transport { this.eventBus.on(SendSetTargetTroopRatioEvent, (e) => this.onSendSetTargetTroopRatioEvent(e), ); + this.eventBus.on(SendSetInvestmentRateEvent, (e) => + this.onSendSetInvestmentRateEvent(e), + ); + this.eventBus.on(BuildUnitIntentEvent, (e) => this.onBuildUnitIntent(e)); this.eventBus.on(PauseGameEvent, (e) => this.onPauseGameEvent(e)); @@ -408,6 +422,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", @@ -503,6 +527,14 @@ export class Transport { }); } + private onSendSetInvestmentRateEvent(event: SendSetInvestmentRateEvent) { + this.sendIntent({ + type: "investment_rate", + clientID: this.lobbyConfig.clientID, + rate: event.rate, + }); + } + private onBuildUnitIntent(event: BuildUnitIntentEvent) { this.sendIntent({ type: "build_unit", diff --git a/src/client/Utils.ts b/src/client/Utils.ts index d8e782f16..379f73c34 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -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.WARN: return severityColors["warn"]; case MessageType.CHAT: case MessageType.ALLIANCE_REQUEST: diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 94cd29190..61acea252 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -40,7 +40,10 @@ export function createRenderer( ): GameRenderer { const transformHandler = new TransformHandler(game, eventBus, canvas); - const uiState = { attackRatio: 20 }; + const uiState: UIState = { + attackRatio: 0.2, // 20% as a float + investmentRate: 0.5, // 50% default investment rate + }; //hide when the game renders const startingModal = document.querySelector( diff --git a/src/client/graphics/UIState.ts b/src/client/graphics/UIState.ts index be985a580..3e627c52c 100644 --- a/src/client/graphics/UIState.ts +++ b/src/client/graphics/UIState.ts @@ -1,3 +1,4 @@ export interface UIState { attackRatio: number; + investmentRate: number; } diff --git a/src/client/graphics/layers/ControlPanel.ts b/src/client/graphics/layers/ControlPanel.ts index 69336d812..fa6284dda 100644 --- a/src/client/graphics/layers/ControlPanel.ts +++ b/src/client/graphics/layers/ControlPanel.ts @@ -5,7 +5,10 @@ import { EventBus } from "../../../core/EventBus"; import { Gold } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; import { AttackRatioEvent } from "../../InputHandler"; -import { SendSetTargetTroopRatioEvent } from "../../Transport"; +import { + SendSetInvestmentRateEvent, + SendSetTargetTroopRatioEvent, +} from "../../Transport"; import { renderNumber, renderTroops } from "../../Utils"; import { UIState } from "../UIState"; import { Layer } from "./Layer"; @@ -25,6 +28,9 @@ export class ControlPanel extends LitElement implements Layer { @state() private currentTroopRatio = 0.6; + @state() + private investmentRate: number = 0.5; // default to 50% + @state() private _population: number; @@ -52,6 +58,12 @@ export class ControlPanel extends LitElement implements Layer { @state() private _gold: Gold; + @state() + private _productivity: number; + + @state() + private _productivityGrowth: number; + @state() private _goldPerSecond: Gold; @@ -68,6 +80,10 @@ export class ControlPanel extends LitElement implements Layer { this.targetTroopRatio = Number( localStorage.getItem("settings.troopRatio") ?? "0.6", ); + this.investmentRate = Number( + localStorage.getItem("settings.investmentRate") ?? "0.5", + ); + this.uiState.investmentRate = this.investmentRate; this.init_ = true; this.uiState.attackRatio = this.attackRatio; this.currentTroopRatio = this.targetTroopRatio; @@ -102,6 +118,7 @@ export class ControlPanel extends LitElement implements Layer { this.eventBus.emit( new SendSetTargetTroopRatioEvent(this.targetTroopRatio), ); + this.eventBus.emit(new SendSetInvestmentRateEvent(this.investmentRate)); this.init_ = false; } @@ -126,6 +143,8 @@ export class ControlPanel extends LitElement implements Layer { this._maxPopulation = this.game.config().maxPopulation(player); this._hospitalReturns = player.hospitalReturns() * 10; this._gold = player.gold(); + this._productivity = player.productivity(); + this._productivityGrowth = player.productivityGrowthPerMinute(); this._troops = player.troops(); this._workers = player.workers(); this.popRate = this.game.config().populationIncreaseRate(player) * 10; @@ -138,6 +157,9 @@ export class ControlPanel extends LitElement implements Layer { onAttackRatioChange(newRatio: number) { this.uiState.attackRatio = newRatio; } + onInvestmentRateChange(newRate: number) { + this.eventBus.emit(new SendSetInvestmentRateEvent(newRate)); + } renderLayer(context: CanvasRenderingContext2D) { // Render any necessary canvas elements @@ -310,6 +332,42 @@ export class ControlPanel extends LitElement implements Layer { /> +
+ +
+ Prod: ${Math.round(this._productivity * 100)}% + (${this._productivityGrowth >= 0 ? "+" : ""}${( + this._productivityGrowth * 100 + ).toFixed(1)}%/min) +
+
+
+
+ { + this.investmentRate = + parseInt((e.target as HTMLInputElement).value) / 100; + this.onInvestmentRateChange(this.investmentRate); + }} + class="absolute left-0 right-0 top-2 m-0 h-4 cursor-pointer" + /> +
+
`; } diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index f05409f7d..1477b0aa8 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -32,7 +32,9 @@ import { import { CancelAttackIntentEvent, CancelBoatIntentEvent, + SendAllianceExtensionIntentEvent, SendAllianceReplyIntentEvent, + SendAllianceRequestIntentEvent, } from "../../Transport"; import { Layer } from "./Layer"; @@ -66,6 +68,7 @@ interface GameEvent { duration?: Tick; focusID?: number; unitView?: UnitView; + tags?: string[]; } @customElement("events-display") @@ -152,6 +155,7 @@ export class EventsDisplay extends LitElement implements Layer { [GameUpdateType.TargetPlayer, (u) => this.onTargetPlayerEvent(u)], [GameUpdateType.Emoji, (u) => this.onEmojiMessageEvent(u)], [GameUpdateType.UnitIncoming, (u) => this.onUnitIncomingEvent(u)], + [GameUpdateType.AllianceExpired, (u) => this.onAllianceExpiredEvent(u)], ]); constructor() { @@ -172,7 +176,7 @@ export class EventsDisplay extends LitElement implements Layer { this.requestUpdate(); } - const myPlayer = this.game.myPlayer(); + let myPlayer = this.game.myPlayer(); if (!myPlayer || !myPlayer.isAlive()) { if (this._isVisible) { this._isVisible = false; @@ -188,6 +192,82 @@ export class EventsDisplay extends LitElement implements Layer { } } + myPlayer = this.game.myPlayer(); + if (!myPlayer || !myPlayer.isAlive()) { + if (this._isVisible) { + this._isVisible = false; + this.requestUpdate(); + } + return; + } + + // ---- Start Alliance Extension Prompt Logic ---- + const alliances = this.game.alliances(); // from gameview + const ticks = this.game.ticks(); + const duration = this.game.config().allianceDuration(); + const promptOffset = this.game.config().allianceExtensionPromptOffset(); + + for (const alliance of alliances) { + const createdAt = alliance.createdAt; + const timeSinceCreation = ticks - createdAt; + const ticksLeft = duration - timeSinceCreation; + + if (ticksLeft < promptOffset || ticksLeft > promptOffset + 3) continue; + + // Only check alliances involving the local player + if ( + alliance.requestorID !== myPlayer.smallID() && + alliance.recipientID !== myPlayer.smallID() + ) { + continue; + } + + const otherID = + alliance.requestorID === myPlayer.smallID() + ? alliance.recipientID + : alliance.requestorID; + + // avoid duplicate events + const tag = `about_to_expire_${alliance.requestorID}_${alliance.recipientID}_${alliance.createdAt}`; + if (this.events.some((e) => e.tags?.includes(tag))) continue; + + const other = this.game.playerBySmallID(otherID); + if ( + !other || + !(other instanceof PlayerView) || + !myPlayer.isAlive() || + !other.isAlive() + ) + continue; + + this.addEvent({ + description: translateText("alliance.about_to_expire", { + name: other.name(), + }), + tags: [tag], + type: MessageType.WARN, + duration: 100, + buttons: [ + { + text: translateText("buttons.focus"), + className: "btn-gray", + action: () => this.eventBus.emit(new GoToPlayerEvent(other)), + preventClose: true, + }, + { + text: translateText("buttons.i_want_to_renew"), + className: "btn", + action: () => + this.eventBus.emit(new SendAllianceExtensionIntentEvent(other)), + }, + ], + highlight: true, + createdAt: ticks, + focusID: otherID, + }); + } + // ---- End Alliance Extension Prompt Logic ---- + let remainingEvents = this.events.filter((event) => { const shouldKeep = this.game.ticks() - event.createdAt < (event.duration ?? 600); @@ -249,6 +329,17 @@ export class EventsDisplay extends LitElement implements Layer { ]; } + private removeEventByTags(tags: string[]) { + this.events = this.events.filter((event) => { + if (!event.tags) return true; + for (const tag of tags) { + if (event.tags.includes(tag)) return false; + } + return true; + }); + this.requestUpdate(); + } + shouldTransform(): boolean { return false; } @@ -288,7 +379,7 @@ export class EventsDisplay extends LitElement implements Layer { } this.addEvent({ - description: event.message, + description: translateText(event.message), createdAt: this.game.ticks(), highlight: true, type: event.messageType, @@ -348,16 +439,18 @@ export class EventsDisplay extends LitElement implements Layer { ) as PlayerView; this.addEvent({ - description: `${requestor.name()} requests an alliance!`, + description: translateText("alliance.requested", { + name: requestor.name(), + }), buttons: [ { - text: "Focus", + text: translateText("buttons.focus"), className: "btn-gray", action: () => this.eventBus.emit(new GoToPlayerEvent(requestor)), preventClose: true, }, { - text: "Accept", + text: translateText("buttons.accept"), className: "btn", action: () => this.eventBus.emit( @@ -365,7 +458,7 @@ export class EventsDisplay extends LitElement implements Layer { ), }, { - text: "Reject", + text: translateText("buttons.reject"), className: "btn-info", action: () => this.eventBus.emit( @@ -384,28 +477,42 @@ export class EventsDisplay extends LitElement implements Layer { duration: 150, focusID: update.requestorID, }); + + this.removeEventByTags(["alliance" + update.requestorID]); } onAllianceRequestReplyEvent(update: AllianceRequestReplyUpdate) { const myPlayer = this.game.myPlayer(); - if (!myPlayer || update.request.requestorID !== myPlayer.smallID()) { + if (!myPlayer) return; + + const { requestorID, recipientID } = update.request; + const myID = myPlayer.smallID(); + + if (requestorID !== myID && recipientID !== myID) { return; } - const recipient = this.game.playerBySmallID( - update.request.recipientID, - ) as PlayerView; + // Only show message to recipient if it was accepted + if (!update.accepted && requestorID !== myID) { + return; + } + + const otherID = requestorID === myID ? recipientID : requestorID; + const otherPlayer = this.game.playerBySmallID(otherID) as PlayerView; this.addEvent({ - description: `${recipient.name()} ${ - update.accepted ? "accepted" : "rejected" - } your alliance request`, + description: translateText( + update.accepted + ? "alliance.request_accepted" + : "alliance.request_rejected", + { name: otherPlayer.name() }, + ), type: update.accepted ? MessageType.ALLIANCE_ACCEPTED : MessageType.ALLIANCE_REJECTED, highlight: true, createdAt: this.game.ticks(), - focusID: update.request.recipientID, + focusID: otherID, }); } @@ -439,7 +546,7 @@ export class EventsDisplay extends LitElement implements Layer { } else if (betrayed === myPlayer) { const buttons = [ { - text: "Focus", + text: translateText("buttons.focus"), className: "btn-gray", action: () => this.eventBus.emit(new GoToPlayerEvent(traitor)), preventClose: true, @@ -459,20 +566,41 @@ export class EventsDisplay extends LitElement implements Layer { onAllianceExpiredEvent(update: AllianceExpiredUpdate) { const myPlayer = this.game.myPlayer(); if (!myPlayer) return; - + if ( + update.player1ID !== myPlayer.smallID() && + update.player2ID !== myPlayer.smallID() + ) { + return; + } const otherID = update.player1ID === myPlayer.smallID() ? update.player2ID - : update.player2ID === myPlayer.smallID() - ? update.player1ID - : null; - if (otherID === null) return; + : update.player1ID; + const other = this.game.playerBySmallID(otherID) as PlayerView; if (!other || !myPlayer.isAlive() || !other.isAlive()) return; this.addEvent({ - description: `Your alliance with ${other.name()} expired`, - type: MessageType.ALLIANCE_EXPIRED, + description: translateText("alliance.expired", { name: other.name() }), + type: MessageType.WARN, + tags: [`alliance${otherID}`], + duration: 100, + buttons: [ + { + text: translateText("buttons.focus"), + className: "btn-gray", + action: () => this.eventBus.emit(new GoToPlayerEvent(other)), + preventClose: true, + }, + { + text: translateText("buttons.propose_renewal"), + className: "btn", + action: () => + this.eventBus.emit( + new SendAllianceRequestIntentEvent(myPlayer, other), + ), + }, + ], highlight: true, createdAt: this.game.ticks(), focusID: otherID, diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index 684ac9357..88b5728b3 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -237,6 +237,13 @@ export class PlayerInfoOverlay extends LitElement implements Layer { ${translateText("player_info_overlay.gold")}: ${renderNumber(player.gold())} +
+ ${translateText("player_info_overlay.productivity")}: + ${Math.round(player.productivity() * 100)}% + (${player.productivityGrowthPerMinute() >= 0 ? "+" : ""}${( + player.productivityGrowthPerMinute() * 100 + ).toFixed(1)}%/min) +
${translateText("player_info_overlay.ports")}: ${player.units(UnitType.Port).length} diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index bd7e5519f..f1e5413a7 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -1,7 +1,9 @@ import { placeName } from "../client/graphics/NameBoxCalculator"; import { getConfig } from "./configuration/ConfigLoader"; +import { AllianceExpireCheckExecution } from "./execution/alliance/AllianceExpireCheckExecution"; import { Executor } from "./execution/ExecutionManager"; import { WinCheckExecution } from "./execution/WinCheckExecution"; +import { AllianceImpl } from "./game/AllianceImpl"; import { AllPlayers, Cell, @@ -20,6 +22,7 @@ import { import { createGame } from "./game/GameImpl"; import { TileRef } from "./game/GameMap"; import { + AllianceViewData, ErrorUpdate, GameUpdateType, GameUpdateViewData, @@ -81,23 +84,43 @@ export async function createGameRunner( game, new Executor(game, gameStart.gameID, clientID), callBack, + clientID, ); gr.init(); return gr; } +function toAllianceViewData( + alliance: AllianceImpl, + me: Player, +): AllianceViewData { + return { + requestorID: alliance.requestor().smallID(), + recipientID: alliance.recipient().smallID(), + createdAt: alliance.createdAt(), + extensionRequestedByMe: alliance.extensionRequestedBy(me), + extensionRequestedByOther: alliance.extensionRequestedBy( + alliance.otherPlayer(me), + ), + }; +} + export class GameRunner { private turns: Turn[] = []; private currTurn = 0; private isExecuting = false; private playerViewData: Record = {}; + private clientID: ClientID; constructor( public game: Game, private execManager: Executor, private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void, - ) {} + clientID: ClientID, + ) { + this.clientID = clientID; + } init() { if (this.game.config().bots() > 0) { @@ -109,6 +132,7 @@ export class GameRunner { this.game.addExecution(...this.execManager.fakeHumanExecutions()); } this.game.addExecution(new WinCheckExecution()); + this.game.addExecution(new AllianceExpireCheckExecution()); } public addTurn(turn: Turn): void { @@ -167,12 +191,25 @@ export class GameRunner { // Many tiles are updated to pack it into an array const packedTileUpdates = updates[GameUpdateType.Tile].map((u) => u.update); updates[GameUpdateType.Tile] = []; - + const me = this.game.playerByClientID(this.clientID); + if (!me) { + this.isExecuting = false; + return; + } + const alliances = this.game + .alliances() + .filter( + (a) => + a.requestor().smallID() === me.smallID() || + a.recipient().smallID() === me.smallID(), + ) + .map((a) => toAllianceViewData(a as AllianceImpl, me)); this.callBack({ tick: this.game.ticks(), packedTileUpdates: new BigUint64Array(packedTileUpdates), updates: updates, playerNameViewData: this.playerViewData, + alliances: alliances, }); this.isExecuting = false; } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 6b79a6fa6..a32c27166 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -24,12 +24,14 @@ export type Intent = | CancelBoatIntent | AllianceRequestIntent | AllianceRequestReplyIntent + | AllianceExtensionIntent | BreakAllianceIntent | TargetPlayerIntent | EmojiIntent | DonateGoldIntent | DonateTroopsIntent | TargetTroopRatioIntent + | InvestmentRateIntent | BuildUnitIntent | EmbargoIntent | QuickChatIntent @@ -54,6 +56,7 @@ export type EmbargoIntent = z.infer; export type TargetTroopRatioIntent = z.infer< typeof TargetTroopRatioIntentSchema >; +export type InvestmentRateIntent = z.infer; export type BuildUnitIntent = z.infer; export type MoveWarshipIntent = z.infer; export type QuickChatIntent = z.infer; @@ -221,6 +224,15 @@ export const AllianceRequestReplyIntentSchema = BaseIntentSchema.extend({ accept: z.boolean(), }); +export const AllianceExtensionIntentSchema = BaseIntentSchema.extend({ + type: z.literal("allianceExtension"), + recipient: ID, +}); + +export type AllianceExtensionIntent = z.infer< + typeof AllianceExtensionIntentSchema +>; + export const BreakAllianceIntentSchema = BaseIntentSchema.extend({ type: z.literal("breakAlliance"), recipient: ID, @@ -260,6 +272,11 @@ export const TargetTroopRatioIntentSchema = BaseIntentSchema.extend({ ratio: z.number().min(0).max(1), }); +export const InvestmentRateIntentSchema = BaseIntentSchema.extend({ + type: z.literal("investment_rate"), + rate: z.number().min(0).max(1), +}); + export const BuildUnitIntentSchema = BaseIntentSchema.extend({ type: z.literal("build_unit"), unit: z.enum(UnitType), @@ -304,12 +321,14 @@ const IntentSchema = z.discriminatedUnion("type", [ CancelBoatIntentSchema, AllianceRequestIntentSchema, AllianceRequestReplyIntentSchema, + AllianceExtensionIntentSchema, BreakAllianceIntentSchema, TargetPlayerIntentSchema, EmojiIntentSchema, DonateGoldIntentSchema, DonateTroopIntentSchema, TargetTroopRatioIntentSchema, + InvestmentRateIntentSchema, BuildUnitIntentSchema, EmbargoIntentSchema, MoveWarshipIntentSchema, diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index a56d0b406..d37e0e8bb 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -148,6 +148,7 @@ export interface Config { nukeDeathFactor(humans: number, tilesOwned: number): number; structureMinDist(): number; isReplay(): boolean; + allianceExtensionPromptOffset(): number; } export interface Theme { diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index c142cf7a0..c24e1eb9e 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -725,7 +725,25 @@ export class DefaultConfig implements Config { } goldAdditionRate(player: Player): bigint { - return BigInt(Math.floor(0.081 * player.workers() ** 0.65)); + const base = 0.06 * player.workers() ** 0.65; + const productivity = player.productivity(); + const investmentRate = player.investmentRate(); + const grossGold = base * productivity; + const netGold = grossGold * (1 - investmentRate); + + if (!Number.isFinite(netGold)) { + console.warn("[goldAdditionRate] netGold is NaN or invalid", { + workers: player.workers(), + productivity, + investmentRate, + base, + grossGold, + netGold, + }); + return 0n; + } + + return BigInt(Math.floor(netGold)); } troopAdjustmentRate(player: Player): number { @@ -804,4 +822,8 @@ export class DefaultConfig implements Config { defensePostTargettingRange(): number { return 75; } + + allianceExtensionPromptOffset(): number { + return 300; // 30 seconds before expiration + } } diff --git a/src/core/configuration/DevConfig.ts b/src/core/configuration/DevConfig.ts index 9da20511a..af2ddcc77 100644 --- a/src/core/configuration/DevConfig.ts +++ b/src/core/configuration/DevConfig.ts @@ -50,6 +50,14 @@ export class DevConfig extends DefaultConfig { super(sc, gc, us, isReplay); } + allianceExtensionPromptOffset(): number { + return 200; // 20 seconds + } + + allianceDuration(): number { + return 60 * 10; // 60 seconds × 10 ticks per second = 600 ticks + } + // numSpawnPhaseTurns(): number { // return this.gameConfig().gameType == GameType.Singleplayer ? 70 : 100; // // return 100 diff --git a/src/core/execution/BotExecution.ts b/src/core/execution/BotExecution.ts index 71141929d..21e323b00 100644 --- a/src/core/execution/BotExecution.ts +++ b/src/core/execution/BotExecution.ts @@ -30,6 +30,7 @@ export class BotExecution implements Execution { init(mg: Game) { this.mg = mg; this.bot.setTargetTroopRatio(0.7); + this.bot.setInvestmentRate(0); } tick(ticks: number) { diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index a598e1e91..5873309dc 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -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"; @@ -19,6 +20,7 @@ import { MoveWarshipExecution } from "./MoveWarshipExecution"; import { NoOpExecution } from "./NoOpExecution"; import { QuickChatExecution } from "./QuickChatExecution"; import { RetreatExecution } from "./RetreatExecution"; +import { SetInvestmentRateExecution } from "./SetInvestmentRateExecution"; import { SetTargetTroopRatioExecution } from "./SetTargetTroopRatioExecution"; import { SpawnExecution } from "./SpawnExecution"; import { TargetPlayerExecution } from "./TargetPlayerExecution"; @@ -102,6 +104,8 @@ export class Executor { return new DonateGoldExecution(player, intent.recipient, intent.gold); case "troop_ratio": return new SetTargetTroopRatioExecution(player, intent.ratio); + case "investment_rate": + return new SetInvestmentRateExecution(player, intent.rate); case "embargo": return new EmbargoExecution(player, intent.targetID, intent.action); case "build_unit": @@ -117,6 +121,22 @@ export class Executor { intent.quickChatKey, intent.target, ); + case "allianceExtension": { + const from = player; + const to = + this.mg.players().find((p) => p.id() === intent.recipient) ?? null; + + if ( + from === null || + to === null || + !from.isPlayer?.() || + !to.isPlayer?.() + ) { + return new NoOpExecution(); + } + + return new AllianceExtensionExecution(from, to); + } case "mark_disconnected": return new MarkDisconnectedExecution(player, intent.isDisconnected); default: diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index fedb09f82..aca6e5e59 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -44,6 +44,7 @@ export class FakeHumanExecution implements Execution { private lastNukeSent: [Tick, TileRef][] = []; private embargoMalusApplied = new Set(); private heckleEmoji: number[]; + private hasSetInvestmentRate = false; constructor( gameID: GameID, @@ -161,6 +162,11 @@ export class FakeHumanExecution implements Execution { this.player.setTargetTroopRatio(0.7); } + if (!this.hasSetInvestmentRate) { + this.player.setInvestmentRate(0.1); + this.hasSetInvestmentRate = true; + } + this.updateRelationsFromEmbargos(); this.behavior.handleAllianceRequests(); this.handleEnemies(); diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 651fbb1f3..d478d884e 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -204,6 +204,10 @@ export class NukeExecution implements Execution { const owner = this.mg.owner(tile); if (owner.isPlayer()) { owner.relinquish(tile); + const tileCount = owner.numTilesOwned(); + if (tileCount > 0) { + owner.removeProductivity(3 / tileCount); + } owner.removeTroops( this.mg .config() diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 9f2f02371..1fe86c602 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -65,7 +65,7 @@ export class PlayerExecution implements Execution { this.player.addTroops(popInc * this.player.targetTroopRatio()); const goldFromWorkers = this.config.goldAdditionRate(this.player); this.player.addGold(goldFromWorkers); - + this.player.updateProductivity(); // Record stats this.mg.stats().goldWork(this.player, goldFromWorkers); diff --git a/src/core/execution/SetInvestmentRateExecution.ts b/src/core/execution/SetInvestmentRateExecution.ts new file mode 100644 index 000000000..41e93e504 --- /dev/null +++ b/src/core/execution/SetInvestmentRateExecution.ts @@ -0,0 +1,31 @@ +import { Execution, Game, Player } from "../game/Game"; + +export class SetInvestmentRateExecution implements Execution { + private active = true; + + constructor( + private player: Player, + private rate: number, + ) {} + + init(mg: Game, ticks: number): void {} + + tick(ticks: number): void { + if (this.rate < 0 || this.rate > 1) { + console.warn( + `investment rate ${this.rate} for player ${this.player} invalid`, + ); + } else { + this.player.setInvestmentRate(this.rate); + } + this.active = false; + } + + isActive(): boolean { + return this.active; + } + + activeDuringSpawnPhase(): boolean { + return false; + } +} diff --git a/src/core/execution/alliance/AllianceExpireCheckExecution.ts b/src/core/execution/alliance/AllianceExpireCheckExecution.ts new file mode 100644 index 000000000..5664b63c6 --- /dev/null +++ b/src/core/execution/alliance/AllianceExpireCheckExecution.ts @@ -0,0 +1,47 @@ +import { Execution, Game, Player } from "../../game/Game"; + +/** + * Expiration check for alliances. + */ +export class AllianceExpireCheckExecution implements Execution { + private active = true; + private mg: Game | null = null; + + init(mg: Game, ticks: number): void { + this.mg = mg; + } + + tick(ticks: number) { + if (!this.mg) return; + + const duration = this.mg.config().allianceDuration(); + + for (const alliance of this.mg.alliances()) { + const timeSinceCreation = this.mg.ticks() - alliance.createdAt(); + const key = `${alliance.requestor().id()}-${alliance.recipient().id()}-${alliance.createdAt()}`; + + if (timeSinceCreation >= duration) { + const requestor = alliance.requestor(); + const recipient = alliance.recipient(); + + if (alliance.wantsExtension()) { + alliance.extendDuration(this.mg.ticks()); + continue; + } + alliance.expire(); + } + } + } + + owner(): Player | null { + return null; + } + + isActive(): boolean { + return this.active; + } + + activeDuringSpawnPhase(): boolean { + return false; + } +} diff --git a/src/core/execution/alliance/AllianceExtensionExecution.ts b/src/core/execution/alliance/AllianceExtensionExecution.ts new file mode 100644 index 000000000..4b142aef0 --- /dev/null +++ b/src/core/execution/alliance/AllianceExtensionExecution.ts @@ -0,0 +1,72 @@ +import { + Execution, + Game, + MessageType, + Player, + PlayerType, +} from "../../game/Game"; + +export class AllianceExtensionExecution implements Execution { + private isDone = false; + + constructor( + private readonly from: Player, + private readonly to: Player, + ) {} + + isActive(): boolean { + return !this.isDone; + } + + activeDuringSpawnPhase(): boolean { + return false; + } + + init(mg: Game, ticks: number): void { + const from = this.from; + const alliance = from.allianceWith(this.to); + if (!alliance) { + console.warn( + `[AllianceExtensionExecution] No alliance to extend between ${from.id()} and ${this.to.id()}`, + ); + this.isDone = true; + return; + } + + // Mark this player's intent to extend + alliance.requestExtension(from); + + // If the other player is a bot or fake human, request extension on their behalf + if ( + this.to.type && + typeof this.to.type === "function" && + (this.to.type() === PlayerType.Bot || + this.to.type() === PlayerType.FakeHuman) + ) { + alliance.requestExtension(this.to); + } + + // Only extend if both players want it + if (alliance.wantsExtension()) { + alliance.extendDuration(ticks); + + // Inform both players about the successful extension + mg.displayMessage( + "alliance.renewed", + MessageType.ALLIANCE_ACCEPTED, + from.id(), + ); + mg.displayMessage( + "alliance.renewed", + MessageType.ALLIANCE_ACCEPTED, + this.to.id(), + ); + } + + this.isDone = true; + } + + tick(ticks: number): void { + // No-op + } +} diff --git a/src/core/game/AllianceImpl.ts b/src/core/game/AllianceImpl.ts index b5c2c5836..60c9529d8 100644 --- a/src/core/game/AllianceImpl.ts +++ b/src/core/game/AllianceImpl.ts @@ -1,15 +1,24 @@ import { Game, MutableAlliance, Player, Tick } from "./Game"; export class AllianceImpl implements MutableAlliance { + private extensionRequestedRequestor_: boolean = false; + private extensionRequestedRecipient_: boolean = false; + private readonly _id: number; + private createdAtTick_: Tick; + constructor( private readonly mg: Game, readonly requestor_: Player, readonly recipient_: Player, - readonly createdAtTick_: Tick, - ) {} + createdAtTick: Tick, + id: number, + ) { + this.createdAtTick_ = createdAtTick; + this._id = id; + } other(player: Player): Player { - if (this.requestor_ === player) { + if (this.requestor_.smallID() === player.smallID()) { return this.recipient_; } return this.requestor_; @@ -30,4 +39,47 @@ export class AllianceImpl implements MutableAlliance { expire(): void { this.mg.expireAlliance(this); } + + requestExtension(player: Player): void { + if (this.requestor_.smallID() === player.smallID()) { + this.extensionRequestedRequestor_ = true; + } else if (this.recipient_.smallID() === player.smallID()) { + this.extensionRequestedRecipient_ = true; + } + } + + extensionRequestedBy(player: Player): boolean { + if (this.requestor_.smallID() === player.smallID()) { + return this.extensionRequestedRequestor_; + } else if (this.recipient_.smallID() === player.smallID()) { + return this.extensionRequestedRecipient_; + } + return false; + } + + wantsExtension(): boolean { + return ( + this.extensionRequestedRequestor_ && this.extensionRequestedRecipient_ + ); + } + + clearExtensionRequests(): void { + this.extensionRequestedRequestor_ = false; + this.extensionRequestedRecipient_ = false; + } + + public id(): number { + return this._id; + } + + extendDuration(currentTick: Tick): void { + this.createdAtTick_ = currentTick; + this.clearExtensionRequests(); + } + + public otherPlayer(player: Player): Player { + if (this.requestor_.smallID() === player.smallID()) return this.recipient_; + if (this.recipient_.smallID() === player.smallID()) return this.requestor_; + throw new Error("[AllianceImpl] Player is not part of this alliance"); + } } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index d73877015..d6a170f62 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -332,6 +332,12 @@ export interface Alliance { export interface MutableAlliance extends Alliance { expire(): void; other(player: Player): Player; + wantsExtension(): boolean; + requestExtension(player: Player): void; + extensionRequestedBy(player: Player): boolean; + clearExtensionRequests(): void; + id(): number; + extendDuration(currentTick: Tick): void; } export class PlayerInfo { @@ -474,6 +480,12 @@ export interface Player { workers(): number; troops(): number; targetTroopRatio(): number; + productivity(): number; // Returns the productivity rate based on investment rate + updateProductivity(): void; + productivityGrowthPerMinute(): number; // Returns the productivity growth per minute + removeProductivity(amount: number): void; + investmentRate(): number; // Returns the investment rate (0 to 1) + setInvestmentRate(rate: number): void; addGold(toAdd: Gold): void; removeGold(toRemove: Gold): Gold; addWorkers(toAdd: number): void; @@ -570,7 +582,6 @@ export interface Player { } export interface Game extends GameMap { - expireAlliance(alliance: Alliance); // Map & Dimensions isOnMap(cell: Cell): boolean; width(): number; @@ -592,6 +603,10 @@ export interface Game extends GameMap { teams(): Team[]; + // Alliances + alliances(): MutableAlliance[]; + expireAlliance(alliance: Alliance): void; + // Game State ticks(): Tick; inSpawnPhase(): boolean; @@ -705,6 +720,7 @@ export enum MessageType { SENT_TROOPS_TO_PLAYER, RECEIVED_TROOPS_FROM_PLAYER, CHAT, + WARN, } // Message categories used for filtering events in the EventsDisplay @@ -735,6 +751,7 @@ export const MESSAGE_TYPE_CATEGORIES: Record = { [MessageType.ALLIANCE_REQUEST]: MessageCategory.ALLIANCE, [MessageType.ALLIANCE_BROKEN]: MessageCategory.ALLIANCE, [MessageType.ALLIANCE_EXPIRED]: MessageCategory.ALLIANCE, + [MessageType.WARN]: MessageCategory.ALLIANCE, [MessageType.SENT_GOLD_TO_PLAYER]: MessageCategory.TRADE, [MessageType.RECEIVED_GOLD_FROM_PLAYER]: MessageCategory.TRADE, [MessageType.RECEIVED_GOLD_FROM_TRADE]: MessageCategory.TRADE, diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index c558f9ca8..f9fad2da9 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -64,6 +64,7 @@ export class GameImpl implements Game { allianceRequests: AllianceRequestImpl[] = []; alliances_: AllianceImpl[] = []; + private nextAllianceID = 0; private nextPlayerID = 1; private _nextUnitID = 1; @@ -233,7 +234,9 @@ export class GameImpl implements Game { requestor as PlayerImpl, recipient as PlayerImpl, this._ticks, + this.nextAllianceID++, ); + this.alliances_.push(alliance); (request.requestor() as PlayerImpl).pastOutgoingAllianceRequests.push( request, @@ -790,6 +793,9 @@ export class GameImpl implements Game { stats(): Stats { return this._stats; } + public alliances(): AllianceImpl[] { + return this.alliances_; + } } // Or a more dynamic approach that will catch new enum values: diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 13610550c..ac044dae1 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -18,6 +18,7 @@ export interface GameUpdateViewData { updates: GameUpdates; packedTileUpdates: BigUint64Array; playerNameViewData: Record; + alliances?: AllianceViewData[]; } export interface ErrorUpdate { @@ -40,6 +41,8 @@ export enum GameUpdateType { Win, Hash, UnitIncoming, + AllianceExtensionPrompt, + AllianceExtensionAccepted, } export type GameUpdate = @@ -50,6 +53,7 @@ export type GameUpdate = | AllianceRequestReplyUpdate | BrokeAllianceUpdate | AllianceExpiredUpdate + | AllianceExtensionAcceptedUpdate | DisplayMessageUpdate | DisplayChatMessageUpdate | TargetPlayerUpdate @@ -111,6 +115,9 @@ export interface PlayerUpdate { totalPopulation: number; hospitalReturns: number; workers: number; + productivity: number; + productivityGrowthPerMinute: number; + investmentRate: number; troops: number; targetTroopRatio: number; allies: number[]; @@ -199,3 +206,17 @@ export interface UnitIncomingUpdate { messageType: MessageType; playerID: number; } + +export interface AllianceExtensionAcceptedUpdate { + type: GameUpdateType.AllianceExtensionAccepted; + playerID: number; + allianceID: number; +} + +export interface AllianceViewData { + requestorID: number; + recipientID: number; + createdAt: number; + extensionRequestedByMe: boolean; + extensionRequestedByOther: boolean; +} diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 6655e19e6..66db2b1a5 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -24,6 +24,7 @@ import { } from "./Game"; import { GameMap, TileRef, TileUpdate } from "./GameMap"; import { + AllianceViewData, AttackUpdate, GameUpdateType, GameUpdateViewData, @@ -245,7 +246,15 @@ export class PlayerView { troops(): number { return this.data.troops; } - + productivity(): number { + return this.data.productivity; + } + productivityGrowthPerMinute(): number { + return this.data.productivityGrowthPerMinute; + } + investmentRate(): number { + return this.data.investmentRate; + } isAlliedWith(other: PlayerView): boolean { return this.data.allies.some((n) => other.smallID() === n); } @@ -313,6 +322,7 @@ export class GameView implements GameMap { private _myPlayer: PlayerView | null = null; private _focusedPlayer: PlayerView | null = null; + private _alliances: AllianceViewData[] = []; private unitGrid: UnitGrid; @@ -350,6 +360,9 @@ export class GameView implements GameMap { if (gu.updates === null) { throw new Error("lastUpdate.updates not initialized"); } + if (gu.alliances) { + this._alliances = gu.alliances; + } gu.updates[GameUpdateType.Player].forEach((pu) => { this.smallIDToID.set(pu.smallID, pu.id); const player = this._players.get(pu.id); @@ -387,6 +400,10 @@ export class GameView implements GameMap { }); } + public alliances(): AllianceViewData[] { + return this._alliances; + } + recentlyUpdatedTiles(): TileRef[] { return this.updatedTiles; } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 50c80c655..3df137729 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -71,6 +71,10 @@ export class PlayerImpl implements Player { // 0 to 100 private _targetTroopRatio: bigint; + private _investmentRate: number = 0.5; + private _productivity = 1; + private _productivityGrowthPerMinute = 0; + private _maxproductivity = 1; markedTraitorTick = -1; @@ -87,6 +91,7 @@ export class PlayerImpl implements Player { private _hospitalReturns: number = 0; public pastOutgoingAllianceRequests: AllianceRequest[] = []; + private _expiredAlliances: Alliance[] = []; private targets_: Target[] = []; @@ -148,6 +153,9 @@ export class PlayerImpl implements Player { workers: this.workers(), troops: this.troops(), targetTroopRatio: this.targetTroopRatio(), + productivity: this.productivity(), + productivityGrowthPerMinute: this.productivityGrowthPerMinute(), + investmentRate: this.investmentRate(), allies: this.alliances().map((a) => a.other(this).smallID()), embargoes: new Set([...this.embargoes.keys()].map((p) => p.toString())), isTraitor: this.isTraitor(), @@ -352,6 +360,10 @@ export class PlayerImpl implements Player { ); } + expiredAlliances(): Alliance[] { + return [...this._expiredAlliances]; + } + allies(): Player[] { return this.alliances().map((a) => a.other(this)); } @@ -703,7 +715,7 @@ export class PlayerImpl implements Player { } population(): number { - return Number(this._troops + this._workers); + return this.workers() + this.troops(); } totalPopulation(): number { return this.population() + this.attackingTroops(); @@ -742,6 +754,13 @@ export class PlayerImpl implements Player { } this._targetTroopRatio = toInt(target * 100); } + investmentRate(): number { + return this._investmentRate; + } + + setInvestmentRate(rate: number): void { + this._investmentRate = Math.min(1, Math.max(0, rate)); + } troops(): number { return Number(this._troops); @@ -763,6 +782,48 @@ export class PlayerImpl implements Player { return Number(toRemove); } + productivity(): number { + return this._productivity; + } + productivityGrowthPerMinute(): number { + return this._productivityGrowthPerMinute; + } + updateProductivity(): void { + const alpha = 0.0004; + const beta = 0.5; + + const maxPop = this.mg.config().maxPopulation(this); + const workers = this.workers(); + const rate = (this._investmentRate * workers) / maxPop; + const growth = alpha * Math.pow(rate, beta); + + if (!Number.isFinite(growth) || growth < 0) { + console.warn("[updateProductivity] Invalid growth", { + productivityBefore: this._productivity, + investmentRate: this._investmentRate, + workers, + maxPop, + rate, + growth, + player: this.name?.(), + }); + return; // skip update + } + + this._productivity *= 1 + growth; + // Store per-minute growth for display + this._productivityGrowthPerMinute = + ((1 + growth) ** 600 - 1) * this._productivity; + if (this._productivity > this._maxproductivity) { + this._maxproductivity = this._productivity; + } + } + removeProductivity(amount: number): void { + if (amount < 0) { + throw new Error(`Cannot remove negative productivity: ${amount}`); + } + this._productivity = Math.max(0.33, this._productivity - amount); + } hospitalReturns(): number { return this._hospitalReturns; } diff --git a/tests/AllianceExtensionExecution.test.ts b/tests/AllianceExtensionExecution.test.ts new file mode 100644 index 000000000..570b7558d --- /dev/null +++ b/tests/AllianceExtensionExecution.test.ts @@ -0,0 +1,110 @@ +import { PlayerExecution } from "../src/core/execution/PlayerExecution"; +import { SpawnExecution } from "../src/core/execution/SpawnExecution"; +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, PlayerInfo, PlayerType } from "../src/core/game/Game"; +import { TileRef } from "../src/core/game/GameMap"; +import { setup } from "./util/Setup"; + +let game: Game; +let player1: Player; +let player2: Player; +let spawn1: TileRef; +let spawn2: TileRef; + +describe("AllianceExtensionExecution", () => { + beforeEach(async () => { + game = await setup("ocean_and_land", { + infiniteGold: true, + instantBuild: true, + infiniteTroops: true, + }); + + const p1Info = new PlayerInfo( + "us", // flag + "Player1", // name + PlayerType.Human, // playerType + "client1", // clientID + "p1", // id + ); + + const p2Info = new PlayerInfo( + "us", // flag + "Player2", // name + PlayerType.Human, // playerType + null, // clientID + "p2", // id + ); + + game.addPlayer(p1Info); + game.addPlayer(p2Info); + + spawn1 = game.ref(0, 10); + spawn2 = game.ref(0, 15); + + game.addExecution( + new SpawnExecution(game.player(p1Info.id).info(), spawn1), + new SpawnExecution(game.player(p2Info.id).info(), spawn2), + ); + + let safety = 0; + while (game.inSpawnPhase() && safety++ < 500) { + game.executeNextTick(); + } + expect(safety).toBeLessThan(500); // Sanity check + + player1 = game.player(p1Info.id); + player2 = game.player(p2Info.id); + + game.addExecution( + new PlayerExecution(player1), + new PlayerExecution(player2), + ); + game.executeNextTick(); + }); + + test("Successfully extends existing alliance", () => { + // 1. Send alliance request + game.addExecution(new AllianceRequestExecution(player1, player2.id())); + game.executeNextTick(); // toevoegen + game.executeNextTick(); // uitvoeren + + // 2. Accept alliance request + game.addExecution( + new AllianceRequestReplyExecution(player1.id(), player2, true), + ); + game.executeNextTick(); // add + game.executeNextTick(); // execute + + // ✅ After accepting, both players should have an alliance + expect(player1.allianceWith(player2)).toBeTruthy(); + expect(player2.allianceWith(player1)).toBeTruthy(); + + const allianceBefore = player1.allianceWith(player2)!; + const expirationBefore = + allianceBefore.createdAt() + game.config().allianceDuration(); + + // 3. Extend the alliance + game.addExecution(new AllianceExtensionExecution(player1, player2)); + game.executeNextTick(); + + const allianceAfter = player1.allianceWith(player2)!; + + // ✅ Needs to be the same alliance (same ID) + 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)); + game.executeNextTick(); + + expect(player1.allianceWith(player2)).toBeFalsy(); + expect(player2.allianceWith(player1)).toBeFalsy(); + }); +});