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.
-
- 1st hospital: 10% reduction
- 2nd hospital: 7.5% additional reduction
- 3rd hospital: 5.6% additional reduction
- ... and so on
-
-
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 {
/>
+
+
+ Production Investment Rate:
+ ${(this.investmentRate * 100).toFixed(0)}%
+
+
+ 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();
+ });
+});