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

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

影響:
/記分 多一顆 🎲 隨機配對:勾選出席者(預設全選)後,依 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
+67 -2
View File
@@ -9,6 +9,7 @@ import {
parseTeams,
setFirstServer,
swapCourt,
swapTeams,
undo,
} from '../src/match.js'
import { renderKeyboard, renderText } from '../src/render.js'
@@ -79,6 +80,19 @@ test('undo 可復原分數、發球權與站位', () => {
assert.equal(getServing(m).server, 'A1')
assert.equal(undo(m), true)
assert.deepEqual(m.score, [0, 0])
assert.equal(m.server, 0)
assert.equal(undo(m), true, '0:0 再上一步 → 退回選先發球')
assert.equal(m.server, null)
assert.deepEqual(
renderKeyboard(m).inline_keyboard.map((r) => r.map((b) => b.text)),
[
['🅰️ 先發球', '🅱️ 先發球'],
['🔀 🅰️ 換位', '🔀 🅱️ 換位'],
['🔃 🅰️🅱️ 對換', '🙋 換人計分'],
['🏁 結束'],
],
'回到開局鍵盤',
)
assert.equal(undo(m), false)
})
@@ -112,13 +126,27 @@ test('renderText 會標記發球者、跳脫 HTML,並依畫面位置排列(
assert.match(text, /🅰️ A2 \/ 🏸&lt;A1&gt;/)
})
test('結算:時間到以目前比分定勝負,可 undo;鍵盤為三排', () => {
test('結算:時間到以目前比分定勝負,可 undo;計分鍵盤左欄上下為 🅰️ +1 / 🅱️ +1', () => {
const m = createMatch(['A'], ['B'])
assert.equal(settleMatch(m), false, '未選先發不能結算')
setFirstServer(m, 0)
assert.deepEqual(
renderKeyboard(m).inline_keyboard.map((r) => r.map((b) => b.text)),
[['🅰️ +1', '🅱️ +1'], ['↩️ 上一步', '🔄 重新開始'], ['⏱ 結算', '🏁 結束']],
[
['🅰️ +1', '↩️ 上一步'],
['🅱️ +1', '⏱ 結算'],
['🏁 結束'],
],
'沒有「重新開始」,上一步可退回選先發球',
)
assert.deepEqual(
renderKeyboard({ ...m, confirmEnd: true }).inline_keyboard.map((r) => r.map((b) => b.text)),
[
['🅰️ +1', '↩️ 上一步'],
['🅱️ +1', '⏱ 結算'],
['✅ 確定結束', '↩️ 取消'],
],
'確認結束時只換掉結束那排',
)
addPoint(m, 0)
addPoint(m, 0)
@@ -139,6 +167,43 @@ test('結算:時間到以目前比分定勝負,可 undo;鍵盤為三排',
assert.match(renderText(m), /2 : 2 平手/)
})
test('swapTeams0:0 時 🅰️🅱️ 上下對換(含發球權),開打後不能換;開局鍵盤有對換鈕', () => {
const m = createMatch(['A1', 'A2'], ['B1', 'B2'])
assert.deepEqual(
renderKeyboard(m).inline_keyboard.map((r) => r.map((b) => b.text)),
[
['🅰️ 先發球', '🅱️ 先發球'],
['🔀 🅰️ 換位', '🔀 🅱️ 換位'],
['🔃 🅰️🅱️ 對換', '🙋 換人計分'],
['🏁 結束'],
],
)
assert.deepEqual(
renderKeyboard(createMatch(['A'], ['B'])).inline_keyboard.map((r) => r.map((b) => b.text)),
[
['🅰️ 先發球', '🅱️ 先發球'],
['🔃 🅰️🅱️ 對換', '🙋 換人計分'],
['🏁 結束'],
],
'單打沒有換位,但有對換',
)
swapCourt(m, 1)
assert.equal(swapTeams(m), true)
assert.deepEqual(m.teams.map((t) => t.players), [['B1', 'B2'], ['A1', 'A2']])
assert.equal(m.teams[0].right, 1, '站位跟著隊伍走')
assert.match(renderText(m), /🅰️ B2 \/ B1/, 'B2 在右發球區 → 上排鏡像顯示在左')
assert.match(renderText(m), /🅱️ A2 \/ A1/)
setFirstServer(m, 0) // 現在的 🅰️B 隊)先發
assert.equal(swapTeams(m), true)
assert.equal(m.server, 1, '對換後發球權跟著隊伍走')
assert.deepEqual(m.teams.map((t) => t.players), [['A1', 'A2'], ['B1', 'B2']])
addPoint(m, 1)
assert.equal(swapTeams(m), false, '開打後不能對換')
const c = createMatch(['A'], ['B'])
c.court = 2
assert.match(renderText(c), /羽球記分板 2️⃣ 號場地/)
})
test('scoreList 沿用 web 版格式 [round, starter, winCount, winner]undo/reset 一併處理', () => {
const m = createMatch(['A1', 'A2'], ['B1', 'B2'])
setFirstServer(m, 0)