diff --git a/resources/lang/en.json b/resources/lang/en.json index 178783d1d..afe8d1d2f 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -130,7 +130,9 @@ "disable_nations": "Disable Nations", "instant_build": "Instant build", "infinite_gold": "Infinite gold", + "donate_gold": "Donate gold", "infinite_troops": "Infinite troops", + "donate_troops": "Donate troops", "disable_nukes": "Disable Nukes", "enables_title": "Enable Settings", "start": "Start Game" @@ -222,7 +224,9 @@ "disable_nations": "Disable Nations", "instant_build": "Instant build", "infinite_gold": "Infinite gold", + "donate_gold": "Donate gold", "infinite_troops": "Infinite troops", + "donate_troops": "Donate troops", "enables_title": "Enable Settings", "player": "Player", "players": "Players", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 8b5e256e3..ab805c62e 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -41,7 +41,9 @@ export class HostLobbyModal extends LitElement { @state() private teamCount: TeamCountConfig = 2; @state() private bots: number = 400; @state() private infiniteGold: boolean = false; + @state() private donateGold: boolean = false; @state() private infiniteTroops: boolean = false; + @state() private donateTroops: boolean = false; @state() private instantBuild: boolean = false; @state() private lobbyId = ""; @state() private copySuccess = false; @@ -362,6 +364,38 @@ export class HostLobbyModal extends LitElement { + + + + + ${translateText("host_modal.donate_gold")} + + + + + + + + ${translateText("host_modal.donate_troops")} + + + Object.values(UnitType).find((ut) => ut === u)) diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index 93b9e8cc5..7f4f425db 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -232,7 +232,8 @@ export class PlayerPanel extends LitElement implements Layer { } other = other as PlayerView; - const canDonate = this.actions?.interaction?.canDonate; + const canDonateGold = this.actions?.interaction?.canDonateGold; + const canDonateTroops = this.actions?.interaction?.canDonateTroops; const canSendAllianceRequest = this.actions?.interaction?.canSendAllianceRequest; const canSendEmoji = @@ -421,7 +422,7 @@ export class PlayerPanel extends LitElement implements Layer { ` : ""} - ${canDonate + ${canDonateTroops ? html` this.handleDonateTroopClick(e, myPlayer, other)} @@ -436,7 +437,7 @@ export class PlayerPanel extends LitElement implements Layer { /> ` : ""} - ${canDonate + ${canDonateGold ? html` this.handleDonateGoldClick(e, myPlayer, other)} diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index f749d9356..3e2445d26 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -208,7 +208,7 @@ const allyDonateGoldElement: MenuElement = { id: "ally_donate_gold", name: "donate gold", disabled: (params: MenuElementParams) => - !params.playerActions?.interaction?.canDonate, + !params.playerActions?.interaction?.canDonateGold, color: COLORS.ally, icon: donateGoldIcon, action: (params: MenuElementParams) => { @@ -221,7 +221,7 @@ const allyDonateTroopsElement: MenuElement = { id: "ally_donate_troops", name: "donate troops", disabled: (params: MenuElementParams) => - !params.playerActions?.interaction?.canDonate, + !params.playerActions?.interaction?.canDonateTroops, color: COLORS.ally, icon: donateTroopIcon, action: (params: MenuElementParams) => { diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 2e8201240..93959f28e 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -194,7 +194,8 @@ export class GameRunner { canTarget: player.canTarget(other), canSendAllianceRequest: player.canSendAllianceRequest(other), canBreakAlliance: player.isAlliedWith(other), - canDonate: player.canDonate(other), + canDonateGold: player.canDonateGold(other), + canDonateTroops: player.canDonateTroops(other), canEmbargo: !player.hasEmbargoAgainst(other), }; const alliance = player.allianceWith(other as Player); diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 72cb496c5..04c3d8a17 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -145,6 +145,8 @@ export type TeamCountConfig = z.infer; export const GameConfigSchema = z.object({ gameMap: z.enum(GameMapType), difficulty: z.enum(Difficulty), + donateGold: z.boolean(), + donateTroops: z.boolean(), gameType: z.enum(GameType), gameMode: z.enum(GameMode), disableNPCs: z.boolean(), diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 06c2249ea..6ed0d83a3 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -82,7 +82,9 @@ export interface Config { isUnitDisabled(unitType: UnitType): boolean; bots(): number; infiniteGold(): boolean; + donateGold(): boolean; infiniteTroops(): boolean; + donateTroops(): boolean; instantBuild(): boolean; numSpawnPhaseTurns(): number; userSettings(): UserSettings; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 08d2733eb..1e7264623 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -325,9 +325,16 @@ export class DefaultConfig implements Config { infiniteGold(): boolean { return this._gameConfig.infiniteGold; } + donateGold(): boolean { + return this._gameConfig.donateGold; + } infiniteTroops(): boolean { return this._gameConfig.infiniteTroops; } + donateTroops(): boolean { + return this._gameConfig.donateTroops; + } + trainSpawnRate(numPlayerFactories: number): number { // hyperbolic decay, midpoint at 10 factories // expected number of trains = numPlayerFactories / trainSpawnRate(numPlayerFactories) diff --git a/src/core/execution/DonateGoldExecution.ts b/src/core/execution/DonateGoldExecution.ts index 5f43b10eb..214c7e915 100644 --- a/src/core/execution/DonateGoldExecution.ts +++ b/src/core/execution/DonateGoldExecution.ts @@ -25,7 +25,7 @@ export class DonateGoldExecution implements Execution { tick(ticks: number): void { if (this.gold === null) throw new Error("not initialized"); if ( - this.sender.canDonate(this.recipient) && + this.sender.canDonateGold(this.recipient) && this.sender.donateGold(this.recipient, this.gold) ) { this.recipient.updateRelation(this.sender, 50); diff --git a/src/core/execution/DonateTroopExecution.ts b/src/core/execution/DonateTroopExecution.ts index 9e98c514e..00af5de7c 100644 --- a/src/core/execution/DonateTroopExecution.ts +++ b/src/core/execution/DonateTroopExecution.ts @@ -28,7 +28,7 @@ export class DonateTroopsExecution implements Execution { tick(ticks: number): void { if (this.troops === null) throw new Error("not initialized"); if ( - this.sender.canDonate(this.recipient) && + this.sender.canDonateTroops(this.recipient) && this.sender.donateTroops(this.recipient, this.troops) ) { this.recipient.updateRelation(this.sender, 50); diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 9b5422acb..2d8ccf83c 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -589,7 +589,8 @@ export interface Player { sendEmoji(recipient: Player | typeof AllPlayers, emoji: string): void; // Donation - canDonate(recipient: Player): boolean; + canDonateGold(recipient: Player): boolean; + canDonateTroops(recipient: Player): boolean; donateTroops(recipient: Player, troops: number): boolean; donateGold(recipient: Player, gold: Gold): boolean; canDeleteUnit(): boolean; @@ -742,7 +743,8 @@ export interface PlayerInteraction { canSendAllianceRequest: boolean; canBreakAlliance: boolean; canTarget: boolean; - canDonate: boolean; + canDonateGold: boolean; + canDonateTroops: boolean; canEmbargo: boolean; allianceExpiresAt?: Tick; } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 758d34255..a52cd26e8 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -572,7 +572,7 @@ export class PlayerImpl implements Player { return true; } - canDonate(recipient: Player): boolean { + canDonateGold(recipient: Player): boolean { if (!this.isFriendly(recipient)) { return false; } @@ -583,6 +583,36 @@ export class PlayerImpl implements Player { ) { return false; } + if (this.mg.config().donateGold() === false) { + return false; + } + for (const donation of this.sentDonations) { + if (donation.recipient === recipient) { + if ( + this.mg.ticks() - donation.tick < + this.mg.config().donateCooldown() + ) { + return false; + } + } + } + return true; + } + + canDonateTroops(recipient: Player): boolean { + if (!this.isFriendly(recipient)) { + return false; + } + if ( + recipient.type() === PlayerType.Human && + this.mg.config().gameConfig().gameMode === GameMode.FFA && + this.mg.config().gameConfig().gameType === GameType.Public + ) { + return false; + } + if (this.mg.config().donateTroops() === false) { + return false; + } for (const donation of this.sentDonations) { if (donation.recipient === recipient) { if ( diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 2375948f1..a3014f461 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -39,6 +39,8 @@ export class GameManager { Date.now(), this.config, { + donateGold: false, + donateTroops: false, gameMap: GameMapType.World, gameType: GameType.Private, difficulty: Difficulty.Medium, diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index b16dae9b3..1b2340ba5 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -93,9 +93,15 @@ export class GameServer { if (gameConfig.infiniteGold !== undefined) { this.gameConfig.infiniteGold = gameConfig.infiniteGold; } + if (gameConfig.donateGold !== undefined) { + this.gameConfig.donateGold = gameConfig.donateGold; + } if (gameConfig.infiniteTroops !== undefined) { this.gameConfig.infiniteTroops = gameConfig.infiniteTroops; } + if (gameConfig.donateTroops !== undefined) { + this.gameConfig.donateTroops = gameConfig.donateTroops; + } if (gameConfig.instantBuild !== undefined) { this.gameConfig.instantBuild = gameConfig.instantBuild; } diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 946747222..94395d427 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -75,6 +75,8 @@ export class MapPlaylist { // Create the default public game config (from your GameManager) return { + donateGold: true, + donateTroops: true, gameMap: map, maxPlayers: config.lobbyMaxPlayers(map, mode, playerTeams), gameType: GameType.Public, diff --git a/tests/Donate.test.ts b/tests/Donate.test.ts new file mode 100644 index 000000000..bbbccd4f9 --- /dev/null +++ b/tests/Donate.test.ts @@ -0,0 +1,252 @@ +import { DonateGoldExecution } from "../src/core/execution/DonateGoldExecution"; +import { DonateTroopsExecution } from "../src/core/execution/DonateTroopExecution"; +import { SpawnExecution } from "../src/core/execution/SpawnExecution"; +import { PlayerInfo, PlayerType } from "../src/core/game/Game"; +import { setup } from "./util/Setup"; + +describe("Donate troops to an ally", () => { + it("Troops should be successfully donated", async () => { + const game = await setup("ocean_and_land", { + infiniteTroops: false, + donateTroops: true, + }); + + const donorInfo = new PlayerInfo( + "donor", + PlayerType.Human, + null, + "donor_id", + ); + const recipientInfo = new PlayerInfo( + "recipient", + PlayerType.Human, + null, + "recipient_id", + ); + + game.addPlayer(donorInfo); + game.addPlayer(recipientInfo); + + const donor = game.player(donorInfo.id); + const recipient = game.player(recipientInfo.id); + + // Spawn both players + const spawnA = game.ref(0, 10); + const spawnB = game.ref(0, 15); + + game.addExecution( + new SpawnExecution(donorInfo, spawnA), + new SpawnExecution(recipientInfo, spawnB), + ); + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + // donor sends alliance request to recipient + const allianceRequest = donor.createAllianceRequest(recipient); + expect(allianceRequest).not.toBeNull(); + + // recipient accepts the alliance request + if (allianceRequest) { + allianceRequest.accept(); + } + + // Ensure donor can actually donate the requested amount + donor.addTroops(6000); + const donorTroopsBefore = donor.troops(); + const recipientTroopsBefore = recipient.troops(); + game.addExecution(new DonateTroopsExecution(donor, recipientInfo.id, 5000)); + + for (let i = 0; i < 5; i++) { + game.executeNextTick(); + } + + expect(donor.troops() < donorTroopsBefore).toBe(true); + expect(recipient.troops() > recipientTroopsBefore).toBe(true); + }); +}); + +describe("Donate gold to an ally", () => { + it("Gold should be successfully donated", async () => { + const game = await setup("ocean_and_land", { + infiniteGold: false, + donateGold: true, + }); + + const donorInfo = new PlayerInfo( + "donor", + PlayerType.Human, + null, + "donor_id", + ); + const recipientInfo = new PlayerInfo( + "recipient", + PlayerType.Human, + null, + "recipient_id", + ); + + game.addPlayer(donorInfo); + game.addPlayer(recipientInfo); + + const donor = game.player(donorInfo.id); + const recipient = game.player(recipientInfo.id); + + // Spawn both players + const spawnA = game.ref(0, 10); + const spawnB = game.ref(0, 15); + + game.addExecution( + new SpawnExecution(donorInfo, spawnA), + new SpawnExecution(recipientInfo, spawnB), + ); + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + // donor sends alliance request to recipient + const allianceRequest = donor.createAllianceRequest(recipient); + expect(allianceRequest).not.toBeNull(); + + // recipient accepts the alliance request + if (allianceRequest) { + allianceRequest.accept(); + } + game.executeNextTick(); + + // Ensure donor can actually donate the requested amount + donor.addGold(6000n); + const donorGoldBefore = donor.gold(); + const recipientGoldBefore = recipient.gold(); + game.addExecution(new DonateGoldExecution(donor, recipientInfo.id, 5000n)); + + for (let i = 0; i < 5; i++) { + game.executeNextTick(); + } + + expect(donor.gold() < donorGoldBefore).toBe(true); + expect(recipient.gold() > recipientGoldBefore).toBe(true); + }); +}); + +describe("Donate troops to a non ally", () => { + it("Troops should not be donated", async () => { + const game = await setup("ocean_and_land", { + infiniteTroops: false, + donateTroops: true, + }); + + const donorInfo = new PlayerInfo( + "donor", + PlayerType.Human, + null, + "donor_id", + ); + const recipientInfo = new PlayerInfo( + "recipient", + PlayerType.Human, + null, + "recipient_id", + ); + + game.addPlayer(donorInfo); + game.addPlayer(recipientInfo); + + const donor = game.player(donorInfo.id); + const recipient = game.player(recipientInfo.id); + + // Spawn both players + const spawnA = game.ref(0, 10); + const spawnB = game.ref(0, 15); + + game.addExecution( + new SpawnExecution(donorInfo, spawnA), + new SpawnExecution(recipientInfo, spawnB), + ); + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + // Donor sends alliance request to Recipient + const allianceRequest = donor.createAllianceRequest(recipient); + expect(allianceRequest).not.toBeNull(); + + // Donor rejects the Recipient + if (allianceRequest) { + allianceRequest.reject(); + } + + const donorTroopsBefore = donor.troops(); + const recipientTroopsBefore = recipient.troops(); + + game.addExecution(new DonateTroopsExecution(donor, recipientInfo.id, 5000)); + game.executeNextTick(); + + // Troops should not be donated since they are not allies + expect(donor.troops() >= donorTroopsBefore).toBe(true); + expect(recipient.troops() >= recipientTroopsBefore).toBe(true); + }); +}); + +describe("Donate Gold to a non ally", () => { + it("Gold should not be donated", async () => { + const game = await setup("ocean_and_land", { + infiniteGold: false, + donateGold: true, + }); + + const donorInfo = new PlayerInfo( + "donor", + PlayerType.Human, + null, + "donor_id", + ); + const recipientInfo = new PlayerInfo( + "recipient", + PlayerType.Human, + null, + "recipient_id", + ); + + game.addPlayer(donorInfo); + game.addPlayer(recipientInfo); + + const donor = game.player(donorInfo.id); + const recipient = game.player(recipientInfo.id); + + // Spawn both players + const spawnA = game.ref(0, 10); + const spawnB = game.ref(0, 15); + + game.addExecution( + new SpawnExecution(donorInfo, spawnA), + new SpawnExecution(recipientInfo, spawnB), + ); + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + // Donor sends alliance request to Recipient + const allianceRequest = donor.createAllianceRequest(recipient); + expect(allianceRequest).not.toBeNull(); + + // Donor rejects the Recipient + if (allianceRequest) { + allianceRequest.reject(); + } + + const donorGoldBefore = donor.gold(); + const recipientGoldBefore = donor.gold(); + + game.addExecution(new DonateGoldExecution(donor, recipientInfo.id, 5000n)); + game.executeNextTick(); + + // Gold should not be donated since they are not allies + expect(donor.gold() >= donorGoldBefore).toBe(true); + expect(recipient.gold() >= recipientGoldBefore).toBe(true); + }); +}); diff --git a/tests/client/graphics/RadialMenuElements.test.ts b/tests/client/graphics/RadialMenuElements.test.ts index 64821581a..df8c796d4 100644 --- a/tests/client/graphics/RadialMenuElements.test.ts +++ b/tests/client/graphics/RadialMenuElements.test.ts @@ -129,7 +129,8 @@ describe("RadialMenuElements", () => { interaction: { canSendAllianceRequest: true, canBreakAlliance: false, - canDonate: true, + canDonateTroops: true, + canDonateGold: true, }, }; diff --git a/tests/util/Setup.ts b/tests/util/Setup.ts index 20e8d67d5..71da580b2 100644 --- a/tests/util/Setup.ts +++ b/tests/util/Setup.ts @@ -62,6 +62,8 @@ export async function setup( gameType: GameType.Singleplayer, difficulty: Difficulty.Medium, disableNPCs: false, + donateGold: false, + donateTroops: false, bots: 0, infiniteGold: false, infiniteTroops: false,