Add per-recipient cooldown to QuickChatExecution (#4012)

## Description:

`QuickChatExecution` had no cooldown, allowing a player to spam
quick-chat intents and flood a recipient's chat UI. This could bury
incoming alliance request notifications, preventing them from being seen
or accepted.

This fix mirrors the existing emoji cooldown pattern:

- Added `quickChatCooldown()` to `Config` (default: 30 ticks / 3
seconds)
- Added `canSendQuickChat(recipient)` and `recordQuickChat(recipient)`
to `Player` / `PlayerImpl`, tracking outgoing chats per recipient
- `QuickChatExecution.tick()` now checks `canSendQuickChat` before
displaying and records before the display calls (so the cooldown is
always written even if display throws)

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

jish

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Josh Harris
2026-05-26 16:39:07 +01:00
committed by GitHub
parent ddca5251d7
commit 6eff1fc196
6 changed files with 114 additions and 0 deletions
+1
View File
@@ -121,6 +121,7 @@ export interface Config {
targetCooldown(): Tick;
emojiMessageCooldown(): Tick;
emojiMessageDuration(): Tick;
quickChatCooldown(): Tick;
donateCooldown(): Tick;
embargoAllCooldown(): Tick;
deletionMarkDuration(): Tick;
+3
View File
@@ -572,6 +572,9 @@ export class DefaultConfig implements Config {
emojiMessageCooldown(): Tick {
return 5 * 10;
}
quickChatCooldown(): Tick {
return 3 * 10;
}
targetDuration(): Tick {
return 10 * 10;
}
+7
View File
@@ -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],
+2
View File
@@ -783,6 +783,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;
+16
View File
@@ -90,6 +90,7 @@ export class PlayerImpl implements Player {
private targets_: Target[] = [];
private outgoingEmojis_: EmojiMessage[] = [];
private outgoingQuickChats_ = new Map<number, Tick>();
private sentDonations: Donation[] = [];
@@ -735,6 +736,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() ||
+85
View File
@@ -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);
});
});