記分板新增隨機配對、計分人權限與換人計分,調整按鈕排法

根本原因:
每打完一輪都要人工決定誰上場、誰搭誰、誰負責記分,記分板也沒有「兩隊對換」和退回選先發球的操作,現場排場地或換人都得重開記分板。

影響:
/記分 多一顆 🎲 隨機配對:勾選出席者(預設全選)後,依 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>
This commit is contained in:
2026-08-24 22:24:16 +08:00
co-authored by Claude Fable 5
parent 2e88a7d85a
commit 0386c94546
15 changed files with 1234 additions and 61 deletions
+266
View File
@@ -221,3 +221,269 @@ test('選人中按取消 → 訊息鎖定並移除狀態', async () => {
assert.match(bot.last('editMessageText').text, /已取消選人/)
assert.equal(sb.store.get(-100, 500), null)
})
// 固定亂數(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 TEN = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J']
const TEN_SKILL = { A: 9, B: 9, C: 9, D: 9, E: 1, F: 1, G: 1, H: 1, I: 5, J: 5 }
const TEN_ID = Object.fromEntries(TEN.map((name, i) => [name, 11 + i])) // A=11 … J=20
const TEN_MEMBERS = TEN.map((name) => ({ name, skill: TEN_SKILL[name], userId: TEN_ID[name] }))
const buttonTexts = (call) => call.opts.reply_markup.inline_keyboard.flat().map((b) => b.text)
const playersOf = (m) => m.teams.flatMap((t) => t.players)
test('🎲 隨機配對:預設全選 → 可切換 → 依戰績配對預覽 → 開記分板推出 2、3 號場地,計分人從休息者抽', async () => {
const bot = fakeBot()
const todayGames = [{ teams: [['A', 'B'], ['C', 'D']] }, { teams: [['E', 'F'], ['G', 'H']] }] // A~H 剛打完
const sb = createScoreboard(bot, {
dataFile: null,
log: silentLog,
loadAttendees: async () => TEN_MEMBERS,
loadTodayGames: async () => todayGames,
isAdmin: async (_chatId, userId) => userId === 99,
random: seeded(1),
})
await sb.start(-100, 1, undefined, 7) // 開局者 7 不在出席名單
assert.ok(buttonTexts(bot.last('sendMessage')).includes('🎲 隨機配對'))
assert.deepEqual(sb.store.get(-100, 500).skills, TEN_SKILL, '出席名單的 skill 帶進來')
assert.deepEqual(sb.store.get(-100, 500).userIds, TEN_ID, '出席名單的 user id 帶進來')
await sb.onCallback(cq(500, 'rand', 7))
let entry = sb.store.get(-100, 500)
assert.equal(entry.phase, 'roster')
assert.deepEqual(entry.selected, TEN.map(() => true), '預設全選')
let edit = bot.last('editMessageText')
assert.match(edit.text, /已選 10 \/ 10/)
assert.ok(buttonTexts(edit).includes('✅ A'))
assert.ok(buttonTexts(edit).includes('⬜ 全不選'))
await sb.onCallback(cq(500, 'tg8', 7)) // 取消勾 I
assert.equal(sb.store.get(-100, 500).selected[8], false)
edit = bot.last('editMessageText')
assert.match(edit.text, /已選 9 \/ 10/)
assert.ok(buttonTexts(edit).includes('⬜ I'))
assert.ok(buttonTexts(edit).includes('✅ 全選'))
await sb.onCallback(cq(500, 'tg8', 7)) // 勾回
await sb.onCallback(cq(500, 'rall', 7)) // 全不選
assert.equal(sb.store.get(-100, 500).selected.filter(Boolean).length, 0)
await sb.onCallback(cq(500, 'rgo', 7))
assert.equal(bot.last('answerCallbackQuery').opts.text, '至少要選 4 人')
assert.equal(sb.store.get(-100, 500).phase, 'roster')
await sb.onCallback(cq(500, 'rall', 7)) // 全選
await sb.onCallback(cq(500, 'rgo', 7))
entry = sb.store.get(-100, 500)
assert.equal(entry.phase, 'paired')
assert.deepEqual(entry.games.map((g) => g.court), [2, 3])
const active = entry.games.flatMap((g) => [...g.teamA, ...g.teamB])
assert.ok(active.includes('I') && active.includes('J'), '沒打過的 I、J 一定上場')
assert.equal(entry.resting.length, 2)
assert.ok(entry.resting.every((n) => 'ABCDEFGH'.includes(n)), '休息的是剛打完的人')
const scorers = entry.games.map((g) => g.scorer)
assert.ok(scorers.every((s) => s && entry.resting.includes(s)), '計分人從休息者抽')
assert.notEqual(scorers[0], scorers[1], '一人負責一個場地')
edit = bot.last('editMessageText')
assert.match(edit.text, /2️⃣ 號場地 🅰️ .+ vs 🅱️ .+ 📝 計分:/)
assert.match(edit.text, /3️⃣ 號場地/)
assert.match(edit.text, /😴 休息:/)
assert.ok(buttonTexts(edit).includes('✅ 開記分板') && buttonTexts(edit).includes('🔁 重新配對'))
await sb.onCallback(cq(500, 'rgo', 7)) // 重新配對
entry = sb.store.get(-100, 500)
assert.equal(entry.phase, 'paired')
await sb.onCallback(cq(500, 'pgo', 8)) // 路人
assert.equal(bot.last('answerCallbackQuery').opts.text, '只有開局者或群組管理員可以操作記分板')
bot.calls.length = 0
await sb.onCallback(cq(500, 'pgo', 7))
const sent = bot.calls.filter((c) => c.fn === 'sendMessage')
assert.equal(sent.length, 2, '2、3 號場地各一則記分板')
assert.match(sent[0].text, /羽球記分板 2️⃣ 號場地/)
assert.match(sent[1].text, /羽球記分板 3️⃣ 號場地/)
const m2 = sb.store.get(-100, 501)
const m3 = sb.store.get(-100, 502)
assert.equal(m2.court, 2)
assert.equal(m3.court, 3)
assert.deepEqual(m2.teams.map((t) => t.players), [entry.games[0].teamA, entry.games[0].teamB])
assert.equal(m2.server, null)
const scorer2 = entry.games[0].scorer
assert.equal(m2.scorer.name, scorer2)
assert.equal(m2.ownerId, TEN_ID[scorer2], '權限給抽到的計分人,不是開局者')
assert.equal(m3.ownerId, TEN_ID[entry.games[1].scorer])
assert.match(sent[0].text, new RegExp(`📝 計分:${scorer2}`))
assert.equal(sb.store.get(-100, 500), null, '預覽訊息狀態移除')
edit = bot.last('editMessageText')
assert.match(edit.text, /記分板已推出/)
assert.deepEqual(edit.opts.reply_markup.inline_keyboard, [])
// 開局者(7)不是計分人 → 不能按;計分人本人可以
await sb.onCallback(cq(501, 'srv0', 7))
assert.equal(bot.last('answerCallbackQuery').opts.text, '只有開局者或群組管理員可以操作記分板')
assert.equal(m2.server, null)
await sb.onCallback(cq(501, 'swapab', TEN_ID[scorer2]))
assert.deepEqual(sb.store.get(-100, 501).teams.map((t) => t.players), [entry.games[0].teamB, entry.games[0].teamA])
await sb.onCallback(cq(501, 'srv0', 99)) // 管理員也可以
await sb.onCallback(cq(501, 'pt0', TEN_ID[scorer2]))
assert.deepEqual(sb.store.get(-100, 501).score, [1, 0])
})
test('🙋 換人計分:只有計分人 / 管理員能操作、只能在開始計分前;可轉交給場外的人或改成任何人', async () => {
const bot = fakeBot()
const sb = createScoreboard(bot, {
dataFile: null,
log: silentLog,
loadAttendees: async () => TEN_MEMBERS,
loadTodayGames: async () => [],
isAdmin: async (_chatId, userId) => userId === 99,
scorerExclude: new Set(['J']),
random: seeded(3),
})
await sb.start(-100, 1, undefined, 7)
await sb.onCallback(cq(500, 'rand', 7))
await sb.onCallback(cq(500, 'rgo', 7))
const entry = sb.store.get(-100, 500)
assert.ok(entry.games.every((g) => g.scorer !== 'J'), '排除名單不當計分人')
await sb.onCallback(cq(500, 'pgo', 7))
const m = sb.store.get(-100, 501)
const scorer = m.scorer.name
const scorerId = m.ownerId
assert.equal(scorerId, TEN_ID[scorer])
const players = playersOf(m)
// 路人 / 開局者不能開換人
await sb.onCallback(cq(501, 'ho', 7))
assert.equal(bot.last('answerCallbackQuery').opts.text, '只有開局者或群組管理員可以操作記分板')
assert.equal(m.handover, undefined)
// 計分人開換人:候選 = 出席 − 場上 4 人 − 排除名單
await sb.onCallback(cq(501, 'ho', scorerId))
let edit = bot.last('editMessageText')
assert.match(edit.text, new RegExp(`選擇新的計分人.*目前:${scorer}`))
const candidates = m.handover.map((c) => c.name)
assert.ok(candidates.every((n) => !players.includes(n)), '場上的人不列')
assert.ok(!candidates.includes('J'), '排除名單不列')
assert.ok(!candidates.includes(scorer), '現任計分人自己不列')
assert.deepEqual(candidates, TEN.filter((n) => !players.includes(n) && n !== 'J' && n !== scorer))
const texts = buttonTexts(edit)
assert.ok(texts.includes('👥 任何人(不鎖權限)') && texts.includes('↩️ 取消'))
assert.ok(!texts.includes('🅰️ 先發球'))
// 取消 → 回到開局鍵盤
await sb.onCallback(cq(501, 'hocancel', scorerId))
assert.equal(m.handover, undefined)
assert.ok(buttonTexts(bot.last('editMessageText')).includes('🙋 換人計分'))
assert.equal(m.ownerId, scorerId)
// 換人中按別的鍵也會關掉換人面板
await sb.onCallback(cq(501, 'ho', scorerId))
await sb.onCallback(cq(501, 'swapab', scorerId))
assert.equal(m.handover, undefined)
assert.ok(buttonTexts(bot.last('editMessageText')).includes('🅰️ 先發球'))
// 轉交給候選第一位 → 權限換人,原計分人不能再按
await sb.onCallback(cq(501, 'ho', scorerId))
const next = m.handover[0]
await sb.onCallback(cq(501, 'ho0', scorerId))
assert.equal(bot.last('answerCallbackQuery').opts.text, `計分人已改為 ${next.name}`)
assert.equal(m.ownerId, TEN_ID[next.name])
assert.equal(m.scorer.name, next.name)
assert.match(bot.last('editMessageText').text, new RegExp(`📝 計分:${next.name}`))
await sb.onCallback(cq(501, 'srv0', scorerId))
assert.equal(bot.last('answerCallbackQuery').opts.text, '只有開局者或群組管理員可以操作記分板')
assert.equal(m.server, null)
// 管理員改成任何人 → 不鎖權限,開局者也能按
await sb.onCallback(cq(501, 'ho', 99))
await sb.onCallback(cq(501, 'hoany', 99))
assert.equal(m.ownerId, null)
assert.deepEqual(m.scorer, { name: null, userId: null })
assert.match(bot.last('editMessageText').text, /📝 計分:任何人/)
await sb.onCallback(cq(501, 'srv0', 7))
assert.equal(m.server, 0)
// 開始計分後不能換人
await sb.onCallback(cq(501, 'ho', 7))
assert.equal(bot.last('answerCallbackQuery').opts.text, '開始計分後不能換人計分')
assert.equal(m.handover, undefined)
// 沒開換人面板就按候選 → 提示
await sb.onCallback(cq(501, 'ho0', 7))
assert.equal(bot.last('answerCallbackQuery').opts.text, '請重新按「換人計分」')
})
test('🎲 隨機配對:返回 / 取消;4~7 人只推 2 號場地;休息者都被排除 → 任何人;讀不到戰績時照配並提示', async () => {
const bot = fakeBot()
const sb = createScoreboard(bot, {
dataFile: null,
log: silentLog,
loadAttendees: async () => ['A', 'B', 'C', 'D', 'E'],
loadTodayGames: async () => {
throw new Error('db down')
},
scorerExclude: new Set(['A', 'B', 'C', 'D', 'E']),
random: seeded(2),
})
await sb.start(-100, 1, undefined)
await sb.onCallback(cq(500, 'rand'))
await sb.onCallback(cq(500, 'rback'))
assert.equal(sb.store.get(-100, 500).phase, 'pick')
assert.match(bot.last('editMessageText').text, /選擇上場人員/)
await sb.onCallback(cq(500, 'rand'))
await sb.onCallback(cq(500, 'rgo'))
let entry = sb.store.get(-100, 500)
assert.equal(entry.phase, 'paired')
assert.equal(entry.games.length, 1)
assert.equal(entry.games[0].court, 2)
assert.equal(entry.resting.length, 1)
assert.equal(entry.games[0].scorer, null, '休息的人在排除名單 → 沒有計分人')
let edit = bot.last('editMessageText')
assert.match(edit.text, /⚠️ 讀不到今日戰績/)
assert.match(edit.text, /📝 計分:任何人/)
await sb.onCallback(cq(500, 'rback'))
entry = sb.store.get(-100, 500)
assert.equal(entry.phase, 'roster')
assert.equal(entry.games, undefined)
assert.deepEqual(entry.selected, [true, true, true, true, true], '勾選狀態保留')
await sb.onCallback(cq(500, 'rgo'))
bot.calls.length = 0
await sb.onCallback(cq(500, 'pgo'))
const sent = bot.calls.filter((c) => c.fn === 'sendMessage')
assert.equal(sent.length, 1)
const m = sb.store.get(-100, 501)
assert.equal(m.ownerId, null, '沒有計分人 → 不鎖權限')
assert.deepEqual(m.scorer, { name: null, userId: null })
assert.match(sent[0].text, /📝 計分:任何人/)
await sb.onCallback(cq(501, 'srv1', 12345)) // 任何人都能按
assert.equal(m.server, 1)
// 0:0 按上一步 → 退回選先發球,才能換人
await sb.onCallback(cq(501, 'undo', 12345))
assert.equal(m.server, null)
assert.ok(buttonTexts(bot.last('editMessageText')).includes('🙋 換人計分'))
// 換人面板:出席的人都被排除 → 只剩任何人 / 取消
await sb.onCallback(cq(501, 'ho', 12345))
assert.equal(bot.last('answerCallbackQuery').opts.text, '今天沒有其他出席的人可選,可改成「任何人」')
assert.deepEqual(buttonTexts(bot.last('editMessageText')), ['👥 任何人(不鎖權限)', '↩️ 取消'])
await sb.onCallback(cq(501, 'hocancel', 12345))
const bot2 = fakeBot()
const sb2 = createScoreboard(bot2, { dataFile: null, log: silentLog, loadAttendees: async () => ['A', 'B', 'C', 'D'] })
await sb2.start(-100, 1, undefined)
await sb2.onCallback(cq(500, 'rand'))
await sb2.onCallback(cq(500, 'end'))
assert.match(bot2.last('editMessageText').text, /已取消配對/)
assert.equal(sb2.store.get(-100, 500), null)
})