diff --git a/resources/lang/en.json b/resources/lang/en.json index a30e2b801c..75fbc39075 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1157,6 +1157,9 @@ "limit_reached_info": "Free matches refill daily. Subscribe for unlimited ranked play.", "limit_upsell": "Get unlimited ranked", "no_elo": "No ELO yet", + "party_searching_duo": "Searching for game as a duo...", + "party_waiting": "Waiting for your teammate...", + "party_waiting_hint": "You'll queue together once {id} picks 2v2 with your ID.", "queue_size": "Players in queue: {count}", "replaced": "You joined matchmaking from another tab or window.", "searching": "Searching for game...", @@ -1377,6 +1380,10 @@ "delete_unit_description": "Click to delete the nearest unit", "delete_unit_title": "Delete Unit" }, + "ranked_modal": { + "teammate_hint": "Optional. Both of you must enter each other's player ID to queue as a team.", + "teammate_placeholder": "Teammate Player ID (optional)" + }, "relation": { "default": "Default", "distrustful": "Distrustful", diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts index f3df5df372..d43f29b400 100644 --- a/src/client/Matchmaking.ts +++ b/src/client/Matchmaking.ts @@ -9,6 +9,7 @@ import "./components/Difficulties"; import { modalHeader } from "./components/ui/ModalHeader"; import { crazyGamesSDK } from "./CrazyGamesSDK"; import type { JoinLobbyEvent } from "./Main"; +import { getRankedTeammate } from "./RankedTeammate"; import type { UsernameInput } from "./UsernameInput"; import { translateText } from "./Utils"; @@ -16,6 +17,7 @@ type MatchmakingJoin = { type: "join"; jwt: string; clanTag?: string; + teammatePublicId?: string; }; @customElement("matchmaking-modal") @@ -29,7 +31,17 @@ export class MatchmakingModal extends BaseModal { // Which queue to join; set by Main from the open-matchmaking event // before the modal opens. public mode: "1v1" | "2v2" = "1v1"; + // Optional 2v2 teammate (public id), read from storage at queue time so the + // ranked screen, the requeue URL and an in-place requeue all agree. The server + // holds this player out of matching until that teammate names them back. + // Chosen before queueing, so you can never be matched solo mid-setup. + private teammatePublicId: string | null = null; @state() private connected = false; + // Server-reported pairing state: waiting = held for the teammate, + // ready = both named each other and the duo is searching. + @state() private partyStatus: "waiting" | "ready" | null = null; + // Own id, so a stale self-reference is never sent as a teammate. + private myPublicId: string | null = null; @state() private socket: WebSocket | null = null; @state() private gameID: string | null = null; @state() private limitReached = false; @@ -97,6 +109,21 @@ export class MatchmakingModal extends BaseModal { ); } if (this.gameID === null) { + // Held for a teammate: don't claim to be searching, since this player is + // excluded from the pool until the other side queues too. + if (this.partyStatus === "waiting") { + return html` + ${this.renderLoadingSpinner( + translateText("matchmaking_modal.party_waiting"), + "yellow", + )} +

+ ${translateText("matchmaking_modal.party_waiting_hint", { + id: this.teammatePublicId ?? "", + })} +

+ `; + } return html` ${this.queueSize !== null ? html` @@ -108,7 +135,11 @@ export class MatchmakingModal extends BaseModal { ` : ""} ${this.renderLoadingSpinner( - translateText("matchmaking_modal.searching"), + translateText( + this.partyStatus === "ready" + ? "matchmaking_modal.party_searching_duo" + : "matchmaking_modal.searching", + ), "green", )} `; @@ -139,10 +170,19 @@ export class MatchmakingModal extends BaseModal { this.limitReached = false; this.queueSize = null; this.reconnectAttempts = 0; + this.loadTeammate(); this.connect(); return true; } + // Re-read on every queue entry so all paths agree. The status itself is never + // assumed, only taken from the server: guessing "held" would strand a client + // deployed ahead of the server on "waiting" while it queues normally. + private loadTeammate() { + this.teammatePublicId = + this.mode === "2v2" ? getRankedTeammate(this.myPublicId) : null; + } + private openSubscriptions = () => { // The matchmaking modal isn't registered with the modal router, so it // won't be closed by the store opening from the hash change. @@ -232,6 +272,10 @@ export class MatchmakingModal extends BaseModal { } private async connect() { + // Pairing state belongs to the socket that reported it. Reconnects (watchdog, + // close retry) call connect() directly, so clearing here stops a stale + // waiting/ready outliving its connection. + this.partyStatus = null; // Pending timers from a previous socket must not fire on this one. this.clearWatchdog(); if (this.connectTimeout) { @@ -276,6 +320,10 @@ export class MatchmakingModal extends BaseModal { ...(this.selectedClanTag === null ? {} : { clanTag: this.selectedClanTag }), + // Server pairs two queued players who name each other. + ...(this.teammatePublicId === null + ? {} + : { teammatePublicId: this.teammatePublicId }), }; this.socket.send(JSON.stringify(message)); this.connected = true; @@ -293,6 +341,10 @@ export class MatchmakingModal extends BaseModal { this.queueSize = data.count; return; } + if (data.type === "party-status") { + this.partyStatus = data.status === "ready" ? "ready" : "waiting"; + return; + } if (data.type === "match-assignment") { this.clearWatchdog(); this.intentionalClose = true; @@ -392,6 +444,7 @@ export class MatchmakingModal extends BaseModal { return; } + this.myPublicId = userMe.player.publicId; const row = this.mode === "2v2" ? userMe.player.leaderboard?.twoVtwo @@ -405,6 +458,7 @@ export class MatchmakingModal extends BaseModal { this.limitReached = false; this.queueSize = null; this.reconnectAttempts = 0; + this.loadTeammate(); this.connect(); } diff --git a/src/client/RankedTeammate.ts b/src/client/RankedTeammate.ts new file mode 100644 index 0000000000..cf2701df18 --- /dev/null +++ b/src/client/RankedTeammate.ts @@ -0,0 +1,46 @@ +// The chosen ranked 2v2 teammate, by public id. Persisted because the requeue +// path (/?requeue=2v2) reloads the page, so nothing in memory survives it. One +// source of truth keeps every queue entry path in agreement. +const TEAMMATE_KEY = "ranked-2v2-teammate"; + +// Mirror, so the feature still works for this page when storage is unavailable +// (private mode) instead of silently queueing solo. +let inMemory: string | null = null; + +/** + * The stored teammate, or null when unset. Pass the signed-in player's own id to + * reject (and clear) a self-reference: two accounts in one browser can leave the + * other's id behind, and queueing with your own means waiting on yourself. + */ +export function getRankedTeammate(ownPublicId?: string | null): string | null { + let value: string | null = inMemory; + try { + value = localStorage.getItem(TEAMMATE_KEY) ?? inMemory; + } catch { + /* storage disabled; the in-memory mirror stands in */ + } + // Empty means "no teammate"; normalise so callers only test for null. + if (value === null || value === "") return null; + if ( + ownPublicId !== undefined && + ownPublicId !== null && + value === ownPublicId + ) { + setRankedTeammate(""); + return null; + } + return value; +} + +export function setRankedTeammate(publicId: string): void { + inMemory = publicId === "" ? null : publicId; + try { + if (publicId) { + localStorage.setItem(TEAMMATE_KEY, publicId); + } else { + localStorage.removeItem(TEAMMATE_KEY); + } + } catch { + /* best-effort; the in-memory mirror keeps it for this page */ + } +} diff --git a/src/client/components/RankedModal.ts b/src/client/components/RankedModal.ts index d377546fe9..4839ab070d 100644 --- a/src/client/components/RankedModal.ts +++ b/src/client/components/RankedModal.ts @@ -4,6 +4,7 @@ import { UserMeResponse } from "../../core/ApiSchemas"; import { getUserMe, hasLinkedAccount } from "../Api"; import { userAuth } from "../Auth"; import { crazyGamesSDK } from "../CrazyGamesSDK"; +import { getRankedTeammate, setRankedTeammate } from "../RankedTeammate"; import { translateText } from "../Utils"; import { BaseModal } from "./BaseModal"; import { modalHeader } from "./ui/ModalHeader"; @@ -12,6 +13,10 @@ import { modalHeader } from "./ui/ModalHeader"; export class RankedModal extends BaseModal { protected routerName = "ranked"; + // Shared by both live cards; h-full keeps them the same height. + private static readonly CARD_CLASS = + "flex flex-col w-full h-full min-h-[9.5rem] rounded-2xl bg-malibu-blue border-0 transition-all duration-200 hover:bg-aquarius hover:scale-[1.03] hover:shadow-[var(--shadow-action-card-hover)] active:bg-malibu-blue/80 active:scale-[0.98] p-6 items-center justify-center gap-3"; + @state() private elo: number | string = "..."; @state() private elo2v2: number | string = "..."; @state() private userMeResponse: UserMeResponse | false = false; @@ -19,6 +24,15 @@ export class RankedModal extends BaseModal { // CrazyGames players authenticate through the SDK, not a linked // Discord/Google/email account, so track that separately for ranked. @state() private crazyGamesSignedIn = false; + // Optional 2v2 teammate, by public id. Empty = ordinary solo queue. Ids are + // permanent, so a regular duo exchanges them once. + @state() private teammateId = ""; + + private ownPublicId(): string | null { + return this.userMeResponse === false + ? null + : this.userMeResponse.player.publicId; + } // Eligible to see/play ranked: a linked account or a signed-in CrazyGames one. private isRankedEligible(): boolean { @@ -75,6 +89,7 @@ export class RankedModal extends BaseModal { this.elo = "..."; this.elo2v2 = "..."; this.errorMessage = null; + this.teammateId = getRankedTeammate() ?? ""; try { const userMe = await getUserMe(); @@ -89,6 +104,8 @@ export class RankedModal extends BaseModal { this.elo = translateText("map_component.error"); this.elo2v2 = translateText("map_component.error"); } finally { + // Re-check now the player is known, so a stale self-reference isn't shown. + this.teammateId = getRankedTeammate(this.ownPublicId()) ?? ""; this.updateElo(); } } @@ -111,20 +128,10 @@ export class RankedModal extends BaseModal {
${this.renderCard( translateText("mode_selector.ranked_1v1_title"), - this.errorMessage ?? - (this.isRankedEligible() - ? translateText("matchmaking_modal.elo", { elo: this.elo }) - : translateText("mode_selector.ranked_title")), + this.modeSubtitle(this.elo), () => this.handleRanked("1v1"), )} - ${this.renderCard( - translateText("mode_selector.ranked_2v2_title"), - this.errorMessage ?? - (this.isRankedEligible() - ? translateText("matchmaking_modal.elo", { elo: this.elo2v2 }) - : translateText("mode_selector.ranked_title")), - () => this.handleRanked("2v2"), - )} + ${this.render2v2Card()} ${this.renderDisabledCard( translateText("mode_selector.coming_soon"), "", @@ -138,45 +145,106 @@ export class RankedModal extends BaseModal { `; } + // Error, else this mode's ELO, else a plain label when ranked isn't available. + private modeSubtitle(elo: number | string): string { + if (this.errorMessage !== null) return this.errorMessage; + return this.isRankedEligible() + ? translateText("matchmaking_modal.elo", { elo }) + : translateText("mode_selector.ranked_title"); + } + + // Shared by every card so the variants can't drift apart typographically. + private cardBody(title: string, subtitle: string, muted = false) { + return html` +
+

+ ${title} +

+

+ ${subtitle} +

+
+ `; + } + private renderCard(title: string, subtitle: string, onClick: () => void) { return html` - `; } + // The teammate field is pinned to the bottom and out of the flow, so the + // title/ELO block stays centred like the 1v1 card's. A div rather than a button + // because