Override SendouQ rejoin yes vote when queueing elsewhere

A player who voted to continue with their group and then queued up
elsewhere still showed as a yes to the rest. They would wait for a vote
that was never coming, and once everyone else had voted the rejoin fell
through and dumped them back to /q instead of giving them their group.
This commit is contained in:
Kalle
2026-08-08 11:30:12 +03:00
parent 514a29d4e1
commit 730ba45774
3 changed files with 140 additions and 16 deletions

View File

@@ -10,14 +10,18 @@ import * as GroupMatchContinueVoteRepository from "~/features/sendouq-match/Grou
import { FULL_GROUP_SIZE } from "./q-constants";
import * as SQGroupRepository from "./SQGroupRepository.server";
const setupConcludedMatch = async () => {
const users = await UserFactory.createMany(FULL_GROUP_SIZE * 2);
const alphaMembers = users.slice(0, FULL_GROUP_SIZE);
const setupConcludedMatch = async (
/** Give the same members another match, for a user with a match history. */
returningAlphaMembers?: Array<{ id: number }>,
) => {
const bravoMembers = await UserFactory.createMany(FULL_GROUP_SIZE);
const alphaMembers =
returningAlphaMembers ?? (await UserFactory.createMany(FULL_GROUP_SIZE));
const match = await SQMatchFactory.create(
{
alphaUserIds: alphaMembers.map((member) => member.id),
bravoUserIds: users.slice(FULL_GROUP_SIZE).map((member) => member.id),
bravoUserIds: bravoMembers.map((member) => member.id),
isMatchmade: true,
},
{ isConcluded: true },
@@ -57,8 +61,9 @@ describe("insert", () => {
expect(result.chatCodeToRevalidate).toBe(matchChatCode);
});
test("preserves existing vote when user already voted yes on previous match", async () => {
const { alphaGroupId, alphaMembers } = await setupConcludedMatch();
test("overrides the user's own yes vote on the previous match", async () => {
const { alphaGroupId, alphaMembers, matchChatCode } =
await setupConcludedMatch();
await castYesVote(alphaMembers[0].id, alphaGroupId);
@@ -67,10 +72,12 @@ describe("insert", () => {
userId: alphaMembers[0].id,
});
// leaving a yes vote standing would let the rest of the group reach a
// unanimous vote for a group the user is no longer available for
const votes = await fetchVotes(alphaGroupId);
expect(votes).toHaveLength(1);
expect(votes[0].isContinuing).toBe(true);
expect(result.chatCodeToRevalidate).toBeNull();
expect(votes[0].isContinuing).toBe(false);
expect(result.chatCodeToRevalidate).toBe(matchChatCode);
});
test("clears other members' yes votes on the previous group when recording implicit no", async () => {
@@ -92,6 +99,54 @@ describe("insert", () => {
expect(votes[0].isContinuing).toBe(false);
});
test("records the implicit no-vote on the newest matchmade group of many", async () => {
const { alphaGroupId: olderGroupId, alphaMembers } =
await setupConcludedMatch();
const { alphaGroupId: newerGroupId, matchChatCode } =
await setupConcludedMatch(alphaMembers);
const olderVotesBefore = await fetchVotes(olderGroupId);
const result = await SQGroupRepository.insert({
status: "ACTIVE",
userId: alphaMembers[0].id,
});
expect(await fetchVotes(olderGroupId)).toEqual(olderVotesBefore);
const votes = await fetchVotes(newerGroupId);
expect(votes).toHaveLength(1);
expect(votes[0].userId).toBe(alphaMembers[0].id);
expect(votes[0].isContinuing).toBe(false);
expect(result.chatCodeToRevalidate).toBe(matchChatCode);
});
test("leaves the previous group's votes alone on a later, unrelated queue action", async () => {
const { alphaGroupId, alphaMembers } = await setupConcludedMatch();
await SQGroupRepository.insert({
status: "ACTIVE",
userId: alphaMembers[0].id,
});
// the three who stayed settle the vote among themselves
for (const member of alphaMembers.slice(1)) {
await castYesVote(member.id, alphaGroupId);
}
// ...meanwhile the one who left gives up on queueing alone and queues again
await SQGroupRepository.leaveGroup(alphaMembers[0].id);
const result = await SQGroupRepository.insert({
status: "ACTIVE",
userId: alphaMembers[0].id,
});
const votes = await fetchVotes(alphaGroupId);
expect(votes.filter((vote) => vote.isContinuing)).toHaveLength(
FULL_GROUP_SIZE - 1,
);
expect(result.chatCodeToRevalidate).toBeNull();
});
test("does not record any vote when user has no previous matchmade group", async () => {
const user = await UserFactory.create();

View File

@@ -1045,6 +1045,13 @@ async function deleteReadyCheckInTrx(
.execute();
}
/**
* Records the user as not continuing with the group they last played a matchmade
* match with, taking that group's votes in favour down with it. Getting a group
* elsewhere overrides a vote already cast in favour: the group can no longer
* continue at the size that vote was for, so the rest have to vote again. Once
* their no vote is in, that group is settled and later queue actions leave it be.
*/
async function recordImplicitRejoinNoVote(
userId: number,
trx: Transaction<DB>,
@@ -1060,18 +1067,19 @@ async function recordImplicitRejoinNoVote(
]),
),
)
.leftJoin("GroupMatchContinueVote", (join) =>
join
.onRef("GroupMatchContinueVote.groupId", "=", "Group.id")
.on("GroupMatchContinueVote.userId", "=", userId),
)
.select(["Group.id as groupId", "GroupMatch.chatCode as matchChatCode"])
.select((eb) => [
"Group.id as groupId",
"GroupMatch.chatCode as matchChatCode",
hasVotedNo(eb, userId).as("alreadySettled"),
])
.where("GroupMember.userId", "=", userId)
.where("Group.matchmade", "=", 1)
.where("GroupMatchContinueVote.id", "is", null)
// only the group they came from is still voting, older ones are long settled
.orderBy("Group.id", "desc")
.limit(1)
.executeTakeFirst();
if (!candidate) return null;
if (!candidate || candidate.alreadySettled) return null;
await trx
.deleteFrom("GroupMatchContinueVote")
@@ -1094,6 +1102,18 @@ async function recordImplicitRejoinNoVote(
return candidate.matchChatCode;
}
/** Matches the `Group` rows the given user has already voted against continuing with. */
function hasVotedNo(eb: ExpressionBuilder<DB, "Group">, userId: number) {
return eb.exists(
eb
.selectFrom("GroupMatchContinueVote")
.select("GroupMatchContinueVote.id")
.whereRef("GroupMatchContinueVote.groupId", "=", "Group.id")
.where("GroupMatchContinueVote.userId", "=", userId)
.where("GroupMatchContinueVote.isContinuing", "=", 0),
);
}
/** Matches the `GroupMember` rows that have no confirmation for the given ready check. */
function didNotConfirmReadyCheck(
eb: ExpressionBuilder<DB, "GroupMember">,

View File

@@ -242,6 +242,55 @@ test.describe("SendouQ match page", () => {
await expect(match.locators.declinedText).toBeVisible();
});
test("Rejoin vote: queueing solo after voting yes shows the rest a no", async ({
page,
factories,
}) => {
const { matchId, alpha } = await createMatch(factories, {
isMatchmade: true,
isConcluded: true,
});
const [owner, impatient, memberC, memberD] = alpha;
await impersonate(page, owner.id);
const match = new SendouQMatchPage(page);
await match.goto(matchId);
await match.voteYes();
await impersonate(page, impatient.id);
await match.goto(matchId);
await match.voteYes();
// ...and then gives up on waiting for the rest and queues up alone instead
const q = new SendouQPage(page);
await q.goto();
await q.joinSolo();
await impersonate(page, owner.id);
await match.goto(matchId);
// their checkmark turned into a cross, taking the yes votes cast for a
// group of four with it
await expect(match.locators.votedNo).toHaveCount(1);
await expect(match.locators.votedYes).toHaveCount(0);
await expect(match.locators.pendingVotes).toHaveCount(3);
for (const member of [owner, memberC, memberD]) {
await impersonate(page, member.id);
await match.goto(matchId);
await match.voteYes();
}
// the three who stayed get their group, rather than being sent back to /q
await impersonate(page, owner.id);
await q.goto();
await expect(page).toHaveURL(SENDOUQ_LOOKING_PAGE);
const looking = new SendouQLookingPage(page);
await expect(looking.ownGroupCard.members).toHaveCount(3);
});
test("Rejoin vote: cascade wipes yes on no, revote completes and rejoins", async ({
page,
factories,