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
7 changes: 7 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down Expand Up @@ -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",
Expand Down
56 changes: 55 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,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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -392,6 +444,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 +458,7 @@ export class MatchmakingModal extends BaseModal {
this.limitReached = false;
this.queueSize = null;
this.reconnectAttempts = 0;
this.loadTeammate();
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