Add filters tabs to EvensDisplay to let users filter events (#1080)

## Description:
Big update to the EventsDisplay

- Style update for EventsDisplay, look & feel similar to other windows
- Component now hidden during spawn phase
- Adds new functionality for filtering events by category. Allows the
player to remove specific event types
- Displays latest gold amount, decays after 5 seconds

<img width="1147" alt="Screenshot 2025-06-07 at 20 18 55"
src="https://github.com/user-attachments/assets/11c39818-55ad-4ba1-a998-360057e2856c"
/>

<img width="422" alt="Screenshot 2025-06-07 at 19 01 55"
src="https://github.com/user-attachments/assets/09c0b998-6046-49fb-9fba-33b4f57f337b"
/>

<img width="444" alt="Screenshot 2025-06-07 at 20 20 25"
src="https://github.com/user-attachments/assets/022deadc-3a49-442a-85f5-f1cd128a5805"
/>

![Screen Shot 2025-06-07 at 20 32
07](https://github.com/user-attachments/assets/d8575ea0-109d-4841-b661-b233201a304a)



## 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
- [X] I understand that submitting code with bugs that could have been
caught through manual testing blocks releases and new features for all
contributors

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

maxion_


Fixes #1025 
Fixes #1034
This commit is contained in:
Max Lundgren
2025-06-08 12:16:23 +03:00
committed by GitHub
parent 67c8e2799a
commit 4170aca548
17 changed files with 712 additions and 249 deletions
+4
View File
@@ -402,6 +402,10 @@
"health": "Health",
"attitude": "Attitude"
},
"events_display": {
"retreating": "retreating",
"boat": "Boat"
},
"relation": {
"hostile": "Hostile",
"distrustful": "Distrustful",
+55
View File
@@ -1,3 +1,4 @@
import { MessageType } from "../core/game/Game";
import { LangSelector } from "./LangSelector";
export function renderTroops(troops: number): string {
@@ -95,3 +96,57 @@ export const translateText = (
return langSelector.translateText(key, params);
};
/**
* Severity colors mapping for message types
*/
export const severityColors: Record<string, string> = {
fail: "text-red-400",
warn: "text-yellow-400",
success: "text-green-400",
info: "text-gray-200",
blue: "text-blue-400",
white: "text-white",
};
/**
* Gets the CSS classes for styling message types based on their severity
* @param type The message type to get styling for
* @returns CSS class string for the message type
*/
export function getMessageTypeClasses(type: MessageType): string {
switch (type) {
case MessageType.SAM_HIT:
case MessageType.CAPTURED_ENEMY_UNIT:
case MessageType.RECEIVED_GOLD_FROM_TRADE:
case MessageType.CONQUERED_PLAYER:
return severityColors["success"];
case MessageType.ATTACK_FAILED:
case MessageType.ALLIANCE_REJECTED:
case MessageType.ALLIANCE_BROKEN:
case MessageType.UNIT_CAPTURED_BY_ENEMY:
case MessageType.UNIT_DESTROYED:
return severityColors["fail"];
case MessageType.ATTACK_CANCELLED:
case MessageType.ATTACK_REQUEST:
case MessageType.ALLIANCE_ACCEPTED:
case MessageType.SENT_GOLD_TO_PLAYER:
case MessageType.SENT_TROOPS_TO_PLAYER:
case MessageType.RECEIVED_GOLD_FROM_PLAYER:
case MessageType.RECEIVED_TROOPS_FROM_PLAYER:
return severityColors["blue"];
case MessageType.MIRV_INBOUND:
case MessageType.NUKE_INBOUND:
case MessageType.HYDROGEN_BOMB_INBOUND:
case MessageType.SAM_MISS:
case MessageType.ALLIANCE_EXPIRED:
case MessageType.NAVAL_INVASION_INBOUND:
return severityColors["warn"];
case MessageType.CHAT:
case MessageType.ALLIANCE_REQUEST:
return severityColors["info"];
default:
console.warn(`Message type ${type} has no explicit color`);
return severityColors["white"];
}
}
+496 -224
View File
@@ -2,9 +2,15 @@ import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { DirectiveResult } from "lit/directive.js";
import { unsafeHTML, UnsafeHTMLDirective } from "lit/directives/unsafe-html.js";
import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg";
import chatIcon from "../../../../resources/images/ChatIconWhite.svg";
import donateGoldIcon from "../../../../resources/images/DonateGoldIconWhite.svg";
import swordIcon from "../../../../resources/images/SwordIconWhite.svg";
import { EventBus } from "../../../core/EventBus";
import {
AllPlayers,
getMessageCategory,
MessageCategory,
MessageType,
PlayerType,
Tick,
@@ -32,14 +38,14 @@ import { Layer } from "./Layer";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { onlyImages } from "../../../core/Util";
import { renderTroops } from "../../Utils";
import { renderNumber, renderTroops } from "../../Utils";
import {
GoToPlayerEvent,
GoToPositionEvent,
GoToUnitEvent,
} from "./Leaderboard";
import { translateText } from "../../Utils";
import { getMessageTypeClasses, translateText } from "../../Utils";
interface GameEvent {
description: string;
@@ -73,7 +79,50 @@ export class EventsDisplay extends LitElement implements Layer {
@state() private outgoingLandAttacks: AttackUpdate[] = [];
@state() private outgoingBoats: UnitView[] = [];
@state() private _hidden: boolean = false;
@state() private _isVisible: boolean = false;
@state() private newEvents: number = 0;
@state() private latestGoldAmount: bigint | null = null;
@state() private goldAmountAnimating: boolean = false;
private goldAmountTimeoutId: ReturnType<typeof setTimeout> | null = null;
@state() private eventsFilters: Map<MessageCategory, boolean> = new Map([
[MessageCategory.ATTACK, false],
[MessageCategory.TRADE, false],
[MessageCategory.ALLIANCE, false],
[MessageCategory.CHAT, false],
]);
private renderButton(options: {
content: any; // Can be string, TemplateResult, or other renderable content
onClick?: () => void;
className?: string;
disabled?: boolean;
translate?: boolean;
hidden?: boolean;
}) {
const {
content,
onClick,
className = "",
disabled = false,
translate = true,
hidden = false,
} = options;
if (hidden) {
return html``;
}
return html`
<button
class="${className}"
@click=${onClick}
?disabled=${disabled}
?translate=${translate}
>
${content}
</button>
`;
}
private toggleHidden() {
this._hidden = !this._hidden;
@@ -83,6 +132,12 @@ export class EventsDisplay extends LitElement implements Layer {
this.requestUpdate();
}
private toggleEventFilter(filterName: MessageCategory) {
const currentState = this.eventsFilters.get(filterName) || false;
this.eventsFilters.set(filterName, !currentState);
this.requestUpdate();
}
private updateMap = new Map([
[GameUpdateType.DisplayEvent, (u) => this.onDisplayMessageEvent(u)],
[GameUpdateType.DisplayChatEvent, (u) => this.onDisplayChatEvent(u)],
@@ -109,6 +164,21 @@ export class EventsDisplay extends LitElement implements Layer {
tick() {
this.active = true;
if (!this._isVisible && !this.game.inSpawnPhase()) {
this._isVisible = true;
this.requestUpdate();
}
const myPlayer = this.game.myPlayer();
if (!myPlayer || !myPlayer.isAlive()) {
if (this._isVisible) {
this._isVisible = false;
this.requestUpdate();
}
return;
}
const updates = this.game.updatesSinceLastTick();
if (updates) {
for (const [ut, fn] of this.updateMap) {
@@ -134,11 +204,6 @@ export class EventsDisplay extends LitElement implements Layer {
this.requestUpdate();
}
const myPlayer = this.game.myPlayer();
if (!myPlayer) {
return;
}
// Update attacks
this.incomingAttacks = myPlayer.incomingAttacks().filter((a) => {
const t = (this.game.playerBySmallID(a.attackerID) as PlayerView).type();
@@ -160,6 +225,13 @@ export class EventsDisplay extends LitElement implements Layer {
this.requestUpdate();
}
disconnectedCallback() {
if (this.goldAmountTimeoutId !== null) {
clearTimeout(this.goldAmountTimeoutId);
this.goldAmountTimeoutId = null;
}
}
private addEvent(event: GameEvent) {
this.events = [...this.events, event];
if (this._hidden === true) {
@@ -190,6 +262,29 @@ export class EventsDisplay extends LitElement implements Layer {
return;
}
if (event.goldAmount !== undefined) {
const hasChanged = this.latestGoldAmount !== event.goldAmount;
this.latestGoldAmount = event.goldAmount;
if (this.goldAmountTimeoutId !== null) {
clearTimeout(this.goldAmountTimeoutId);
}
this.goldAmountTimeoutId = setTimeout(() => {
this.latestGoldAmount = null;
this.goldAmountTimeoutId = null;
this.requestUpdate();
}, 5000);
if (hasChanged) {
this.goldAmountAnimating = true;
setTimeout(() => {
this.goldAmountAnimating = false;
this.requestUpdate();
}, 600);
}
}
this.addEvent({
description: event.message,
createdAt: this.game.ticks(),
@@ -267,7 +362,7 @@ export class EventsDisplay extends LitElement implements Layer {
},
],
highlight: true,
type: MessageType.INFO,
type: MessageType.ALLIANCE_REQUEST,
createdAt: this.game.ticks(),
onDelete: () =>
this.eventBus.emit(
@@ -293,7 +388,9 @@ export class EventsDisplay extends LitElement implements Layer {
description: `${recipient.name()} ${
update.accepted ? "accepted" : "rejected"
} your alliance request`,
type: update.accepted ? MessageType.SUCCESS : MessageType.ERROR,
type: update.accepted
? MessageType.ALLIANCE_ACCEPTED
: MessageType.ALLIANCE_REJECTED,
highlight: true,
createdAt: this.game.ticks(),
focusID: update.request.recipientID,
@@ -322,7 +419,7 @@ export class EventsDisplay extends LitElement implements Layer {
description:
`You broke your alliance with ${betrayed.name()}, making you a TRAITOR ` +
`(${malusPercent}% defense debuff for ${durationText})`,
type: MessageType.ERROR,
type: MessageType.ALLIANCE_BROKEN,
highlight: true,
createdAt: this.game.ticks(),
focusID: update.betrayedID,
@@ -330,7 +427,7 @@ export class EventsDisplay extends LitElement implements Layer {
} else if (betrayed === myPlayer) {
this.addEvent({
description: `${traitor.name()} broke their alliance with you`,
type: MessageType.ERROR,
type: MessageType.ALLIANCE_BROKEN,
highlight: true,
createdAt: this.game.ticks(),
focusID: update.traitorID,
@@ -354,7 +451,7 @@ export class EventsDisplay extends LitElement implements Layer {
this.addEvent({
description: `Your alliance with ${other.name()} expired`,
type: MessageType.WARN,
type: MessageType.ALLIANCE_EXPIRED,
highlight: true,
createdAt: this.game.ticks(),
focusID: otherID,
@@ -370,7 +467,7 @@ export class EventsDisplay extends LitElement implements Layer {
this.addEvent({
description: `${other.name()} requests you attack ${target.name()}`,
type: MessageType.INFO,
type: MessageType.ATTACK_REQUEST,
highlight: true,
createdAt: this.game.ticks(),
focusID: event.targetID,
@@ -419,7 +516,7 @@ export class EventsDisplay extends LitElement implements Layer {
this.addEvent({
description: `${sender.displayName()}:${update.emoji.message}`,
unsafeDescription: true,
type: MessageType.INFO,
type: MessageType.CHAT,
highlight: true,
createdAt: this.game.ticks(),
focusID: update.emoji.senderID,
@@ -430,7 +527,7 @@ export class EventsDisplay extends LitElement implements Layer {
update.emoji.message
}`,
unsafeDescription: true,
type: MessageType.INFO,
type: MessageType.CHAT,
highlight: true,
createdAt: this.game.ticks(),
focusID: recipient.smallID(),
@@ -457,23 +554,6 @@ export class EventsDisplay extends LitElement implements Layer {
});
}
private getMessageTypeClasses(type: MessageType): string {
switch (type) {
case MessageType.SUCCESS:
return "text-green-300";
case MessageType.INFO:
return "text-gray-200";
case MessageType.CHAT:
return "text-gray-200";
case MessageType.WARN:
return "text-yellow-300";
case MessageType.ERROR:
return "text-red-300";
default:
return "text-white";
}
}
private getEventDescription(
event: GameEvent,
): string | DirectiveResult<typeof UnsafeHTMLDirective> {
@@ -506,27 +586,24 @@ export class EventsDisplay extends LitElement implements Layer {
return html`
${this.incomingAttacks.length > 0
? html`
<tr class="border-t border-gray-700">
<td class="lg:p-3 p-1 text-left text-red-400">
${this.incomingAttacks.map(
(attack) => html`
<button
translate="no"
class="ml-2"
@click=${() => this.attackWarningOnClick(attack)}
>
${renderTroops(attack.troops)}
${(
this.game.playerBySmallID(
attack.attackerID,
) as PlayerView
)?.name()}
</button>
${attack.retreating ? "(retreating...)" : ""}
${this.incomingAttacks.map(
(attack) => html`
${this.renderButton({
content: html`
${renderTroops(attack.troops)}
${(
this.game.playerBySmallID(attack.attackerID) as PlayerView
)?.name()}
${attack.retreating
? `(${translateText("events_display.retreating")}...)`
: ""}
`,
)}
</td>
</tr>
onClick: () => this.attackWarningOnClick(attack),
className: "text-left text-red-400",
translate: false,
})}
`,
)}
`
: ""}
`;
@@ -536,35 +613,39 @@ export class EventsDisplay extends LitElement implements Layer {
return html`
${this.outgoingAttacks.length > 0
? html`
<tr class="border-t border-gray-700">
<td class="lg:p-3 p-1 text-left text-blue-400">
${this.outgoingAttacks.map(
(attack) => html`
<button
translate="no"
class="ml-2"
@click=${async () => this.attackWarningOnClick(attack)}
>
${renderTroops(attack.troops)}
${(
this.game.playerBySmallID(attack.targetID) as PlayerView
)?.name()}
</button>
<div class="flex flex-wrap gap-y-1 gap-x-2">
${this.outgoingAttacks.map(
(attack) => html`
<div class="inline-flex items-center gap-1">
${this.renderButton({
content: html`
${renderTroops(attack.troops)}
${(
this.game.playerBySmallID(
attack.targetID,
) as PlayerView
)?.name()}
`,
onClick: async () => this.attackWarningOnClick(attack),
className: "text-left text-blue-400",
translate: false,
})}
${!attack.retreating
? html`<button
${attack.retreating ? "disabled" : ""}
@click=${() => {
this.emitCancelAttackIntent(attack.id);
}}
>
</button>`
: "(retreating...)"}
`,
)}
</td>
</tr>
? this.renderButton({
content: "",
onClick: () => this.emitCancelAttackIntent(attack.id),
className: "text-left flex-shrink-0",
disabled: attack.retreating,
})
: html`<span class="flex-shrink-0 text-blue-400"
>(${translateText(
"events_display.retreating",
)}...)</span
>`}
</div>
`,
)}
</div>
`
: ""}
`;
@@ -574,28 +655,33 @@ export class EventsDisplay extends LitElement implements Layer {
return html`
${this.outgoingLandAttacks.length > 0
? html`
<tr class="border-t border-gray-700">
<td class="lg:p-3 p-1 text-left text-gray-400">
${this.outgoingLandAttacks.map(
(landAttack) => html`
<button translate="no" class="ml-2">
${renderTroops(landAttack.troops)} Wilderness
</button>
<div class="flex flex-wrap gap-y-1 gap-x-2">
${this.outgoingLandAttacks.map(
(landAttack) => html`
<div class="inline-flex items-center gap-1">
${this.renderButton({
content: html`${renderTroops(landAttack.troops)}
Wilderness`,
className: "text-left text-gray-400",
translate: false,
})}
${!landAttack.retreating
? html`<button
${landAttack.retreating ? "disabled" : ""}
@click=${() => {
this.emitCancelAttackIntent(landAttack.id);
}}
>
</button>`
: "(retreating...)"}
`,
)}
</td>
</tr>
? this.renderButton({
content: "",
onClick: () =>
this.emitCancelAttackIntent(landAttack.id),
className: "text-left flex-shrink-0",
disabled: landAttack.retreating,
})
: html`<span class="flex-shrink-0 text-blue-400"
>(${translateText(
"events_display.retreating",
)}...)</span
>`}
</div>
`,
)}
</div>
`
: ""}
`;
@@ -605,41 +691,71 @@ export class EventsDisplay extends LitElement implements Layer {
return html`
${this.outgoingBoats.length > 0
? html`
<tr class="border-t border-gray-700">
<td class="lg:p-3 p-1 text-left text-blue-400">
${this.outgoingBoats.map(
(boat) => html`
<button
translate="no"
@click=${() => this.emitGoToUnitEvent(boat)}
>
Boat: ${renderTroops(boat.troops())}
</button>
<div class="flex flex-wrap gap-y-1 gap-x-2">
${this.outgoingBoats.map(
(boat) => html`
<div class="inline-flex items-center gap-1">
${this.renderButton({
content: html`${translateText("events_display.boat")}:
${renderTroops(boat.troops())}`,
onClick: () => this.emitGoToUnitEvent(boat),
className: "text-left text-blue-400",
translate: false,
})}
${!boat.retreating()
? html`<button
${boat.retreating() ? "disabled" : ""}
@click=${() => {
this.emitBoatCancelIntent(boat.id());
}}
>
</button>`
: "(retreating...)"}
`,
)}
</td>
</tr>
? this.renderButton({
content: "",
onClick: () => this.emitBoatCancelIntent(boat.id()),
className: "text-left flex-shrink-0",
disabled: boat.retreating(),
})
: html`<span class="flex-shrink-0 text-blue-400"
>(${translateText(
"events_display.retreating",
)}...)</span
>`}
</div>
`,
)}
</div>
`
: ""}
`;
}
render() {
if (!this.active) {
if (!this.active || !this._isVisible) {
return html``;
}
this.events.sort((a, b) => {
const styles = html`
<style>
@keyframes goldBounce {
0% {
transform: scale(1);
}
30% {
transform: scale(1.3);
}
50% {
transform: scale(1.1);
}
70% {
transform: scale(1.2);
}
100% {
transform: scale(1);
}
}
</style>
`;
const filteredEvents = this.events.filter((event) => {
const category = getMessageCategory(event.type);
return !this.eventsFilters.get(category);
});
filteredEvents.sort((a, b) => {
const aPrior = a.priority ?? 100000;
const bPrior = b.priority ?? 100000;
if (aPrior === bPrior) {
@@ -649,109 +765,265 @@ export class EventsDisplay extends LitElement implements Layer {
});
return html`
<div
class="${this._hidden
? "w-fit px-[10px] py-[5px]"
: ""} rounded-md bg-black bg-opacity-60 relative max-h-[30vh] flex flex-col-reverse overflow-y-auto w-full lg:bottom-2.5 lg:right-2.5 z-50 lg:max-w-[30vw] lg:w-full lg:w-auto"
>
<div>
<div class="w-full bg-black/80 sticky top-0 px-[10px]">
<button
class="text-white cursor-pointer pointer-events-auto ${this
._hidden
? "hidden"
: ""}"
@click=${this.toggleHidden}
>
Hide
</button>
</div>
<button
class="text-white cursor-pointer pointer-events-auto ${this._hidden
? ""
: "hidden"}"
@click=${this.toggleHidden}
>
Events
<span
class="${this.newEvents
? ""
: "hidden"} inline-block px-2 bg-red-500 rounded-sm"
>${this.newEvents}</span
>
</button>
<table
class="w-full border-collapse text-white shadow-lg lg:text-xl text-xs ${this
._hidden
? "hidden"
: ""}"
style="pointer-events: auto;"
>
<tbody>
${this.events.map(
(event, index) => html`
<tr
class="border-b border-opacity-0 ${this.getMessageTypeClasses(
event.type,
)}"
${styles}
<!-- Events Toggle (when hidden) -->
${this._hidden
? html`
<div class="relative w-fit lg:bottom-2.5 lg:right-2.5 z-50">
${this.renderButton({
content: html`
Events
<span
class="${this.newEvents
? ""
: "hidden"} inline-block px-2 bg-red-500 rounded-xl text-sm"
>${this.newEvents}</span
>
<td class="lg:p-3 p-1 text-left">
${event.focusID
? html`<button
@click=${() => {
event.focusID &&
this.emitGoToPlayerEvent(event.focusID);
}}
>
${this.getEventDescription(event)}
</button>`
: event.unitView
? html`<button
@click=${() => {
event.unitView &&
this.emitGoToUnitEvent(event.unitView);
}}
`,
onClick: this.toggleHidden,
className:
"text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 rounded-md bg-gray-800/70 backdrop-blur",
})}
</div>
`
: html`
<!-- Main Events Display -->
<div
class="relative w-full lg:bottom-2.5 lg:right-2.5 z-50 lg:w-96 backdrop-blur"
>
<!-- Button Bar -->
<div
class="w-full p-2 lg:p-3 rounded-t-none md:rounded-t-md bg-gray-800/70"
>
<div class="flex justify-between items-center">
<div class="flex gap-4">
${this.renderButton({
content: html`<img
src="${swordIcon}"
class="w-5 h-5"
style="filter: ${this.eventsFilters.get(
MessageCategory.ATTACK,
)
? "grayscale(1) opacity(0.5)"
: "none"}"
/>`,
onClick: () =>
this.toggleEventFilter(MessageCategory.ATTACK),
className: "cursor-pointer pointer-events-auto",
})}
${this.renderButton({
content: html`<img
src="${donateGoldIcon}"
class="w-5 h-5"
style="filter: ${this.eventsFilters.get(
MessageCategory.TRADE,
)
? "grayscale(1) opacity(0.5)"
: "none"}"
/>`,
onClick: () =>
this.toggleEventFilter(MessageCategory.TRADE),
className: "cursor-pointer pointer-events-auto",
})}
${this.renderButton({
content: html`<img
src="${allianceIcon}"
class="w-5 h-5"
style="filter: ${this.eventsFilters.get(
MessageCategory.ALLIANCE,
)
? "grayscale(1) opacity(0.5)"
: "none"}"
/>`,
onClick: () =>
this.toggleEventFilter(MessageCategory.ALLIANCE),
className: "cursor-pointer pointer-events-auto",
})}
${this.renderButton({
content: html`<img
src="${chatIcon}"
class="w-5 h-5"
style="filter: ${this.eventsFilters.get(
MessageCategory.CHAT,
)
? "grayscale(1) opacity(0.5)"
: "none"}"
/>`,
onClick: () =>
this.toggleEventFilter(MessageCategory.CHAT),
className: "cursor-pointer pointer-events-auto",
})}
</div>
<div class="flex items-center gap-3">
${this.latestGoldAmount !== null
? html`<span
class="text-green-400 font-semibold transition-all duration-300 ${this
.goldAmountAnimating
? "animate-pulse scale-110"
: "scale-100"}"
style="animation: ${this.goldAmountAnimating
? "goldBounce 0.6s ease-out"
: "none"}"
>+${renderNumber(this.latestGoldAmount)}</span
>`
: ""}
${this.renderButton({
content: "Hide",
onClick: this.toggleHidden,
className:
"text-white cursor-pointer pointer-events-auto",
})}
</div>
</div>
</div>
<!-- Content Area -->
<div
class="rounded-b-none md:rounded-b-md bg-gray-800/70 max-h-[30vh] flex flex-col-reverse overflow-y-auto w-full h-full"
>
<div>
<table
class="w-full max-h-none border-collapse text-white shadow-lg lg:text-base text-md md:text-xs"
style="pointer-events: auto;"
>
<tbody>
${filteredEvents.map(
(event, index) => html`
<tr>
<td
class="lg:px-2 lg:py-1 p-1 text-left ${getMessageTypeClasses(
event.type,
)}"
>
${this.getEventDescription(event)}
</button>`
: this.getEventDescription(event)}
${event.buttons
? html`
<div class="flex flex-wrap gap-1.5 mt-1">
${event.buttons.map(
(btn) => html`
<button
class="inline-block px-3 py-1 text-white rounded text-sm cursor-pointer transition-colors duration-300
${event.focusID
? this.renderButton({
content: this.getEventDescription(event),
onClick: () => {
event.focusID &&
this.emitGoToPlayerEvent(event.focusID);
},
className: "text-left",
})
: event.unitView
? this.renderButton({
content: this.getEventDescription(event),
onClick: () => {
event.unitView &&
this.emitGoToUnitEvent(
event.unitView,
);
},
className: "text-left",
})
: this.getEventDescription(event)}
<!-- Events with buttons (Alliance requests) -->
${event.buttons
? html`
<div class="flex flex-wrap gap-1.5 mt-1">
${event.buttons.map(
(btn) => html`
<button
class="inline-block px-3 py-1 text-white rounded text-md md:text-sm cursor-pointer transition-colors duration-300
${btn.className.includes("btn-info")
? "bg-blue-500 hover:bg-blue-600"
: btn.className.includes("btn-gray")
? "bg-gray-500 hover:bg-gray-600"
: "bg-green-600 hover:bg-green-700"}"
@click=${() => {
btn.action();
if (!btn.preventClose) {
this.removeEvent(index);
}
this.requestUpdate();
}}
>
${btn.text}
</button>
`,
)}
</div>
? "bg-blue-500 hover:bg-blue-600"
: btn.className.includes(
"btn-gray",
)
? "bg-gray-500 hover:bg-gray-600"
: "bg-green-600 hover:bg-green-700"}"
@click=${() => {
btn.action();
if (!btn.preventClose) {
const originalIndex =
this.events.findIndex(
(e) => e === event,
);
if (originalIndex !== -1) {
this.removeEvent(
originalIndex,
);
}
}
this.requestUpdate();
}}
>
${btn.text}
</button>
`,
)}
</div>
`
: ""}
</td>
</tr>
`,
)}
<!--- Incoming attacks row -->
${this.incomingAttacks.length > 0
? html`
<tr class="lg:px-2 lg:py-1 p-1">
<td class="lg:px-2 lg:py-1 p-1 text-left">
${this.renderIncomingAttacks()}
</td>
</tr>
`
: ""}
</td>
</tr>
`,
)}
${this.renderIncomingAttacks()} ${this.renderOutgoingAttacks()}
${this.renderOutgoingLandAttacks()} ${this.renderBoats()}
</tbody>
</table>
</div>
</div>
<!--- Outgoing attacks row -->
${this.outgoingAttacks.length > 0
? html`
<tr class="lg:px-2 lg:py-1 p-1">
<td class="lg:px-2 lg:py-1 p-1 text-left">
${this.renderOutgoingAttacks()}
</td>
</tr>
`
: ""}
<!--- Outgoing land attacks row -->
${this.outgoingLandAttacks.length > 0
? html`
<tr class="lg:px-2 lg:py-1 p-1">
<td class="lg:px-2 lg:py-1 p-1 text-left">
${this.renderOutgoingLandAttacks()}
</td>
</tr>
`
: ""}
<!--- Boats row -->
${this.outgoingBoats.length > 0
? html`
<tr class="lg:px-2 lg:py-1 p-1">
<td class="lg:px-2 lg:py-1 p-1 text-left">
${this.renderBoats()}
</td>
</tr>
`
: ""}
<!--- Empty row when no events or attacks -->
${filteredEvents.length === 0 &&
this.incomingAttacks.length === 0 &&
this.outgoingAttacks.length === 0 &&
this.outgoingLandAttacks.length === 0 &&
this.outgoingBoats.length === 0
? html`
<tr>
<td
class="lg:px-2 lg:py-1 p-1 min-w-72 text-left"
>
&nbsp;
</td>
</tr>
`
: ""}
</tbody>
</table>
</div>
</div>
</div>
`}
`;
}
+3 -2
View File
@@ -180,7 +180,7 @@ export class AttackExecution implements Execution {
if (deaths) {
this.mg.displayMessage(
`Attack cancelled, ${renderTroops(deaths)} soldiers killed during retreat.`,
MessageType.SUCCESS,
MessageType.ATTACK_CANCELLED,
this._owner.id(),
);
}
@@ -340,8 +340,9 @@ export class AttackExecution implements Execution {
`Conquered ${this.target.displayName()} received ${renderNumber(
gold,
)} gold`,
MessageType.SUCCESS,
MessageType.CONQUERED_PLAYER,
this._owner.id(),
gold,
);
this.target.removeGold(gold);
this._owner.addGold(gold);
+2 -1
View File
@@ -67,8 +67,9 @@ export class MirvExecution implements Execution {
this.mg.displayIncomingUnit(
this.nuke.id(),
// TODO TranslateText
`⚠️⚠️⚠️ ${this.player.name()} - MIRV INBOUND ⚠️⚠️⚠️`,
MessageType.ERROR,
MessageType.MIRV_INBOUND,
this.targetPlayer.id(),
);
}
+4 -2
View File
@@ -111,16 +111,18 @@ export class NukeExecution implements Execution {
} else if (this.nukeType === UnitType.AtomBomb) {
this.mg.displayIncomingUnit(
this.nuke.id(),
// TODO TranslateText
`${this.player.name()} - atom bomb inbound`,
MessageType.ERROR,
MessageType.NUKE_INBOUND,
target.id(),
);
this.breakAlliances(this.tilesToDestroy());
} else if (this.nukeType === UnitType.HydrogenBomb) {
this.mg.displayIncomingUnit(
this.nuke.id(),
// TODO TranslateText
`${this.player.name()} - hydrogen bomb inbound`,
MessageType.ERROR,
MessageType.HYDROGEN_BOMB_INBOUND,
target.id(),
);
this.breakAlliances(this.tilesToDestroy());
+2 -1
View File
@@ -207,8 +207,9 @@ export class PlayerExecution implements Execution {
`Conquered ${this.player.displayName()} received ${renderNumber(
gold,
)} gold`,
MessageType.SUCCESS,
MessageType.CONQUERED_PLAYER,
capturing.id(),
gold,
);
capturing.addGold(gold);
this.player.removeGold(gold);
+2 -2
View File
@@ -174,7 +174,7 @@ export class SAMLauncherExecution implements Execution {
if (!hit) {
this.mg.displayMessage(
`Missile failed to intercept ${type}`,
MessageType.ERROR,
MessageType.SAM_MISS,
this.sam.owner().id(),
);
} else {
@@ -182,7 +182,7 @@ export class SAMLauncherExecution implements Execution {
// Message
this.mg.displayMessage(
`${mirvWarheadTargets.length} MIRV warheads intercepted`,
MessageType.SUCCESS,
MessageType.SAM_HIT,
this.sam.owner().id(),
);
// Delete warheads
+1 -1
View File
@@ -62,7 +62,7 @@ export class SAMMissileExecution implements Execution {
if (result === true) {
this.mg.displayMessage(
`Missile intercepted ${this.target.type()}`,
MessageType.SUCCESS,
MessageType.SAM_HIT,
this._owner.id(),
);
this.active = false;
+6 -3
View File
@@ -131,21 +131,24 @@ export class TradeShipExecution implements Execution {
this.tradeShip!.owner().addGold(gold);
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from ship captured from ${this.origOwner.displayName()}`,
MessageType.SUCCESS,
MessageType.CAPTURED_ENEMY_UNIT,
this.tradeShip!.owner().id(),
gold,
);
} else {
this.srcPort.owner().addGold(gold);
this._dstPort.owner().addGold(gold);
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from trade with ${this.srcPort.owner().displayName()}`,
MessageType.SUCCESS,
MessageType.RECEIVED_GOLD_FROM_TRADE,
this._dstPort.owner().id(),
gold,
);
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from trade with ${this._dstPort.owner().displayName()}`,
MessageType.SUCCESS,
MessageType.RECEIVED_GOLD_FROM_TRADE,
this.srcPort.owner().id(),
gold,
);
}
return;
+3 -2
View File
@@ -62,7 +62,7 @@ export class TransportShipExecution implements Execution {
) {
mg.displayMessage(
`No boats available, max ${mg.config().boatMaxNumber()}`,
MessageType.WARN,
MessageType.ATTACK_FAILED,
this.attacker.id(),
);
this.active = false;
@@ -130,8 +130,9 @@ export class TransportShipExecution implements Execution {
if (this.targetID && this.targetID !== mg.terraNullius().id()) {
mg.displayIncomingUnit(
this.boat.id(),
// TODO TranslateText
`Naval invasion incoming from ${this.attacker.displayName()}`,
MessageType.WARN,
MessageType.NAVAL_INVASION_INBOUND,
this.targetID,
);
}
+67 -4
View File
@@ -587,6 +587,7 @@ export interface Game extends GameMap {
message: string,
type: MessageType,
playerID: PlayerID | null,
goldAmount?: bigint,
): void;
displayIncomingUnit(
unitID: number,
@@ -653,13 +654,75 @@ export interface EmojiMessage {
}
export enum MessageType {
SUCCESS,
INFO,
WARN,
ERROR,
ATTACK_FAILED,
ATTACK_CANCELLED,
ATTACK_REQUEST,
CONQUERED_PLAYER,
MIRV_INBOUND,
NUKE_INBOUND,
HYDROGEN_BOMB_INBOUND,
NAVAL_INVASION_INBOUND,
SAM_MISS,
SAM_HIT,
CAPTURED_ENEMY_UNIT,
UNIT_CAPTURED_BY_ENEMY,
UNIT_DESTROYED,
ALLIANCE_ACCEPTED,
ALLIANCE_REJECTED,
ALLIANCE_REQUEST,
ALLIANCE_BROKEN,
ALLIANCE_EXPIRED,
SENT_GOLD_TO_PLAYER,
RECEIVED_GOLD_FROM_PLAYER,
RECEIVED_GOLD_FROM_TRADE,
SENT_TROOPS_TO_PLAYER,
RECEIVED_TROOPS_FROM_PLAYER,
CHAT,
}
// Message categories used for filtering events in the EventsDisplay
export enum MessageCategory {
ATTACK = "ATTACK",
ALLIANCE = "ALLIANCE",
TRADE = "TRADE",
CHAT = "CHAT",
}
// Ensures that all message types are included in a category
export const MESSAGE_TYPE_CATEGORIES: Record<MessageType, MessageCategory> = {
[MessageType.ATTACK_FAILED]: MessageCategory.ATTACK,
[MessageType.ATTACK_CANCELLED]: MessageCategory.ATTACK,
[MessageType.ATTACK_REQUEST]: MessageCategory.ATTACK,
[MessageType.CONQUERED_PLAYER]: MessageCategory.ATTACK,
[MessageType.MIRV_INBOUND]: MessageCategory.ATTACK,
[MessageType.NUKE_INBOUND]: MessageCategory.ATTACK,
[MessageType.HYDROGEN_BOMB_INBOUND]: MessageCategory.ATTACK,
[MessageType.NAVAL_INVASION_INBOUND]: MessageCategory.ATTACK,
[MessageType.SAM_MISS]: MessageCategory.ATTACK,
[MessageType.SAM_HIT]: MessageCategory.ATTACK,
[MessageType.CAPTURED_ENEMY_UNIT]: MessageCategory.ATTACK,
[MessageType.UNIT_CAPTURED_BY_ENEMY]: MessageCategory.ATTACK,
[MessageType.UNIT_DESTROYED]: MessageCategory.ATTACK,
[MessageType.ALLIANCE_ACCEPTED]: MessageCategory.ALLIANCE,
[MessageType.ALLIANCE_REJECTED]: MessageCategory.ALLIANCE,
[MessageType.ALLIANCE_REQUEST]: MessageCategory.ALLIANCE,
[MessageType.ALLIANCE_BROKEN]: MessageCategory.ALLIANCE,
[MessageType.ALLIANCE_EXPIRED]: MessageCategory.ALLIANCE,
[MessageType.SENT_GOLD_TO_PLAYER]: MessageCategory.TRADE,
[MessageType.RECEIVED_GOLD_FROM_PLAYER]: MessageCategory.TRADE,
[MessageType.RECEIVED_GOLD_FROM_TRADE]: MessageCategory.TRADE,
[MessageType.SENT_TROOPS_TO_PLAYER]: MessageCategory.TRADE,
[MessageType.RECEIVED_TROOPS_FROM_PLAYER]: MessageCategory.TRADE,
[MessageType.CHAT]: MessageCategory.CHAT,
} as const;
/**
* Get the category of a message type
*/
export function getMessageCategory(messageType: MessageType): MessageCategory {
return MESSAGE_TYPE_CATEGORIES[messageType];
}
export interface NameViewData {
x: number;
y: number;
+2
View File
@@ -612,6 +612,7 @@ export class GameImpl implements Game {
message: string,
type: MessageType,
playerID: PlayerID | null,
goldAmount?: bigint,
): void {
let id: number | null = null;
if (playerID !== null) {
@@ -622,6 +623,7 @@ export class GameImpl implements Game {
messageType: type,
message: message,
playerID: id,
goldAmount: goldAmount,
});
}
+1
View File
@@ -162,6 +162,7 @@ export interface DisplayMessageUpdate {
type: GameUpdateType.DisplayEvent;
message: string;
messageType: MessageType;
goldAmount?: bigint;
playerID: number | null;
}
+5 -4
View File
@@ -558,12 +558,12 @@ export class PlayerImpl implements Player {
this.sentDonations.push(new Donation(recipient, this.mg.ticks()));
this.mg.displayMessage(
`Sent ${renderTroops(troops)} troops to ${recipient.name()}`,
MessageType.INFO,
MessageType.SENT_TROOPS_TO_PLAYER,
this.id(),
);
this.mg.displayMessage(
`Received ${renderTroops(troops)} troops from ${this.name()}`,
MessageType.SUCCESS,
MessageType.RECEIVED_TROOPS_FROM_PLAYER,
recipient.id(),
);
return true;
@@ -578,13 +578,14 @@ export class PlayerImpl implements Player {
this.sentDonations.push(new Donation(recipient, this.mg.ticks()));
this.mg.displayMessage(
`Sent ${renderNumber(gold)} gold to ${recipient.name()}`,
MessageType.INFO,
MessageType.SENT_GOLD_TO_PLAYER,
this.id(),
);
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from ${this.name()}`,
MessageType.SUCCESS,
MessageType.RECEIVED_GOLD_FROM_PLAYER,
recipient.id(),
gold,
);
return true;
}
+4 -3
View File
@@ -160,6 +160,7 @@ export class UnitImpl implements Unit {
case UnitType.City:
this.mg.stats().unitCapture(newOwner, this._type);
this.mg.stats().unitLose(this._owner, this._type);
break;
}
this._lastOwner = this._owner;
this._lastOwner._units = this._lastOwner._units.filter((u) => u !== this);
@@ -168,12 +169,12 @@ export class UnitImpl implements Unit {
this.mg.addUpdate(this.toUpdate());
this.mg.displayMessage(
`Your ${this.type()} was captured by ${newOwner.displayName()}`,
MessageType.ERROR,
MessageType.UNIT_CAPTURED_BY_ENEMY,
this._lastOwner.id(),
);
this.mg.displayMessage(
`Captured ${this.type()} from ${this._lastOwner.displayName()}`,
MessageType.SUCCESS,
MessageType.CAPTURED_ENEMY_UNIT,
newOwner.id(),
);
}
@@ -200,7 +201,7 @@ export class UnitImpl implements Unit {
if (displayMessage !== false && this._type !== UnitType.MIRVWarhead) {
this.mg.displayMessage(
`Your ${this._type} was destroyed`,
MessageType.ERROR,
MessageType.UNIT_DESTROYED,
this.owner().id(),
);
}
+55
View File
@@ -0,0 +1,55 @@
import { getMessageTypeClasses, severityColors } from "../src/client/Utils";
import { MessageType } from "../src/core/game/Game";
describe("getMessageTypeClasses", () => {
// Spy on console.warn to track when the default case is hit
let consoleSpy: jest.SpyInstance;
beforeEach(() => {
consoleSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
});
it("should return a valid CSS class for every MessageType", () => {
const messageTypes = Object.values(MessageType).filter(
(value) => typeof value === "number",
) as MessageType[];
messageTypes.forEach((messageType) => {
const result = getMessageTypeClasses(messageType);
expect(Object.values(severityColors)).toContain(result);
expect(result).toBeTruthy();
expect(typeof result).toBe("string");
});
});
it("should not trigger console.warn for any MessageType", () => {
const messageTypes = Object.values(MessageType).filter(
(value) => typeof value === "number",
) as MessageType[];
messageTypes.forEach((messageType) => {
getMessageTypeClasses(messageType);
});
// No message type should fall through to the default case
expect(consoleSpy).not.toHaveBeenCalled();
});
it("should return white color and warn for unknown message types", () => {
// Cast to MessageType to test the default case
const unknownType = 999 as MessageType;
const result = getMessageTypeClasses(unknownType);
expect(result).toBe(severityColors["white"]);
expect(consoleSpy).toHaveBeenCalledWith(
"Message type 999 has no explicit color",
);
});
});