記分板新增隨機配對、計分人權限與換人計分,調整按鈕排法
根本原因: 每打完一輪都要人工決定誰上場、誰搭誰、誰負責記分,記分板也沒有「兩隊對換」和退回選先發球的操作,現場排場地或換人都得重開記分板。 影響: /記分 多一顆 🎲 隨機配對:勾選出席者(預設全選)後,依 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:
@@ -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 ?? '',
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-2
@@ -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: '指令說明' },
|
||||
])
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
+208
@@ -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)]
|
||||
}
|
||||
+126
-29
@@ -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(`🏸 <b>羽球記分板</b> ${isDoubles(match) ? '雙打' : '單打'}・${match.target} 分制`)
|
||||
const title = match.court != null ? `羽球記分板 ${courtLabel(match.court)}` : '羽球記分板'
|
||||
lines.push(`🏸 <b>${title}</b> ${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)} <b>${match.score[teamIndex]}</b>${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(`🙋 <b>選擇新的計分人</b>(目前:${scorerLabel(match)})`)
|
||||
} else if (match.confirmEnd) {
|
||||
if (match.finished) lines.push('❓ <b>確定要結束嗎?</b>會把這場寫入歷史戰績')
|
||||
else if (hasPoints) lines.push('❓ <b>確定要結束嗎?</b>比賽未結算,不會寫入戰績(要存請先按 ⏱ 結算)')
|
||||
@@ -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]} <b>${escapeHtml(serving.server)}</b>(${COURT_LABEL[serving.court]})` +
|
||||
@@ -68,24 +86,30 @@ export function renderText(match, { ended = false } = {}) {
|
||||
// callback_data 格式:sc:<action>
|
||||
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 = [
|
||||
`🎲 <b>隨機配對</b> 勾選今天要排的人(已選 ${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 = [`🎲 <b>配對結果</b> ${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')],
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
+251
-13
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+19
-5
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user