功能:首頁改為 V2 場地排程,載入與手動產生皆為 2 場地各 8 局並依實力配對

摘要:
- 從資料庫讀取 match-hub V2 資料(battlecombination 以場地為 key)時,
  顯示「2 號場地」「3 號場地」各 8 局賽程,挑一個場地帶進記分板
- 手動分組移除 A / B 區,改為「今日出席名單」+「其他區名單」,
  演算法移植 match-hub V2:依 tg_members.skill 實力強弱互補、兩隊平衡,
  搭檔輪完一遍前不重複、每人局數落差最多 1 局;8 人以上排兩場地
- server 新增 GET /api/skills(表名可用 DB_MEMBERS_TABLE 覆寫)
- 選隊面板預設隊伍照局分組並顯示局數標題;修正帶入現有對戰時
  第 2 隊不顯示「已放入」且點擊無法取消的問題(配對比對改為不分順序)
- 舊 V1 三輪資料(personnel 含 B 區)仍照「第 N 組」顯示,自動判別

根本原因:
- 實際打球流程是 2、3 號場地各打 8 局、每局換搭檔輪休,
  舊的三輪配對制不符流程,也完全沒考慮實力;
  選隊面板的隊伍比對寫死正序,帶入現有對戰時右隊兩人反序就對不上

影響:
- 首頁載入與手動產生的結果格式一致(N 號場地 × 8 局),操作流程不變
- 舊版 A / B 區的 localStorage 內容自動合併進今日出席名單

修法:
- match.ts 移除跨區平衡三輪邏輯,移植 buildSchedule/pickBestMatching 等演算法,
  新增 buildCourtGroups 輸出場地格式;RoundGroup 增加 label
- convertDbRecordToGroups 依 personnel 是否含 B 區自動分流 V1 / V2
- ScoreboardPage 的 getPresetTeamSelectionSlot / removePresetTeamFromDraft
  改為兩人同隊即成立、不看順序

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 12:19:53 +08:00
co-authored by Claude Fable 5
parent 6ae1eda7e2
commit 6f38c155f0
9 changed files with 546 additions and 163 deletions
+3 -1
View File
@@ -6,7 +6,9 @@
- 選隊伍頁面
- 可依指定日期從資料庫讀取分組資料。
- 若當天沒有資料,可手動輸入 A、B 區名單建立配對
- match-hub V2 場地排程資料(`battlecombination` 以場地為 key、每場地 8 局 × 2 隊)會顯示成「2 號場地」「3 號場地」各 8 局賽程,挑一個場地帶進記分板;選隊面板的預設隊伍也會照局分組
- 舊的 V1 三輪資料(personnel 含 B 區)仍照「第 N 組」顯示,兩種格式自動判別。
- 若當天沒有資料,可手動輸入「今日出席名單」排賽程:演算法與 match-hub V2 相同,依 `tg_members.skill` 實力(`GET /api/skills`,表名可用 `DB_MEMBERS_TABLE` 覆寫)做強弱互補與兩隊平衡,搭檔在全部組合輪過一遍前不重複、每人局數落差最多 1 局;8 人以上排 2、3 號兩個場地,未滿 8 人只排 2 號場地。
- 點進記分板時會直接帶入該組對戰。
- 記分板
- 兩隊隊員可自由交換上下、左右位置。
+50
View File
@@ -12,6 +12,8 @@ const matchTableName = process.env.DB_TABLE ?? 'badminton'
const historyTableName = process.env.DB_HISTORY_TABLE ?? 'history'
// 出席統計的來源:TG 報名 bot 寫入的 attendance 表(每天每人一列)。
const attendanceTableName = process.env.DB_ATTENDANCE_TABLE ?? 'attendance'
// 成員實力表(match-hub 維護的 tg_members.skill)。
const membersTableName = process.env.DB_MEMBERS_TABLE ?? 'tg_members'
const appVersion = process.env.APP_VERSION ?? `${Date.now()}`
const appStartedAt = new Date().toISOString()
const LIVE_ROOM_STALE_MS = 30_000
@@ -380,6 +382,43 @@ app.get('/api/match-results/:time', async (request, response) => {
}
})
// 全體成員實力表(1~10,預設 1),給首頁手動排賽程時做實力配對用。
app.get('/api/skills', async (_request, response) => {
if (!pool) {
response.status(500).json({
ok: false,
message: `DB 尚未設定完成,缺少 ${missingEnv.join(', ')}`,
})
return
}
try {
const [rows] = await pool.execute(
`SELECT nickname, skill FROM \`${membersTableName}\` WHERE nickname IS NOT NULL`,
)
const skills = {}
rows.forEach((row) => {
const name = String(row.nickname ?? '').trim()
if (name) {
skills[name] = clampSkill(row.skill)
}
})
response.json({
ok: true,
data: { skills },
})
} catch (error) {
console.error('skills load error:', error)
response.status(500).json({
ok: false,
message: error instanceof Error ? error.message : '實力資料讀取失敗。',
})
}
})
app.get('/api/attendance', async (_request, response) => {
if (!pool) {
response.status(500).json({
@@ -895,6 +934,17 @@ function broadcastRoomList() {
})
}
// 實力限制在 1~10 的整數,缺值或壞值一律當 1。
function clampSkill(value) {
const skill = Number(value)
if (!Number.isFinite(skill)) {
return 1
}
return Math.min(10, Math.max(1, Math.round(skill)))
}
function getAttendanceName(rawName) {
const name = String(rawName ?? '').trim()
return ATTENDANCE_ALIASES[name] ?? name
+51 -5
View File
@@ -319,11 +319,7 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
/* 其他區名單跨滿整行,放在 A/B 區下方。 */
.field-area-other {
grid-column: 1 / -1;
}
/* 其他區名單和今日出席並排,各占一欄。 */
.field-area-other textarea {
min-height: 72px;
}
@@ -432,6 +428,44 @@
color: var(--panel-soft);
}
/* V2 場地排程:首頁每個場地卡片內的 8 局賽程列表。 */
.court-game-list {
display: grid;
gap: 8px;
margin-top: 18px;
}
.court-game-row {
display: grid;
grid-template-columns: 58px minmax(0, 1fr) auto minmax(0, 1fr);
gap: 10px;
align-items: center;
padding: 10px 14px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(10, 51, 45, 0.08);
}
.court-game-index {
color: var(--panel-soft);
font-size: 0.82rem;
white-space: nowrap;
}
.court-game-team {
font-weight: 700;
overflow-wrap: anywhere;
}
.court-game-team:last-child {
text-align: right;
}
.court-game-vs {
color: var(--panel-soft);
font-size: 0.78rem;
}
.team-name {
margin-top: 10px;
font-size: 1.08rem;
@@ -1633,6 +1667,18 @@
overflow: auto;
}
/* V2 場地排程:預設隊伍清單裡每局的小標題(兩隊一局)。 */
.preset-game-label {
margin-top: 4px;
color: var(--panel-soft);
font-size: 0.74rem;
letter-spacing: 0.08em;
}
.preset-game-label:first-child {
margin-top: 0;
}
.preset-team-card {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
+58 -29
View File
@@ -5,6 +5,7 @@ import {
createLiveRoom,
loadHistoryPlayCounts,
loadMatchResults,
loadMemberSkills,
releaseLiveRoom,
rememberCertNotAfter,
saveMatchHistory,
@@ -12,7 +13,7 @@ import {
updateLiveRoom,
} from './lib/api'
import {
buildManualGroups,
buildCourtGroups,
convertDateToKey,
convertDbRecordToGroups,
formatDateInputValue,
@@ -51,10 +52,26 @@ const STORAGE_KEYS = {
areaOther: 'badminton-scoreboard::area-other',
history: 'badminton-scoreboard::history',
playCounts: 'badminton-scoreboard::play-counts',
roster: 'badminton-scoreboard::roster-today',
} as const
const defaultAreaA = ['柏威', '建喵', 'Yuki', '阿釧']
const defaultAreaB = ['RURU', '玟瑄', '培根', 'Tim']
// 首次啟用今日出席名單時,把舊版 A / B 區的儲存內容合併過來。
function loadInitialRoster() {
const stored = loadStoredText(STORAGE_KEYS.roster, '')
if (stored.trim()) {
return stored
}
const legacy = [
loadStoredText(STORAGE_KEYS.areaA, ''),
loadStoredText(STORAGE_KEYS.areaB, ''),
]
.join('\n')
.trim()
return legacy
}
const initialScoreState: ScoreState = {
scoreLeft: 0,
@@ -119,12 +136,11 @@ function App() {
const isScoreboardRoute = location.pathname === '/scoreboard'
const [targetDate, setTargetDate] = useState(() => formatDateInputValue())
const [areaAInput, setAreaAInput] = useState(() =>
loadStoredText(STORAGE_KEYS.areaA, defaultAreaA.join('\n')),
)
const [areaBInput, setAreaBInput] = useState(() =>
loadStoredText(STORAGE_KEYS.areaB, defaultAreaB.join('\n')),
)
// 今日出席名單:手動排賽程的對象,取代舊版 A / B 區。
const [rosterInput, setRosterInput] = useState(() => loadInitialRoster())
// 全體成員實力表(tg_members.skill),排賽程時做強弱互補與兩隊平衡。
const [skillMap, setSkillMap] = useState<Record<string, number>>({})
const [skillsReady, setSkillsReady] = useState(false)
// 其他區:不參與分組配對,但會帶進記分板讓臨時上場的人可以被選到。
const [areaOtherInput, setAreaOtherInput] = useState(() =>
loadStoredText(STORAGE_KEYS.areaOther, ''),
@@ -167,8 +183,7 @@ function App() {
const creatingRoomRef = useRef(false)
const lastSyncedRoomSignatureRef = useRef('')
const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput])
const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput])
const parsedRoster = useMemo(() => parseRoster(rosterInput), [rosterInput])
const parsedAreaOther = useMemo(() => parseRoster(areaOtherInput), [areaOtherInput])
const selectedGroup = groups.find((group) => group.id === selectedGroupId) ?? null
const leftTeam = activeMatchup.leftTeam
@@ -177,12 +192,21 @@ function App() {
const isNavigationLocked = Boolean(leftTeam && rightTeam && scoreState.serving !== null)
useEffect(() => {
window.localStorage.setItem(STORAGE_KEYS.areaA, areaAInput)
}, [areaAInput])
window.localStorage.setItem(STORAGE_KEYS.roster, rosterInput)
}, [rosterInput])
// 開站先載入實力表,載入完成前不能手動產生賽程(跟 match-hub 一致)。
useEffect(() => {
window.localStorage.setItem(STORAGE_KEYS.areaB, areaBInput)
}, [areaBInput])
void (async () => {
try {
const skills = await loadMemberSkills()
setSkillMap(skills)
setSkillsReady(true)
} catch (error) {
console.error('skills load error:', error)
}
})()
}, [])
useEffect(() => {
window.localStorage.setItem(STORAGE_KEYS.areaOther, areaOtherInput)
@@ -682,8 +706,7 @@ function App() {
}
const nextData = convertDbRecordToGroups(record)
setAreaAInput(nextData.areaA.join('\n'))
setAreaBInput(nextData.areaB.join('\n'))
setRosterInput([...nextData.areaA, ...nextData.areaB].join('\n'))
setGroups(nextData.groups)
setGroupSource('db')
setLoadStatus('loaded')
@@ -700,21 +723,31 @@ function App() {
}
const generateManualGroups = () => {
if (parsedAreaA.length === 0 || parsedAreaB.length === 0) {
if (parsedRoster.length < 4) {
setGroups([])
setSelectedGroupId(null)
setActiveMatchup({ leftTeam: null, rightTeam: null })
setGroupSource('idle')
setLoadStatus('error')
setLoadMessage('A 區與 B 區至少都要有 1 位成員。')
setLoadMessage('今日出席至少需要 4 位成員才能排賽程。')
return
}
const nextGroups = buildManualGroups(parsedAreaA, parsedAreaB)
if (!skillsReady) {
setLoadStatus('error')
setLoadMessage('實力資料還沒載入完成,請稍候再產生賽程(或重新整理頁面)。')
return
}
const nextGroups = buildCourtGroups(parsedRoster, skillMap)
setGroups(nextGroups)
setGroupSource('manual')
setLoadStatus('loaded')
setLoadMessage('已產生手動配對結果,請選擇要使用的組別。')
setLoadMessage(
nextGroups.length === 2
? '已依實力產生 2、3 號場地各 8 局賽程,挑一個場地進記分板。'
: '人數不足 8 人,只排 2 號場地 8 局賽程。',
)
selectGroup(nextGroups[0]?.id ?? 1, nextGroups)
}
@@ -1091,17 +1124,15 @@ function App() {
path="/"
element={
<TeamSelectionPage
areaAInput={areaAInput}
areaBInput={areaBInput}
areaOtherInput={areaOtherInput}
groups={groups}
groupSource={groupSource}
loadMessage={loadMessage}
loadStatus={loadStatus}
rosterInput={rosterInput}
targetDate={targetDate}
onAreaAInputChange={setAreaAInput}
onAreaBInputChange={setAreaBInput}
onAreaOtherInputChange={setAreaOtherInput}
onRosterInputChange={setRosterInput}
onGenerateManualGroups={generateManualGroups}
onLoadGroupsFromDb={() => void loadGroupsFromDb()}
onTargetDateChange={setTargetDate}
@@ -1113,17 +1144,15 @@ function App() {
path="/teams"
element={
<TeamSelectionPage
areaAInput={areaAInput}
areaBInput={areaBInput}
areaOtherInput={areaOtherInput}
groups={groups}
groupSource={groupSource}
loadMessage={loadMessage}
loadStatus={loadStatus}
rosterInput={rosterInput}
targetDate={targetDate}
onAreaAInputChange={setAreaAInput}
onAreaBInputChange={setAreaBInput}
onAreaOtherInputChange={setAreaOtherInput}
onRosterInputChange={setRosterInput}
onGenerateManualGroups={generateManualGroups}
onLoadGroupsFromDb={() => void loadGroupsFromDb()}
onTargetDateChange={setTargetDate}
+16
View File
@@ -136,6 +136,22 @@ export async function loadHistoryList(page = 1, pageSize = 20): Promise<HistoryL
}
}
// 讀取全體成員實力表(tg_members.skill1~10),給手動排賽程做實力配對。
export async function loadMemberSkills(): Promise<Record<string, number>> {
const response = await apiFetch('/api/skills')
const payload = (await readJsonSafely(response)) as {
ok?: boolean
message?: string
data?: { skills?: Record<string, number> }
}
if (!response.ok || !payload.ok || !payload.data?.skills) {
throw new Error(payload.message ?? '實力資料讀取失敗。')
}
return payload.data.skills
}
export async function loadAttendanceStats(): Promise<AttendanceStats> {
const response = await apiFetch('/api/attendance')
const payload = (await readJsonSafely(response)) as {
+273 -55
View File
@@ -7,7 +7,23 @@ import type {
} from '../types'
const PLACEHOLDER_NAME = '輪空'
const TOTAL_GROUPS = 3
// match-hub V2 場地排程實際使用的場地編號(依 battlecombination key 排序後對應)。
const COURT_NUMBERS = [2, 3]
// 以下常數與演算法移植自 match-hub 的 V2 場地排程。
const GAMES_PER_COURT = 8
const PLAYERS_PER_GAME = 4
const PAIR_SCORE_BASE = 16
const SCHEDULE_ATTEMPTS = 40
type Pair = [string, string]
type CourtGame = {
court: number
teamA: Pair
teamB: Pair
}
export type SkillMap = Record<string, number>
export function parseRoster(input: string) {
const uniqueNames = new Set<string>()
@@ -21,57 +37,254 @@ export function parseRoster(input: string) {
return Array.from(uniqueNames)
}
export function buildManualGroups(areaA: string[], areaB: string[]) {
const shuffledA = shuffleList(areaA)
const shuffledB = shuffleList(areaB)
// 手動產生賽程(移植 match-hub V2):每局從全員挑 8 人分兩場地打雙打,其餘輪休,
// 搭檔在全部組合輪過一遍前不重複、強弱互補,每局兩隊實力總和盡量接近。
// 回傳跟 DB V2 資料同樣的場地格式:每個場地一組,teams 依序是每局的兩隊。
export function buildCourtGroups(players: string[], skillMap: SkillMap) {
const rounds = buildSchedule(players, skillMap)
const courtPairs = new Map<number, Pair[]>()
// 每輪先做跨區平衡再配對:兩區差距 2 以上時抽「差距÷2」人跨區支援,
// 且每輪輪替不同人;只有總人數為奇數時才會補 1 個輪空。
return Array.from({ length: TOTAL_GROUPS }, (_, roundIndex) => {
const balanced = balanceZones(shuffledA, shuffledB, roundIndex)
const targetCount = Math.max(balanced.areaA.length, balanced.areaB.length)
const paddedA = padTeams(balanced.areaA, targetCount)
const paddedB = padTeams(balanced.areaB, targetCount)
rounds.forEach((games) => {
games.forEach((game) => {
const pairs = courtPairs.get(game.court) ?? []
pairs.push(game.teamA, game.teamB)
courtPairs.set(game.court, pairs)
})
})
return {
id: roundIndex + 1,
teams: paddedA.map((playerA, index) =>
createTeam(
index + 1,
playerA,
paddedB[(index + roundIndex) % paddedB.length],
),
return Array.from(courtPairs.entries())
.sort((left, right) => left[0] - right[0])
.map(([court, pairs], courtIndex) => ({
id: courtIndex + 1,
label: `${court} 號場地`,
teams: pairs.map(([playerA, playerB], teamIndex) =>
createTeam(teamIndex + 1, playerA, playerB),
),
}))
}
// 重試多次取「搭檔重複最少、整份賽程兩隊實力差總和最小」的一份。
function buildSchedule(players: string[], skillMap: SkillMap) {
let best: CourtGame[][] = []
let bestScore = Number.POSITIVE_INFINITY
for (let attempt = 0; attempt < SCHEDULE_ATTEMPTS; attempt += 1) {
const { rounds, repeatScore } = buildScheduleOnce(players, skillMap)
const imbalance = rounds.reduce(
(sum, games) =>
sum +
games.reduce(
(gameSum, game) =>
gameSum +
Math.abs(pairSkillSum(game.teamA, skillMap) - pairSkillSum(game.teamB, skillMap)),
0,
),
0,
)
// 不重複優先(權重放大),再比整份賽程的兩隊實力差總和
const score = repeatScore * 100000 + imbalance
if (score < bestScore) {
bestScore = score
best = rounds
}
if (bestScore === 0) {
break
}
}
return best
}
function buildScheduleOnce(players: string[], skillMap: SkillMap) {
const courtCount = players.length >= COURT_NUMBERS.length * PLAYERS_PER_GAME ? 2 : 1
const pairUse = new Map<string, number>()
const opponentUse = new Map<string, number>()
const playCount = new Map(players.map((name) => [name, 0]))
const rounds: CourtGame[][] = []
for (let roundIndex = 0; roundIndex < GAMES_PER_COURT; roundIndex += 1) {
const active = pickActivePlayers(players, playCount, courtCount * PLAYERS_PER_GAME)
const pairs = pickBestMatching(active, pairUse, skillMap)
const games = pickBestGames(pairs, opponentUse, skillMap)
games.forEach((game) => {
bumpUse(pairUse, pairKey(game.teamA[0], game.teamA[1]))
bumpUse(pairUse, pairKey(game.teamB[0], game.teamB[1]))
game.teamA.forEach((left) => {
game.teamB.forEach((right) => bumpUse(opponentUse, pairKey(left, right)))
})
;[...game.teamA, ...game.teamB].forEach((name) => {
playCount.set(name, (playCount.get(name) ?? 0) + 1)
})
})
rounds.push(games)
}
const totalPairs = (players.length * (players.length - 1)) / 2
const allowedUse = Math.max(
1,
Math.ceil((GAMES_PER_COURT * courtCount * 2) / totalPairs),
)
let repeatScore = 0
pairUse.forEach((useCount) => {
if (useCount > allowedUse) {
repeatScore += useCount - allowedUse
}
})
return { rounds, repeatScore }
}
function balanceZones(areaA: string[], areaB: string[], roundIndex: number) {
const moveCount = Math.floor(Math.abs(areaA.length - areaB.length) / 2)
if (moveCount === 0) {
return { areaA, areaB }
}
if (areaA.length > areaB.length) {
const { moved, remaining } = pickCrossMembers(areaA, moveCount, roundIndex)
return { areaA: remaining, areaB: [...areaB, ...moved] }
}
const { moved, remaining } = pickCrossMembers(areaB, moveCount, roundIndex)
return { areaA: [...areaA, ...moved], areaB: remaining }
// 上場局數少的人優先,讓每人局數落差最多 1 局。
function pickActivePlayers(players: string[], playCount: Map<string, number>, needed: number) {
return shuffleList(players)
.sort((left, right) => (playCount.get(left) ?? 0) - (playCount.get(right) ?? 0))
.slice(0, needed)
}
// 依輪次輪替抽出要跨區支援的人,讓跨區的負擔平均分攤在不同人身上。
function pickCrossMembers(list: string[], moveCount: number, roundIndex: number) {
const movedIndexes = new Set(
Array.from({ length: moveCount }, (_, offset) => (roundIndex * moveCount + offset) % list.length),
)
function pickBestMatching(active: string[], pairUse: Map<string, number>, skillMap: SkillMap) {
const matchings = enumerateMatchings(active)
let bestScore = Number.POSITIVE_INFINITY
let candidates: Pair[][] = []
return {
moved: list.filter((_, index) => movedIndexes.has(index)),
remaining: list.filter((_, index) => !movedIndexes.has(index)),
matchings.forEach((matching) => {
const score = matching.reduce(
(sum, pair) => sum + PAIR_SCORE_BASE ** (pairUse.get(pairKey(pair[0], pair[1])) ?? 0),
0,
)
if (score < bestScore) {
bestScore = score
candidates = [matching]
} else if (score === bestScore) {
candidates.push(matching)
}
})
// 在不重複的前提下,優先挑各組搭檔實力總和最接近的組合(強弱互補)
let bestSpread = Number.POSITIVE_INFINITY
let balanced: Pair[][] = []
candidates.forEach((matching) => {
const sums = matching.map((pair) => pairSkillSum(pair, skillMap))
const spread = Math.max(...sums) - Math.min(...sums)
if (spread < bestSpread) {
bestSpread = spread
balanced = [matching]
} else if (spread === bestSpread) {
balanced.push(matching)
}
})
return pickRandom(balanced)
}
function enumerateMatchings(names: string[]): Pair[][] {
if (names.length < 2) {
return [[]]
}
const [first, ...rest] = names
const results: Pair[][] = []
rest.forEach((partner, index) => {
const remaining = rest.filter((_, restIndex) => restIndex !== index)
enumerateMatchings(remaining).forEach((subMatching) => {
results.push([[first, partner], ...subMatching])
})
})
return results
}
function pickBestGames(
pairs: Pair[],
opponentUse: Map<string, number>,
skillMap: SkillMap,
): CourtGame[] {
if (pairs.length === 2) {
return [{ court: COURT_NUMBERS[0], teamA: pairs[0], teamB: pairs[1] }]
}
const groupings: [Pair, Pair][][] = [
[
[pairs[0], pairs[1]],
[pairs[2], pairs[3]],
],
[
[pairs[0], pairs[2]],
[pairs[1], pairs[3]],
],
[
[pairs[0], pairs[3]],
[pairs[1], pairs[2]],
],
]
// 先求兩隊實力總和最接近,實力相同時再挑對手重複最少的組合
let bestImbalance = Number.POSITIVE_INFINITY
let bestOpponentScore = Number.POSITIVE_INFINITY
let balanced: [Pair, Pair][][] = []
groupings.forEach((grouping) => {
const imbalance = grouping.reduce(
(sum, [teamA, teamB]) =>
sum + Math.abs(pairSkillSum(teamA, skillMap) - pairSkillSum(teamB, skillMap)),
0,
)
const opponentScore = grouping.reduce((sum, [teamA, teamB]) => {
let gameScore = 0
teamA.forEach((left) => {
teamB.forEach((right) => {
gameScore += PAIR_SCORE_BASE ** (opponentUse.get(pairKey(left, right)) ?? 0)
})
})
return sum + gameScore
}, 0)
if (
imbalance < bestImbalance ||
(imbalance === bestImbalance && opponentScore < bestOpponentScore)
) {
bestImbalance = imbalance
bestOpponentScore = opponentScore
balanced = [grouping]
} else if (imbalance === bestImbalance && opponentScore === bestOpponentScore) {
balanced.push(grouping)
}
})
const orderedGames = shuffleList(pickRandom(balanced))
return orderedGames.map(([teamA, teamB], index) => ({
court: COURT_NUMBERS[index],
teamA,
teamB,
}))
}
function pairSkillSum(pair: Pair, skillMap: SkillMap) {
return skillOf(pair[0], skillMap) + skillOf(pair[1], skillMap)
}
function skillOf(name: string, skillMap: SkillMap) {
const skill = skillMap[name]
return typeof skill === 'number' && Number.isFinite(skill) ? skill : 1
}
function pairKey(left: string, right: string) {
return [left, right].sort().join('|')
}
function bumpUse(useMap: Map<string, number>, key: string) {
useMap.set(key, (useMap.get(key) ?? 0) + 1)
}
function pickRandom<T>(list: T[]) {
return list[Math.floor(Math.random() * list.length)]
}
export function convertDbRecordToGroups(record: MatchResultsRecord) {
@@ -83,16 +296,31 @@ export function convertDbRecordToGroups(record: MatchResultsRecord) {
const areaA = personnel.filter(([group]) => group === 1).map(([, name]) => name)
const areaB = personnel.filter(([group]) => group === 0).map(([, name]) => name)
const sortedKeys = Object.keys(battlecombination).sort(
(left, right) => Number(left) - Number(right),
)
const groups = Object.keys(battlecombination)
.sort((left, right) => Number(left) - Number(right))
.map((key, roundIndex) => ({
id: roundIndex + 1,
// match-hub V2 場地排程:personnel 沒有 B 區,battlecombination 以場地為 key
// 每個場地存 8 局 × 2 隊的搭檔。顯示成「N 號場地」讓使用者挑一個場地帶進記分板。
if (areaB.length === 0) {
const groups = sortedKeys.map((key, courtIndex) => ({
id: courtIndex + 1,
label: `${COURT_NUMBERS[courtIndex] ?? courtIndex + COURT_NUMBERS[0]} 號場地`,
teams: battlecombination[key].map(([playerA, playerB], teamIndex) =>
createTeam(teamIndex + 1, playerA, playerB),
),
}))
return { areaA, areaB, groups }
}
const groups = sortedKeys.map((key, roundIndex) => ({
id: roundIndex + 1,
teams: battlecombination[key].map(([playerA, playerB], teamIndex) =>
createTeam(teamIndex + 1, playerA, playerB),
),
}))
return { areaA, areaB, groups }
}
@@ -230,16 +458,6 @@ function createTeam(id: number, playerA: string, playerB: string) {
}
}
function padTeams(list: string[], targetCount: number) {
const next = [...list]
while (next.length < targetCount) {
next.push(PLACEHOLDER_NAME)
}
return next
}
function shuffleList<T>(list: T[]) {
const next = [...list]
+38 -29
View File
@@ -1,5 +1,5 @@
import type { Dispatch, SetStateAction } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import {
getCourtAssignments,
@@ -1020,7 +1020,7 @@ function TeamPickerModal({
<div>
<strong></strong>
<p>
{group.id} / {sourceLabel} / {targetDate || '-'}
{group.label ?? `${group.id}`} / {sourceLabel} / {targetDate || '-'}
</p>
</div>
</div>
@@ -1111,30 +1111,35 @@ function TeamPickerModal({
</div>
<div className="preset-team-list">
{presetTeams.map((team) => {
{presetTeams.map((team, teamIndex) => {
const selectedSlot = getPresetTeamSelectionSlot(draftPlayers, team)
// V2 場地排程(有 label):兩隊一局,於每局第一隊前顯示局數標題。
const gameLabel =
group.label && teamIndex % 2 === 0 ? `${teamIndex / 2 + 1}` : null
return (
<button
className={
selectedSlot === null
? 'preset-team-card'
: 'preset-team-card preset-team-card-active'
}
key={`preset-team-${team.id}`}
type="button"
onClick={() => onTogglePresetTeam(team)}
>
<span className="preset-team-index">{team.id}</span>
<div>
<strong>{getTeamDisplayName(team)}</strong>
<small>
{selectedSlot === null
? '點擊套用這一隊'
: `已放入第 ${selectedSlot === 0 ? '1' : '2'}`}
</small>
</div>
</button>
<Fragment key={`preset-team-${team.id}`}>
{gameLabel ? <div className="preset-game-label">{gameLabel}</div> : null}
<button
className={
selectedSlot === null
? 'preset-team-card'
: 'preset-team-card preset-team-card-active'
}
type="button"
onClick={() => onTogglePresetTeam(team)}
>
<span className="preset-team-index">{team.id}</span>
<div>
<strong>{getTeamDisplayName(team)}</strong>
<small>
{selectedSlot === null
? '點擊套用這一隊'
: `已放入第 ${selectedSlot === 0 ? '1' : '2'}`}
</small>
</div>
</button>
</Fragment>
)
})}
</div>
@@ -1335,14 +1340,13 @@ function sanitizeTargetScore(value: string) {
}
function removePresetTeamFromDraft(players: string[], team: GroupTeam) {
const firstPairSelected = players[0] === team.playerA && players[1] === team.playerB
const secondPairSelected = players[2] === team.playerA && players[3] === team.playerB
const slot = getPresetTeamSelectionSlot(players, team)
if (firstPairSelected) {
if (slot === 0) {
return players.slice(2)
}
if (secondPairSelected) {
if (slot === 1) {
return players.slice(0, 2)
}
@@ -1350,11 +1354,16 @@ function removePresetTeamFromDraft(players: string[], team: GroupTeam) {
}
function getPresetTeamSelectionSlot(players: string[], team: GroupTeam) {
if (players[0] === team.playerA && players[1] === team.playerB) {
// 兩人都在同一隊就算選到,不看先後順序(帶入現有對戰時右隊兩人是反序)。
const matchesSlot = (first?: string, second?: string) =>
(first === team.playerA && second === team.playerB) ||
(first === team.playerB && second === team.playerA)
if (matchesSlot(players[0], players[1])) {
return 0
}
if (players[2] === team.playerA && players[3] === team.playerB) {
if (matchesSlot(players[2], players[3])) {
return 1
}
+55 -44
View File
@@ -3,37 +3,33 @@ import { getTeamDisplayName } from '../lib/match'
import type { LoadStatus, RoundGroup } from '../types'
type TeamSelectionPageProps = {
areaAInput: string
areaBInput: string
areaOtherInput: string
groups: RoundGroup[]
groupSource: 'idle' | 'db' | 'manual'
loadMessage: string
loadStatus: LoadStatus
rosterInput: string
targetDate: string
onAreaAInputChange: (value: string) => void
onAreaBInputChange: (value: string) => void
onAreaOtherInputChange: (value: string) => void
onGenerateManualGroups: () => void
onLoadGroupsFromDb: () => void
onRosterInputChange: (value: string) => void
onTargetDateChange: (value: string) => void
onUseGroup: (groupId: number) => void
}
export function TeamSelectionPage({
areaAInput,
areaBInput,
areaOtherInput,
groups,
groupSource,
loadMessage,
loadStatus,
rosterInput,
targetDate,
onAreaAInputChange,
onAreaBInputChange,
onAreaOtherInputChange,
onGenerateManualGroups,
onLoadGroupsFromDb,
onRosterInputChange,
onTargetDateChange,
onUseGroup,
}: TeamSelectionPageProps) {
@@ -78,25 +74,16 @@ export function TeamSelectionPage({
<div className="double-grid">
<label className="field">
<span>A </span>
<span></span>
<textarea
placeholder="每行一位球員"
value={areaAInput}
onChange={(event) => onAreaAInputChange(event.target.value)}
/>
</label>
<label className="field">
<span>B </span>
<textarea
placeholder="每行一位球員"
value={areaBInput}
onChange={(event) => onAreaBInputChange(event.target.value)}
value={rosterInput}
onChange={(event) => onRosterInputChange(event.target.value)}
/>
</label>
<label className="field field-area-other">
<span></span>
<span></span>
<textarea
placeholder="每行一位球員"
value={areaOtherInput}
@@ -121,30 +108,54 @@ export function TeamSelectionPage({
<div className="group-board">
{hasGroups ? (
groups.map((group) => (
<article key={group.id} className="group-card">
<div className="group-head group-head-compact">
<div>
<p className="panel-kicker"> {group.id} </p>
<h3></h3>
</div>
<div className="group-actions">
<Link className="primary-button inline-link" to="/scoreboard" onClick={() => onUseGroup(group.id)}>
</Link>
</div>
</div>
groups.map((group) => {
// V2 場地排程(有 label):teams 依序是每局的兩隊,兩兩一組顯示成 8 局賽程。
const courtGames = group.label
? Array.from({ length: Math.floor(group.teams.length / 2) }, (_, gameIndex) => ({
id: gameIndex + 1,
teamA: group.teams[gameIndex * 2],
teamB: group.teams[gameIndex * 2 + 1],
}))
: null
<div className="team-stage-grid">
{group.teams.map((team) => (
<article key={`${group.id}-${team.id}`} className="team-stage-card">
<span className="team-index"> {team.id}</span>
<div className="team-name">{getTeamDisplayName(team)}</div>
</article>
))}
</div>
</article>
))
return (
<article key={group.id} className="group-card">
<div className="group-head group-head-compact">
<div>
<p className="panel-kicker">{group.label ?? `${group.id}`}</p>
<h3>{courtGames ? `本場地 ${courtGames.length} 局賽程` : '本組對戰名單'}</h3>
</div>
<div className="group-actions">
<Link className="primary-button inline-link" to="/scoreboard" onClick={() => onUseGroup(group.id)}>
</Link>
</div>
</div>
{courtGames ? (
<div className="court-game-list">
{courtGames.map((game) => (
<div key={`${group.id}-game-${game.id}`} className="court-game-row">
<span className="court-game-index"> {game.id} </span>
<span className="court-game-team">{getTeamDisplayName(game.teamA)}</span>
<span className="court-game-vs">vs</span>
<span className="court-game-team">{getTeamDisplayName(game.teamB)}</span>
</div>
))}
</div>
) : (
<div className="team-stage-grid">
{group.teams.map((team) => (
<article key={`${group.id}-${team.id}`} className="team-stage-card">
<span className="team-index"> {team.id}</span>
<div className="team-name">{getTeamDisplayName(team)}</div>
</article>
))}
</div>
)}
</article>
)
})
) : (
<div className="empty-state">
<h3></h3>
+2
View File
@@ -29,6 +29,8 @@ export type GroupTeam = {
export type RoundGroup = {
id: number
// V2 場地排程用的顯示名(例如「2 號場地」),沒有就顯示「第 N 組」。
label?: string
teams: GroupTeam[]
}