Skip to content
Open
Show file tree
Hide file tree
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
65 changes: 41 additions & 24 deletions src/client/graphics/layers/MainRadialMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ const swordIcon = assetUrl("images/SwordIconWhite.svg");

import { ContextMenuEvent } from "../../InputHandler";

function emptyPlayerActions(): PlayerActions {
return {
canAttack: false,
buildableUnits: [],
canSendEmojiAllPlayers: false,
};
}

@customElement("main-radial-menu")
export class MainRadialMenu extends LitElement implements Layer {
private radialMenu: RadialMenu;
Expand Down Expand Up @@ -87,27 +95,40 @@ export class MainRadialMenu extends LitElement implements Layer {
if (!this.game.isValidCoord(worldCoords.x, worldCoords.y)) {
return;
}
if (this.game.myPlayer() === null) {
const myPlayer = this.game.myPlayer();
const isReplay = this.game.config().isReplay();
if (myPlayer === null && !isReplay) {
return;
}
Comment on lines +100 to 102
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so in this case we haven't spawned yet?

this.clickedTile = this.game.ref(worldCoords.x, worldCoords.y);
this.game
.myPlayer()!
.actions(this.clickedTile)
.then((actions) => {
this.updatePlayerActions(
this.game.myPlayer()!,
actions,
this.clickedTile!,
event.x,
event.y,
);
});
if (myPlayer === null) {
// Replay: only show the info-only radial when right-clicking on a player
if (!this.game.owner(this.clickedTile).isPlayer()) {
return;
}
this.updatePlayerActions(
null,
emptyPlayerActions(),
this.clickedTile,
event.x,
event.y,
);
return;
}
myPlayer.actions(this.clickedTile).then((actions) => {
this.updatePlayerActions(
myPlayer,
actions,
this.clickedTile!,
event.x,
event.y,
);
});
Comment on lines +118 to +126
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Capture tile locally before async callbacks to avoid stale-menu updates

At Line 118 and Line 188, the callback reads this.clickedTile again after the async call. If the user right-clicks another tile before resolve, actions for tile A can be applied to tile B.

Proposed fix
-      this.clickedTile = this.game.ref(worldCoords.x, worldCoords.y);
+      this.clickedTile = this.game.ref(worldCoords.x, worldCoords.y);
+      const clickedTile = this.clickedTile;
...
-      myPlayer.actions(this.clickedTile).then((actions) => {
+      myPlayer.actions(clickedTile).then((actions) => {
         this.updatePlayerActions(
           myPlayer,
           actions,
-          this.clickedTile!,
+          clickedTile,
           event.x,
           event.y,
         );
       });
...
-    myPlayer.actions(this.clickedTile).then((actions) => {
-      this.updatePlayerActions(myPlayer, actions, this.clickedTile!);
+    const clickedTile = this.clickedTile;
+    myPlayer.actions(clickedTile).then((actions) => {
+      this.updatePlayerActions(myPlayer, actions, clickedTile);
     });

Also applies to: 188-190

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/graphics/layers/MainRadialMenu.ts` around lines 118 - 126, The
issue is that this.clickedTile is accessed inside async callbacks in the methods
around lines 118 and 188, leading to stale or incorrect tile usage if it changes
before the promise resolves. Fix this by capturing the current clickedTile value
into a local constant before calling myPlayer.actions or other async functions,
then use that local constant inside the callback to update the player actions
consistently for the intended tile.

});
}

private async updatePlayerActions(
myPlayer: PlayerView,
myPlayer: PlayerView | null,
actions: PlayerActions,
tile: TileRef,
screenX: number | null = null,
Expand Down Expand Up @@ -139,6 +160,7 @@ export class MainRadialMenu extends LitElement implements Layer {
};

const isFriendlyTarget =
myPlayer !== null &&
recipient !== null &&
recipient.isFriendly(myPlayer) &&
!recipient.isDisconnected();
Expand All @@ -161,16 +183,11 @@ export class MainRadialMenu extends LitElement implements Layer {

async tick() {
if (!this.radialMenu.isMenuVisible() || this.clickedTile === null) return;
this.game
.myPlayer()!
.actions(this.clickedTile)
.then((actions) => {
this.updatePlayerActions(
this.game.myPlayer()!,
actions,
this.clickedTile!,
);
});
const myPlayer = this.game.myPlayer();
if (myPlayer === null) return; // replay mode: nothing to refresh
myPlayer.actions(this.clickedTile).then((actions) => {
this.updatePlayerActions(myPlayer, actions, this.clickedTile!);
});
}

renderLayer(context: CanvasRenderingContext2D) {
Expand Down
35 changes: 22 additions & 13 deletions src/client/graphics/layers/PlayerPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -867,7 +867,8 @@ export class PlayerPanel extends LitElement implements Layer {
if (!this.isVisible) return html``;

const my = this.g.myPlayer();
if (!my) return html``;
const isReplay = this.g.config().isReplay();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it maybe be cleaner to create:

this.g.isSpectator()

and it just does:

isSpectator() {
!(this.myPlayer()?.isAlive) || this.config.isReplay
}

if (!my && !isReplay) return html``;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so in this case we are in a live match but haven'ts spawned in? so we don't render the player panel?

if (!this.tile) return html``;

const owner = this.g.owner(this.tile);
Expand All @@ -877,8 +878,10 @@ export class PlayerPanel extends LitElement implements Layer {
return html``;
}
const other = owner as PlayerView;
const myGoldNum = my.gold();
const myTroopsNum = Number(my.troops());
// In replay mode myPlayer() is null; use other as stand-in so read-only rendering works
const viewer = my ?? other;
const myGoldNum = viewer.gold();
const myTroopsNum = Number(viewer.troops());

return html`
<style>
Expand Down Expand Up @@ -946,7 +949,9 @@ export class PlayerPanel extends LitElement implements Layer {
class="p-6 flex flex-col gap-2 font-sans antialiased text-[14.5px] leading-relaxed"
>
<!-- Identity (flag, name, type, traitor, relation) -->
<div class="mb-1">${this.renderIdentityRow(other, my)}</div>
<div class="mb-1">
${this.renderIdentityRow(other, viewer)}
</div>

${this.sendTarget
? html`
Expand All @@ -957,7 +962,7 @@ export class PlayerPanel extends LitElement implements Layer {
? myTroopsNum
: myGoldNum}
.uiState=${this.uiState}
.myPlayer=${my}
.myPlayer=${viewer}
.target=${this.sendTarget}
.gameView=${this.g}
.eventBus=${this.eventBus}
Expand All @@ -973,7 +978,7 @@ export class PlayerPanel extends LitElement implements Layer {
? html`
<player-moderation-modal
.open=${true}
.myPlayer=${my}
.myPlayer=${viewer}
.target=${this.moderationTarget}
.eventBus=${this.eventBus}
.isAdmin=${this.isAdminRole}
Expand All @@ -992,12 +997,14 @@ export class PlayerPanel extends LitElement implements Layer {
${this.renderResources(other)}

<!-- Rocket direction toggle -->
${other === my ? this.renderRocketDirectionToggle() : ""}
${other === viewer && !isReplay
? this.renderRocketDirectionToggle()
: ""}

<ui-divider></ui-divider>

<!-- Stats: betrayals / trading -->
${this.renderStats(other, my)}
${this.renderStats(other, viewer)}

<ui-divider></ui-divider>

Expand All @@ -1006,11 +1013,13 @@ export class PlayerPanel extends LitElement implements Layer {

<!-- Alliance time remaining -->
${this.renderAllianceExpiry()}

<ui-divider></ui-divider>

<!-- Actions -->
${this.renderActions(my, other)}
${isReplay
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, instead of isReplay, it's isSpectator because being dead is the equivalent of watching a replay

? ""
: html`
<ui-divider></ui-divider>
<!-- Actions -->
${this.renderActions(viewer, other)}
`}
Comment on lines 1015 to +1022
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Hide or clear alliance-expiry state in replay mode

Line 1015 still renders alliance expiry in replay, but replay does not refresh that value (live-only update path), so stale text can leak from previous state.

Proposed fix
-                    <!-- Alliance time remaining -->
-                    ${this.renderAllianceExpiry()}
+                    <!-- Alliance time remaining -->
+                    ${isReplay ? "" : this.renderAllianceExpiry()}

Optionally also clear on replay entry in show()/tick() for extra safety.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/graphics/layers/PlayerPanel.ts` around lines 1015 - 1022,
renderAllianceExpiry() is still rendered when isReplay is true, causing stale
expiry text to persist because replay doesn't update the live-only value; stop
rendering or clear the underlying state when in replay: update the template
where renderAllianceExpiry() is used (near the current conditional around
renderActions) so it is not called when isReplay is true, and additionally
ensure show() and/or tick() detect entering replay mode and clear the
alliance-expiry state used by renderAllianceExpiry() (or set it to an
empty/undefined value) to be extra safe; reference renderAllianceExpiry(),
renderActions(), show(), and tick() when making the changes.

</div>
</div>
</div>
Expand Down
25 changes: 15 additions & 10 deletions src/client/graphics/layers/RadialMenuElements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const traitorIcon = assetUrl("images/TraitorIconWhite.svg");
const xIcon = assetUrl("images/XIcon.svg");

export interface MenuElementParams {
myPlayer: PlayerView;
myPlayer: PlayerView | null;
selected: PlayerView | null;
tile: TileRef;
playerActions: PlayerActions;
Expand Down Expand Up @@ -124,7 +124,7 @@ export enum Slot {

function isFriendlyTarget(params: MenuElementParams): boolean {
const selectedPlayer = params.selected;
if (selectedPlayer === null) return false;
if (selectedPlayer === null || params.myPlayer === null) return false;
const isFriendly = (selectedPlayer as PlayerView).isFriendly;
if (typeof isFriendly !== "function") return false;
return isFriendly.call(selectedPlayer, params.myPlayer);
Expand Down Expand Up @@ -215,7 +215,7 @@ const allyRequestElement: MenuElement = {
icon: allianceIcon,
action: (params: MenuElementParams) => {
params.playerActionHandler.handleAllianceRequest(
params.myPlayer,
params.myPlayer!,
params.selected!,
);
params.closeMenu();
Expand Down Expand Up @@ -266,7 +266,7 @@ const allyBreakElement: MenuElement = {
icon: traitorIcon,
action: (params: MenuElementParams) => {
params.playerActionHandler.handleBreakAlliance(
params.myPlayer,
params.myPlayer!,
params.selected!,
);
params.closeMenu();
Expand Down Expand Up @@ -500,8 +500,10 @@ const donateGoldRadialElement: MenuElement = {
export const deleteUnitElement: MenuElement = {
id: Slot.Delete,
name: "delete",
cooldown: (params: MenuElementParams) => params.myPlayer.deleteUnitCooldown(),
cooldown: (params: MenuElementParams) =>
params.myPlayer?.deleteUnitCooldown() ?? 0,
disabled: (params: MenuElementParams) => {
if (params.myPlayer === null) return true;
const tileOwner = params.game.owner(params.tile);
const isLand = params.game.isLand(params.tile);

Expand Down Expand Up @@ -548,8 +550,8 @@ export const deleteUnitElement: MenuElement = {
],
action: (params: MenuElementParams) => {
const DELETE_SELECTION_RADIUS = 5;
const myUnits = params.myPlayer
.units()
const myUnits = params
.myPlayer!.units()
.filter(
(unit) =>
params.game.manhattanDist(unit.tile(), params.tile) <=
Expand Down Expand Up @@ -591,7 +593,7 @@ export const boatMenuElement: MenuElement = {
color: COLORS.boat,

action: async (params: MenuElementParams) => {
params.playerActionHandler.handleBoatAttack(params.myPlayer, params.tile);
params.playerActionHandler.handleBoatAttack(params.myPlayer!, params.tile);

params.closeMenu();
},
Expand Down Expand Up @@ -627,7 +629,7 @@ export const centerButtonElement: CenterButtonElement = {
if (isFriendlyTarget(params) && !isDisconnectedTarget(params)) {
const selectedPlayer = params.selected as PlayerView;
const ratio = params.uiState?.attackRatio ?? 1;
const troopsToDonate = Math.floor(ratio * params.myPlayer.troops());
const troopsToDonate = Math.floor(ratio * params.myPlayer!.troops());
if (troopsToDonate > 0) {
params.playerActionHandler.handleDonateTroops(
selectedPlayer,
Expand All @@ -636,7 +638,7 @@ export const centerButtonElement: CenterButtonElement = {
}
} else {
params.playerActionHandler.handleAttack(
params.myPlayer,
params.myPlayer!,
params.selected?.id() ?? null,
);
}
Expand All @@ -652,6 +654,9 @@ export const rootMenuElement: MenuElement = {
icon: infoIcon,
color: COLORS.info,
subMenu: (params: MenuElementParams) => {
if (params.myPlayer === null) {
return [infoMenuElement];
}
const isAllied = params.selected?.isAlliedWith(params.myPlayer);
const isDisconnected = isDisconnectedTarget(params);

Expand Down
Loading