import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import {
BrokeAllianceUpdate,
GameUpdateType,
} from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
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 {
public game: GameView;
private userSettings: UserSettings = new UserSettings();
@state()
private isActive = false;
@state()
private alertType: "betrayal" | "land-attack" = "betrayal";
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 {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
border: 17px solid;
box-sizing: border-box;
z-index: 40;
opacity: 0;
}
.alert-border.betrayal {
border-color: #ee0000;
}
.alert-border.land-attack {
border-color: #ffa500;
}
.alert-border.animate {
animation: alertBlink ${ALERT_SPEED}s ease-in-out ${ALERT_COUNT};
}
@keyframes alertBlink {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
`;
constructor() {
super();
if (!document.querySelector("style[data-alert-frame]")) {
const styleEl = document.createElement("style");
styleEl.setAttribute("data-alert-frame", "");
styleEl.textContent = AlertFrame.styles.cssText;
document.head.appendChild(styleEl);
}
}
createRenderRoot() {
return this;
}
init() {
// Listen for BrokeAllianceUpdate events directly from game updates
}
tick() {
if (!this.game) {
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
shouldTransform(): boolean {
return false;
}
private onBrokeAllianceUpdate(update: BrokeAllianceUpdate) {
const myPlayer = this.game.myPlayer();
if (!myPlayer) return;
const betrayed = this.game.playerBySmallID(update.betrayedID);
// Only trigger alert if the current player is the betrayed one
if (betrayed === myPlayer) {
this.alertType = "betrayal";
this.activateAlert();
}
}
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.alertType = "land-attack";
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) {
clearTimeout(this.animationTimeout);
this.animationTimeout = null;
}
this.requestUpdate();
}
render() {
if (!this.isActive) {
return html``;
}
return html`
this.dismissAlert()}
>
`;
}
}