diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 994e06dc9..9244ebcfc 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -513,6 +513,9 @@ export class Config { emojiMessageCooldown(): Tick { return 5 * 10; } + quickChatCooldown(): Tick { + return 3 * 10; + } targetDuration(): Tick { return 10 * 10; } diff --git a/src/core/execution/QuickChatExecution.ts b/src/core/execution/QuickChatExecution.ts index bb4f5f525..d9dc13c15 100644 --- a/src/core/execution/QuickChatExecution.ts +++ b/src/core/execution/QuickChatExecution.ts @@ -27,8 +27,15 @@ export class QuickChatExecution implements Execution { } tick(ticks: number): void { + if (!this.sender.canSendQuickChat(this.recipient)) { + this.active = false; + return; + } + const message = this.getMessageFromKey(this.quickChatKey); + this.sender.recordQuickChat(this.recipient); + this.mg.displayChat( message[1], message[0], diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 7b5684984..195b498cf 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -808,6 +808,8 @@ export interface Player { canSendEmoji(recipient: Player | typeof AllPlayers): boolean; outgoingEmojis(): EmojiMessage[]; sendEmoji(recipient: Player | typeof AllPlayers, emoji: string): void; + canSendQuickChat(recipient: Player): boolean; + recordQuickChat(recipient: Player): void; // Donation canDonateGold(recipient: Player): boolean; diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index c40c7d6da..d9b477b4e 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -89,6 +89,7 @@ export class PlayerImpl implements Player { private targets_: Target[] = []; private outgoingEmojis_: EmojiMessage[] = []; + private outgoingQuickChats_ = new Map(); private sentDonations: Donation[] = []; @@ -759,6 +760,21 @@ export class PlayerImpl implements Player { return true; } + canSendQuickChat(recipient: Player): boolean { + if (recipient === this) { + return false; + } + const lastSentAt = this.outgoingQuickChats_.get(recipient.smallID()); + return ( + lastSentAt === undefined || + this.mg.ticks() - lastSentAt >= this.mg.config().quickChatCooldown() + ); + } + + recordQuickChat(recipient: Player): void { + this.outgoingQuickChats_.set(recipient.smallID(), this.mg.ticks()); + } + canDonateGold(recipient: Player): boolean { if ( !this.isAlive() || diff --git a/tests/QuickChat.test.ts b/tests/QuickChat.test.ts new file mode 100644 index 000000000..54470a6ef --- /dev/null +++ b/tests/QuickChat.test.ts @@ -0,0 +1,85 @@ +import { QuickChatExecution } from "../src/core/execution/QuickChatExecution"; +import { Game, Player, PlayerType } from "../src/core/game/Game"; +import { playerInfo, setup } from "./util/Setup"; + +let game: Game; +let player1: Player; +let player2: Player; +let player3: Player; + +describe("QuickChat cooldown", () => { + beforeEach(async () => { + game = await setup("plains", {}, [ + playerInfo("player1", PlayerType.Human), + playerInfo("player2", PlayerType.Human), + playerInfo("player3", PlayerType.Human), + ]); + + player1 = game.player("player1"); + player1.conquer(game.ref(0, 0)); + + player2 = game.player("player2"); + player2.conquer(game.ref(0, 1)); + + player3 = game.player("player3"); + player3.conquer(game.ref(0, 2)); + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + }); + + // Helper: add an execution and advance two ticks so tick() actually runs. + // (addExecution → unInitExecs; first tick: init(); second tick: tick()) + function sendQuickChat(sender: Player, recipient: Player) { + game.addExecution( + new QuickChatExecution(sender, recipient.id(), "greet.hello", undefined), + ); + game.executeNextTick(); // init + game.executeNextTick(); // tick + } + + test("first quick chat is sent", () => { + expect(player1.canSendQuickChat(player2)).toBe(true); + sendQuickChat(player1, player2); + expect(player1.canSendQuickChat(player2)).toBe(false); + }); + + test("second quick chat within cooldown is blocked", () => { + sendQuickChat(player1, player2); + expect(player1.canSendQuickChat(player2)).toBe(false); + + // Even after the second attempt, cooldown persists + sendQuickChat(player1, player2); + expect(player1.canSendQuickChat(player2)).toBe(false); + }); + + test("quick chat is allowed again after cooldown expires", () => { + sendQuickChat(player1, player2); + expect(player1.canSendQuickChat(player2)).toBe(false); + + // Advance past the cooldown (3 * 10 = 30 ticks) + const cooldown = game.config().quickChatCooldown(); + for (let i = 0; i < cooldown; i++) { + game.executeNextTick(); + } + + expect(player1.canSendQuickChat(player2)).toBe(true); + }); + + test("cooldown is per-sender — different sender is not affected", () => { + sendQuickChat(player1, player2); + expect(player1.canSendQuickChat(player2)).toBe(false); + + // player2 sending to player1 is independent + expect(player2.canSendQuickChat(player1)).toBe(true); + }); + + test("cooldown is per-recipient — same sender can still chat with a different recipient", () => { + sendQuickChat(player1, player2); + expect(player1.canSendQuickChat(player2)).toBe(false); + + // player1 is on cooldown for player2 but not for player3 + expect(player1.canSendQuickChat(player3)).toBe(true); + }); +});