功能:首頁改為 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:
@@ -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 號場地。
|
||||
- 點進記分板時會直接帶入該組對戰。
|
||||
- 記分板
|
||||
- 兩隊隊員可自由交換上下、左右位置。
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -136,6 +136,22 @@ export async function loadHistoryList(page = 1, pageSize = 20): Promise<HistoryL
|
||||
}
|
||||
}
|
||||
|
||||
// 讀取全體成員實力表(tg_members.skill,1~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
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -29,6 +29,8 @@ export type GroupTeam = {
|
||||
|
||||
export type RoundGroup = {
|
||||
id: number
|
||||
// V2 場地排程用的顯示名(例如「2 號場地」),沒有就顯示「第 N 組」。
|
||||
label?: string
|
||||
teams: GroupTeam[]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user