Files
OpenFrontIO/src/client/hud/layers/WinModal.ts
T
49c358d304 feat: cancel ranked 2v2 matches that don't fill or fully spawn (#4655)
## Problem

A ranked 2v2 that starts with a missing player, or where a matched
player idles through the spawn phase, plays out as a lopsided 2v1 — and
records a ranked result (ELO) for a match that was never fair.

## Solution

Void the match from inside the sim: `WinCheckExecution` runs a one-time
check on the first check tick after the spawn phase ends. For
`rankedType === "2v2"`, if fewer than `maxPlayers` humans have spawned,
the game ends immediately with **no winner**. A player who never joined
isn't in the game start info at all, so the single spawn-count check
covers both the missing-at-start and never-spawned cases.

- `setWinner` now accepts `null` and emits a `WinUpdate` with `winner:
undefined` — the wire schema (`WinnerSchema`) already allowed an absent
winner.
- `WinModal`'s previously-empty `winner === undefined` branch shows
"Match cancelled — not all players spawned" and still votes the
winnerless result to the server, so the winner-vote consensus is reached
and the record archives promptly.
- The archived record simply has no `winner`; the API voids winnerless
ranked records (same shape any ranked game already produces when the
winner vote never reaches consensus).
- Only server change: winner votes are keyed with `JSON.stringify(winner
?? null)`, since `JSON.stringify(undefined)` is not a string and would
break the `VoteRound` key contract.

The check counts via `allPlayers()` rather than `players()`, since the
latter filters out tile-less players — which is exactly what a
never-spawned player is.

Ranked 1v1 is deliberately untouched (the existing last-human-connected
walkover still applies).

## Tests

`tests/Ranked2v2Cancel.test.ts`:
- 3 of 4 players spawn → game ends with a winnerless `WinUpdate`
- a 4th player never joined (absent from start info) → same
- all 4 spawn → no cancellation
- unranked team game short a player → no cancellation

Full suite (2094 tests), tsc, ESLint, and Prettier all pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 11:18:26 -07:00

336 lines
9.9 KiB
TypeScript

import { html, LitElement, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import {
getGamesPlayed,
isInIframe,
translateText,
TUTORIAL_VIDEO_URL,
} from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import { RankedType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { getUserMe } from "../../Api";
import "../../components/CosmeticButton";
import { Controller } from "../../Controller";
import {
fetchCosmetics,
purchaseCosmetic,
resolveCosmetics,
} from "../../Cosmetics";
import { crazyGamesSDK } from "../../CrazyGamesSDK";
import { Platform } from "../../Platform";
import { SendWinnerEvent } from "../../Transport";
import { GameView } from "../../view";
@customElement("win-modal")
export class WinModal extends LitElement implements Controller {
public game: GameView;
public eventBus: EventBus;
private hasShownDeathModal = false;
@state()
isVisible = false;
@state()
showButtons = false;
@state()
private isWin = false;
@state()
private isRankedGame = false;
@state()
private patternContent: TemplateResult | null = null;
private _title: string;
private rand = Math.random();
// Override to prevent shadow DOM creation
createRenderRoot() {
return this;
}
constructor() {
super();
}
render() {
return html`
<div
class="${this.isVisible
? "fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-gray-800/70 p-6 shrink-0 rounded-lg z-[10010] shadow-2xl backdrop-blur-xs text-white w-87.5 max-w-[90%] md:w-175"
: "hidden"}"
>
<h2 class="m-0 mb-4 text-[26px] text-center text-white">
${this._title || ""}
</h2>
${this.innerHtml()}
<div
class="${this.showButtons
? "flex justify-between gap-2.5"
: "hidden"}"
>
<o-button
variant="primary"
width="block"
class="flex-1"
translationKey="win_modal.exit"
@click=${this._handleExit}
></o-button>
${this.isRankedGame
? html`
<o-button
variant="primary"
width="block"
class="flex-1"
translationKey="win_modal.requeue"
@click=${this._handleRequeue}
></o-button>
`
: null}
<o-button
variant="primary"
width="block"
class="flex-1"
.title=${this.game?.myPlayer()?.isAlive()
? translateText("win_modal.keep")
: translateText("win_modal.spectate")}
@click=${this.hide}
></o-button>
</div>
</div>
`;
}
innerHtml() {
if (isInIframe()) {
return this.steamWishlist();
}
if (!this.isWin && getGamesPlayed() < 3) {
return this.renderYoutubeTutorial();
}
if (this.rand < 0.25) {
return this.steamWishlist();
} else if (this.rand < 0.5) {
return this.discordDisplay();
} else {
return this.renderPatternButton();
}
}
renderYoutubeTutorial() {
return html`
<div class="text-center mb-6 bg-black/30 p-2.5 rounded-sm">
<h3 class="text-xl font-semibold text-white mb-3">
${translateText("win_modal.youtube_tutorial")}
</h3>
<!-- 56.25% = 9:16 -->
<div class="relative w-full pb-[56.25%]">
<iframe
class="absolute top-0 left-0 w-full h-full rounded-sm"
src="${this.isVisible ? TUTORIAL_VIDEO_URL : ""}"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
></iframe>
</div>
</div>
`;
}
renderPatternButton() {
return html`
<div class="text-center mb-6 bg-black/30 p-2.5 rounded-sm">
<h3 class="text-xl font-semibold text-white mb-3">
${translateText("win_modal.support_openfront")}
</h3>
<p class="text-white mb-3">
${translateText("win_modal.territory_pattern")}
</p>
<div class="flex justify-center">${this.patternContent}</div>
</div>
`;
}
async loadPatternContent() {
const me = await getUserMe();
const cosmetics = await fetchCosmetics();
const purchasable = resolveCosmetics(cosmetics, me, null).filter(
(r) => r.type === "pattern" && r.relationship === "purchasable",
);
if (purchasable.length === 0) {
this.patternContent = html``;
return;
}
// Shuffle the array and take patterns based on screen size
const shuffled = [...purchasable].sort(() => Math.random() - 0.5);
const maxPatterns = Platform.isMobileWidth ? 1 : 3;
const selected = shuffled.slice(0, Math.min(maxPatterns, shuffled.length));
this.patternContent = html`
<div class="flex gap-4 flex-wrap justify-start">
${selected.map(
(r) => html`
<cosmetic-button
.resolved=${r}
.onPurchase=${purchaseCosmetic}
></cosmetic-button>
`,
)}
</div>
`;
}
steamWishlist(): TemplateResult {
return html`<p class="m-0 mb-5 text-center bg-black/30 p-2.5 rounded-sm">
<a
href="https://store.steampowered.com/app/3560670"
target="_blank"
rel="noopener noreferrer"
class="text-[#4a9eff] underline font-medium transition-colors duration-200 text-2xl hover:text-[#6db3ff]"
>
${translateText("win_modal.wishlist")}
</a>
</p>`;
}
discordDisplay(): TemplateResult {
return html`
<div class="text-center mb-6 bg-black/30 p-2.5 rounded-sm">
<h3 class="text-xl font-semibold text-white mb-3">
${translateText("win_modal.join_discord")}
</h3>
<p class="text-white mb-3">
${translateText("win_modal.discord_description")}
</p>
<a
href="https://discord.com/invite/openfront"
target="_blank"
rel="noopener noreferrer"
class="inline-block px-6 py-3 bg-indigo-600 text-white rounded-sm font-semibold transition-all duration-200 hover:bg-indigo-700 hover:-translate-y-px no-underline"
>
${translateText("win_modal.join_server")}
</a>
</div>
`;
}
async show() {
crazyGamesSDK.gameplayStop();
await this.loadPatternContent();
// Check if this is a ranked game
this.isRankedGame =
this.game.config().gameConfig().rankedType !== undefined;
this.isVisible = true;
this.requestUpdate();
setTimeout(() => {
this.showButtons = true;
this.requestUpdate();
}, 3000);
}
hide() {
this.isVisible = false;
this.showButtons = false;
this.requestUpdate();
}
private _handleExit() {
this.hide();
window.location.href = "/";
}
private _handleRequeue() {
this.hide();
// Navigate to homepage and open matchmaking modal for the same mode
const requeue =
this.game.config().gameConfig().rankedType === RankedType.TwoVTwo
? "/?requeue=2v2"
: "/?requeue";
window.location.href = requeue;
}
init() {}
tick() {
const myPlayer = this.game.myPlayer();
if (
!this.hasShownDeathModal &&
myPlayer &&
!myPlayer.isAlive() &&
!this.game.inSpawnPhase() &&
myPlayer.hasSpawned()
) {
this.hasShownDeathModal = true;
this._title = translateText("win_modal.died");
this.show();
}
const updates = this.game.updatesSinceLastTick();
const winUpdates = updates !== null ? updates[GameUpdateType.Win] : [];
winUpdates.forEach((wu) => {
if (wu.winner === undefined) {
// Match cancelled (e.g. a ranked 2v2 that didn't fill or fully
// spawn): the game ends with no winner. Still vote the result to the
// server so the record is archived winnerless (never ranked).
this.eventBus.emit(new SendWinnerEvent(undefined, wu.allPlayersStats));
this._title = translateText("win_modal.match_cancelled");
this.isWin = false;
history.replaceState(null, "", `${window.location.pathname}?replay`);
this.show();
} else if (wu.winner[0] === "team") {
this.eventBus.emit(new SendWinnerEvent(wu.winner, wu.allPlayersStats));
if (wu.winner[1] === this.game.myPlayer()?.team()) {
this._title = translateText("win_modal.your_team");
this.isWin = true;
crazyGamesSDK.happytime();
} else {
this._title = translateText("win_modal.other_team", {
team: wu.winner[1],
});
this.isWin = false;
}
history.replaceState(null, "", `${window.location.pathname}?replay`);
this.show();
} else if (wu.winner[0] === "nation") {
this._title = translateText("win_modal.nation_won", {
nation: wu.winner[1],
});
this.isWin = false;
this.show();
} else {
const winner = this.game.playerByClientID(wu.winner[1]);
if (!winner?.isPlayer()) return;
const winnerClient = winner.clientID();
if (winnerClient !== null) {
this.eventBus.emit(
new SendWinnerEvent(["player", winnerClient], wu.allPlayersStats),
);
}
if (
winnerClient !== null &&
winnerClient === this.game.myPlayer()?.clientID()
) {
this._title = translateText("win_modal.you_won");
this.isWin = true;
crazyGamesSDK.happytime();
} else {
this._title = translateText("win_modal.other_won", {
player: winner.displayName(),
});
this.isWin = false;
}
history.replaceState(null, "", `${window.location.pathname}?replay`);
this.show();
}
});
}
}