diff --git a/.env.example b/.env.example
index dfe6e20..0c68692 100644
--- a/.env.example
+++ b/.env.example
@@ -10,6 +10,8 @@ SIGNUP_CRON_CHAT_ID=-4679836160
# 記分板預設目標分數
TARGET_SCORE=21
+# 隨機配對時不會被抽去當計分人的名字(逗號分隔,對 attendance.nickname);留空則所有休息的人都可能被抽到
+SCORER_EXCLUDE=景涵
# 記分板狀態持久化檔案
DATA_FILE=./data/matches.json
diff --git a/README.md b/README.md
index fa41fb5..9449947 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@ Telegram bot「建喵打羽球 @JianMiauBadmintonBot」的 Node.js 版本,包
| `/羽球名單` | `/bdlist` | 回今天的參加名單 |
| `/羽球暱稱 名字` | `/bdname` | 設定報名顯示的名字(存 `tg_members`,綁 user_id) |
| `/記分` | `/score`、`/new` | 列出今天出席的人(`attendance`),依 1→4 點選上場人員:1、2 一隊,3、4 一隊;點滿 4 人自動開局,選 2 人可按「單打開始」 |
+| `/記分` → `🎲 隨機配對` | | 勾選今天要排的人(預設全選)→ 依 `tg_members.skill` 與今天的 `history` 戰績自動排 2、3 號場地 → 預覽後一鍵推出各場地的記分板(見下方「隨機配對」) |
| `/記分 小明 小華 vs 阿強 阿美` | `/score`、`/new` | 直接開一場雙打記分板(單打各 1 人;最後加數字可改目標分) |
| `/help` | | 指令說明 |
@@ -30,19 +31,42 @@ Telegram bot「建喵打羽球 @JianMiauBadmintonBot」的 Node.js 版本,包
- **權限**:只有開局者(下 `/記分` 的人)或群組管理員(creator / administrator,查詢結果快取 5 分鐘)能按;其他人按了只跳提示「只有開局者或群組管理員可以操作記分板」。報名按鈕不受限。
-- 選人(無參數開局):點名字依序標 1️⃣~4️⃣,`↩️ 取消上一位`、`▶️ 單打開始`(剛好 2 人時)、`✖️ 取消`
-- 開局:`🅰️ / 🅱️ 先發球`;雙打可 `🔀 換位` 決定誰站右區(誰先發/先接)
-- 進行中(三排):`🅰️ +1` `🅱️ +1` / `↩️ 上一步` `🔄 重新開始` / `⏱ 結算` `🏁 結束`
+- 選人(無參數開局):點名字依序標 1️⃣~4️⃣,`↩️ 取消上一位`、`▶️ 單打開始`(剛好 2 人時)、`🎲 隨機配對`(還沒點人時)、`✖️ 取消`
+- 開局:`🅰️ / 🅱️ 先發球`;雙打可 `🔀 換位` 決定誰站右區(誰先發/先接);`🔃 🅰️🅱️ 對換` 把兩隊上下對調(只能在 0:0);`🙋 換人計分` 轉交計分人(見下方「計分人」)
+- 進行中(左欄上下是加分):`🅰️ +1` `↩️ 上一步` / `🅱️ +1` `⏱ 結算` / `🏁 結束`;`↩️ 上一步` 在 0:0 時會退回「選先發球」(所以沒有「重新開始」)
- `⏱ 結算`:時間到以目前比分定勝負(領先方 🏆、同分平手),可 `上一步` 復原
- 分出勝負顯示 🏆;`🏁 結束` 需兩段確認(第一次按 → 出現 `✅ 確定結束 / ↩️ 取消`,按其他按鈕也會取消),確定後移除按鈕、鎖定
- 規則:偶數分右區發、發球方得分同隊換位續發、失分換發不換位、21 分制、Deuce 領先 2 分、30 分封頂(與 badminton-scoreboard 一致)
+### 隨機配對(`/記分` → `🎲 隨機配對`)
+
+同一則訊息依序經過三個階段(`phase`:`pick` → `roster` → `paired`),權限同記分板(開局者/管理員):
+
+1. **勾選名單**:今天出席的人全部預設 ✅,點名字切換,`✅ 全選 / ⬜ 全不選`;至少 4 人。`↩️ 返回選人` 回到 1→4 點選
+2. **配對預覽**:顯示 `2️⃣ 號場地 🅰️ … vs 🅱️ …`、`3️⃣ 號場地 …`、`😴 休息:…`;`🔁 重新配對` 再抽一次、`↩️ 返回勾選` 改名單
+3. **`✅ 開記分板`**:每個場地各推一則記分板(標題帶場地號、`📝 計分:某某`);預覽訊息鎖定
+
+**計分人**(權限靠 `attendance.user_id` 對 Telegram 帳號):
+
+- 每個場地從 **休息的人** 隨機抽一位當計分人,只有他和群組管理員能按那則記分板;`SCORER_EXCLUDE`(`.env`,逗號分隔暱稱,例如 `景涵`)裡的人不會被抽到
+- 休息人數不夠(一場一人)→ 該場顯示 `📝 計分:任何人`,不鎖權限
+- **`🙋 換人計分`**(開局鍵盤,只能在還沒選先發球前):由目前計分人或管理員操作,列出今天出席、不在場上、不是現任計分人、不在排除名單的人,點一下就轉交;也可改成「任何人」;已選先發球但還是 0:0 時,按 `↩️ 上一步` 退回就能換
+- `/記分 A B vs C D` 或 1→4 點選開的記分板仍是開局者持有,但一樣可用 `🙋 換人計分` 轉交
+
+配對規則(`src/pairing.js`,移植自 badminton-match-hub V2 並改成一次只排下一輪):
+
+- **誰上場**:不是平均場數,而是「每個人都要休息到」——依今天 `history` 戰績算「休息了幾輪」(一輪 = 場地數),休息最久的優先、剛打完的最後;同輪再比今天打的場數少者優先,最後隨機。沒打過的人一定上場
+- **搭檔**:今天沒搭過的優先(`16^搭檔次數` 加總最小),不重複的前提下各組實力總和最接近(強弱互補)
+- **對戰**:兩隊實力總和最接近優先,再挑今天對戰次數最少的組合;兩場隨機分到 2、3 號場地
+- **實力**:`tg_members.skill`(1~10,優先用 `user_id` 對,對不到用暱稱對,都沒有當 1),與 match-hub 相同;bot 只讀不寫這欄
+- 8 人以上排 2、3 號場地各一場,4~7 人只排 2 號場地;讀不到 `history` 時照配、預覽會標示未考慮戰績
+
## 安裝與啟動
```bash
npm install
cp .env.example .env # 填 BOT_TOKEN、DB_*、ALLOWED_CHAT_IDS
-npm test # 15 個單元測試(規則、報名流程、選人、指令解析)
+npm test # 35 個單元測試(規則、報名流程、選人、隨機配對、歷史戰績、指令解析)
npm start # 或 npm run dev
```
@@ -89,7 +113,7 @@ sudo docker compose up -d --build
- `starter` 發球位置編號 0~3 = 畫面 1~4 號位減 1(🅰️ 上排:偶數分 0、奇數分 1;🅱️ 下排:偶數分 2、奇數分 3;只看位置不看人)
- `winCount` 同隊連續得分數,第一分為 0
- `winner` 得分隊 0 = 🅰️、1 = 🅱️
-- 上一步會一併回退;重新開始清空;結算不影響
+- 上一步會一併回退(退回選先發球時清空);再一局清空;結算不影響
## 專案結構
@@ -100,12 +124,14 @@ src/
db.js mysql2 連線池
util.js 共用小工具(HTML 跳脫、日期、指令 regex)
signup.js 報名功能(移植自 Node-RED bd_sm 狀態機)
- scoreboard.js 記分板功能
+ scoreboard.js 記分板功能(含選人 / 隨機配對流程)
match.js 羽球規則(純邏輯)
- render.js 記分板文字 + 鍵盤
+ pairing.js 隨機配對規則(純邏輯:休息輪數、搭檔 / 對手不重複、實力平衡)
+ history.js history 表寫入 / 讀今日戰績
+ render.js 記分板、選人、勾選、配對預覽的文字 + 鍵盤
store.js 記分板狀態持久化
scripts/
nodered-switch.mjs 暫停 / 恢復 Node-RED 舊流程
test/
- match.test.js signup.test.js
+ match.test.js pairing.test.js scoreboard.test.js history.test.js signup.test.js
```
diff --git a/src/config.js b/src/config.js
index 45e1646..176a1fa 100644
--- a/src/config.js
+++ b/src/config.js
@@ -13,6 +13,13 @@ export const config = {
signupCron: (env.SIGNUP_CRON ?? '').trim(),
signupCronChatId: (env.SIGNUP_CRON_CHAT_ID ?? '').trim(),
targetScore: Number(env.TARGET_SCORE) || 21,
+ // 隨機配對時不會被抽去當計分人的名字(逗號分隔,對 attendance.nickname)
+ scorerExclude: new Set(
+ (env.SCORER_EXCLUDE ?? '')
+ .split(',')
+ .map((name) => name.trim())
+ .filter(Boolean),
+ ),
dataFile: env.DATA_FILE || './data/matches.json',
db: {
host: env.DB_HOST ?? '',
diff --git a/src/history.js b/src/history.js
index 32cd1de..3a45f51 100644
--- a/src/history.js
+++ b/src/history.js
@@ -7,6 +7,7 @@
import { query as dbQuery } from './db.js'
import { config } from './config.js'
import { isDoubles } from './match.js'
+import { todayKey } from './util.js'
const DDL = (table) => `
CREATE TABLE IF NOT EXISTS \`${table}\` (
@@ -44,6 +45,37 @@ export function buildHistoryPayload(match, now = new Date()) {
}
}
+// 該日(YYYYMMDD)00:00 的 unix 秒;整個專案都以台北時間為準(無日光節約)
+const TZ_OFFSET = '+08:00'
+export function dayStartUnix(key) {
+ return Math.floor(Date.parse(`${key.slice(0, 4)}-${key.slice(4, 6)}-${key.slice(6, 8)}T00:00:00${TZ_OFFSET}`) / 1000)
+}
+
+// history 一列 → { id, time, teams: [[名字...], [名字...]] };team 欄壞掉的列回傳 null
+export function parseGameRow(row) {
+ try {
+ const teams = JSON.parse(row.team)
+ if (!Array.isArray(teams) || teams.length !== 2) return null
+ return {
+ id: row.id,
+ time: Number(row.time),
+ teams: teams.map((team) => (Array.isArray(team) ? team.map((name) => String(name)) : [])),
+ }
+ } catch {
+ return null
+ }
+}
+
+// 今天已寫入 history 的比賽(依時間先後),給隨機配對判斷誰剛打完、誰搭過誰用
+export async function loadTodayGames({ query = dbQuery, table = config.db.historyTable, date = todayKey() } = {}) {
+ const start = dayStartUnix(date)
+ const rows = await query(
+ `SELECT id, time, team FROM \`${table}\` WHERE time >= ? AND time < ? ORDER BY time ASC, id ASC`,
+ [start, start + 86400],
+ )
+ return rows.map(parseGameRow).filter(Boolean)
+}
+
let ensured = false
// 回傳新增的 history.id
diff --git a/src/index.js b/src/index.js
index 5dfc1f1..1ddebd4 100644
--- a/src/index.js
+++ b/src/index.js
@@ -27,7 +27,8 @@ const scoreboard = createScoreboard(bot, {
dataFile: config.dataFile,
targetScore: config.targetScore,
log,
- loadAttendees: () => signup.attendeeNames(),
+ loadAttendees: () => signup.attendees(),
+ scorerExclude: config.scorerExclude,
})
const HELP_TEXT = [
@@ -103,7 +104,7 @@ async function main() {
{ command: 'badminton', description: '發今日羽球報名(可加 YYYYMMDD)' },
{ command: 'bdlist', description: '看今天的報名名單' },
{ command: 'bdname', description: '設定報名顯示的名字:/bdname 建喵' },
- { command: 'score', description: '開記分板(無參數=從今日出席選人)' },
+ { command: 'score', description: '開記分板(無參數=從今日出席選人/🎲 隨機配對)' },
{ command: 'new', description: '同 /score:/new 小明 小華 vs 阿強 阿美' },
{ command: 'help', description: '指令說明' },
])
diff --git a/src/match.js b/src/match.js
index 0247742..b3b6625 100644
--- a/src/match.js
+++ b/src/match.js
@@ -109,9 +109,10 @@ function pushHistory(match) {
match.history.push(snapshot(match))
}
-// 決定先發球隊(只允許在 0:0 時設定)
+// 決定先發球隊(只允許在 0:0 時設定);有記到 history,所以「上一步」可以退回選先發球
export function setFirstServer(match, teamIndex) {
if (match.score[0] !== 0 || match.score[1] !== 0) return false
+ pushHistory(match)
match.server = teamIndex
return true
}
@@ -124,6 +125,14 @@ export function swapCourt(match, teamIndex) {
return true
}
+// 🅰️🅱️ 兩隊上下對換(只允許在 0:0 時)
+export function swapTeams(match) {
+ if (match.score[0] !== 0 || match.score[1] !== 0) return false
+ match.teams = [match.teams[1], match.teams[0]]
+ if (match.server !== null) match.server = 1 - match.server
+ return true
+}
+
// 某隊得分
export function addPoint(match, teamIndex) {
if (match.finished || match.server === null) return false
diff --git a/src/pairing.js b/src/pairing.js
new file mode 100644
index 0000000..32e29ef
--- /dev/null
+++ b/src/pairing.js
@@ -0,0 +1,208 @@
+// 隨機配對:從勾選的人裡挑出 2、3 號場地的上場人員並分隊(純邏輯,不依賴 Telegram / DB)
+//
+// 規則移植自 badminton-match-hub V2(src/AppV2.tsx),差異:
+// - 一次只排「下一輪」(2、3 號場地各一場),不是整份 8 局賽程
+// - 誰上場不看「打了幾場要平均」,改看「休息了幾輪」:休息最久的先上、剛打完的最後
+// (今天打過的比賽從共用 DB 的 history 表讀,含 web 記分板寫入的)
+// - 搭檔:今天沒搭過的優先(16^搭檔次數 加總最小),再挑各組實力總和最接近(強弱互補)
+// - 對戰:兩隊實力總和最接近優先,再挑今天對戰次數最少的組合
+// - 實力 skill 來自 tg_members(查不到當 1,1~10),與 match-hub 相同
+//
+// 人數 4~7 人只排 2 號場地一場;8 人以上排 2、3 號場地各一場,其餘休息。
+
+export const COURT_NUMBERS = [2, 3]
+export const PLAYERS_PER_GAME = 4
+const PAIR_SCORE_BASE = 16
+
+export function pairKey(a, b) {
+ return [a, b].sort().join('|')
+}
+
+export function clampSkill(value) {
+ const skill = Number(value)
+ if (!Number.isFinite(skill)) return 1
+ return Math.min(10, Math.max(1, Math.round(skill)))
+}
+
+export function skillOf(name, skills) {
+ return clampSkill(skills?.[name] ?? 1)
+}
+
+export function pairSkillSum(pair, skills) {
+ return pair.reduce((sum, name) => sum + skillOf(name, skills), 0)
+}
+
+function bump(map, key) {
+ map.set(key, (map.get(key) ?? 0) + 1)
+}
+
+// games: 今天已打過的比賽(依時間先後),每筆 { teams: [[名字...], [名字...]] }
+export function buildStats(games = []) {
+ const playCount = new Map()
+ const pairUse = new Map()
+ const opponentUse = new Map()
+ const lastGame = new Map() // name -> 最後一場在 games 裡的 index
+ games.forEach((game, index) => {
+ const [a = [], b = []] = game.teams ?? []
+ new Set([...a, ...b]).forEach((name) => {
+ bump(playCount, name)
+ lastGame.set(name, index)
+ })
+ if (a.length === 2) bump(pairUse, pairKey(a[0], a[1]))
+ if (b.length === 2) bump(pairUse, pairKey(b[0], b[1]))
+ a.forEach((x) => b.forEach((y) => bump(opponentUse, pairKey(x, y))))
+ })
+ return { playCount, pairUse, opponentUse, lastGame, total: games.length }
+}
+
+// 休息了幾輪(一輪 = courtCount 場):沒打過 = Infinity、剛打完這輪 = 0
+export function roundsRested(name, stats, courtCount) {
+ const last = stats.lastGame.get(name)
+ if (last == null) return Infinity
+ return Math.floor((stats.total - 1 - last) / Math.max(1, courtCount))
+}
+
+// 上場優先順序:休息越久越前面 → 今天打得場數少的 → 隨機
+export function rankByRest(names, stats, courtCount, random = Math.random) {
+ return shuffle(names, random)
+ .map((name) => ({
+ name,
+ rested: roundsRested(name, stats, courtCount),
+ played: stats.playCount.get(name) ?? 0,
+ }))
+ .sort((l, r) => r.rested - l.rested || l.played - r.played)
+ .map((entry) => entry.name)
+}
+
+// 回傳 { courtCount, games: [{ court, teamA, teamB }], resting: [...] } 或 { error }
+export function pairRound(names, skills = {}, stats = buildStats([]), { random = Math.random } = {}) {
+ const unique = [...new Set((names ?? []).map((name) => String(name ?? '').trim()).filter(Boolean))]
+ if (unique.length < PLAYERS_PER_GAME) return { error: `至少要 ${PLAYERS_PER_GAME} 人才能配對` }
+
+ const courtCount = unique.length >= COURT_NUMBERS.length * PLAYERS_PER_GAME ? COURT_NUMBERS.length : 1
+ const needed = courtCount * PLAYERS_PER_GAME
+ const active = rankByRest(unique, stats, courtCount, random).slice(0, needed)
+ const activeSet = new Set(active)
+
+ const pairs = pickBestMatching(active, stats.pairUse, skills, random)
+ const games = pickBestGames(pairs, stats.opponentUse, skills, random)
+ return {
+ courtCount,
+ games,
+ resting: unique.filter((name) => !activeSet.has(name)),
+ }
+}
+
+// 每個場地從休息的人抽一位計分人(排除名單不抽);不夠的位置回傳 null(= 任何人都能記分)
+export function assignScorers(resting, count, { exclude = new Set(), random = Math.random } = {}) {
+ const pool = shuffle((resting ?? []).filter((name) => !exclude.has(name)), random)
+ return Array.from({ length: count }, (_, i) => pool[i] ?? null)
+}
+
+// 所有兩兩配對的分法:8 人 105 種、4 人 3 種
+export function enumerateMatchings(names) {
+ if (names.length < 2) return [[]]
+ const [first, ...rest] = names
+ const results = []
+ rest.forEach((partner, index) => {
+ const remaining = rest.filter((_, i) => i !== index)
+ enumerateMatchings(remaining).forEach((sub) => results.push([[first, partner], ...sub]))
+ })
+ return results
+}
+
+function pickBestMatching(active, pairUse, skills, random) {
+ let bestScore = Infinity
+ let candidates = []
+ for (const matching of enumerateMatchings(active)) {
+ const score = matching.reduce(
+ (sum, [a, b]) => sum + PAIR_SCORE_BASE ** (pairUse.get(pairKey(a, b)) ?? 0),
+ 0,
+ )
+ if (score < bestScore) {
+ bestScore = score
+ candidates = [matching]
+ } else if (score === bestScore) {
+ candidates.push(matching)
+ }
+ }
+
+ // 不重複的前提下,各組搭檔實力總和越接近越好(強弱互補)
+ let bestSpread = Infinity
+ let balanced = []
+ for (const matching of candidates) {
+ const sums = matching.map((pair) => pairSkillSum(pair, skills))
+ const spread = Math.max(...sums) - Math.min(...sums)
+ if (spread < bestSpread) {
+ bestSpread = spread
+ balanced = [matching]
+ } else if (spread === bestSpread) {
+ balanced.push(matching)
+ }
+ }
+ return pickRandom(balanced, random)
+}
+
+function pickBestGames(pairs, opponentUse, skills, random) {
+ if (pairs.length === 2) {
+ return [{ court: COURT_NUMBERS[0], teamA: pairs[0], teamB: pairs[1] }]
+ }
+ const groupings = [
+ [
+ [pairs[0], pairs[1]],
+ [pairs[2], pairs[3]],
+ ],
+ [
+ [pairs[0], pairs[2]],
+ [pairs[1], pairs[3]],
+ ],
+ [
+ [pairs[0], pairs[3]],
+ [pairs[1], pairs[2]],
+ ],
+ ]
+
+ // 先求兩隊實力總和最接近,再挑對手重複最少的
+ let bestImbalance = Infinity
+ let bestOpponent = Infinity
+ let balanced = []
+ for (const grouping of groupings) {
+ const imbalance = grouping.reduce(
+ (sum, [teamA, teamB]) => sum + Math.abs(pairSkillSum(teamA, skills) - pairSkillSum(teamB, skills)),
+ 0,
+ )
+ const opponent = grouping.reduce((sum, [teamA, teamB]) => {
+ let score = 0
+ teamA.forEach((x) => teamB.forEach((y) => {
+ score += PAIR_SCORE_BASE ** (opponentUse.get(pairKey(x, y)) ?? 0)
+ }))
+ return sum + score
+ }, 0)
+ if (imbalance < bestImbalance || (imbalance === bestImbalance && opponent < bestOpponent)) {
+ bestImbalance = imbalance
+ bestOpponent = opponent
+ balanced = [grouping]
+ } else if (imbalance === bestImbalance && opponent === bestOpponent) {
+ balanced.push(grouping)
+ }
+ }
+
+ return shuffle(pickRandom(balanced, random), random).map(([teamA, teamB], index) => ({
+ court: COURT_NUMBERS[index],
+ teamA,
+ teamB,
+ }))
+}
+
+export function shuffle(list, random = Math.random) {
+ const next = [...list]
+ for (let i = next.length - 1; i > 0; i -= 1) {
+ const j = Math.floor(random() * (i + 1))
+ ;[next[i], next[j]] = [next[j], next[i]]
+ }
+ return next
+}
+
+function pickRandom(list, random = Math.random) {
+ return list[Math.floor(random() * list.length)]
+}
diff --git a/src/render.js b/src/render.js
index 70b1d21..a7cba87 100644
--- a/src/render.js
+++ b/src/render.js
@@ -1,9 +1,17 @@
// 把比賽狀態轉成 Telegram 訊息文字(HTML)與 inline 鍵盤
import { getServing, getWinner, isDoubles } from './match.js'
+import { PLAYERS_PER_GAME } from './pairing.js'
import { escapeHtml } from './util.js'
const TEAM_ICON = ['🅰️', '🅱️']
const COURT_LABEL = { right: '右區', left: '左區' }
+const COURT_NO = { 2: '2️⃣', 3: '3️⃣' }
+
+// 2 → "2️⃣ 號場地"
+export function courtLabel(court) {
+ if (court == null) return ''
+ return `${COURT_NO[court] ?? court} 號場地`
+}
// 隊員名單,依「畫面位置」排列(同 web 版:上排 1 2、下排 4 3),發球者前面加 🏸
// 上排 🅰️ 為鏡像:畫面左 = 自己的右發球區;下排 🅱️:畫面右 = 自己的右發球區
@@ -23,12 +31,19 @@ function renderPlayers(match, teamIndex, serving) {
.join(' / ')
}
+// 計分人顯示:有名字 → 名字;隨機配對沒抽到人 → 任何人;手動開局沒設定 → 開局者
+function scorerLabel(match) {
+ if (!match.scorer) return '開局者'
+ return match.scorer.name ? escapeHtml(match.scorer.name) : '任何人'
+}
+
export function renderText(match, { ended = false } = {}) {
const serving = getServing(match)
const winner = getWinner(match)
const lines = []
- lines.push(`🏸 羽球記分板 ${isDoubles(match) ? '雙打' : '單打'}・${match.target} 分制`)
+ const title = match.court != null ? `羽球記分板 ${courtLabel(match.court)}` : '羽球記分板'
+ lines.push(`🏸 ${title} ${isDoubles(match) ? '雙打' : '單打'}・${match.target} 分制`)
lines.push('')
for (const teamIndex of [0, 1]) {
@@ -37,6 +52,7 @@ export function renderText(match, { ended = false } = {}) {
`${TEAM_ICON[teamIndex]} ${renderPlayers(match, teamIndex, serving)} ${match.score[teamIndex]}${marker}`,
)
}
+ if (match.scorer) lines.push(`📝 計分:${scorerLabel(match)}`)
lines.push('')
const hasPoints = (match.scoreList ?? []).length > 0
@@ -44,6 +60,8 @@ export function renderText(match, { ended = false } = {}) {
if (match.historyId) lines.push(`⏹ 已結束 📝 已存入歷史戰績 #${match.historyId}`)
else if (hasPoints && !match.finished) lines.push('⏹ 已結束(未結算,未存入戰績)')
else lines.push('⏹ 已結束')
+ } else if (match.handover) {
+ lines.push(`🙋 選擇新的計分人(目前:${scorerLabel(match)})`)
} else if (match.confirmEnd) {
if (match.finished) lines.push('❓ 確定要結束嗎?會把這場寫入歷史戰績')
else if (hasPoints) lines.push('❓ 確定要結束嗎?比賽未結算,不會寫入戰績(要存請先按 ⏱ 結算)')
@@ -54,7 +72,7 @@ export function renderText(match, { ended = false } = {}) {
} else if (winner !== null) {
lines.push(`🎉 ${TEAM_ICON[winner]} 獲勝! ${match.score[0]} : ${match.score[1]}`)
} else if (!serving) {
- lines.push('👉 請先選擇由哪一隊先發球')
+ lines.push('👉 請先選擇由哪一隊先發球(可先換位 / 🅰️🅱️ 對換)')
} else {
lines.push(
`發球:${TEAM_ICON[serving.team]} ${escapeHtml(serving.server)}(${COURT_LABEL[serving.court]})` +
@@ -68,24 +86,30 @@ export function renderText(match, { ended = false } = {}) {
// callback_data 格式:sc:
const btn = (text, action) => ({ text, callback_data: `sc:${action}` })
-export function renderKeyboard(match, { ended = false } = {}) {
- if (ended) return { inline_keyboard: [] }
- const rows = buildKeyboardRows(match)
- if (match.confirmEnd) {
- // 兩段式確認:把「結束」換成「確定結束 / 取消」
- return {
- inline_keyboard: rows.map((row) =>
- row.some((b) => b.callback_data === 'sc:end')
- ? [btn('✅ 確定結束', 'end'), btn('↩️ 取消', 'endcancel')]
- : row,
- ),
- }
- }
+// 換人計分:列出可接手的人(今天出席、不在場上、不在排除名單)+ 任何人 / 取消
+function renderHandoverKeyboard(match) {
+ const list = match.handover ?? []
+ const rows = nameRows(list.map((c) => c.name), (name) => name, 'ho', list.length > 8 ? 3 : 2)
+ rows.push([btn('👥 任何人(不鎖權限)', 'hoany'), btn('↩️ 取消', 'hocancel')])
return { inline_keyboard: rows }
}
-function buildKeyboardRows(match) {
+export function renderKeyboard(match, { ended = false } = {}) {
+ if (ended) return { inline_keyboard: [] }
+ if (match.handover) return renderHandoverKeyboard(match)
+ const rows = buildKeyboardRows(match)
+ if (!match.confirmEnd) return { inline_keyboard: rows }
+ // 兩段式確認:「結束」換成「確定結束 / 取消」(同排的「結算」先收起來)
+ return {
+ inline_keyboard: rows.map((row) => {
+ if (!row.some((b) => b.callback_data === 'sc:end')) return row
+ const kept = row.filter((b) => b.callback_data !== 'sc:end' && b.callback_data !== 'sc:settle')
+ return [...kept, btn('✅ 確定結束', 'end'), btn('↩️ 取消', 'endcancel')]
+ }),
+ }
+}
+function buildKeyboardRows(match) {
if (match.finished) {
return [
[btn('↩️ 上一步', 'undo'), btn('🔄 再一局', 'reset')],
@@ -98,14 +122,16 @@ function buildKeyboardRows(match) {
if (isDoubles(match)) {
rows.push([btn('🔀 🅰️ 換位', 'swap0'), btn('🔀 🅱️ 換位', 'swap1')])
}
+ rows.push([btn('🔃 🅰️🅱️ 對換', 'swapab'), btn('🙋 換人計分', 'ho')])
rows.push([btn('🏁 結束', 'end')])
return rows
}
+ // 計分中:左欄上下是 🅰️ +1 / 🅱️ +1;上一步在 0:0 時會退回選先發球(所以沒有「重新開始」)
return [
- [btn('🅰️ +1', 'pt0'), btn('🅱️ +1', 'pt1')],
- [btn('↩️ 上一步', 'undo'), btn('🔄 重新開始', 'reset')],
- [btn('⏱ 結算', 'settle'), btn('🏁 結束', 'end')],
+ [btn('🅰️ +1', 'pt0'), btn('↩️ 上一步', 'undo')],
+ [btn('🅱️ +1', 'pt1'), btn('⏱ 結算', 'settle')],
+ [btn('🏁 結束', 'end')],
]
}
@@ -121,31 +147,102 @@ export function renderPickerText(picker) {
`🅱️ 4️⃣ ${slot(3)} 3️⃣ ${slot(2)}`,
'',
]
- if (picker.picked.length === 0) lines.push('👉 點名字加入;1、2 一隊,3、4 一隊(位置同 web 版:上 1 2/下 4 3);點滿 4 人自動開局')
+ if (picker.picked.length === 0)
+ lines.push(
+ '👉 點名字加入;1、2 一隊,3、4 一隊(位置同 web 版:上 1 2/下 4 3);點滿 4 人自動開局',
+ '🎲 或按「隨機配對」自動排 2、3 號場地',
+ )
else if (picker.picked.length === 2) lines.push('👉 繼續點第 3、4 位,或按「單打開始」')
else if (picker.picked.length < 4) lines.push(`👉 繼續點第 ${picker.picked.length + 1} 位`)
return lines.join('\n')
}
-export function renderPickerKeyboard(picker) {
+function nameRows(names, label, action, perRow) {
const rows = []
- const perRow = picker.names.length > 8 ? 3 : 2
let row = []
- picker.names.forEach((name, i) => {
- const order = picker.picked.indexOf(i)
- const label = order >= 0 ? `${PICK_NUM[order]} ${name}` : name
- row.push({ text: label, callback_data: `sc:pick${i}` })
+ names.forEach((name, i) => {
+ row.push({ text: label(name, i), callback_data: `sc:${action}${i}` })
if (row.length === perRow) {
rows.push(row)
row = []
}
})
if (row.length) rows.push(row)
+ return rows
+}
+
+export function renderPickerKeyboard(picker) {
+ const perRow = picker.names.length > 8 ? 3 : 2
+ const rows = nameRows(
+ picker.names,
+ (name, i) => {
+ const order = picker.picked.indexOf(i)
+ return order >= 0 ? `${PICK_NUM[order]} ${name}` : name
+ },
+ 'pick',
+ perRow,
+ )
const controls = []
- if (picker.picked.length > 0) controls.push({ text: '↩️ 取消上一位', callback_data: 'sc:unpick' })
- if (picker.picked.length === 2) controls.push({ text: '▶️ 單打開始', callback_data: 'sc:pickgo' })
- controls.push({ text: '✖️ 取消', callback_data: 'sc:end' })
+ if (picker.picked.length === 0) controls.push(btn('🎲 隨機配對', 'rand'))
+ if (picker.picked.length > 0) controls.push(btn('↩️ 取消上一位', 'unpick'))
+ if (picker.picked.length === 2) controls.push(btn('▶️ 單打開始', 'pickgo'))
+ controls.push(btn('✖️ 取消', 'end'))
rows.push(controls)
return { inline_keyboard: rows }
}
+
+// ---------- 隨機配對:勾選名單 ----------
+export function renderRosterText(roster) {
+ const count = roster.selected.filter(Boolean).length
+ const lines = [
+ `🎲 隨機配對 勾選今天要排的人(已選 ${count} / ${roster.names.length})`,
+ '',
+ '點名字可切換 ✅ / ⬜。8 人以上排 2、3 號場地各一場,4~7 人只排 2 號場地。',
+ '休息最久的優先上場、剛打完的先休息;搭檔盡量不重複、依實力平衡分隊。',
+ ]
+ if (count < PLAYERS_PER_GAME) lines.push('', `⚠️ 至少要選 ${PLAYERS_PER_GAME} 人`)
+ return lines.join('\n')
+}
+
+export function renderRosterKeyboard(roster) {
+ const perRow = roster.names.length > 8 ? 3 : 2
+ const rows = nameRows(roster.names, (name, i) => `${roster.selected[i] ? '✅' : '⬜'} ${name}`, 'tg', perRow)
+ const allOn = roster.selected.every(Boolean)
+ rows.push([btn(allOn ? '⬜ 全不選' : '✅ 全選', 'rall'), btn('🎲 開始配對', 'rgo')])
+ rows.push([btn('↩️ 返回選人', 'rback'), btn('✖️ 取消', 'end')])
+ return { inline_keyboard: rows }
+}
+
+// ---------- 隨機配對:結果預覽 ----------
+const teamNames = (pair) => pair.map((name) => escapeHtml(name)).join(' / ')
+
+export function renderPairedText(paired, { launched = false } = {}) {
+ const active = paired.games.length * PLAYERS_PER_GAME
+ const resting = paired.resting ?? []
+ const lines = [`🎲 配對結果 ${active} 人上場${resting.length ? `・休息 ${resting.length} 人` : ''}`, '']
+ for (const game of paired.games) {
+ lines.push(
+ `${courtLabel(game.court)} 🅰️ ${teamNames(game.teamA)} vs 🅱️ ${teamNames(game.teamB)}` +
+ ` 📝 計分:${game.scorer ? escapeHtml(game.scorer) : '任何人'}`,
+ )
+ }
+ if (resting.length) lines.push(`😴 休息:${escapeHtml(resting.join('、'))}`)
+ if (paired.statsNote) lines.push(`⚠️ ${escapeHtml(paired.statsNote)}`)
+ lines.push('')
+ lines.push(
+ launched
+ ? '✅ 記分板已推出,請到各場地的記分板選先發球'
+ : '👉 按「開記分板」推出各場地的記分板(計分人只有本人和管理員能按,開始計分前可換人);不滿意可「重新配對」',
+ )
+ return lines.join('\n')
+}
+
+export function renderPairedKeyboard() {
+ return {
+ inline_keyboard: [
+ [btn('✅ 開記分板', 'pgo'), btn('🔁 重新配對', 'rgo')],
+ [btn('↩️ 返回勾選', 'rback'), btn('✖️ 取消', 'end')],
+ ],
+ }
+}
diff --git a/src/scoreboard.js b/src/scoreboard.js
index 21928c3..cdec3a0 100644
--- a/src/scoreboard.js
+++ b/src/scoreboard.js
@@ -2,6 +2,9 @@
//
// - /記分 A B vs C D:直接開局
// - /記分(無參數):列出今日出席名單按鈕,依 1→4 點選上場人員(1、2 一隊,3、4 一隊)
+// 或按 🎲 隨機配對:勾選要排的人(預設全選)→ 依實力與今日戰績排 2、3 號場地 → 推出各場地記分板
+//
+// 同一則訊息用 phase 區分階段:'pick'(點 1→4)→ 'roster'(勾選)→ 'paired'(預覽)→ 推出記分板後移除
import {
addPoint,
createMatch,
@@ -10,10 +13,21 @@ import {
setFirstServer,
settleMatch,
swapCourt,
+ swapTeams,
undo,
} from './match.js'
-import { renderKeyboard, renderPickerKeyboard, renderPickerText, renderText } from './render.js'
-import { saveHistory as defaultSaveHistory } from './history.js'
+import {
+ renderKeyboard,
+ renderPairedKeyboard,
+ renderPairedText,
+ renderPickerKeyboard,
+ renderPickerText,
+ renderRosterKeyboard,
+ renderRosterText,
+ renderText,
+} from './render.js'
+import { loadTodayGames as defaultLoadTodayGames, saveHistory as defaultSaveHistory } from './history.js'
+import { assignScorers, buildStats, pairRound, PLAYERS_PER_GAME } from './pairing.js'
import { createStore } from './store.js'
import { commandRegex, isNotModifiedError } from './util.js'
@@ -26,6 +40,7 @@ const ACTIONS = {
srv1: (m) => setFirstServer(m, 1),
swap0: (m) => swapCourt(m, 0),
swap1: (m) => swapCourt(m, 1),
+ swapab: (m) => swapTeams(m),
undo: (m) => undo(m),
settle: (m) => settleMatch(m),
// reset(再一局)在 onCallback 另外處理:要先存歷史戰績
@@ -36,27 +51,56 @@ const ACTION_FAIL_HINT = {
pt0: '請先選擇先發球隊',
pt1: '請先選擇先發球隊',
settle: '還沒開始比賽',
+ swap0: '只能在 0:0 時換位',
+ swap1: '只能在 0:0 時換位',
+ swapab: '只能在 0:0 時對換',
}
export const SCOREBOARD_HELP = [
'/記分 - 列出今天出席的人,依 1→4 點選上場人員(1、2 一隊,3、4 一隊)',
+ '/記分 → 🎲 隨機配對 - 勾選今天要排的人(預設全選),自動排 2、3 號場地並推出記分板:休息最久的優先上場、剛打完的先休息,搭檔盡量不重複,依 tg_members 實力平衡分隊',
'/記分 小明 小華 vs 阿強 阿美 - 直接開一場雙打',
'/記分 小明 vs 阿強 - 開一場單打',
'/記分 小明 vs 阿強 15 - 最後加數字可改目標分數(預設 21)',
'英文別名:/score、/new',
'只有開局者或群組管理員能按記分板按鈕。',
- '開局後按訊息下方按鈕:先選哪隊先發球(雙打可先「換位」),🅰️ +1 / 🅱️ +1 加分,🏸 標記發球者,↩️ 上一步可復原,⏱ 結算=時間到以目前比分定勝負,🏁 結束會鎖住記分板。',
+ '隨機配對推出的記分板:每場從休息的人抽一位計分人(排除名單不抽),只有他和管理員能按;休息人不夠就任何人都能按。開始計分前可按「🙋 換人計分」轉交(管理員或目前計分人操作),也可改成「任何人」。',
+ '開局後按訊息下方按鈕:先選哪隊先發球(可先「換位」或 🅰️🅱️「對換」上下),🅰️ +1 / 🅱️ +1 加分,🏸 標記發球者,↩️ 上一步可復原(0:0 時退回選先發球),⏱ 結算=時間到以目前比分定勝負,🏁 結束會鎖住記分板。',
]
const ADMIN_CACHE_MS = 5 * 60 * 1000
const NO_PERMISSION_HINT = '只有開局者或群組管理員可以操作記分板'
-// loadAttendees: async () => ['名字', ...](今日出席名單)
+// 出席名單允許給純名字或 { name, skill, userId }
+function normalizeMembers(list) {
+ return (list ?? [])
+ .map((m) =>
+ typeof m === 'string'
+ ? { name: m, skill: 1, userId: null }
+ : { name: String(m?.name ?? '').trim(), skill: m?.skill ?? 1, userId: m?.userId ?? null },
+ )
+ .filter((m) => m.name)
+}
+
+// loadAttendees: async () => [{ name, skill }](今日出席名單;也接受純名字陣列)
// isAdmin: async (chatId, userId) => boolean(預設用 getChatMember 判斷 creator / administrator,快取 5 分鐘)
// saveHistory: async (match) => history.id(預設寫入共用 DB 的 history 表)
+// loadTodayGames: async () => [{ teams: [[...], [...]] }](今天已打過的比賽,隨機配對用)
+// scorerExclude: Set<名字>(隨機配對 / 換人計分時不列為計分人)
+// random: () => [0, 1)(測試時可注入固定亂數)
export function createScoreboard(
bot,
- { dataFile, targetScore = 21, log = console, loadAttendees = null, isAdmin = null, saveHistory = defaultSaveHistory },
+ {
+ dataFile,
+ targetScore = 21,
+ log = console,
+ loadAttendees = null,
+ isAdmin = null,
+ saveHistory = defaultSaveHistory,
+ loadTodayGames = defaultLoadTodayGames,
+ scorerExclude = new Set(),
+ random = Math.random,
+ },
) {
const store = createStore(dataFile)
@@ -117,26 +161,40 @@ export function createScoreboard(
const editPicker = (chatId, messageId, picker) =>
editMessage(chatId, messageId, renderPickerText(picker), renderPickerKeyboard(picker))
+ const editRoster = (chatId, messageId, roster) =>
+ editMessage(chatId, messageId, renderRosterText(roster), renderRosterKeyboard(roster))
+
+ const editPaired = (chatId, messageId, paired) =>
+ editMessage(chatId, messageId, renderPairedText(paired), renderPairedKeyboard())
+
// 無參數:從今日出席名單選人
async function startPicker(chatId, replyTo, ownerId) {
- const names = loadAttendees ? await loadAttendees() : []
- if (names.length < 2) {
+ const members = normalizeMembers(loadAttendees ? await loadAttendees() : [])
+ if (members.length < 2) {
await bot.sendMessage(
chatId,
- names.length === 0
+ members.length === 0
? '今天還沒有人報名,請先用 /羽球 報名,或直接:/記分 小明 小華 vs 阿強 阿美'
: '今天只有 1 人報名,請直接:/記分 小明 小華 vs 阿強 阿美',
{ reply_to_message_id: replyTo },
)
return
}
- const picker = { phase: 'pick', names, picked: [], target: targetScore, ownerId }
+ const picker = {
+ phase: 'pick',
+ names: members.map((m) => m.name),
+ skills: Object.fromEntries(members.map((m) => [m.name, m.skill])),
+ userIds: Object.fromEntries(members.map((m) => [m.name, m.userId])),
+ picked: [],
+ target: targetScore,
+ ownerId,
+ }
const sent = await bot.sendMessage(chatId, renderPickerText(picker), {
parse_mode: 'HTML',
reply_markup: renderPickerKeyboard(picker),
})
store.set(chatId, sent.message_id, picker)
- log.info(`[score] picker chat=${chatId} mid=${sent.message_id} attendees=${names.length}`)
+ log.info(`[score] picker chat=${chatId} mid=${sent.message_id} attendees=${members.length}`)
}
async function start(chatId, replyTo, argsText, ownerId = null) {
@@ -177,6 +235,15 @@ export function createScoreboard(
log.info(`[score] picker→match chat=${chatId} mid=${messageId} ${JSON.stringify(teams)}`)
}
+ if (action === 'rand') {
+ // 進入隨機配對:預設全選
+ picker.phase = 'roster'
+ picker.picked = []
+ picker.selected = picker.names.map(() => true)
+ store.set(chatId, messageId, picker)
+ await editRoster(chatId, messageId, picker)
+ return answer()
+ }
if (action.startsWith('pick') && action !== 'pickgo') {
const index = Number(action.slice(4))
if (!Number.isInteger(index) || index < 0 || index >= picker.names.length) return answer()
@@ -206,6 +273,121 @@ export function createScoreboard(
return answer()
}
+ // 隨機配對:讀今日戰績 → 配對 → 進入預覽(roster / paired 階段都可呼叫,重新配對就是再跑一次)
+ async function runPairing(cq, entry) {
+ const chatId = cq.message.chat.id
+ const messageId = cq.message.message_id
+ const answer = (text) => bot.answerCallbackQuery(cq.id, text ? { text } : undefined)
+
+ const selectedNames = entry.names.filter((_, i) => entry.selected?.[i])
+ if (selectedNames.length < PLAYERS_PER_GAME) return answer(`至少要選 ${PLAYERS_PER_GAME} 人`)
+
+ let games = []
+ let statsNote = null
+ try {
+ games = await loadTodayGames()
+ } catch (error) {
+ log.warn('讀取今日戰績失敗,改用無戰績配對:', error.message)
+ statsNote = '讀不到今日戰績,這次沒有考慮休息與搭檔紀錄'
+ }
+ const result = pairRound(selectedNames, entry.skills ?? {}, buildStats(games), { random })
+ if (result.error) return answer(result.error)
+ // 每個場地從休息的人抽一位計分人;不夠就 null(任何人都能記分)
+ const scorers = assignScorers(result.resting, result.games.length, { exclude: scorerExclude, random })
+ result.games.forEach((game, i) => {
+ game.scorer = scorers[i]
+ })
+
+ entry.phase = 'paired'
+ entry.games = result.games
+ entry.resting = result.resting
+ entry.courtCount = result.courtCount
+ entry.statsNote = statsNote
+ store.set(chatId, messageId, entry)
+ await editPaired(chatId, messageId, entry)
+ log.info(
+ `[score] paired chat=${chatId} mid=${messageId} history=${games.length} ` +
+ `games=${JSON.stringify(result.games.map((g) => [g.court, g.teamA, g.teamB]))} resting=${JSON.stringify(result.resting)}`,
+ )
+ return answer('已配對')
+ }
+
+ // 勾選名單階段的按鈕
+ async function onRosterCallback(cq, roster, action) {
+ const chatId = cq.message.chat.id
+ const messageId = cq.message.message_id
+ const answer = (text) => bot.answerCallbackQuery(cq.id, text ? { text } : undefined)
+
+ if (action.startsWith('tg')) {
+ const index = Number(action.slice(2))
+ if (!Number.isInteger(index) || index < 0 || index >= roster.names.length) return answer()
+ roster.selected[index] = !roster.selected[index]
+ store.set(chatId, messageId, roster)
+ await editRoster(chatId, messageId, roster)
+ return answer()
+ }
+ if (action === 'rall') {
+ const allOn = roster.selected.every(Boolean)
+ roster.selected = roster.names.map(() => !allOn)
+ store.set(chatId, messageId, roster)
+ await editRoster(chatId, messageId, roster)
+ return answer()
+ }
+ if (action === 'rback') {
+ roster.phase = 'pick'
+ roster.picked = []
+ delete roster.selected
+ store.set(chatId, messageId, roster)
+ await editPicker(chatId, messageId, roster)
+ return answer()
+ }
+ if (action === 'rgo') return runPairing(cq, roster)
+ return answer()
+ }
+
+ // 預覽階段的按鈕
+ async function onPairedCallback(cq, paired, action) {
+ const chatId = cq.message.chat.id
+ const messageId = cq.message.message_id
+ const answer = (text) => bot.answerCallbackQuery(cq.id, text ? { text } : undefined)
+
+ if (action === 'rgo') return runPairing(cq, paired)
+ if (action === 'rback') {
+ paired.phase = 'roster'
+ delete paired.games
+ delete paired.resting
+ delete paired.courtCount
+ delete paired.statsNote
+ store.set(chatId, messageId, paired)
+ await editRoster(chatId, messageId, paired)
+ return answer()
+ }
+ if (action === 'pgo') {
+ // 每個場地各推一則記分板;權限給抽到的計分人(沒抽到 → 任何人都能按)
+ for (const game of paired.games) {
+ const match = createMatch(game.teamA, game.teamB, paired.target)
+ const scorerId = game.scorer ? (paired.userIds?.[game.scorer] ?? null) : null
+ if (game.scorer && scorerId == null) log.warn(`[score] 計分人 ${game.scorer} 沒有 user_id,這場不鎖權限`)
+ match.scorer = { name: game.scorer ?? null, userId: scorerId }
+ match.ownerId = scorerId
+ match.court = game.court
+ const sent = await bot.sendMessage(chatId, renderText(match), {
+ parse_mode: 'HTML',
+ reply_markup: renderKeyboard(match),
+ })
+ store.set(chatId, sent.message_id, match)
+ log.info(
+ `[score] paired→match chat=${chatId} mid=${sent.message_id} court=${game.court} ` +
+ `${JSON.stringify([game.teamA, game.teamB])} scorer=${game.scorer ?? '(任何人)'}/${scorerId}`,
+ )
+ }
+ store.delete(chatId, messageId)
+ await editMessage(chatId, messageId, renderPairedText(paired, { launched: true }), { inline_keyboard: [] })
+ return answer('記分板已推出')
+ }
+ return answer()
+ }
+
async function onCallback(cq) {
const chatId = cq.message.chat.id
const messageId = cq.message.message_id
@@ -221,14 +403,70 @@ export function createScoreboard(
return
}
- if (entry.phase === 'pick') {
+ if (entry.phase) {
if (action === 'end') {
store.delete(chatId, messageId)
- await editMessage(chatId, messageId, '⏹ 已取消選人', { inline_keyboard: [] })
+ await editMessage(chatId, messageId, entry.phase === 'pick' ? '⏹ 已取消選人' : '⏹ 已取消配對', {
+ inline_keyboard: [],
+ })
await bot.answerCallbackQuery(cq.id, { text: '已取消' })
return
}
- await onPickerCallback(cq, entry, action)
+ if (entry.phase === 'pick') await onPickerCallback(cq, entry, action)
+ else if (entry.phase === 'roster') await onRosterCallback(cq, entry, action)
+ else if (entry.phase === 'paired') await onPairedCallback(cq, entry, action)
+ else await bot.answerCallbackQuery(cq.id)
+ return
+ }
+
+ // 換人計分:只能在還沒開始計分時;由目前計分人或管理員操作(canOperate 已擋)
+ const isHandoverAction = action === 'ho' || action === 'hoany' || action === 'hocancel' || /^ho\d+$/.test(action)
+ if (!isHandoverAction && entry.handover) delete entry.handover
+ if (action === 'ho') {
+ if (entry.server !== null) {
+ await bot.answerCallbackQuery(cq.id, { text: '開始計分後不能換人計分' })
+ return
+ }
+ // 候選:今天出席、不在場上、不是現任計分人、不在排除名單
+ const skip = new Set([...entry.teams.flatMap((t) => t.players), entry.scorer?.name].filter(Boolean))
+ const members = normalizeMembers(loadAttendees ? await loadAttendees() : [])
+ entry.handover = members
+ .filter((m) => !skip.has(m.name) && !scorerExclude.has(m.name))
+ .map((m) => ({ name: m.name, userId: m.userId }))
+ delete entry.confirmEnd
+ store.set(chatId, messageId, entry)
+ await editBoard(chatId, messageId, entry)
+ await bot.answerCallbackQuery(
+ cq.id,
+ entry.handover.length ? undefined : { text: '今天沒有其他出席的人可選,可改成「任何人」' },
+ )
+ return
+ }
+ if (isHandoverAction) {
+ if (!entry.handover) {
+ await bot.answerCallbackQuery(cq.id, { text: '請重新按「換人計分」' })
+ return
+ }
+ let hint
+ if (action === 'hoany') {
+ entry.scorer = { name: null, userId: null }
+ entry.ownerId = null
+ hint = '已改為任何人都能記分'
+ } else if (action !== 'hocancel') {
+ const candidate = entry.handover[Number(action.slice(2))]
+ if (!candidate) {
+ await bot.answerCallbackQuery(cq.id, { text: '請重新按「換人計分」' })
+ return
+ }
+ entry.scorer = { name: candidate.name, userId: candidate.userId }
+ entry.ownerId = candidate.userId ?? null
+ hint = `計分人已改為 ${candidate.name}${candidate.userId == null ? '(找不到他的 TG id,不鎖權限)' : ''}`
+ log.info(`[score] handover chat=${chatId} mid=${messageId} → ${candidate.name}/${candidate.userId}`)
+ }
+ delete entry.handover
+ store.set(chatId, messageId, entry)
+ await editBoard(chatId, messageId, entry)
+ await bot.answerCallbackQuery(cq.id, hint ? { text: hint } : undefined)
return
}
diff --git a/src/signup.js b/src/signup.js
index 3c970bc..f857a0d 100644
--- a/src/signup.js
+++ b/src/signup.js
@@ -6,10 +6,18 @@
// - 按鈕 callback_data:bd|yes||YYYYMMDD / bd|no||YYYYMMDD(與舊訊息相容)
// - 每週排程自動發到正式群
import { query as dbQuery } from './db.js'
+import { clampSkill } from './pairing.js'
import { commandRegex, dateLabel, escapeHtml, fromInfo, isNotModifiedError, nowHM, todayKey } from './util.js'
const SQL_LOAD_RSVP =
'SELECT user_id, nickname FROM attendance WHERE poll_date = ? ORDER BY joined_at ASC, user_id ASC'
+// 出席名單 + 實力:skill 優先用 user_id 對 tg_members,對不到再用暱稱對,都沒有當 1(與 badminton-match-hub 相同)
+const SQL_LOAD_ATTENDEES_SKILL = `SELECT a.nickname, a.user_id,
+ COALESCE(m.skill, (SELECT MAX(m2.skill) FROM tg_members m2 WHERE m2.nickname = a.nickname), 1) AS skill
+ FROM attendance a
+ LEFT JOIN tg_members m ON m.user_id = a.user_id
+ WHERE a.poll_date = ?
+ ORDER BY a.joined_at ASC, a.user_id ASC`
const SQL_LOAD_POLL = 'SELECT chat_id, message_id FROM tg_poll WHERE poll_date = ?'
const SQL_UPSERT_MEMBER = `INSERT INTO tg_members (user_id, tg_name, username) VALUES (?,?,?)
ON DUPLICATE KEY UPDATE tg_name=VALUES(tg_name), username=VALUES(username)`
@@ -52,10 +60,16 @@ export function createSignup(bot, { log = console, query = dbQuery } = {}) {
}
}
- // 今日出席名單(給記分板選人用)
- async function attendeeNames(date = todayKey()) {
- const rows = await query(SQL_LOAD_RSVP, [date])
- return rows.map((row) => row.nickname)
+ // 今日出席名單 + 實力 + TG user id(給記分板選人 / 隨機配對 / 計分人權限用):[{ name, skill, userId }]
+ async function attendees(date = todayKey()) {
+ const rows = await query(SQL_LOAD_ATTENDEES_SKILL, [date])
+ return rows
+ .map((row) => ({
+ name: String(row.nickname ?? '').trim(),
+ skill: clampSkill(row.skill),
+ userId: row.user_id != null ? Number(row.user_id) : null,
+ }))
+ .filter((member) => member.name)
}
// 發報名訊息(/羽球 或排程)
@@ -177,7 +191,7 @@ export function createSignup(bot, { log = console, query = dbQuery } = {}) {
}
- return { post, list, setNickname, rsvp, attendeeNames, registerCommands }
+ return { post, list, setNickname, rsvp, attendees, registerCommands }
}
export const SIGNUP_HELP = [
diff --git a/test/history.test.js b/test/history.test.js
index cd48e0c..d1c650d 100644
--- a/test/history.test.js
+++ b/test/history.test.js
@@ -1,7 +1,7 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { addPoint, createMatch, setFirstServer, settleMatch } from '../src/match.js'
-import { buildHistoryPayload, saveHistory } from '../src/history.js'
+import { buildHistoryPayload, dayStartUnix, loadTodayGames, parseGameRow, saveHistory } from '../src/history.js'
test('buildHistoryPayload:欄位對齊 web 版 history(雙打)', () => {
const m = createMatch(['Gary', '蓉蓉'], ['柏威', '小念'])
@@ -53,3 +53,31 @@ test('saveHistory:先 ensure table 再 INSERT,回傳 insertId', async () =>
assert.equal(sqls[1].params[6], '[["A","B"],["C","D"]]')
assert.equal(sqls[1].params[7], '[[0,0,0,0]]')
})
+
+test('loadTodayGames:只抓當天(台北時間)的 history,依時間排序並解析 team;壞掉的列略過', async () => {
+ assert.equal(dayStartUnix('20260824'), Math.floor(Date.parse('2026-08-24T00:00:00+08:00') / 1000))
+ assert.deepEqual(parseGameRow({ id: 1, time: '10', team: '[["A","B"],["C","D"]]' }), {
+ id: 1,
+ time: 10,
+ teams: [['A', 'B'], ['C', 'D']],
+ })
+ assert.deepEqual(parseGameRow({ id: 2, time: 10, team: '[["A"],["B"]]' }).teams, [['A'], ['B']], '單打')
+ assert.equal(parseGameRow({ id: 3, time: 10, team: 'oops' }), null)
+ assert.equal(parseGameRow({ id: 4, time: 10, team: '[["A","B"]]' }), null)
+
+ const sqls = []
+ const query = async (sql, params) => {
+ sqls.push({ sql: sql.replace(/\s+/g, ' ').trim(), params })
+ return [
+ { id: 7, time: 100, team: '[["A","B"],["C","D"]]' },
+ { id: 8, time: 200, team: 'bad' },
+ { id: 9, time: 300, team: '[["E"],["F"]]' },
+ ]
+ }
+ const games = await loadTodayGames({ query, table: 'history_test', date: '20260824' })
+ const start = dayStartUnix('20260824')
+ assert.match(sqls[0].sql, /SELECT id, time, team FROM `history_test` WHERE time >= \? AND time < \? ORDER BY time ASC, id ASC/)
+ assert.deepEqual(sqls[0].params, [start, start + 86400])
+ assert.deepEqual(games.map((g) => g.id), [7, 9])
+ assert.deepEqual(games[1].teams, [['E'], ['F']])
+})
diff --git a/test/match.test.js b/test/match.test.js
index ab72a77..e7d2cb4 100644
--- a/test/match.test.js
+++ b/test/match.test.js
@@ -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 \/ 🏸<A1>/)
})
-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('swapTeams:0: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)
diff --git a/test/pairing.test.js b/test/pairing.test.js
new file mode 100644
index 0000000..847b0c2
--- /dev/null
+++ b/test/pairing.test.js
@@ -0,0 +1,151 @@
+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])
+})
diff --git a/test/scoreboard.test.js b/test/scoreboard.test.js
index 30ba63a..c53aecd 100644
--- a/test/scoreboard.test.js
+++ b/test/scoreboard.test.js
@@ -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)
+})
diff --git a/test/signup.test.js b/test/signup.test.js
index 5a63018..ea6e737 100644
--- a/test/signup.test.js
+++ b/test/signup.test.js
@@ -16,6 +16,15 @@ function fakeDb() {
if (s.startsWith('SELECT user_id, nickname FROM attendance')) {
return attendance.filter((r) => r.poll_date === params[0]).map((r) => ({ user_id: r.user_id, nickname: r.nickname }))
}
+ if (s.startsWith('SELECT a.nickname, a.user_id, COALESCE(m.skill')) {
+ return attendance
+ .filter((r) => r.poll_date === params[0])
+ .map((r) => {
+ const byId = members.get(r.user_id)
+ const byName = [...members.values()].find((m) => m.nickname === r.nickname)
+ return { nickname: r.nickname, user_id: r.user_id, skill: byId?.skill ?? byName?.skill ?? 1 }
+ })
+ }
if (s.startsWith('SELECT m.nickname, m.user_id AS member')) {
const [uid, date] = params
const m = members.get(uid)
@@ -175,3 +184,23 @@ test('commandRegex:支援 @BotName 後綴與中英文別名', () => {
assert.equal('/score 小明 vs 阿強'.match(s)[1], '小明 vs 阿強')
assert.equal('/記分'.match(s)[1], undefined)
})
+
+test('attendees:出席名單帶 tg_members.skill(user_id 對不到就用暱稱對,都沒有當 1)', async () => {
+ const db = fakeDb()
+ const bot = fakeBot()
+ const signup = createSignup(bot, { log: silentLog, query: db.query })
+ db.members.set(1, { nickname: '建喵', skill: 9 })
+ db.members.set(3, { nickname: '同名', skill: 4 })
+ db.attendance.push(
+ { poll_date: DATE, user_id: 1, nickname: '建喵' },
+ { poll_date: DATE, user_id: 2, nickname: '路人' },
+ { poll_date: DATE, user_id: 99, nickname: '同名' },
+ { poll_date: DATE, user_id: 100, nickname: ' ' },
+ )
+ assert.deepEqual(await signup.attendees(DATE), [
+ { name: '建喵', skill: 9, userId: 1 },
+ { name: '路人', skill: 1, userId: 2 },
+ { name: '同名', skill: 4, userId: 99 },
+ ])
+ assert.deepEqual(await signup.attendees('20000101'), [])
+})