根本原因: 每打完一輪都要人工決定誰上場、誰搭誰、誰負責記分,記分板也沒有「兩隊對換」和退回選先發球的操作,現場排場地或換人都得重開記分板。 影響: /記分 多一顆 🎲 隨機配對:勾選出席者(預設全選)後,依 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>
152 lines
6.9 KiB
JavaScript
152 lines
6.9 KiB
JavaScript
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('pairRound:4~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])
|
||
})
|