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

233 lines
8.2 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 {
addPoint,
createMatch,
settleMatch,
getServing,
getWinner,
parseTeams,
setFirstServer,
swapCourt,
swapTeams,
undo,
} from '../src/match.js'
import { renderKeyboard, renderText } from '../src/render.js'
test('parseTeams:雙打 / 單打 / 目標分數', () => {
assert.deepEqual(parseTeams('小明 小華 vs 阿強 阿美'), {
teams: [['小明', '小華'], ['阿強', '阿美']],
target: 21,
})
assert.deepEqual(parseTeams('小明/小華 VS 阿強、阿美 15'), {
teams: [['小明', '小華'], ['阿強', '阿美']],
target: 15,
})
assert.deepEqual(parseTeams('小明 vs 阿強').teams, [['小明'], ['阿強']])
assert.ok(parseTeams('小明 小華 vs 阿強').error)
assert.ok(parseTeams('小明 小華').error)
assert.ok(parseTeams('').error)
})
test('雙打發球規則:0 分右區、同隊得分換位、失分換發不換位', () => {
const m = createMatch(['A1', 'A2'], ['B1', 'B2'])
assert.equal(addPoint(m, 0), false, '未選先發不能加分')
setFirstServer(m, 0)
let s = getServing(m)
assert.deepEqual([s.server, s.court, s.receiver], ['A1', 'right', 'B1'])
addPoint(m, 0) // A 1:0,A 隊換位,1 分左區 → A1 在左區發
s = getServing(m)
assert.deepEqual([m.score, s.server, s.court, s.receiver], [[1, 0], 'A1', 'left', 'B2'])
addPoint(m, 0) // A 2:0,再換位,2 分右區 → A1 仍在右區發
s = getServing(m)
assert.deepEqual([s.server, s.court, s.receiver], ['A1', 'right', 'B1'])
addPoint(m, 1) // B 得分 2:1,換發,B 隊不換位,1 分左區 → B2 發
s = getServing(m)
assert.deepEqual([m.score, s.team, s.server, s.court], [[2, 1], 1, 'B2', 'left'])
// 接發者:A 隊左區的人。A 隊換位兩次回到原位 → A2 在左區
assert.equal(s.receiver, 'A2')
})
test('單打發球:只看分數奇偶決定發球區', () => {
const m = createMatch(['A'], ['B'])
setFirstServer(m, 1)
addPoint(m, 1)
const s = getServing(m)
assert.deepEqual([s.server, s.court, s.receiver], ['B', 'left', 'A'])
})
test('swapCourt 只能在 0:0,且會改變先發者', () => {
const m = createMatch(['A1', 'A2'], ['B1', 'B2'])
assert.equal(swapCourt(m, 0), true)
setFirstServer(m, 0)
assert.equal(getServing(m).server, 'A2')
addPoint(m, 0)
assert.equal(swapCourt(m, 0), false)
})
test('undo 可復原分數、發球權與站位', () => {
const m = createMatch(['A1', 'A2'], ['B1', 'B2'])
setFirstServer(m, 0)
addPoint(m, 0)
addPoint(m, 1)
assert.equal(undo(m), true)
assert.deepEqual(m.score, [1, 0])
assert.equal(m.server, 0)
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)
})
test('勝負判定:21 分、Deuce 領先 2 分、30 分封頂', () => {
const m = createMatch(['A'], ['B'])
setFirstServer(m, 0)
m.score = [20, 20]
addPoint(m, 0)
assert.equal(getWinner(m), null, '21:20 未分勝負')
addPoint(m, 0)
assert.equal(getWinner(m), 0, '22:20 A 勝')
assert.equal(m.finished, true)
assert.equal(addPoint(m, 1), false, '結束後不能加分')
const n = createMatch(['A'], ['B'])
setFirstServer(n, 0)
n.score = [29, 29]
addPoint(n, 1)
assert.equal(getWinner(n), 1, '30:29 直接獲勝')
})
test('renderText 會標記發球者、跳脫 HTML,並依畫面位置排列(上 1 2 / 下 4 3)', () => {
const m = createMatch(['<A1>', 'A2'], ['B1', 'B2'])
setFirstServer(m, 0)
let text = renderText(m)
assert.match(text, /🅰️ 🏸&lt;A1&gt; \/ A2/)
assert.match(text, /🅱️ B2 \/ B1/, '下排 B2(4) 在左、B1(3) 在右')
assert.match(text, /發球:🅰️ <b>&lt;A1&gt;<\/b>(右區) 接發:B1/)
addPoint(m, 0) // A 得分換位:A1 到左區 → 畫面上排右邊
text = renderText(m)
assert.match(text, /🅰️ A2 \/ 🏸&lt;A1&gt;/)
})
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', '⏱ 結算'],
['🏁 結束'],
],
'沒有「重新開始」,上一步可退回選先發球',
)
assert.deepEqual(
renderKeyboard({ ...m, confirmEnd: true }).inline_keyboard.map((r) => r.map((b) => b.text)),
[
['🅰️ +1', '↩️ 上一步'],
['🅱️ +1', '⏱ 結算'],
['✅ 確定結束', '↩️ 取消'],
],
'確認結束時只換掉結束那排',
)
addPoint(m, 0)
addPoint(m, 0)
addPoint(m, 1)
assert.equal(settleMatch(m), true)
assert.equal(m.finished, true)
assert.equal(getWinner(m), 0)
assert.match(renderText(m), /⏱ 時間到結算 2 : 1 🅰️ 獲勝/)
assert.equal(addPoint(m, 1), false)
assert.equal(undo(m), true)
assert.equal(m.finished, false)
assert.equal(m.settled, false)
assert.deepEqual(m.score, [2, 1])
addPoint(m, 1)
settleMatch(m)
assert.equal(getWinner(m), null)
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)
addPoint(m, 0) // 0:0 A 發,偶數 → 1 號位(0)
addPoint(m, 0) // 1:0 A 發,奇數 → 2 號位(1),連勝 1
addPoint(m, 1) // 2:0 A 發,偶數 → 0;B 得分,連勝歸 0
addPoint(m, 1) // 2:1 B 發,奇數 → 4 號位(3)B 連勝 1
addPoint(m, 1) // 2:2 B 發,偶數 → 3 號位(2)B 連勝 2
assert.deepEqual(m.scoreList, [
[0, 0, 0, 0],
[1, 1, 1, 0],
[2, 0, 0, 1],
[3, 3, 1, 1],
[4, 2, 2, 1],
])
assert.ok(!/starter|winCount/.test(renderText(m)), '不顯示在 TG 記分板')
undo(m)
assert.equal(m.scoreList.length, 4)
settleMatch(m)
undo(m)
assert.equal(m.scoreList.length, 4, '結算/復原不影響 scoreList')
const singles = createMatch(['A'], ['B'])
setFirstServer(singles, 1)
addPoint(singles, 0)
assert.deepEqual(singles.scoreList, [[0, 2, 0, 0]], '單打同樣以位置編號記')
})