Skip to content
Closed
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
38 changes: 12 additions & 26 deletions components/match/MatchLineupScoreDisplay.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
</template>
</span>

<small v-if="matchMap && halves">
<small v-if="matchMap && halves && hasSideWins">
[<span class="text-yellow-500">{{ tWins }}</span
>:<span class="text-blue-400">{{ ctWins }}</span
>]
Expand All @@ -31,7 +31,7 @@
</template>

<script lang="ts">
import { e_sides_enum } from "~/generated/zeus";
import { matchSideWins } from "~/utilities/matchSideWins";

export default {
props: {
Expand Down Expand Up @@ -172,32 +172,18 @@ export default {
return this.match.lineup_1_id === this.lineup.id;
},
sideWins() {
if (!this.matchMap?.rounds) {
if (!this.matchMap) {
return { ct: 0, t: 0 };
}
const chronological = [...this.matchMap.rounds].reverse();
let ct = 0;
let t = 0;
let prev1 = 0;
let prev2 = 0;
for (const round of chronological) {
const lineupWon = this.isLineup1
? round.lineup_1_score > prev1
: round.lineup_2_score > prev2;
const lineupSide = this.isLineup1
? round.lineup_1_side
: round.lineup_2_side;
if (lineupWon) {
if (lineupSide === e_sides_enum.CT) {
ct++;
} else if (lineupSide === e_sides_enum.TERRORIST) {
t++;
}
}
prev1 = round.lineup_1_score;
prev2 = round.lineup_2_score;
}
return { ct, t };
return matchSideWins(this.matchMap.rounds, this.isLineup1, {
currentSide: this.isLineup1
? this.matchMap.lineup_1_side
: this.matchMap.lineup_2_side,
mr: this.match.options?.mr,
});
},
hasSideWins() {
return this.sideWins.ct + this.sideWins.t > 0;
},
ctWins() {
return this.matchMap ? this.sideWins.ct : undefined;
Expand Down
128 changes: 128 additions & 0 deletions tests/utilities/matchSideWins.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";
import {
matchSideWins,
normalizeSide,
oppositeSide,
sideForRoundNumber,
} from "~/utilities/matchSideWins";

describe("normalizeSide", () => {
it("treats T as TERRORIST", () => {
expect(normalizeSide("T")).toBe("TERRORIST");
expect(normalizeSide("TERRORIST")).toBe("TERRORIST");
expect(normalizeSide("CT")).toBe("CT");
expect(normalizeSide(null)).toBeNull();
});
});

describe("sideForRoundNumber", () => {
it("uses the current side for the whole first half", () => {
expect(sideForRoundNumber(4, "TERRORIST", 8, 12)).toBe("TERRORIST");
});

it("flips first-half sides once the map is past MR", () => {
expect(sideForRoundNumber(4, "CT", 16, 12)).toBe("TERRORIST");
expect(sideForRoundNumber(14, "CT", 16, 12)).toBe("CT");
});

it("swaps every three overtime rounds", () => {
expect(sideForRoundNumber(25, "CT", 26, 12)).toBe("CT");
expect(sideForRoundNumber(28, "CT", 28, 12)).toBe("TERRORIST");
});
});

describe("matchSideWins", () => {
it("returns zeros when there are no rounds", () => {
expect(matchSideWins([], true)).toEqual({ ct: 0, t: 0 });
});

it("counts from score deltas so a wrong winning_side cannot steal a round", () => {
const rounds = [
{
round: 1,
lineup_1_score: 1,
lineup_2_score: 0,
lineup_1_side: "TERRORIST",
winning_side: "CT",
},
{
round: 2,
lineup_1_score: 1,
lineup_2_score: 1,
lineup_1_side: "TERRORIST",
winning_side: "TERRORIST",
},
];
expect(matchSideWins(rounds, true)).toEqual({ ct: 0, t: 1 });
expect(matchSideWins(rounds, false)).toEqual({ ct: 1, t: 0 });
});

it("accepts T as the terrorist side", () => {
const rounds = [
{
round: 1,
lineup_1_score: 1,
lineup_2_score: 0,
lineup_1_side: "T",
winning_side: "T",
},
];
expect(matchSideWins(rounds, true)).toEqual({ ct: 0, t: 1 });
});

it("does not depend on the incoming round order", () => {
const rounds = [
{
round: 2,
lineup_1_score: 2,
lineup_2_score: 0,
lineup_1_side: "TERRORIST",
},
{
round: 1,
lineup_1_score: 1,
lineup_2_score: 0,
lineup_1_side: "TERRORIST",
},
];
expect(matchSideWins(rounds, true)).toEqual({ ct: 0, t: 2 });
});

it("infers sides from the current map assignment when rounds omit them", () => {
const rounds = [
{ round: 1, lineup_1_score: 1, lineup_2_score: 0 },
{ round: 2, lineup_1_score: 2, lineup_2_score: 0 },
{ round: 13, lineup_1_score: 3, lineup_2_score: 0 },
];
expect(
matchSideWins(rounds, true, { currentSide: "CT", mr: 12 }),
).toEqual({ ct: 1, t: 2 });
});

it("falls back to winning_side when scores never increment", () => {
const rounds = [
{
round: 1,
lineup_1_score: 0,
lineup_2_score: 0,
lineup_1_side: "TERRORIST",
winning_side: "TERRORIST",
},
{
round: 2,
lineup_1_score: 0,
lineup_2_score: 0,
lineup_1_side: "TERRORIST",
winning_side: "CT",
},
];
expect(matchSideWins(rounds, true)).toEqual({ ct: 0, t: 1 });
});
});

describe("oppositeSide", () => {
it("swaps CT and T", () => {
expect(oppositeSide("CT")).toBe("TERRORIST");
expect(oppositeSide("TERRORIST")).toBe("CT");
});
});
163 changes: 163 additions & 0 deletions utilities/matchSideWins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { e_sides_enum } from "~/generated/zeus";

export type Side = "CT" | "TERRORIST";

export type SideWinRound = {
round?: number | null;
lineup_1_score?: number | null;
lineup_2_score?: number | null;
lineup_1_side?: string | null;
lineup_2_side?: string | null;
winning_side?: string | null;
};

export type SideWins = { ct: number; t: number };

const EMPTY: SideWins = { ct: 0, t: 0 };

export function normalizeSide(
value: string | null | undefined,
): Side | null {
if (value === "CT" || value === e_sides_enum.CT) {
return "CT";
}
if (
value === "T" ||
value === "TERRORIST" ||
value === e_sides_enum.TERRORIST
) {
return "TERRORIST";
}
return null;
}

export function oppositeSide(side: Side): Side {
return side === "CT" ? "TERRORIST" : "CT";
}

function scoreOf(value: number | null | undefined): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}

// Current map sides apply to the half being played now. First half is
// rounds 1..mr; second half flips; CS2 overtime is 3-round halves starting
// on the second-half assignment.
export function sideForRoundNumber(
roundNumber: number,
currentSide: Side | null,
latestRound: number,
mr: number,
): Side | null {
if (!currentSide || roundNumber <= 0 || mr <= 0) {
return null;
}

const firstHalfSide =
latestRound <= mr ? currentSide : oppositeSide(currentSide);

if (roundNumber <= mr) {
return firstHalfSide;
}
if (roundNumber <= mr * 2) {
return oppositeSide(firstHalfSide);
}

const otIndex = roundNumber - mr * 2;
const otHalf = Math.ceil(otIndex / 3);
const secondHalfSide = oppositeSide(firstHalfSide);
return otHalf % 2 === 1 ? secondHalfSide : firstHalfSide;
}

function lineupSideForRound(
round: SideWinRound,
isLineup1: boolean,
currentSide: Side | null,
latestRound: number,
mr: number,
): Side | null {
const own = normalizeSide(
isLineup1 ? round.lineup_1_side : round.lineup_2_side,
);
if (own) {
return own;
}
const other = normalizeSide(
isLineup1 ? round.lineup_2_side : round.lineup_1_side,
);
if (other) {
return oppositeSide(other);
}
return sideForRoundNumber(round.round ?? 0, currentSide, latestRound, mr);
}

function lineupWonRound(
round: SideWinRound,
isLineup1: boolean,
prev1: number,
prev2: number,
lineupSide: Side | null,
): boolean {
const score1 = scoreOf(round.lineup_1_score);
const score2 = scoreOf(round.lineup_2_score);
// Once either score moves, the delta is the source of truth — the game
// server has been known to report winning_side for the other team.
if (score1 > prev1 || score2 > prev2) {
return isLineup1 ? score1 > prev1 : score2 > prev2;
}
// Live rows sometimes keep both scores at 0; winning_side is the fallback,
// after normalizing the "T" alias the game server sends.
const winning = normalizeSide(round.winning_side);
return !!(winning && lineupSide && winning === lineupSide);
}

export function matchSideWins(
rounds: SideWinRound[] | null | undefined,
isLineup1: boolean,
options?: {
currentSide?: string | null;
mr?: number | null;
},
): SideWins {
if (!rounds?.length) {
return { ...EMPTY };
}

const chronological = rounds
.filter((round) => (round.round ?? 0) > 0)
.slice()
.sort((a, b) => (a.round ?? 0) - (b.round ?? 0));

if (!chronological.length) {
return { ...EMPTY };
}

const mr = options?.mr && options.mr > 0 ? options.mr : 12;
const currentSide = normalizeSide(options?.currentSide);
const latestRound = chronological[chronological.length - 1]?.round ?? 0;

let ct = 0;
let t = 0;
let prev1 = 0;
let prev2 = 0;

for (const round of chronological) {
const lineupSide = lineupSideForRound(
round,
isLineup1,
currentSide,
latestRound,
mr,
);
if (lineupWonRound(round, isLineup1, prev1, prev2, lineupSide)) {
if (lineupSide === "CT") {
ct++;
} else if (lineupSide === "TERRORIST") {
t++;
}
}
prev1 = scoreOf(round.lineup_1_score);
prev2 = scoreOf(round.lineup_2_score);
}

return { ct, t };
}
Loading