Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions src/client/graphics/layers/AlertFrame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -21,6 +23,10 @@ export class AlertFrame extends LitElement implements Layer {
private isActive = false;

private animationTimeout: number | null = null;
private seenAttackIds: Set<string> = new Set();
private lastAlertTick: number = -1;
// Map of player ID -> tick when we last attacked them
private outgoingAttackTicks: Map<number, number> = new Map();

static styles = css`
.alert-border {
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down
Loading