兩人專用的西洋棋對弈站。一方落子,另一方的 Discord 就收到推播。
- 同時只有一盤進行中,隨時中斷隨時接續
- 每一步都留紀錄(SAN、UCI、局面、思考時間)
- 可以從殘局開局:貼 FEN 或直接擺子
- 本地 Stockfish 分析 + 評估走勢圖,一鍵複製 PGN
npm install
cp .env.example .env.local # 填值,見下
npm run dev第一次要用 http://localhost:3000/?as=<你的 token> 進站,token 寫進 cookie 後就
可以直接開首頁。token 在 players 表裡,schema.sql 的 seed 是佔位字串,
建好 DB 之後記得 update 成自己的。
DATABASE_URL=postgres://...
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/.../...
APP_URL=https://your-app.vercel.app
# Pusher Channels(即時推播:對方下完棋立刻刷新頁面)
PUSHER_APP_ID=...
PUSHER_SECRET=...
NEXT_PUBLIC_PUSHER_KEY=...
NEXT_PUBLIC_PUSHER_CLUSTER=ap1
# 選配:推播裡的棋盤圖
# 不設 → 用 chessvision.ai 的 fen2image(第三方)
# BOARD_IMAGE=off → 不放圖,只留棋步和連結
# BOARD_IMAGE_BASE=https://... → 自架 lila-gif 的 /image.gif
Neon 的連線字串結尾要手動拿掉 &channel_binding=require。
postgres.js 會把它當成啟動參數送給伺服器,而 Postgres 不認得這個參數名,
留著會連不上。換機器重裝時最容易再踩一次。
schema.sql 資料表 + seed + 補給既有 DB 的欄位 + 備忘查詢
lib/db.ts 連線與型別
lib/auth.ts 秘密網址認證:token → cookie
lib/chess.ts chess.js 包裝:走子、終局判定、局面比對、PGN 產生
lib/discord.ts webhook 推播
lib/pusher.ts Pusher 後端實例(即時推播)
lib/themes.ts 棋盤配色定義
app/actions.ts playMove / newGame / resign / takeback / 提和 / 備註 / 主題 / 聊天
app/enter/route.ts 秘密網址的落點:驗 token、寫 cookie、導回首頁
app/RealtimeRefresh.tsx 訂閱 Pusher 事件,對方下完立刻刷新
app/layout.tsx 外框與導覽
app/globals.css 全部樣式(沒有用 Tailwind)
app/icon.png favicon:貓頭 + 深色圓角底
app/apple-icon.png iOS 加到主畫面的圖示
public/rose.png header 的貓:暖白線稿、透明底
app/page.tsx 目前這盤,或開局表單
app/NewGame.tsx 開局表單(含擺子編輯器)
app/Board.tsx 可下棋的棋盤(含落點提示、上一步高亮、悔棋、提和)
app/MoveLog.tsx 記譜表
app/EvaluationGraph.tsx 評估走勢圖(含 hover 顯示每步評估值)
app/MoveLogChatTabs.tsx 記譜與聊天的分頁切換
app/PgnButton.tsx 複製 PGN
app/ThemeSelector.tsx 棋盤配色切換
app/ChatBox.tsx 局中聊天(含即時推播)
app/history/page.tsx 歷史對局與戰績
app/stats/page.tsx 個人統計
app/game/[id]/ 單局重播(含備註編輯)
analyze-local.mjs 本地 Stockfish 分析工具(15 秒/步,能算多深算多深)
tests/ 棋規與擺子編輯器的測試
schema.sql 上面的 create table 是給全新資料庫用的;線上那套是一路手動加欄位
長出來的,所以檔案下半部另外留了一段可重複執行的 alter table,補欄位時貼那段。
npm test或分開跑:
node tests/chess.test.mjs # 走子、入堡、過路兵、終局、三次重複、PGN round-trip
node tests/editor.test.mjs # 擺子編輯器的 FEN 解析與往返測試是直接複製 lib/chess.ts、NewGame.tsx 和 playMove 的判定邏輯貼進去跑的
(沒有建置步驟),所以改動那些檔案時記得同步更新測試檔。
單步規則判定全部交給 chess.js。 入堡的四個條件、吃過路兵、升變、逼和、50 步規則——自己刻一定會漏掉某個邊界情況。
唯一的例外是三次重複,那要自己數。 applyMove 每步都是 new Chess(fen) 重建,實例裡沒有歷史局面,所以 isThreefoldRepetition() 永遠回傳 false——這條曾經寫在 readOutcome 裡,是死碼,重複再多次也判不出和棋。現在改成在 playMove 裡撈該局的 initial_fen 加上所有 moves.fen_after,數同一個局面出現幾次,滿三次就判和。比對局面只看 FEN 的前四欄(盤面、行動方、入堡權、過路兵格),後兩欄是半步計時和回合數,算進去的話同一個局面永遠不會相等。
踩過的坑:move.isCapture() 對吃過路兵回傳 false。 chess.js 只認 flag 'c',而過路兵的 flag 是 'e'。改用 move.captured !== undefined,兩種都涵蓋。不修的話所有過路兵都會被記成沒吃子,之後統計會少算。
「同時只有一盤」在資料庫層擋。 games 上的 partial unique index:對 status 建索引、條件 where status = 'ongoing'。符合條件的列 status 恆等於 'ongoing',唯一性就等於「最多一列」。應用層就算有 bug 也開不出第二盤。
併發保護兩層。 select ... for update 鎖住棋局;expectedPly 比對擋掉「開著舊分頁按下去」——早上在電腦開頁面、中午在手機走了一步、晚上回電腦點舊分頁,會被擋下來要求重新整理,而不是走出一步鬼棋。
Token 進站後從網址移除。 第一次 ?as=xxx 進來寫進 cookie 就 redirect,避免截圖或分享棋局連結時把憑證一起洩出去。
手擺局面會清掉入堡權和過路兵格。 手動擺出來的棋盤沒有「王有沒有動過」這段歷史,保留原本的 KQkq 會讓系統允許不該有的入堡。
推播用 after()。 落子的 response 先回,Discord 請求晚一步跑。推播失敗只記 log,不會讓落子失敗。
貓是暖白線稿,favicon 自己帶底色。 原圖是黑線畫在白底上,站上是深色的,所以線條重新上成 --chalk 的暖白、白底去掉,米色斑紋保留。header 那隻直接疊在深色底上就好;favicon 不行——分頁列的顏色不歸網站控制,暖白線條在淺色分頁列上會消失,所以圖示自己帶一塊 --felt 的深色圓角底,深淺兩種分頁列都看得見。favicon 另外只裁頭部並加粗線條,整隻貓縮到 16px 只會是一團灰。
本地分析用 Stockfish。 analyze-local.mjs 可以分析已結束的棋局,每步給 15 秒思考時間(能算多深算多深),將評估值、最佳走法、走法分類(best/good/inaccuracy/mistake/blunder)存入 move_analysis 表。評估圖從使用者視角顯示(執白看到白方優勢往上、執黑看到黑方優勢往上),CP 和 Mate 分數都會根據輪到誰走正確翻轉,避免震盪。
- ✅ 即時推播:對方下完棋 < 1 秒自動刷新頁面(Pusher Channels)
- ✅ 悔棋:每人每局 2 次,刪掉自己剛下的步
- ✅ 落點提示:點選棋子顯示合法走法(綠點 = 普通走法、紅環 = 吃子)
- ✅ 上一步高亮:把剛走的那步起點和終點標出來(視覺回饋)
- ✅ 升變選子:兵推到底線時彈出選單,選擇升變為皇后、城堡、主教或騎士
- ✅ 提和功能:任一方可提出和棋,對方接受或拒絕
- ✅ 三次重複和棋判定:自動比對歷史局面,滿 3 次宣告和棋
- ✅ 步數與子數顯示:即時顯示目前第幾步、雙方剩餘棋子數
- ✅ 個人統計頁面:戰績(勝/敗/和、勝率)、棋步統計(吃子率、將軍率)、思考時間(平均/最快/最慢)
- ✅ 棋局備註:每局可新增/編輯註解(例如「某某開局」「紀念局」)
- ✅ 歷史對局列表:顯示所有已結束的棋局與戰績統計
- ✅ 單局重播:逐步重播任一歷史對局
- ✅ 本地 Stockfish 分析:
analyze-local.mjs可分析已結束棋局,標記失誤與最佳走法 - ✅ 評估走勢圖:視覺化顯示整局評估變化,滑鼠 hover 顯示每步評估值與著法