Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
7 changes: 7 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1156,6 +1156,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...",
Expand Down Expand Up @@ -1376,6 +1379,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",
Expand Down
53 changes: 52 additions & 1 deletion src/client/Matchmaking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ 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";

type MatchmakingJoin = {
type: "join";
jwt: string;
clanTag?: string;
teammatePublicId?: string;
};

@customElement("matchmaking-modal")
Expand All @@ -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;
Expand Down Expand Up @@ -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",
)}
<p class="text-center text-xs text-white/40">
${translateText("matchmaking_modal.party_waiting_hint", {
id: this.teammatePublicId ?? "",
})}
</p>
`;
}
return html`
${this.queueSize !== null
? html`
Expand All @@ -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",
)}
`;
Expand Down Expand Up @@ -139,10 +170,20 @@ export class MatchmakingModal extends BaseModal {
this.limitReached = false;
this.queueSize = null;
this.reconnectAttempts = 0;
this.resetPartyState();
this.connect();
return true;
}

// Re-read on every queue entry so all paths agree. partyStatus stays null
// until the SERVER reports pairing: assuming "held" would strand a client
// deployed ahead of the server on "waiting" while it queues normally.
private resetPartyState() {
this.teammatePublicId =
this.mode === "2v2" ? getRankedTeammate(this.myPublicId) : null;
this.partyStatus = null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

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.
Expand Down Expand Up @@ -276,6 +317,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;
Expand All @@ -293,6 +338,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;
Expand Down Expand Up @@ -392,6 +441,7 @@ export class MatchmakingModal extends BaseModal {
return;
}

this.myPublicId = userMe.player.publicId;
const row =
this.mode === "2v2"
? userMe.player.leaderboard?.twoVtwo
Expand All @@ -405,6 +455,7 @@ export class MatchmakingModal extends BaseModal {
this.limitReached = false;
this.queueSize = null;
this.reconnectAttempts = 0;
this.resetPartyState();
this.connect();
}

Expand Down
46 changes: 46 additions & 0 deletions src/client/RankedTeammate.ts
Original file line number Diff line number Diff line change
@@ -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 */
}
}
Loading
Loading