diff --git a/src/client/graphics/layers/AlertFrame.ts b/src/client/graphics/layers/AlertFrame.ts index cccff9efa..973a9a169 100644 --- a/src/client/graphics/layers/AlertFrame.ts +++ b/src/client/graphics/layers/AlertFrame.ts @@ -11,6 +11,8 @@ import { Layer } from "./Layer"; // Parameters for the alert animation const ALERT_SPEED = 1.6; const ALERT_COUNT = 2; +const RETALIATION_WINDOW_TICKS = 15 * 10; // 15 seconds +const ALERT_COOLDOWN_TICKS = 15 * 10; // 15 seconds @customElement("alert-frame") export class AlertFrame extends LitElement implements Layer { @@ -21,6 +23,10 @@ export class AlertFrame extends LitElement implements Layer { private isActive = false; private animationTimeout: number | null = null; + private seenAttackIds: Set = new Set(); + private lastAlertTick: number = -1; + // Map of player ID -> tick when we last attacked them + private outgoingAttackTicks: Map = new Map(); static styles = css` .alert-border { @@ -76,12 +82,28 @@ export class AlertFrame extends LitElement implements Layer { return; // Game not initialized yet } + const myPlayer = this.game.myPlayer(); + + // Clear tracked attacks if player dies or doesn't exist + if (!myPlayer || !myPlayer.isAlive()) { + this.seenAttackIds.clear(); + this.outgoingAttackTicks.clear(); + this.lastAlertTick = -1; + return; + } + + // Track outgoing attacks to detect retaliation + this.trackOutgoingAttacks(); + // Check for BrokeAllianceUpdate events this.game .updatesSinceLastTick() ?.[GameUpdateType.BrokeAlliance]?.forEach((update) => { this.onBrokeAllianceUpdate(update as BrokeAllianceUpdate); }); + + // Check for new incoming attacks + this.checkForNewAttacks(); } // The alert frame is not affected by the camera transform @@ -104,10 +126,101 @@ export class AlertFrame extends LitElement implements Layer { private activateAlert() { if (this.userSettings.alertFrame()) { this.isActive = true; + this.lastAlertTick = this.game.ticks(); this.requestUpdate(); } } + private trackOutgoingAttacks() { + const myPlayer = this.game.myPlayer(); + if (!myPlayer || !myPlayer.isAlive()) { + return; + } + + const currentTick = this.game.ticks(); + const outgoingAttacks = myPlayer.outgoingAttacks(); + + // Track when we attack other players (not terra nullius) + for (const attack of outgoingAttacks) { + // Only track attacks on players (targetID !== 0 means it's a player, not unclaimed land) + if (attack.targetID !== 0 && !attack.retreating) { + const existingTick = this.outgoingAttackTicks.get(attack.targetID); + + // Only update timestamp if: + // 1. This is a new attack (not in map yet), OR + // 2. The existing entry has expired (older than retaliation window) + if ( + existingTick === undefined || + currentTick - existingTick >= RETALIATION_WINDOW_TICKS + ) { + this.outgoingAttackTicks.set(attack.targetID, currentTick); + } + } + } + + // Clean up old entries (older than retaliation window) + for (const [playerID, tick] of this.outgoingAttackTicks.entries()) { + if (currentTick - tick > RETALIATION_WINDOW_TICKS) { + this.outgoingAttackTicks.delete(playerID); + } + } + } + + private checkForNewAttacks() { + const myPlayer = this.game.myPlayer(); + if (!myPlayer || !myPlayer.isAlive()) { + return; + } + + const incomingAttacks = myPlayer.incomingAttacks(); + const currentTick = this.game.ticks(); + + // Check if we're in cooldown (within 10 seconds of last alert) + const inCooldown = + this.lastAlertTick !== -1 && + currentTick - this.lastAlertTick < ALERT_COOLDOWN_TICKS; + + // Find new attacks that we haven't seen yet + const playerTroops = myPlayer.troops(); + const minAttackTroopsThreshold = playerTroops / 5; // 1/5 of current troops + + for (const attack of incomingAttacks) { + // Only alert for non-retreating attacks + if (!attack.retreating && !this.seenAttackIds.has(attack.id)) { + // Check if this is a retaliation (we attacked them recently) + const ourAttackTick = this.outgoingAttackTicks.get(attack.attackerID); + const isRetaliation = + ourAttackTick !== undefined && + currentTick - ourAttackTick < RETALIATION_WINDOW_TICKS; + + // Check if attack is too small (less than 1/5 of our troops) + const isSmallAttack = attack.troops < minAttackTroopsThreshold; + + // Don't alert if: + // 1. We're in cooldown from a recent alert + // 2. This is a retaliation (we attacked them within 15 seconds) + // 3. The attack is too small (less than 1/5 of our troops) + if (!inCooldown && !isRetaliation && !isSmallAttack) { + this.seenAttackIds.add(attack.id); + this.activateAlert(); + } else { + // Still mark as seen so we don't alert later + this.seenAttackIds.add(attack.id); + } + } + } + + // Clean up IDs for attacks that are no longer active (retreating or completed) + const activeAttackIds = new Set(incomingAttacks.map((a) => a.id)); + + // Remove IDs for attacks that are no longer in the incoming attacks list + for (const attackId of this.seenAttackIds) { + if (!activeAttackIds.has(attackId)) { + this.seenAttackIds.delete(attackId); + } + } + } + public dismissAlert() { this.isActive = false; if (this.animationTimeout) {