From 582830208b5895a8b7dd06913c2a5d87281da495 Mon Sep 17 00:00:00 2001 From: Eshaan Bansal Date: Wed, 13 Nov 2024 01:23:04 +0530 Subject: [PATCH 1/2] refactor byes, add byes to leaderboard --- src/stackr/transitions.ts | 103 ++++++++++++++++++++++++++++++-------- 1 file changed, 81 insertions(+), 22 deletions(-) diff --git a/src/stackr/transitions.ts b/src/stackr/transitions.ts index 422e174..94b5147 100644 --- a/src/stackr/transitions.ts +++ b/src/stackr/transitions.ts @@ -14,6 +14,7 @@ export enum LogAction { export type LeaderboardEntry = { won: number; lost: number; + byes: number; points: number; id: number; name: string; @@ -45,22 +46,21 @@ export const getLeaderboard = (state: LeagueState): LeaderboardEntry[] => { const { teams, matches, meta } = state; const completedMatches = matches.filter((m) => m.endTime); - const leaderboard = teams.map((team) => { - return { - ...team, - won: 0, - lost: 0, - points: 0, - }; + const leaderboard = teams.map((team) => ({ + ...team, + won: 0, + lost: 0, + byes: 0, + points: 0, + })); + + // a bye is given 1 point + meta.byes.forEach((bye) => { + const teamIndex = leaderboard.findIndex((l) => l.id === bye.teamId); + leaderboard[teamIndex].byes += 1; + leaderboard[teamIndex].points += 1; }); - if (meta.byes.length) { - meta.byes.forEach((bye) => { - const teamIndex = leaderboard.findIndex((l) => l.id === bye.teamId); - leaderboard[teamIndex].points += 1; - }); - } - completedMatches.forEach((match) => { const { winnerTeamId, scores } = match; const loserTeamId = Object.keys(scores).find((k) => +k !== winnerTeamId); @@ -79,9 +79,12 @@ export const getLeaderboard = (state: LeagueState): LeaderboardEntry[] => { return leaderboard.sort((a, b) => { if (a.points === b.points) { - return a.won - b.won; + if (a.won === b.won) { + return a.byes - b.byes; // Sort by most byes last + } + return b.won - a.won; // Sort by most wins first } - return b.points - a.points; + return b.points - a.points; // Sort by most points first }); }; @@ -94,20 +97,63 @@ const getTopNTeams = (state: LeagueState, n?: number) => { return leaderboard.slice(0, n); }; +const getTeamsInCurrentRound = (state: LeagueState) => { + const { meta, teams } = state; + const totalTeams = teams.length; + // Calculate the number of teams in the current round by halving the teams each round + const numTeamsInCurrentRound = Math.ceil( + totalTeams / Math.pow(2, meta.round) + ); + const topTeams = getTopNTeams(state, numTeamsInCurrentRound); + return topTeams; +}; + +const isByeRequiredInCurrentRound = (state: LeagueState): boolean => { + const { meta } = state; + + // If the tournament has ended or not all matches are complete, return false + if (!areAllMatchesComplete(state) || !!meta.endTime) { + return false; + } + + const teamsInCurrentRound = getTeamsInCurrentRound(state); + + // If only one team is left, return false + if (teamsInCurrentRound.length === 1) { + return false; + } + + // If the number of remaining teams is odd, check if a bye is required + if (teamsInCurrentRound.length % 2 !== 0) { + const allTeamsHaveSamePoints = teamsInCurrentRound.every( + (t, _, arr) => t.points === arr[0].points + ); + + // If all teams have the same points, return true + if (allTeamsHaveSamePoints) { + return true; + } + } + + return false; +}; + const computeMatchFixtures = (state: LeagueState, blockTime: number) => { const { meta, teams } = state; + // If the tournament has ended, return without scheduling matches if (!areAllMatchesComplete(state) || !!meta.endTime) { return; } const totalTeams = teams.length; + // Calculate the number of teams in the current round by halving the teams each round const teamsInCurrentRound = Math.ceil(totalTeams / Math.pow(2, meta.round)); // this is assuming that the bye will be given to the team with lower score, and they'll get a chance to play with the top 3 teams const shouldIncludeOneBye = teamsInCurrentRound !== 1 && - teamsInCurrentRound % 2 === 1 && - meta.byes.length === 1 + teamsInCurrentRound % 2 === 1 && + meta.byes.filter(({ round }) => round === meta.round).length === 1 ? 1 : 0; @@ -116,16 +162,20 @@ const computeMatchFixtures = (state: LeagueState, blockTime: number) => { teamsInCurrentRound + shouldIncludeOneBye ); + // If only one team is left, declare it the winner and end the tournament if (topTeams.length === 1) { state.meta.winnerTeamId = topTeams[0].id; state.meta.endTime = blockTime; return; } + // If the number of top teams is odd, handle the odd team out if (topTeams.length % 2 !== 0) { const allTeamsHaveSamePoints = topTeams[0].points === topTeams[teamsInCurrentRound - 1].points; + // If all teams have the same points, return without scheduling matches + // This situation requires a bye to be given in current round if (allTeamsHaveSamePoints) { return; } @@ -133,7 +183,9 @@ const computeMatchFixtures = (state: LeagueState, blockTime: number) => { const oneTeamHasHigherPoints = topTeams[0].points > topTeams[1].points && topTeams[0].points > topTeams[2].points; - // plan the match the rest of even teams + + // Remove the team with the highest points to ensure competitive balance + // Otherwise, remove the team with the lowest points if (oneTeamHasHigherPoints) { topTeams.shift(); } else { @@ -141,6 +193,7 @@ const computeMatchFixtures = (state: LeagueState, blockTime: number) => { } } + // Generate match fixtures for the remaining teams for (let i = 0; i < topTeams.length; i += 2) { const team1 = topTeams[i]; const team2 = topTeams[i + 1]; @@ -296,9 +349,6 @@ const logGoal = League.STF({ }, handler: ({ state, inputs, block }) => { const { matchId, playerId } = inputs; - if (hasTournamentEnded(state)) { - throw new Error("TOURNAMENT_ENDED"); - } const { match, teamId } = getValidMatchAndTeam(state, matchId, playerId); @@ -486,6 +536,15 @@ const logByes = League.STF({ }, handler: ({ state, inputs, block }) => { const { teamId } = inputs; + if (hasTournamentEnded(state)) { + throw new Error("TOURNAMENT_ENDED"); + } + + // Is Bye required in the current round? + if (!isByeRequiredInCurrentRound(state)) { + throw new Error("BYE_NOT_REQUIRED_IN_THIS_ROUND"); + } + state.meta.byes.push({ teamId, round: state.meta.round }); computeMatchFixtures(state, block.timestamp); return state; From 60470dd1fb6189ab1093729b76e2499826245246 Mon Sep 17 00:00:00 2001 From: Eshaan Bansal Date: Wed, 13 Nov 2024 01:28:34 +0530 Subject: [PATCH 2/2] refactor tests --- tests/mru.4-teams.test.ts | 25 ++++++++++---- tests/mru.6-teams.test.ts | 71 +++++++++++++++++++++++++++++---------- 2 files changed, 71 insertions(+), 25 deletions(-) diff --git a/tests/mru.4-teams.test.ts b/tests/mru.4-teams.test.ts index c829e84..ce24f41 100644 --- a/tests/mru.4-teams.test.ts +++ b/tests/mru.4-teams.test.ts @@ -59,13 +59,16 @@ describe("League with 4 teams", async () => { }; const domain = mru.config.domain; const types = mru.getStfSchemaMap()[name]; - const {msgSender, signature} = await signByOperator(domain, types, { name, inputs }); + const { msgSender, signature } = await signByOperator(domain, types, { + name, + inputs, + }); const actionParams = { name, inputs, msgSender, signature, - } + }; const ack = await mru.submitAction(actionParams); const action = await ack.waitFor(ActionConfirmationStatus.C1); return action; @@ -152,7 +155,7 @@ describe("League with 4 teams", async () => { expect(machine.state.meta.round).to.equal(2); expect(machine.state.matches.length).to.equal(3); - // should have 1 incomplete match at round 2 + // should have 1 new (incomplete) match at round 2 const incompleteMatches = machine.state.matches.filter((m) => !m.endTime); expect(incompleteMatches.length).to.equal(1); }); @@ -179,7 +182,9 @@ describe("League with 4 teams", async () => { // check error for "MATCH_NOT_STARTED" assert.typeOf(errors, "array"); expect(errors.length).to.equal(1); - expect(errors[0].message).to.equal("Transition logGoal failed to execute: MATCH_NOT_STARTED"); + expect(errors[0].message).to.equal( + "Transition logGoal failed to execute: MATCH_NOT_STARTED" + ); }); it("should be able complete a round 2 (final)", async () => { @@ -244,7 +249,9 @@ describe("League with 4 teams", async () => { // check error for "PLAYER_NOT_FOUND" assert.typeOf(errors1, "array"); expect(errors1.length).to.equal(1); - expect(errors1[0].message).to.equal("Transition logGoal failed to execute: INVALID_TEAM"); + expect(errors1[0].message).to.equal( + "Transition logGoal failed to execute: INVALID_TEAM" + ); // remove a goal when not scored const { logs: logs2, errors: errors2 } = await performAction( @@ -261,7 +268,9 @@ describe("League with 4 teams", async () => { // check error for "PLAYER_NOT_FOUND" assert.typeOf(errors2, "array"); expect(errors2?.length).to.equal(1); - expect(errors2[0].message).to.equal("Transition removeGoal failed to execute: NO_GOALS_TO_REMOVE"); + expect(errors2[0].message).to.equal( + "Transition removeGoal failed to execute: NO_GOALS_TO_REMOVE" + ); // second team score a goal await performAction("logGoal", { @@ -346,7 +355,9 @@ describe("League with 4 teams", async () => { // check error for "TOURNAMENT_ENDED" assert.typeOf(errors, "array"); expect(errors.length).to.equal(1); - expect(errors[0].message).to.equal("Transition logGoal failed to execute: TOURNAMENT_ENDED"); + expect(errors[0].message).to.equal( + "Transition logGoal failed to execute: TOURNAMENT_ENDED" + ); }); it("should end the tournament", async () => { diff --git a/tests/mru.6-teams.test.ts b/tests/mru.6-teams.test.ts index e33f146..0aff680 100644 --- a/tests/mru.6-teams.test.ts +++ b/tests/mru.6-teams.test.ts @@ -9,7 +9,7 @@ import { StateMachine } from "@stackr/sdk/machine"; import { STATE_MACHINES } from "../src/stackr/machines"; import { League, LeagueState } from "../src/stackr/state"; -import { transitions } from "../src/stackr/transitions"; +import { getLeaderboard, transitions } from "../src/stackr/transitions"; import { signByOperator } from "../src/utils"; import { stackrConfig } from "../stackr.config"; @@ -59,13 +59,16 @@ describe("League with 6 teams", async () => { }; const domain = mru.config.domain; const types = mru.getStfSchemaMap()[name]; - const {msgSender, signature} = await signByOperator(domain, types, { name, inputs }); + const { msgSender, signature } = await signByOperator(domain, types, { + name, + inputs, + }); const actionParams = { name, inputs, msgSender, signature, - } + }; const ack = await mru.submitAction(actionParams); const action = await ack.waitFor(ActionConfirmationStatus.C1); return action; @@ -78,11 +81,29 @@ describe("League with 6 teams", async () => { it("should be able to start a tournament", async () => { await performAction("startTournament", {}); - // should have 2 matches in the first round + // should have 3 matches in the first round expect(machine.state.meta.round).to.equal(1); expect(machine.state.matches.length).to.equal(3); }); + it("should not be able to log a bye since not required in round 1", async () => { + const team0 = machine.state.teams[0]; + + const { errors } = await performAction("logByes", { + teamId: team0.id, + }); + + if (!errors) { + throw new Error("Error not found"); + } + // check error for "BYE_NOT_REQUIRED_IN_THIS_ROUND" + assert.typeOf(errors, "array"); + expect(errors.length).to.equal(1); + expect(errors[0].message).to.equal( + "Transition logByes failed to execute: BYE_NOT_REQUIRED_IN_THIS_ROUND" + ); + }); + it("should be able to complete round 1", async () => { for (const match of machine.state.matches) { const matchId = match.id; @@ -146,30 +167,33 @@ describe("League with 6 teams", async () => { expect(_match?.scores[team1Id]).to.greaterThan(_match?.scores[team2Id]!); } - // no new match scheduled for odd teams - // should have 0 incomplete match at round 2 + // no new round and no new match scheduled since odd teams w eq points + // should have 0 new (incomplete) match at round 1 + expect(machine.state.meta.round).to.equal(1); const incompleteMatches = machine.state.matches.filter((m) => !m.endTime); expect(incompleteMatches.length).to.equal(0); }); - it("should be able to give a bye to team 5", async () => { + it("should be able to give a bye to a team not in round", async () => { expect(machine.state.meta.round).to.equal(1); - // give bye to the team 5 + const leaderboard = getLeaderboard(machine.state); + + // give bye to the team at 4th position in the leaderboard await performAction("logByes", { - teamId: 5, + teamId: leaderboard[3].id, }); - // should have 2 new matches in the second round + // should have 5 total matches at round 2 expect(machine.state.meta.round).to.equal(2); expect(machine.state.matches.length).to.equal(5); - // should have 1 incomplete match at round 2 + // should have 2 new (incomplete) matches at round 2 const incompleteMatches = machine.state.matches.filter((m) => !m.endTime); expect(incompleteMatches.length).to.equal(2); }); - it("should be able to complete round 2", async () => { + it("should be able to complete round 2", async () => { for (const match of machine.state.matches) { const matchId = match.id; if (match.endTime) { @@ -239,7 +263,10 @@ describe("League with 6 teams", async () => { expect(_match?.scores[team1Id]).to.greaterThan(_match?.scores[team2Id]!); } - // should have 1 incomplete match at round 3 + // should have 6 total matches at round 3 + expect(machine.state.meta.round).to.equal(3); + expect(machine.state.matches.length).to.equal(6); + // should have 1 new (incomplete) match at round 3 const incompleteMatches = machine.state.matches.filter((m) => !m.endTime); expect(incompleteMatches.length).to.equal(1); }); @@ -265,7 +292,9 @@ describe("League with 6 teams", async () => { // check error for "MATCH_NOT_STARTED" assert.typeOf(errors, "array"); expect(errors.length).to.equal(1); - expect(errors[0].message).to.equal("Transition logGoal failed to execute: MATCH_NOT_STARTED"); + expect(errors[0].message).to.equal( + "Transition logGoal failed to execute: MATCH_NOT_STARTED" + ); }); it("should be able complete a round 3", async () => { @@ -329,7 +358,9 @@ describe("League with 6 teams", async () => { // check error for "INVALID_TEAM" assert.typeOf(errors1, "array"); expect(errors1.length).to.equal(1); - expect(errors1[0].message).to.equal("Transition logGoal failed to execute: INVALID_TEAM"); + expect(errors1[0].message).to.equal( + "Transition logGoal failed to execute: INVALID_TEAM" + ); // remove a goal when not scored const { errors: errors2 } = await performAction("removeGoal", { @@ -343,7 +374,9 @@ describe("League with 6 teams", async () => { // check error for "NO_GOALS_TO_REMOVE" assert.typeOf(errors2, "array"); expect(errors2.length).to.equal(1); - expect(errors2[0].message).to.equal("Transition removeGoal failed to execute: NO_GOALS_TO_REMOVE"); + expect(errors2[0].message).to.equal( + "Transition removeGoal failed to execute: NO_GOALS_TO_REMOVE" + ); // second team score a goal await performAction("logGoal", { @@ -428,11 +461,13 @@ describe("League with 6 teams", async () => { // check error for "TOURNAMENT_ENDED" assert.typeOf(errors, "array"); expect(errors.length).to.equal(1); - expect(errors[0].message).to.equal("Transition logGoal failed to execute: TOURNAMENT_ENDED"); + expect(errors[0].message).to.equal( + "Transition logGoal failed to execute: TOURNAMENT_ENDED" + ); }); it("should end the tournament", async () => { expect(machine.state.meta.endTime).to.not.equal(0); - expect(machine.state.meta.winnerTeamId).to.equal(5); + expect(machine.state.meta.winnerTeamId).to.equal(2); }); });