Files
JianMiauandClaude Fable 5 0386c94546 記分板新增隨機配對、計分人權限與換人計分,調整按鈕排法
根本原因:
每打完一輪都要人工決定誰上場、誰搭誰、誰負責記分,記分板也沒有「兩隊對換」和退回選先發球的操作,現場排場地或換人都得重開記分板。

影響:
/記分 多一顆 🎲 隨機配對:勾選出席者(預設全選)後,依 tg_members.skill 與今天的 history 戰績排 2、3 號場地(休息最久優先、搭檔與對手不重複、實力平衡),預覽後一鍵推出各場地記分板;每場從休息者抽一位計分人(SCORER_EXCLUDE 不抽),只有他和管理員能按,休息人不夠則不鎖權限;開局可 🙋 換人計分、🔃 🅰️🅱️ 對換;計分鍵盤改為左欄 🅰️+1/🅱️+1,拿掉「重新開始」,0:0 時上一步可退回選先發球。

修法:
- 新增 src/pairing.js:移植 badminton-match-hub V2 的配對規則,改成一次只排下一輪、以「休息輪數」取代平均場數;assignScorers 從休息者抽計分人
- history.js 新增 loadTodayGames 讀今日戰績;signup.js attendees() 帶 skill 與 user_id(權限靠 TG id 比對)
- scoreboard.js 以 phase(pick → roster → paired)管理同一則訊息的選人/勾選/預覽流程,推出的記分板 ownerId 指向計分人;新增換人計分(ho / hoN / hoany / hocancel),候選排除場上、現任計分人與 SCORER_EXCLUDE
- match.js 新增 swapTeams,setFirstServer 記入 history 讓 undo 可退回開局;render.js 重排鍵盤並新增勾選、預覽、換人面板與場地標題
- config 新增 SCORER_EXCLUDE;README、.env.example 與 37 個測試同步更新

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:24:16 +08:00

152 lines
6.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
assignScorers,
buildStats,
clampSkill,
enumerateMatchings,
pairKey,
pairRound,
pairSkillSum,
rankByRest,
roundsRested,
} from '../src/pairing.js'
// 固定亂數(mulberry32),讓測試可重現
function seeded(seed) {
let a = seed >>> 0
return () => {
a = (a + 0x6d2b79f5) >>> 0
let t = a
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
const game = (a, b) => ({ teams: [a, b] })
const NAMES = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L']
const activeOf = (result) => result.games.flatMap((g) => [...g.teamA, ...g.teamB])
const pairsOf = (result) => result.games.flatMap((g) => [pairKey(...g.teamA), pairKey(...g.teamB)])
test('buildStats:從今日戰績統計場數、搭檔、對手與最後一場', () => {
const stats = buildStats([game(['A', 'B'], ['C', 'D']), game(['A', 'E'], ['F', 'G']), game(['H'], ['A'])])
assert.equal(stats.total, 3)
assert.equal(stats.playCount.get('A'), 3)
assert.equal(stats.playCount.get('B'), 1)
assert.equal(stats.pairUse.get(pairKey('A', 'B')), 1)
assert.equal(stats.pairUse.get(pairKey('B', 'A')), 1, 'pairKey 不分順序')
assert.equal(stats.pairUse.get(pairKey('H', 'A')), undefined, '單打不算搭檔')
assert.equal(stats.opponentUse.get(pairKey('A', 'C')), 1)
assert.equal(stats.opponentUse.get(pairKey('A', 'H')), 1, '單打算對手')
assert.equal(stats.lastGame.get('A'), 2)
assert.equal(stats.lastGame.get('B'), 0)
assert.equal(stats.lastGame.get('Z'), undefined)
assert.equal(clampSkill('12'), 10)
assert.equal(clampSkill(null), 1)
assert.equal(pairSkillSum(['A', 'B'], { A: 3 }), 4, '查不到的人當 1')
})
test('休息輪數:沒打過 = Infinity、剛打完這輪 = 0,一輪 = 場地數', () => {
// 兩個場地:第 1 輪 A~H,第 2 輪 I~L + A~D
const stats = buildStats([
game(['A', 'B'], ['C', 'D']),
game(['E', 'F'], ['G', 'H']),
game(['I', 'J'], ['K', 'L']),
game(['A', 'C'], ['B', 'D']),
])
assert.equal(roundsRested('Z', stats, 2), Infinity)
assert.equal(roundsRested('A', stats, 2), 0)
assert.equal(roundsRested('I', stats, 2), 0)
assert.equal(roundsRested('E', stats, 2), 1, '第 1 輪打完、第 2 輪休息')
assert.equal(roundsRested('E', stats, 1), 2, '單場地時一輪 = 1 場')
})
test('rankByRest:休息最久優先 → 場數少優先 → 隨機', () => {
const stats = buildStats([
game(['A', 'B'], ['C', 'D']),
game(['E', 'F'], ['G', 'H']),
game(['I', 'J'], ['K', 'L']),
game(['A', 'B'], ['C', 'D']),
])
const ranked = rankByRest([...NAMES, 'M'], stats, 2, seeded(3))
assert.equal(ranked[0], 'M', '沒打過的最前面')
assert.deepEqual(ranked.slice(1, 5).sort(), ['E', 'F', 'G', 'H'], '休息一輪的其次')
assert.deepEqual(ranked.slice(5, 9).sort(), ['I', 'J', 'K', 'L'], '剛打完但只打 1 場')
assert.deepEqual(ranked.slice(9).sort(), ['A', 'B', 'C', 'D'], '剛打完又打了 2 場的最後')
})
test('pairRound:剛打完的先休息、沒打過的一定上場;8 人以上排 2、3 號場地', () => {
const stats = buildStats([game(['A', 'B'], ['C', 'D']), game(['E', 'F'], ['G', 'H'])])
for (let seed = 1; seed <= 20; seed += 1) {
const result = pairRound(NAMES, {}, stats, { random: seeded(seed) })
assert.equal(result.courtCount, 2)
assert.deepEqual(result.games.map((g) => g.court), [2, 3])
const active = activeOf(result)
assert.equal(active.length, 8)
assert.equal(new Set(active).size, 8, '沒有人同時在兩個場地')
for (const name of ['I', 'J', 'K', 'L']) assert.ok(active.includes(name), `${name} 沒打過必須上場`)
assert.equal(result.resting.length, 4)
assert.ok(result.resting.every((n) => 'ABCDEFGH'.includes(n)), '休息的都是剛打完的人')
}
})
test('pairRound:搭檔今天沒搭過的優先、對手也錯開;實力互補與兩隊平衡', () => {
// 8 人全部剛休息完,之前搭過 AB CD EF GH,且 AB 對過 CD、EF 對過 GH
const stats = buildStats([game(['A', 'B'], ['C', 'D']), game(['E', 'F'], ['G', 'H']), game(['I', 'J'], ['K', 'L']), game(['I', 'K'], ['J', 'L'])])
const eight = NAMES.slice(0, 8)
const used = new Set([pairKey('A', 'B'), pairKey('C', 'D'), pairKey('E', 'F'), pairKey('G', 'H')])
for (let seed = 1; seed <= 20; seed += 1) {
const result = pairRound(eight, {}, stats, { random: seeded(seed) })
assert.deepEqual(activeOf(result).sort(), eight, '8 人全上')
for (const key of pairsOf(result)) assert.ok(!used.has(key), `搭檔 ${key} 今天已搭過,不該再配`)
}
// 實力:4 強 4 弱 → 每組一強一弱(合計 10),兩隊實力差 0
const skills = { A: 9, B: 9, C: 9, D: 9, E: 1, F: 1, G: 1, H: 1 }
for (let seed = 1; seed <= 20; seed += 1) {
const result = pairRound(eight, skills, buildStats([]), { random: seeded(seed) })
for (const g of result.games) {
assert.equal(pairSkillSum(g.teamA, skills), 10)
assert.equal(pairSkillSum(g.teamB, skills), 10)
}
}
})
test('pairRound4~7 人只排 2 號場地;不足 4 人回錯誤;名字去重去空白', () => {
const six = pairRound(['A', 'B', 'C', 'D', 'E', 'F'], {}, buildStats([]), { random: seeded(1) })
assert.equal(six.courtCount, 1)
assert.equal(six.games.length, 1)
assert.equal(six.games[0].court, 2)
assert.equal(six.resting.length, 2)
const four = pairRound([' A ', 'B', 'B', 'C', 'D', ''], {}, buildStats([]), { random: seeded(1) })
assert.equal(four.games.length, 1)
assert.deepEqual(activeOf(four).sort(), ['A', 'B', 'C', 'D'])
assert.deepEqual(four.resting, [])
assert.ok(pairRound(['A', 'B', 'C'], {}, buildStats([]), { random: seeded(1) }).error)
assert.equal(enumerateMatchings(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']).length, 105)
})
test('pairRound:實力不平均時搭檔強弱互補、兩隊總和最接近', () => {
const skills = { A: 10, B: 8, C: 2, D: 1 }
for (let seed = 1; seed <= 10; seed += 1) {
const [g] = pairRound(['A', 'B', 'C', 'D'], skills, buildStats([]), { random: seeded(seed) }).games
const sums = [pairSkillSum(g.teamA, skills), pairSkillSum(g.teamB, skills)].sort()
assert.deepEqual(sums, [10, 11], 'A+D=11 對 B+C=10 最平衡')
}
})
test('assignScorers:從休息的人抽計分人、排除名單不抽、不夠補 null', () => {
for (let seed = 1; seed <= 10; seed += 1) {
const s = assignScorers(['A', 'B', 'C'], 2, { exclude: new Set(['B']), random: seeded(seed) })
assert.equal(s.length, 2)
assert.ok(s.every((n) => n === 'A' || n === 'C'), '只從沒被排除的休息者抽')
assert.notEqual(s[0], s[1], '一人只負責一個場地')
}
assert.deepEqual(assignScorers(['A'], 2, { exclude: new Set(['A']), random: seeded(1) }), [null, null])
assert.deepEqual(assignScorers(['A'], 2, { random: seeded(1) }), ['A', null])
assert.deepEqual(assignScorers([], 1, { random: seeded(1) }), [null])
})