diff options
Diffstat (limited to 'src/lib/logic')
| -rw-r--r-- | src/lib/logic/csv.js | 23 | ||||
| -rw-r--r-- | src/lib/logic/csv.test.js | 27 | ||||
| -rw-r--r-- | src/lib/logic/matchmaking.js | 113 | ||||
| -rw-r--r-- | src/lib/logic/matchmaking.test.js | 188 | ||||
| -rw-r--r-- | src/lib/logic/normalize.js | 6 | ||||
| -rw-r--r-- | src/lib/logic/normalize.test.js | 28 | ||||
| -rw-r--r-- | src/lib/logic/players.js | 73 | ||||
| -rw-r--r-- | src/lib/logic/random.js | 8 |
8 files changed, 466 insertions, 0 deletions
diff --git a/src/lib/logic/csv.js b/src/lib/logic/csv.js new file mode 100644 index 0000000..9e085eb --- /dev/null +++ b/src/lib/logic/csv.js @@ -0,0 +1,23 @@ +import { createRegular } from "./players.js"; +import { randomStatus } from "./random.js"; + +export function parseLineToPlayer(line) { + const info = line.split(","); + const id = Number(info[0].trim()); + const firstName = info[1].trim(); + const lastName = info[2].trim(); + const level = Number(info[3].trim()); + return createRegular(id, randomStatus(), level, 0, firstName, lastName); +} + +export function parseCsv(data) { + const lines = data.split("\n"); + const players = []; + for (let i = 1; i < lines.length; i++) { + if (lines[i] === undefined || lines[i].trim() === "") { + continue; + } + players.push(parseLineToPlayer(lines[i])); + } + return players; +} diff --git a/src/lib/logic/csv.test.js b/src/lib/logic/csv.test.js new file mode 100644 index 0000000..0381c06 --- /dev/null +++ b/src/lib/logic/csv.test.js @@ -0,0 +1,27 @@ +import { describe, it, expect } from "vitest"; +import { parseLineToPlayer, parseCsv } from "./csv.js"; + +describe("parseLineToPlayer", () => { + it("parses id, name, and level columns", () => { + const player = parseLineToPlayer("3, Jane, Doe, 7"); + expect(player.id).toBe(3); + expect(player.firstName).toBe("Jane"); + expect(player.lastName).toBe("Doe"); + expect(player.level).toBe(7); + expect(player.games).toBe(0); + }); +}); + +describe("parseCsv", () => { + it("skips the header row and blank lines", () => { + const csv = "id,firstName,lastName,level\n0, Jane, Doe, 7\n\n1, John, Smith, 3\n"; + const players = parseCsv(csv); + expect(players).toHaveLength(2); + expect(players[0].firstName).toBe("Jane"); + expect(players[1].firstName).toBe("John"); + }); + + it("returns an empty array when there's only a header", () => { + expect(parseCsv("id,firstName,lastName,level")).toEqual([]); + }); +}); diff --git a/src/lib/logic/matchmaking.js b/src/lib/logic/matchmaking.js new file mode 100644 index 0000000..eb8be2e --- /dev/null +++ b/src/lib/logic/matchmaking.js @@ -0,0 +1,113 @@ +import { createRegular, createTeam, createGame } from "./players.js"; +import { normalize } from "./normalize.js"; + +export function shuffle(array) { + const copy = [...array]; + for (let i = copy.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [copy[i], copy[j]] = [copy[j], copy[i]]; + } + return copy; +} + +export function createPlaceholderGame(id, description = "---", status = true) { + const player = createRegular(null, null, null, null, description, ""); + const team = createTeam(player, player); + return createGame(id, status, team, team); +} + +export function isPlaceholderGame(game) { + return ["RESERVED", "---"].includes(game.teamOne.playerOne.firstName); +} + +export function createDefaultGames(count) { + const games = []; + for (let i = 0; i < count; i++) { + games.push(createPlaceholderGame(i, "---", true)); + } + return games; +} + +export function addBlockedGames(inactiveCourts) { + return inactiveCourts.map((court) => + createPlaceholderGame(court.id, "RESERVED", false) + ); +} + +export function addInactiveGames(unusedCourts) { + return unusedCourts.map((court) => + createPlaceholderGame(court.id, "---", false) + ); +} + +export function getPossibleGameCount(activePlayerCount, activeCourtCount) { + return Math.min(Math.floor(activePlayerCount / 4), activeCourtCount); +} + +export function selectPlayers(regulars, guests, count) { + const combined = shuffle([...regulars, ...guests]); + combined.sort((a, b) => a.games - b.games); + const selected = combined.slice(0, count); + selected.sort((a, b) => a.level - b.level); + return selected; +} + +export function pairPlayersIntoTeams(players) { + const teamOne = createTeam(players[0], players[3]); + const teamTwo = createTeam(players[1], players[2]); + return [teamOne, teamTwo]; +} + +export function addActiveGames(usedCourts, selectedPlayers) { + const games = []; + for (let index = 0; index < usedCourts.length; index++) { + const startIndex = 4 * index; + const courtPlayers = selectedPlayers.slice(startIndex, startIndex + 4); + const [teamOne, teamTwo] = pairPlayersIntoTeams(courtPlayers); + games.push(createGame(usedCourts[index].id, false, teamOne, teamTwo)); + } + return games; +} + +export function proposeGames({ courts, regulars, guests, skillFactor }) { + const activeCourts = courts.filter((c) => c.status); + const inactiveCourts = courts.filter((c) => !c.status); + const activeRegulars = regulars.filter((r) => r.status); + const activeGuests = normalize( + guests.filter((g) => g.status), + skillFactor + ); + + const blockedGames = addBlockedGames(inactiveCourts); + const possibleMatches = getPossibleGameCount( + activeRegulars.length + activeGuests.length, + activeCourts.length + ); + const usedCourts = activeCourts.slice(0, possibleMatches); + const unusedCourts = activeCourts.slice(possibleMatches); + const inactiveGames = addInactiveGames(unusedCourts); + const selectedPlayers = selectPlayers( + activeRegulars, + activeGuests, + possibleMatches * 4 + ); + const activeGames = addActiveGames(usedCourts, selectedPlayers); + + return [...blockedGames, ...inactiveGames, ...activeGames].sort( + (a, b) => a.id - b.id + ); +} + +export function collectConfirmedPlayerNames(games) { + const names = []; + for (const game of games) { + if (isPlaceholderGame(game)) continue; + names.push( + game.teamOne.playerOne.name, + game.teamOne.playerTwo.name, + game.teamTwo.playerOne.name, + game.teamTwo.playerTwo.name + ); + } + return names; +} diff --git a/src/lib/logic/matchmaking.test.js b/src/lib/logic/matchmaking.test.js new file mode 100644 index 0000000..0401c1c --- /dev/null +++ b/src/lib/logic/matchmaking.test.js @@ -0,0 +1,188 @@ +import { describe, it, expect } from "vitest"; +import { createCourt, createRegular, createGuest } from "./players.js"; +import { + shuffle, + getPossibleGameCount, + selectPlayers, + pairPlayersIntoTeams, + addBlockedGames, + addInactiveGames, + addActiveGames, + proposeGames, + collectConfirmedPlayerNames, + isPlaceholderGame, +} from "./matchmaking.js"; + +describe("shuffle", () => { + it("returns an array with the same elements, without mutating the input", () => { + const original = [1, 2, 3, 4, 5]; + const copy = [...original]; + const result = shuffle(original); + expect(original).toEqual(copy); + expect(result.slice().sort()).toEqual(original.slice().sort()); + }); +}); + +describe("getPossibleGameCount", () => { + it("is limited by available courts", () => { + expect(getPossibleGameCount(40, 2)).toBe(2); + }); + it("is limited by available players (4 per game)", () => { + expect(getPossibleGameCount(5, 10)).toBe(1); + }); + it("is zero when there are no active courts", () => { + expect(getPossibleGameCount(40, 0)).toBe(0); + }); + it("is zero when there are fewer than 4 active players", () => { + expect(getPossibleGameCount(3, 10)).toBe(0); + }); +}); + +describe("selectPlayers", () => { + it("prefers players with fewer games played", () => { + const regulars = [ + createRegular(0, true, 5, 10, "Most", "Games"), + createRegular(1, true, 5, 0, "Fewest", "Games"), + ]; + const selected = selectPlayers(regulars, [], 1); + expect(selected).toHaveLength(1); + expect(selected[0].name).toBe("Fewest Games"); + }); + + it("sorts the final selection by level ascending", () => { + const regulars = [ + createRegular(0, true, 8, 0, "High", "Level"), + createRegular(1, true, 2, 0, "Low", "Level"), + createRegular(2, true, 5, 0, "Mid", "Level"), + ]; + const selected = selectPlayers(regulars, [], 3); + expect(selected.map((p) => p.level)).toEqual([2, 5, 8]); + }); +}); + +describe("pairPlayersIntoTeams", () => { + it("cross-pairs players by rank (0&3 vs 1&2)", () => { + const players = [{ name: "A" }, { name: "B" }, { name: "C" }, { name: "D" }]; + const [teamOne, teamTwo] = pairPlayersIntoTeams(players); + expect(teamOne.playerOne.name).toBe("A"); + expect(teamOne.playerTwo.name).toBe("D"); + expect(teamTwo.playerOne.name).toBe("B"); + expect(teamTwo.playerTwo.name).toBe("C"); + }); +}); + +describe("addBlockedGames / addInactiveGames", () => { + it("marks games for inactive courts as RESERVED and inactive", () => { + const courts = [createCourt(3, false)]; + const [game] = addBlockedGames(courts); + expect(game.id).toBe(3); + expect(game.status).toBe(false); + expect(isPlaceholderGame(game)).toBe(true); + expect(game.teamOne.playerOne.firstName).toBe("RESERVED"); + }); + + it("marks games for unused active courts as placeholders", () => { + const courts = [createCourt(1, true)]; + const [game] = addInactiveGames(courts); + expect(game.teamOne.playerOne.firstName).toBe("---"); + }); +}); + +describe("addActiveGames", () => { + it("creates one game per used court from the selected players", () => { + const usedCourts = [createCourt(0, true), createCourt(1, true)]; + const players = Array.from({ length: 8 }, (_, i) => + createRegular(i, true, 1, 0, `P${i}`, "") + ); + const games = addActiveGames(usedCourts, players); + expect(games).toHaveLength(2); + expect(games[0].id).toBe(0); + expect(games[1].id).toBe(1); + expect(games[0].teamOne.playerOne.name).toBe("P0 "); + }); +}); + +describe("proposeGames", () => { + it("never assigns more games than active courts", () => { + const courts = [createCourt(0, true), createCourt(1, true)]; + const regulars = Array.from({ length: 20 }, (_, i) => + createRegular(i, true, 1, 0, `R${i}`, "") + ); + const games = proposeGames({ + courts, + regulars, + guests: [], + skillFactor: 10, + }); + expect(games).toHaveLength(2); + }); + + it("does not skip the court right after the used ones (no off-by-one gap)", () => { + const courts = [ + createCourt(0, true), + createCourt(1, true), + createCourt(2, true), + ]; + // Only enough players for exactly 1 active game, so courts 1 and 2 + // should both come back as unused-but-active placeholder games. + const regulars = Array.from({ length: 4 }, (_, i) => + createRegular(i, true, 1, 0, `R${i}`, "") + ); + const games = proposeGames({ + courts, + regulars, + guests: [], + skillFactor: 10, + }); + expect(games.map((g) => g.id)).toEqual([0, 1, 2]); + expect(games[1].teamOne.playerOne.firstName).toBe("---"); + expect(games[2].teamOne.playerOne.firstName).toBe("---"); + }); + + it("gives reserved (inactive) courts a RESERVED placeholder", () => { + const courts = [createCourt(0, false)]; + const games = proposeGames({ + courts, + regulars: [], + guests: [], + skillFactor: 10, + }); + expect(games).toHaveLength(1); + expect(games[0].teamOne.playerOne.firstName).toBe("RESERVED"); + }); + + it("applies the skill factor to guests before pairing", () => { + const courts = [createCourt(0, true)]; + const guests = [ + createGuest(0, true, 0), // Beginner, level 1 + createGuest(6, true, 0), // Novice, level 3 + createGuest(12, true, 0), // Intermediate, level 6 + createGuest(1, true, 0), // Beginner, level 1 + ]; + const games = proposeGames({ + courts, + regulars: [], + guests, + skillFactor: 1, + }); + expect(games).toHaveLength(1); + expect(games[0].status).toBe(false); + }); +}); + +describe("collectConfirmedPlayerNames", () => { + it("excludes placeholder (RESERVED/---) games from the name list", () => { + const usedCourts = [createCourt(0, true)]; + const players = [ + createRegular(0, true, 1, 0, "A", ""), + createRegular(1, true, 1, 0, "B", ""), + createRegular(2, true, 1, 0, "C", ""), + createRegular(3, true, 1, 0, "D", ""), + ]; + const realGame = addActiveGames(usedCourts, players)[0]; + const [reservedGame] = addBlockedGames([createCourt(1, false)]); + + const names = collectConfirmedPlayerNames([realGame, reservedGame]); + expect(names).toEqual(["A ", "D ", "B ", "C "]); + }); +}); diff --git a/src/lib/logic/normalize.js b/src/lib/logic/normalize.js new file mode 100644 index 0000000..cfed0c3 --- /dev/null +++ b/src/lib/logic/normalize.js @@ -0,0 +1,6 @@ +export function normalize(data, factor) { + return data.map((player) => ({ + ...player, + level: Math.ceil(player.level / factor), + })); +} diff --git a/src/lib/logic/normalize.test.js b/src/lib/logic/normalize.test.js new file mode 100644 index 0000000..418e427 --- /dev/null +++ b/src/lib/logic/normalize.test.js @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { normalize } from "./normalize.js"; + +describe("normalize", () => { + it("collapses all levels into one bucket when factor equals the max level (\"All Levels\")", () => { + const players = [{ level: 1 }, { level: 5 }, { level: 10 }]; + const result = normalize(players, 10); + expect(result.map((p) => p.level)).toEqual([1, 1, 1]); + }); + + it("leaves levels untouched when factor is 1 (\"Skill Based\")", () => { + const players = [{ level: 1 }, { level: 5 }, { level: 10 }]; + const result = normalize(players, 1); + expect(result.map((p) => p.level)).toEqual([1, 5, 10]); + }); + + it("splits levels into two buckets when factor is 5 (\"2 Levels\")", () => { + const players = [{ level: 1 }, { level: 5 }, { level: 6 }, { level: 10 }]; + const result = normalize(players, 5); + expect(result.map((p) => p.level)).toEqual([1, 1, 2, 2]); + }); + + it("does not mutate the input array", () => { + const players = [{ level: 4 }]; + normalize(players, 2); + expect(players[0].level).toBe(4); + }); +}); diff --git a/src/lib/logic/players.js b/src/lib/logic/players.js new file mode 100644 index 0000000..e11c32f --- /dev/null +++ b/src/lib/logic/players.js @@ -0,0 +1,73 @@ +export function createCourt(id, status = false) { + return { + id, + status, + description: "Court", + name: `Court ${id + 1}`, + }; +} + +export const guestCategories = { + 0: { description: "Beginner", level: 1 }, + 1: { description: "Novice", level: 3 }, + 2: { description: "Intermediate", level: 6 }, +}; + +export const GUESTS_PER_CATEGORY = 6; + +export function createRegular( + id, + status = false, + level = 1, + games = 0, + firstName = "Regular", + lastName = id + 1 +) { + return { + id, + status, + description: "Regular", + level, + games, + firstName, + lastName, + name: `${firstName} ${lastName}`, + }; +} + +export function createGuest(id, status = false, games = 0) { + const categoryId = Math.floor(id / GUESTS_PER_CATEGORY) || 0; + const category = guestCategories[categoryId]; + const guestNumber = (id % GUESTS_PER_CATEGORY) + 1; + const firstName = category.description; + const lastName = guestNumber; + return { + id, + status, + description: "Guest", + level: category.level, + games, + firstName, + lastName, + name: `${firstName} ${lastName}`, + }; +} + +export function createTeam(playerOne, playerTwo) { + return { playerOne, playerTwo }; +} + +export function createGame(id, status, teamOne, teamTwo) { + return { + id, + status, + description: "Game", + teamOne, + teamTwo, + name: `Game ${id + 1}`, + }; +} + +export function isReserved(game) { + return game.teamOne.playerOne.firstName === "RESERVED"; +} diff --git a/src/lib/logic/random.js b/src/lib/logic/random.js new file mode 100644 index 0000000..a696860 --- /dev/null +++ b/src/lib/logic/random.js @@ -0,0 +1,8 @@ +export function randomStatus() { + return Math.random() < 0.5; +} + +export function randomLevel() { + const maxLevel = 10; + return Math.floor(Math.random() * maxLevel + 1); +} |
