教學 › 把一位球員改成你想要的球員
把一位球員改成你想要的球員
你想把喜歡的球員放進這款 2005 年的遊戲裡。
遊戲不能「加人」,但可以「接收」 ——
挑一位你不在乎的球員,把他的名字、背號、跑壘速度改成你要的那個人。
這一課的工具雙擊就能用,一個指令都不用打。
難度
★☆☆ 不用打指令
時間
第一次約 10 分鐘
要會什麼
會雙擊、會打字就夠了
🛟 動手之前:先完整備份整個遊戲資料夾
把整個遊戲資料夾複製一份到別的地方, 包括裡面存放紀錄的資料夾。不要挑檔案,整包複製最省事也最保險。
「紀錄」是指這些(它們跟遊戲檔混在同一個資料夾裡):
Support\MVP Baseball 2005\—— 遊戲自己建的, 裡面是設定(MVPOptions_Options.sav)與手把設定 (controller.cfg)。剛安裝好時沒有這個資料夾,玩過才出現。- 王朝、老闆模式、名單與玩家檔案的存檔(
*_Franchise.sav、*_Owner Mode.sav、*_Rosters.sav、*_Profile.sav)—— 沒有固定的資料夾名,你或你裝的模組包叫它什麼它就叫什麼。找不到就用這一行找:
dir /s /b "〔你的遊戲資料夾〕\*.sav"
find "〔你的遊戲資料夾〕" -name "*.sav"
上面第一行 Windows、第二行 Mac。 本站每一支工具都會自己備份它要動的那一個檔, 但那只保得住那一個檔 —— 整包備份保的是「你玩到現在的一切」。 完整做法看備份與還原 SOP。
⚡ 只想趕快做完?照這四步
- 下載這一包:MVP換球員工具.zip, 雙擊它就會自己解開。裡面有三個檔。
- 雙擊那個 ⚾ 檔案。會跳出一個黑色視窗,它會自己找到遊戲。
- 照著它問的打:先打你要接收的球員名字(英文), 再打新的名、姓、背號、跑壘速度。不想改的直接按 Enter 跳過。
- 它會先給你看預覽,確定了才打
y。 寫進去之前會自動備份,不喜歡再跑一次就能還原。
⚠️ 名字要打英文拼音。名冊裡存不了中文 ——
陳金鋒在遊戲裡就叫 Chin-Feng Chen。
⚠️ 一定要下載那個壓縮檔,不要分開抓。原因寫在下面第 2 步。
🛠 要做一整包模組(二三十個人)?
別走互動問答 —— 看下面的 CSV 批次那一段。
先講一件重要的事:遊戲不能「加人」
你可能想的是「把陳傑憲加進去」。這款遊戲做不到 —— 名冊是固定的一份名單,沒有「新增一列」這種功能。
但這不是壞消息,因為二十年前台灣的前輩早就想通了。
2009 年那群人怎麼做的
本站的那一年做臉的人那一頁裡, 2009 年的台灣名冊說明書寫得很直白:
c183------林彥峰 … 移除Aaron Sele並給予臉皮編號
看懂了嗎?他們沒有「加」林彥峰進去 —— 他們挑了一個既有球員的位置,把它變成林彥峰。 名字換掉、臉換掉、背號換掉,那個位置就是林彥峰了。
(2026-09-05 訂正:本站原本把上面那句寫成「挑了一個 Aaron Sele」。
說明書那一格確實寫著 Aaron Sele,但剛安裝好的原版名冊裡臉皮編號
c183 是 Roberto Hernandez、c214 才是 Aaron Sele ——
說明書把這兩個名字寫顛倒了。本站逐條對過,見當年那兩份說明書,一句一句拿今天的檔案對過。
做法本身沒有變:他們拿的就是一個既有球員的位置。)
本站另一頁把這件事寫成一句話:「臉是搶來的。」 這一課教你做同一件事,只是用滑鼠雙擊,不用像他們當年那樣手動改十六進位。
👉 你要做的事
先想好:你要接收誰?
挑一個你不會用到的球員。小聯盟的、自由球員的、你根本沒聽過的,都可以。 不要挑你喜歡的球員,因為他會被改掉。
想找台灣人的話:原版遊戲裡本來就有兩位 ——
Chin-Feng Chen(陳金鋒)跟 Chin-Hui Tsao(曹錦輝),
2005 年他們真的在大聯盟。
下載 MVP換球員工具.zip,雙擊解開。
裡面有三個檔:工具本體 mvp_player.py、Mac 用的
⚾ 換一位球員(Mac 雙擊).command、Windows 用的
⚾ 換一位球員(Windows 雙擊).bat。
把整個解開的資料夾丟進遊戲資料夾(裡面有 data 那一層)最省事,
這樣工具會自己找到遊戲。
⚠️ 為什麼一定要用壓縮檔,不能一個一個抓
Mac:從網頁直接抓下來的 .command
沒有「可以執行」這個權限 ——
網頁傳檔案傳不了權限,抓下來是 -rw-r--r--,
雙擊會完全沒反應。
壓縮檔裡面存得住那個權限(本站實測解開之後是 -rwxr-xr-x)。
但壓縮檔保不住的是另一件事:從網頁下載的東西會被 macOS 貼上
quarantine 標記,連壓縮檔一起解開的三個檔都還帶著它 ——
那個要用下面那一招放行。
Windows:Chrome 跟 Edge 常常直接擋掉 .bat 的下載,
說它危險。包在壓縮檔裡就不會被擋。
本站的發布流程每次都現做這個壓縮檔,
上線前的稽核會把它打開來看:三個檔逐一比對 SHA-256、
確認 .command 的執行權限還在。
中文檔名是用 UTF-8 旗標寫進壓縮檔的(不然 Windows 解開會看到亂碼)。
所以它不可能跟原始碼不同步。
🍎 Mac 雙擊跳出「無法打開,因為來自身分不明的開發者」,
或是雙擊完全沒有反應?兩種都是同一件事:從網頁下載的檔案帶著
quarantine 標記,而壓縮檔保不住這個標記
(壓縮檔保住的是執行權限,不是這個)。
在那個檔案上按右鍵 → 打開(不是雙擊),再按一次「打開」就會過,
之後就可以直接雙擊。
比較新的 macOS 有時候不跳問句、直接沒反應,看到沒反應就照這一招做。
本站在 macOS 26.6.2 上量過解開之後的三個檔,都還帶著
com.apple.quarantine;「雙擊當下畫面上會不會跳問句」本站還沒實測。
連「右鍵 → 打開」都沒有用?那就開 終端機
(在「應用程式 → 工具程式」裡),把下面這一行的路徑換成
你剛解開的那個資料夾,貼進去按 Enter,
它會把整個資料夾裡的 quarantine 標記拿掉:
xattr -dr com.apple.quarantine "〔解開的那個資料夾〕"
路徑不用自己打 —— 打完前面那一段(到第一個引號為止), 把資料夾拖進終端機視窗,路徑就會自己補上,再自己補上後面那個引號。 這一行只改標記,不改檔案內容。
雙擊 ⚾ 那個檔案。
你會看到一個黑色視窗,上面寫「⚾ 把一位球員改成你想要的球員」, 然後是「找到遊戲了:…」跟「名冊讀好了,裡面有 3247 位球員」 (螢幕上沒有千分位逗號,而且那個數字是你自己那份名冊的人數)。
說「找不到遊戲資料夾」?它會請你把遊戲資料夾拖進視窗再按 Enter。 拖進去就好,不用自己打路徑。
說「這台電腦還沒有 Python」?照它給的網址去裝,
Windows 安裝時記得勾「Add python.exe to PATH」。裝完再雙擊一次。
這支工具需要 Python 3.7 以上(它用到 str.isascii
與 sys.stdout.reconfigure,兩個都是 3.7 才有的)。
今天從 python.org 下載到的一定夠新;會不夠的情況是
這台電腦本來就裝著一個很舊的 python ——
那兩個雙擊檔只確認 python 叫得起來,不會比對版本號,
所以真的踩到的話,改用指令並把 python3 換成新的那一支。
打你要接收的球員名字。打一部分就好,例如 Chin-Feng。
它會把符合的都列出來,前面有編號:
1. Chin-Feng Chen 背號 52 跑壘速度 78
打那個編號,按 Enter。
「找不到名字含 ○○ 的球員」→ 名冊是英文的, 打中文一定找不到。換個拼法,或只打兩三個字母。
一項一項填新的資料。不想改的直接按 Enter。
| 它問你 | 你填 | 不填會怎樣 |
|---|---|---|
| 新的名(first name) | 例如 Chieh-Hsien | 名字不變 |
| 新的姓(last name) | 例如 Chen | 姓不變 |
| 新的背號 | 0 到 99 | 背號不變 |
| 新的跑壘速度 | 0 到 99 | 速度不變 |
打了中文會被擋下來,而且它會告訴你怎麼拼。 被擋下來的時候檔案完全沒有被動到,放心。
看預覽,確定了才打 y。
它會列出「原本 → 要改成」,還會告訴你改完他叫什麼、背號幾號。 這時候還沒有動到任何檔案。
打 y 按 Enter,它會先備份再寫入。打別的都算取消。
進遊戲看看。
在球員名單裡找你剛才改的那個位置,名字跟背號應該都換了。
不喜歡的話,再雙擊一次那個 ⚾ 檔案,
它會問你要不要還原。打 r 就回到你第一次改之前的樣子。
備份要是壞的,它會擋下來,不會拿壞備份蓋掉你的名冊 ——
這道把關跟 --restore 是同一組。
接下來:讓他更像那個人
名字換好了,但他的臉還是原本那個人的。這幾課可以繼續:
| 再做這個 | 去哪 | 難度 |
|---|---|---|
| 換一張臉(球場上那顆頭) | 幫球員換一張臉 | ★☆☆ |
| 換資料頁上那張照片 | 換球員大頭照 | ★★☆ |
| 換打擊姿勢或投球動作 | 換打擊姿勢與投球姿勢 | ★☆☆ |
| 給他一首應援曲 | 把應援曲指給別的球員 | ★☆☆ |
要做一整包模組?走另一道門
上面那條路是一次改一位。如果你要做的是像 2009 年那份台灣名冊那樣的整包模組, 二三十個位置要接收,互動問答會問你兩百多次 —— 那不是給你走的路。
同一支工具還有第二道門:匯出成 CSV,用 Excel 改完再匯回去。
把整份名冊匯出成一張表:
Windows:
python mvp_player.py "〔你的遊戲資料夾〕" --export players.csv
Mac / Linux:
python3 mvp_player.py "〔你的遊戲資料夾〕" --export players.csv
會印「匯出 3247 位球員」(螢幕上沒有千分位逗號)。表裡有五欄:
id、first_name、last_name、
jersey、speed。
⚠️ id 那一欄是配對用的鑰匙,絕對不要動。
換名字不會換 id,所以你之後還可以再匯出一次接著改。
用 Excel、Numbers 或記事本改那張表。空白的欄位代表「這一欄不動」。
挑接收對象的訣竅:先照 speed 排序,
找那些數字低、名字你完全沒聽過的小聯盟球員 —— 他們不會出現在你的先發名單裡。
先預覽。這一步一個位元組都不會寫:
Windows:
python mvp_player.py "〔你的遊戲資料夾〕" --import players.csv
Mac / Linux:
python3 mvp_player.py "〔你的遊戲資料夾〕" --import players.csv
會逐行印「id 欄位 原本 → 要改成」,最後一行是
「這是預覽,一個位元組都沒有寫」。
表裡有任何一格不合格(打了中文、背號超過 99、id 找不到),
整張表會被擋下來,一個位元組都不寫,並且逐行告訴你第幾列錯在哪。
不會寫一半。
確定了才加 --apply:
Windows:
python mvp_player.py "〔你的遊戲資料夾〕" --import players.csv --apply
Mac / Linux:
python3 mvp_player.py "〔你的遊戲資料夾〕" --import players.csv --apply
寫完它會自己讀回來逐項比對, 印「N 位球員、M 個欄位都寫好了,讀回來逐項相符」。
要還原:
Windows:
python mvp_player.py "〔你的遊戲資料夾〕" --restore
Mac / Linux:
python3 mvp_player.py "〔你的遊戲資料夾〕" --restore
本站實際跑過的一次
拿真的旅外名單(王建民、郭泓志、胡金龍、陳鏞基、倪福德、陳偉殷、陽岱鋼、 林子偉、張育成、胡智為、王維中、羅嘉仁)接收 12 個小聯盟位置:
- 名冊剛好改動 12 行,3,248 個 CRLF 一個沒少
- 工具自己讀回來逐項相符
- 故意混進一列中文名、一列背號 200、一列不存在的
id→ 整張表被擋下,檔案逐位元組沒被動,三個問題各自點名 --restore之後逐位元組回到原狀
這幾項鎖在 .tools/site-check/selftest_batch.py,每次都會重跑。
你自己也可以驗一次。工具內建一道自我測試, 它自己造一份名冊來測,一個遊戲檔都不會碰,所以隨時可以跑:
Windows:
python mvp_player.py --selftest
Mac / Linux:
python3 mvp_player.py --selftest
全過的話會印「✅ --selftest 全過:86 道(正向 23 道、反向餌 63 道)。」
(這一行是在 Mac 上逐字抄腳本印出來的樣子,標點跟螢幕上一致。
Windows 上有兩件事測不出來會自動跳過 —— 匯出檔的權限位元、唯讀資料夾 ——
道數會少 4 道,一樣印「全過」;那個少掉的樣子本站是把那兩道閘門關掉模擬出來的,
還沒在 Windows 上實跑過。)
反向餌是故意做壞給它抓的,一共十二塊 —— 例如備份檔先被換成
指向資料夾外的符號連結、換名之前的比對讀到不對的東西、截斷的備份,
以及名冊已經換過了才出狀況、畫面卻只丟一片英文出來。
餌沒被抓到就算失敗:有任何一道沒過就不會印這一行,結束碼也不是 0。
⚠️ 這一課到哪裡為止
只開三個欄位,不是還沒做完,是刻意的。理由逐條寫在這裡:
| 你可能想改 | 行不行 | 為什麼 |
|---|---|---|
| 名字、背號 | ✅ | 純顯示欄位,改了不影響任何計算 |
| 跑壘速度 | ✅ | 本站量過:這一欄的實際範圍是 0 到 97,是真正的 0-99 刻度 |
| 其他能力值 | ❌ | 那些欄位根本不是 0-99 的評分。 本站把測試機那份名冊 3,247 位球員每一欄都量過 (剛安裝好的原版是 2,921 位球員,數字不一樣):守備最大 50、 臂力最大 15、守備範圍最大 13、 明星度最大 4。 讓你往「守備」填 99 不是危險,是沒有意義 —— 本站沒有解開那些欄位的刻度,所以不開 |
| 換球隊 | ➡️ | 球隊不在這個檔裡(46 個欄位裡沒有球隊)。 這件事在遊戲裡做就好:王朝模式裡交易,或簽自由球員 |
| 加一個全新球員 | ❌ | 名冊沒有「新增一列」。所以才用「接收」的做法 —— 2009 年那群人也是這樣做的 |
⚠️ 本站沒驗的
- 沒有實機確認改完的名字會不會出現在轉播畫面上。 名單畫面本站看得到,播報員唸不唸得出來是另一回事 —— 那由語音編號決定,見播報員為什麼不唸新球員的名字。
- 沒有測過已經存檔的王朝模式改名字會怎樣。 存檔裡可能另有一份名字。建議改完再開新的王朝。
- 名字長度上限寫 22,不是遊戲程式的限制,本站沒有反組譯去確認真正的上限。
而且這道門是分別套在「名」與「姓」上的,22 卻對不上
任何一份名冊的單一欄位:本站量過手上九份相異的
attrib.dat, 單一欄位最長只有 15 個字元(tools/Taiwan_20091110那份的 Martinez-Esteve),本站測試機那份MVP2026/data是 12 (Chisholm Jr.),剛安裝好的原版英文版是 14(Van Benschoten)。 22 剛好等於「名接姓」的最長字數(九份都不超過 22),但那只是數字對得上, 本站沒有證據說當初就是這樣訂的。門開得比資料寬只是少擋人,所以留著不收緊。
📌 重點整理
- 一句話 遊戲不能加人,但可以接收 —— 挑一個你不在乎的球員,把名字、背號、跑壘速度改成你要的那個人。
- 不用打指令 下載工具跟雙擊檔放同一個資料夾, 雙擊那個 ⚾ 檔案,它一句一句問你。
- 名字要用英文拼音 名冊裡存不了中文(本站量過, 3,247 位球員的名字非 ASCII 字元 0 個)。打中文會被擋下來, 而且檔案不會被動到。
- 安全網 預覽在前、確認才寫、第一次寫入自動備份,
再跑一次打
r就還原。兩條還原路徑走同一組五道把關 —— 打r跟--restore都一樣,壞掉的備份蓋不上去。 中途按Ctrl + C它會照實說檔案動了沒有(不是寫死一句「什麼都沒有動到」), 而且中斷一律以結束碼 130 收尾。 (2026-09-05 訂正:本站原本在這裡寫「打r這條路不檢查備份」, 那是修補之前的狀況。) - 能改三件、不能改的有理由 其他能力欄位不是 0-99 的刻度 (守備最大 50、臂力最大 15),換球隊在遊戲裡做,加新球員做不到。
- 第一格不是第 0 欄 每一行開頭是這一列的識別碼
(9 位十六進位字串,沒有欄號),它後面才是 46 個欄位。
--export出來的id欄就是它,是配對用的鑰匙,絕對不要動。 - 要做整包模組走另一道門
--export成 CSV、 用 Excel 改完--import回去。表裡有任何一格不合格整張擋下,不會寫一半。 本站實際拿旅外名單接收過 12 個位置(見上面那一段)。 - 這招不是本站發明的 2009 年的台灣名冊說明書寫著 「移除 Aaron Sele 並給予臉皮編號」—— 前輩二十年前就這樣做了。
- 本站沒驗的 沒有實機確認轉播畫面、 沒測過既有王朝存檔、名字長度上限不是從遊戲程式量出來的 (22 守的是單一欄位,而九份名冊裡單一欄位最長只有 15)。
這支腳本在做什麼
不想讀程式碼也沒關係,這一段把它的流程講完。
全遊戲的球員名冊是一個叫 attrib.dat 的純文字檔,
放在遊戲資料夾的 data\database\ 底下。第一行是表頭,
46 格「欄號 空白 欄位名」;之後一位球員一行,
第一格是這一列的識別碼(9 位十六進位字串,像
0f58f3c1b,這一格沒有欄號),第一格之後才是
46 個「欄號 空白 值」的格子,全部用逗號隔開,行尾再一個 ,;:
0f58f3c1b,0 Default,1 Default,2 51,3 0,...,45 11689,;
└───┬───┘ └───┬──┘
列的識別碼 第 0 欄
這個形狀本站在三份名冊上量過:測試機那份 attrib.dat
3,247 位球員、剛安裝好的原版英文版與中文版各 2,921 位。三份都是
「識別碼 + 46 格 + ,;」,表頭的欄號都是 0 到 45。
所以「跑壘速度」不是固定在第幾格 —— 得先從第一行的表頭查出它的欄號,
再到每一行裡找那個欄號的格子。腳本開頭就在做這件事。
(本站測試機那份 attrib.dat 的 playerattrib_speed 排在第 22 欄,
剛安裝好的原版排在第 23 欄,46 個欄位裡有 27 個的編號兩邊不一樣,
欄號會因名冊而異,
所以不要照抄別人寫的欄號。)
第一格那個識別碼,就是 --export 出來的 CSV 裡的 id 欄,
也是批次那道門拿來對回同一位球員的鑰匙。
不要把第一格當成第 0 欄:本站在另一頁上犯過這個錯,
還算出「那份名冊有一半的名字是亂碼」這種假結論,訂正寫在
王建民用的是 Smoltz 的投球姿勢那一頁。
接下來分成兩道門,走的是同一套讀寫與檢查: 雙擊那道一句一句問你(找遊戲 → 問要不要還原 → 找球員 → 問要改什麼 → 印預覽 → 打 y 才寫 → 讀回來複驗); 參數那道把五個欄位倒成 CSV 讓你用 Excel 改, 再整張讀回來一次改幾十個人。兩道門都是先算完、先給你看,確認了才寫。
| 哪一段 | 做什麼 | 為什麼要有它 |
|---|---|---|
guess_gamedirs() |
猜遊戲裝在哪。先看環境變數,再看腳本自己旁邊, 最後才是各平台的常見安裝位置。 | 判準是「這底下有沒有
data/database/attrib.dat」,不是資料夾叫什麼名字 ——
大家都會把遊戲資料夾改名。裡面刻意不放任何寫死的絕對路徑:
放了會逃到真實資料上,也會把私人路徑一起公開出去。 |
read_roster() |
讀名冊,回傳原始位元組、每一行、以及「欄名 → 欄號」的對照表。 | 用 latin-1 讀寫,因為它是一個位元組換一個字元的雙向對應, 搬運過程一定原封不動。換行字元也是認到什麼就用什麼寫回去 (這個檔是 CRLF),不讓 Python 自己決定。 |
parse_row() |
把一行拆成 {欄號: 值},只拿來看,不拿來寫回去。 | 讀跟寫刻意分開:讀的時候可以隨便解析, 寫的時候絕不重組整行。 重組整行等於把沒要改的 45 格也重寫一次。 |
set_field() |
把某一行裡「欄號 值」那一格換掉,連前面的對齊空白都照留。 | 不能用整段取代 —— 像「2 0」這種字串在一行裡會出現在很多地方。 所以逐格走,而且命中數必須剛好一格: 找到 0 格或 2 格以上都代表理解錯了,兩種都拒絕寫入。 |
check_name() |
名字的守門:不能空白、「名」與「姓」各最多 22 個字元、 只收英文字母與空白/點/連字號/撇號。 | 中文單獨挑出來講並附一個拼音例子, 因為那是這一課最常見的第一個錯誤,而使用者需要的是解法不是「格式不符」。 用白名單而不是黑名單,因為逗號會直接把那一行切壞。 22 不是從遊戲程式量出來的,而且它守的是單一欄位:本站量過的 九份名冊裡,單一名字欄位最長只有 15 個字元(Martinez-Esteve), 所以這道門比資料寬得多。 |
check_num() |
背號與跑壘速度的守門:必須是純數字,而且在 0 到 99 之間。 | 同時擋掉負號、小數點與空字串。回傳字串而不是數字, 因為要直接填回名冊那一格,順便把「007」正規化成「7」。 |
cmd_export() |
把五個欄位倒成一張 CSV。完全不寫遊戲檔。 | 刻意只倒五欄而不是 46 欄:表越寬,
使用者不小心動到不該動的那一格的機會就越大。
id 那一欄是等一下對回同一位球員的唯一憑據。
「不寫遊戲檔」現在有程式擋著,不只是一句承諾:輸出檔名不能是名冊本身,
也不能是任何已經存在而且結尾不是 .csv 的檔
(.playerbak 也在這一條裡)。
本站實測過修補之前的樣子:把輸出檔名指到名冊本身,
840,643 位元組的名冊會被蓋成一張 98,784 位元組的 CSV,還印四行成功訊息 ——
而這條路上沒有備份可以救。 |
cmd_import() |
讀 CSV,逐格算出「要改什麼」,全部通過才寫。
沒加 --apply 就只印預覽。 |
順序本身就是安全網:全部算完、全部合格, 才開始動檔案。一張二十幾個位置的表如果邊驗邊寫, 寫到第十五個才發現第十六格打錯,名冊就停在改了一半的狀態。 |
atomic_backup() |
備份。先寫成一個隨機名字的暫存檔,
整份到齊、讀回來逐位元組比對過,才改名成 .playerbak。 |
上層是用「.playerbak 在不在」決定要不要備份的。
留下一個半截卻叫得出名字的備份,下次還原就會拿它蓋掉正本。
名字非隨機不可:暫存檔以前叫 attrib.dat.playerbak.part,是猜得到的 ——
誰先在名冊旁邊放一個同名的符號連結指到資料夾外的檔,
複製就會穿過那個連結去改外面那個檔。本站在複本上重現過:
事先放一個 21 位元組的連結,跑一次 --import … --apply,
外面那個檔就變成 840,643 位元組的名冊內容,畫面上還印著「都寫好了」。
現在暫存檔名由系統現給、獨佔開檔,先佔位的人佔不到;
而且備份檔本身是符號連結就直接拒絕寫
(看的是連結本身,所以連指到不存在的東西那種斷掉的連結也擋得下來)。 |
atomic_write() |
真正把位元組放進檔案的那一條,寫入與還原兩個方向共用:
先看目的檔是不是符號連結(是就直接拒絕,不看它指到哪裡),
再寫一個隨機名字的暫存檔、fsync、套回原檔的權限,
換名之前先把暫存檔讀回來逐位元組比對,全部過了才換名。 |
直接覆蓋是「開檔就把正本截成 0 再往裡面倒」, 中途斷電就停在半截。走這一條,結果只會是舊的完好或新的完整。 權限也要一起搬:暫存檔是新建的,不套回原檔的權限, 寫完那個檔的權限就跟你動手之前不一樣了。 |
_restore_check() |
還原前的五道把關,分成兩組。 只看備份自己的三道:不是 0 位元組、表頭要解得開、 每一列的欄數一致而且表頭剛好比資料列少一格。 拿現在那份名冊當尺的兩道:不得小於正本的一半、列數要跟正本一樣 —— 正本自己壞到讀不出來的時候,這兩道整組跳過, 因為那正是最需要還原的時刻。 | 截斷發生在檔案後面,而表頭在最前面 ——
所以只看表頭的話,半截備份照樣通行。本站在測試機那份
attrib.dat(840,643 位元組、3,247 位球員)的複本上實測:
把只有 105,080 位元組的半截備份丟給 --restore,它擋下來了;
同一份備份走雙擊那道門打 r 的還原,名冊當場變成 105,080 位元組,
畫面上還印著成功。
⚠️ 2026-09-05 訂正:上面那個洞已經補掉。 打 r 那條路現在走的是同一個 _restore_check(),
兩條路都擋得下來;那次實測留在這裡當史料。
誠實的代價:第五道「列數要跟正本一樣」會擋掉 「備份之後換了一份球員人數不同的名冊模組、再想還原舊 .playerbak」
這種情形。那時請自己在檔案總管/Finder 裡把 .playerbak
複製成 attrib.dat。 |
main() |
雙擊時走的那七步,從找遊戲一路問到複驗。 | 改球員這條路在第五步之前一個位元組都沒有寫過,
而且只有打 y 算同意,直接按 Enter 也是取消 ——
對不打指令的人來說,「不小心按到就改掉了」是最需要防的事。
⚠️ 例外是第二步那條還原分支:打 r 再按 Enter,
它會把備份寫回名冊就結束,第三步以後不會跑到。
蓋回去的是你第一次改之前那份名冊,正常情況下不會弄丟東西。
它確實是一次寫入,但跟 --restore 走同一個
_restore_check()(五道把關、先寫暫存檔再換名、
寫完逐位元組比對),備份是壞的就會被擋下來。
(2026-09-05 訂正:這裡原本寫「沒有走上面那格的 _restore_check()」,
那是修補之前的狀況。) |
run_cli() |
解析參數。沒給模式旗標就回報「這不是批次模式」, 讓程式掉回雙擊那道門。 | 兩道門的分岔就在這裡。手寫解析而不用現成的參數工具, 是因為那種工具在參數不對時會自己印一段英文用法就結束, 對這一課的讀者是反效果。 |
安全網一共六層:預覽在前、第一次寫入自動備份(而且已經有備份就保留最早那一份)、
整張表錯一格就全部擋下、
先寫一個隨機名字的暫存檔再換名(名字猜不到,也不會跟著任何符號連結走;
名冊或備份本身是符號連結就直接拒絕寫)、
換名之前先把暫存檔讀回來逐位元組比對、換完再讀回來逐項複驗,
還原有把關(--restore 與互動模式裡打 r 走同一組五道把關)。
做不到的事同樣要講清楚:不能加人(只能接收)、
存不了中文(只能用英文拼音)、改不了球隊、
其他能力欄位一律不開。既有的王朝存檔會不會吃到這次修改,本站沒有測過。
🛑 中途按 Ctrl + C 離開的話,它不會寫死一句「什麼都沒有動到」。
還沒確認之前中斷,它說的就是「什麼都沒有動到」;確認之後才中斷,
它會說「名冊可能已經寫進去了」,把那個檔的路徑印給你,
再告訴你兩條還原的路(再雙擊一次打 r,或是打 --restore)。
還有第三句,留給「換名跟登記中間那一瞬間」:那時它說的是
「正在替換名冊,換好了沒有無法確定」,一樣把還原的路給你。
這三種情形都以結束碼 130 收尾(中斷的慣例),不是 0 ——
0 的意思是「做完了而且成功」,中斷不是那件事。
旗標刻意設在換名之前:寧可多說一句「可以還原」,也不要漏說
(多說的代價是你白跑一次還原,而還原自己有五道把關)。
不是你按的、是這支工具自己出狀況那一種(防毒軟體剛好鎖住名冊之類),
走的是同樣這三句話,只是開頭不會說「你中斷了」,結束碼是 1,
後面還會多一段英文 —— 那段是給班主任回報用的,你不用看懂它。
下面那份原始碼裡有兩個名字看起來像開關,其實不是:APP 與
EDITABLE。整支腳本沒有任何地方讀到它們(只有定義那一行,
沒有一行去用),EDITABLE 只是上面
這一課到哪裡為止那張表的摘要。
真正決定可以改哪幾欄的是兩個地方:批次匯入的 cmd_import() 裡那一組四元組,
以及互動模式 main() 裡的四個問句。想增減欄位要改的是那兩處。
這種「留著但用不到」的東西本站一律標出來,同樣的標註見
球場超過 10MB 就當機那一課的共用區塊。
展開 / 收合完整原始碼
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# ─────────────────────────────────────────────────────────
# 法律與免責(每一支本站腳本都帶著這一段)
#
# · 本工具與 Electronic Arts 無任何官方關聯,也未經其授權或背書。
# MVP Baseball 2005 為 Electronic Arts 之作品與商標。
# · 本工具為原創程式碼,**不含任何 EA 的程式碼或資產**。
# · 本工具不提供、不教學、也不包含任何規避技術保護措施的功能。
# · 使用者應僅對自己合法取得的遊戲副本使用本工具,並自行承擔風險。
# 使用前請自行確認你與遊戲發行商之間的使用者授權合約(EULA)。
# · 本工具按「現狀」提供,不附任何明示或默示的擔保。
# · 授權:MIT(見檔尾)。教學文字另採 CC BY 4.0。
# · 回報與下架:https://toniliumvp.github.io/MVPBaseball/report.html
# 三條管道,其中「直接向 GitHub 提出」不需經過維護者;
# 留言區那條不需要任何帳號。管道有變動只會改那一頁。
# ─────────────────────────────────────────────────────────
"""
mvp_player.py —— 把遊戲裡的一位球員改成你想要的球員。
這一支有**兩道門**:
雙擊(不給參數) → 一句一句問你,為了不會用電腦的人寫的
給參數 → 匯出 / 匯入 CSV,一次改幾十個人,為了做整包模組的人寫的
python3 mvp_player.py "<遊戲資料夾>" --export players.csv
(用 Excel 或記事本改那張表)
python3 mvp_player.py "<遊戲資料夾>" --import players.csv ← 預覽
python3 mvp_player.py "<遊戲資料夾>" --import players.csv --apply ← 真的寫
python3 mvp_player.py "<遊戲資料夾>" --restore
python3 mvp_player.py --selftest ← 自我測試(自己造一份名冊,不碰任何遊戲檔)
需要 Python 3.7 以上(本檔用到 str.isascii 與 sys.stdout.reconfigure,
兩個都是 3.7 才有的)。python.org 現在下載到的一定夠新;會不夠的情況是
「這台電腦本來就有一個很舊的 python」,那就把指令裡的 python3 換成新的那一支。
它能改三件事(而且只有這三件):
名字(英文拼音)· 背號(0-99)· 跑壘速度(0-99)
為什麼只有三件,寫在頁面上,也寫在下面的註解裡。
輸入是什麼、輸出是什麼
---------------------
輸入:**遊戲資料夾**的路徑(不是名冊檔本身)。腳本自己去接
`data/database/attrib.dat` —— 那個檔就是全遊戲的球員名冊,
一位球員一行:第一格是**這一列的識別碼**(9 位十六進位字串,沒有欄號),
第一格之後才是 46 個「欄號 空白 值」的格子,全部用逗號隔開,
行尾再一個「,;」。整個檔是純文字。
不給路徑時會自己猜(見 `guess_gamedirs`),猜不到就請你把資料夾拖進視窗。
批次模式另外吃一張 CSV。
輸出:
· 雙擊(不給參數) 螢幕上一問一答;確認之後改寫 `attrib.dat`,
旁邊留一份 `attrib.dat.playerbak`
· --export 檔名.csv 匯出一張五欄的 CSV(id / first_name / last_name / jersey / speed),
**完全不動遊戲檔** —— 輸出檔名指到名冊本身、或指到任何
已經存在而結尾不是 .csv 的檔,都會被擋下來;
**輸出檔名是符號連結也會被擋下來**,而且它跟名冊走同一條
原子寫入(先寫隨機名字的暫存檔再換名),不是直接開檔覆蓋
· --import 檔名.csv 預覽這張表會改動什麼,**完全不動遊戲檔**
· --import … --apply 才真的寫回名冊
· --restore 拿 `.playerbak` 蓋回名冊
安全網在哪
----------
1. **預覽在前、確認才寫。** 互動模式要打 y,批次模式要加 --apply。沒有「一鍵直接改」。
2. **第一次寫入前自動備份**,而且**已經有備份就保留最早那一份** ——
第二次執行不會把「動手前的名冊」蓋成「已經改過的名冊」。
備份用 `atomic_backup`:先寫一個**隨機名字**的暫存檔再換名,
中斷不會留下半截卻叫得出名字的備份。
3. **整張表一起驗,錯一格就整張擋下。** 批次模式的任何一格不合格就
一個位元組都不寫(見 `cmd_import` 的 errors),不會出現「改了一半」的名冊。
4. **先寫暫存檔再換名**,fsync、套回原檔權限、**換名之前先把暫存檔讀回來逐位元組比對**,
全部過了才換名。寫入、還原、匯出 CSV **三個方向都走同一條**(`_atomic_put`)——
中途斷電只會是「舊的完好」或「新的完整」,不會留下半截的名冊。
暫存檔的名字是 `tempfile.mkstemp` 給的**隨機名字**,不是猜得到的 `.tmp` / `.part`;
**任何要寫的目的檔**(名冊、備份、匯出的 CSV)本身是**符號連結**的話直接拒絕寫。
前兩件是 2026-09-05 補的,匯出那一條是 2026-09-06 補的,
為什麼補寫在 `atomic_backup` 上面那一段。
5. **寫完讀回來逐項複驗**,對不上就叫你還原,而且結束碼不會是 0。
中途按 Ctrl-C 也會照實說:**檔案動了沒有是看三態登記,不是寫死一句「什麼都沒有動到」**。
「換名 + 登記」是**不可中斷的一段**(`_NoInterrupt`):那期間按 Ctrl-C 會先被記下來,
等登記完才丟出去,所以收尾講的話一定跟磁碟上的狀態一致。中斷一律以結束碼 130 收尾。
**沒有人預料到的錯**(防毒軟體鎖住檔案、外接碟被拔掉)也走同一套三態:
先用中文說名冊到底動了沒有、印還原的路,再把英文那一段留給回報用,結束碼 1 ——
不會只丟一片英文 traceback 就結束(見 `_cli_entry`,2026-09-11 補)。
6. **兩條還原路徑走同一組把關**(`_restore_check`)——「--restore」與互動模式
「打 r 還原」都要先過這五道才會覆蓋。編號跟程式裡的註解一致,
所以下面**不是照號碼順序**列的 —— 先列只看備份自己的三道:
(1) 備份不是 0 bytes
(3) 表頭讀得懂(擋的是「指錯檔」)
(4) 每一列的欄數都一樣,而且表頭剛好比資料列少一格
(本站量過的十一份名冊都是表頭 47 格、資料列 48 格)
以及兩道**拿現在那份名冊當尺**的(名冊自己壞到讀不出來時整組跳過,
因為那正是最需要還原的時刻):
(2) 備份不小於正本的一半(粗篩)
(5) 列數跟正本一樣(這支工具是原地改,列數不會變)
過了才寫,寫也是先寫暫存檔再換名,寫完把整份讀回來**逐位元組**比對。
⚠️ 第 (5) 道的副作用要知道:你如果在備份之後換了一份**球員人數不同**的
名冊模組,再來還原會被擋下 —— 那時請自己在檔案總管/Finder 裡
把 `.playerbak` 複製成 `attrib.dat`。
做不到的事
----------
· **不能加人。** 遊戲的名冊是固定筆數,這一課的做法是「接收」——
挑一個你不在乎的球員,把他改成你要的那個人。
· **存不了中文。** 名冊 3,247 位球員的名字用到 56 種字元,非 ASCII 是 0 種。
打中文會被 `check_name` 擋下來,而且檔案不會被動到。
· **改不了球隊。** 46 個欄位裡沒有球隊,換隊要在遊戲裡做。
· **其他能力欄位一律不開。** 理由見下面「為什麼只開這三欄」那一段:
它們根本不是 0-99 的刻度,本站沒有解開它們。
· **不碰王朝存檔。** 只動 `attrib.dat` 這一個檔。已經開始的王朝會不會吃到這次修改,
本站沒有測過。
法律與免責
本教學與本腳本與 Electronic Arts 無任何官方關聯,不含 EA 的程式碼或資產,
也不含任何規避技術保護措施的功能。僅供你對**自己合法取得的副本**使用,
風險自負。本軟體按現狀提供,不附任何擔保。
回報與下架:https://toniliumvp.github.io/MVPBaseball/report.html
—— toni的MVP模組補習班
"""
import csv
import io
import os
import re
import shutil
import signal
import sys
import tempfile
import traceback
# ── 畫面編碼的保險(2026-09-05 補)────────────────────────────
# Windows 繁體中文版把畫面**導向檔案或管線**時(`… > log.txt`),
# sys.stdout 的編碼會是 cp950,而本檔的提示文字裡有 ✅ ⚠️ 🔴 ——
# cp950 編不出這幾個字,print 到那一句就丟 UnicodeEncodeError 直接中止。
# 實測 `--export … > log.txt` 會踩到;`--import … --apply` 更麻煩:
# 檔案其實已經寫好、複驗也過了,卻炸在最後那句「✅ 都寫好了」,
# 使用者會以為失敗而跑去還原。
# 這裡**不改編碼**(改了中文會變亂碼),只把「編不出來的字元」換成 ?,
# 訊息照樣看得懂,而且不會中止。回報頁請人附畫面內容,靠的就是這一段。
for _stream in (sys.stdout, sys.stderr):
try:
if _stream is not None and getattr(_stream, 'encoding', None):
'✅⚠🔴'.encode(_stream.encoding)
except (UnicodeEncodeError, LookupError):
try:
_stream.reconfigure(errors='replace') # Python 3.7+
except (AttributeError, ValueError, OSError):
pass
# 這個常數**整支腳本沒有任何地方讀到**:只有這一行寫,沒有一行用。
# 留著是當自我說明:這支腳本自己叫什麼名字。改這一行不會有任何效果,
# 因為檔名在文字裡是逐字寫的(檔頭用法那幾行,加上 run_cli() 印給人看的那一句,
# 本檔另有 6 處),真要換檔名要改的是那 6 處。
APP = 'mvp_player.py'
# 備份的副檔名。本站每一課用不同的字尾,是為了讓「哪一支工具改過這個檔」一眼看得出來,
# 也讓打包工具(pack-a-mod 那一課)可以靠字尾認出你改過哪些檔。
BAK_SUFFIX = '.playerbak'
# ── 為什麼只開這三欄 ────────────────────────────────────────
# 2026-08-29 在本站測試機那份 data/database/attrib.dat 上量過,那個檔有 3,247 位球員。
# 這五欄在那一份名冊裡的實際範圍是:
# speed 0 ~ 97 ← 只有這一欄是 0-99 的刻度
# fielding 0 ~ 50
# range 0 ~ 13
# throwstrength 0 ~ 15
# starpower 0 ~ 4
# 上面那幾個數字跟著名冊走,不是遊戲的規格:剛安裝好的原版(英文版與中文版都量過)
# 有 2,921 位球員,同樣這五欄量到 speed 39 ~ 99、fielding 2 ~ 15、range 2 ~ 15、
# throwstrength 2 ~ 15、starpower 0 ~ 4。
# 其他「能力」欄位**根本不是 0-99 的評分**,刻度各自不同,本站沒有解開它們。
# 讓人往「守備」填 99(實際上限 50)不是危險,是沒有意義 —— 所以不開。
#
# 名字與背號是純顯示欄位,改了不影響任何衍生計算,所以開。
# 球隊不在這個檔裡(46 欄裡沒有球隊),換隊在遊戲裡做就好。
#
# ⚠️ 下面這個 tuple 是上面那段說明的摘要,**不是開關**。
# 跟上面的 APP 一樣,整支腳本沒有任何地方讀到它:只有這一行寫,沒有一行用。
# 真正決定開哪幾欄的是兩個地方:批次匯入 cmd_import() 裡那一組四元組
# (('first_name', fn, 'name') 開頭的那一行),以及互動模式 main() 裡的四個問句。
# 要增減欄位得改那兩處,改這一行不會有任何效果。
# (同一種標註見「球場超過 10MB 就當機」那一課:那支腳本裡用不到的
# QFS 解壓縮與 FSH 圖片容器區塊,程式碼與頁面上都標著這一課沒有用到。)
EDITABLE = ('first_name', 'last_name', 'playerattrib_jerseynum', 'playerattrib_speed')
class Stop(Exception):
"""帶著一句人話中止。
整支腳本只用這一種例外來表達「停下來,而且我知道為什麼」。
檔尾統一接住它,印成一句中文再等使用者按 Enter ——
這一課的讀者不是工程師,丟一整片 traceback 出去只會嚇到人,
而每一個 Stop 的訊息都已經寫清楚下一步該怎麼辦。
"""
# ── 找遊戲資料夾 ────────────────────────────────────────────
def guess_gamedirs():
"""猜遊戲在哪。先看腳本自己旁邊,再看各平台的常見位置。
⚠️ 這裡**不可以**放任何寫死的絕對路徑。
2026-08-29 第一版放了一條開發用的路徑當方便,結果:
(一)測試跑起來直接改到真的名冊(還好工具自己的備份是好的)
(二)那是班主任的私人路徑,而這支腳本會公開 —— 隱私掃描會抓到
要指定目標請用環境變數 MVP_GAMEDIR,不要寫進程式碼。
"""
# 環境變數最優先,而且**指定了就只認它**(不會再往下猜)。
# 測試就是靠這一條把目標鎖在暫時的複本上,不會誤觸真正的遊戲。
env = os.environ.get('MVP_GAMEDIR')
if env:
return [os.path.realpath(env)] if os.path.isfile(
os.path.join(env, 'data', 'database', 'attrib.dat')) else []
# 接著找腳本自己的附近。使用者多半是把工具丟進遊戲資料夾再雙擊,
# 所以「腳本所在的資料夾」與它的上一層、上上層都要看。
# sys.argv[0] 而不是 __file__:雙擊的情境下這個比較準。
here = os.path.dirname(os.path.abspath(sys.argv[0]))
cwd = os.getcwd()
cands = [cwd, here, os.path.dirname(here), os.path.dirname(os.path.dirname(here))]
# 最後才是各平台的常見安裝位置。這些是**通用**路徑,不是誰的私人路徑。
home = os.path.expanduser('~')
cands += [
r'C:\Program Files (x86)\EA SPORTS\MVP Baseball 2005',
r'C:\Program Files\EA SPORTS\MVP Baseball 2005',
r'C:\Games\MVP Baseball 2005',
os.path.join(home, 'MVP Baseball 2005'),
os.path.join(home, 'Desktop', 'MVP Baseball 2005'),
]
out = []
for c in cands:
try:
# 判準是「這底下有沒有 data/database/attrib.dat」,不是「資料夾叫什麼名字」——
# 使用者常常把遊戲資料夾改名,認名字會認不到。
if c and os.path.isfile(os.path.join(c, 'data', 'database', 'attrib.dat')):
# realpath 把符號連結與 ../ 攤平,免得同一份遊戲因為兩條不同的路徑
# 被列成兩個選項,害使用者以為裝了兩套。
rc = os.path.realpath(c)
if rc not in out:
out.append(rc)
except OSError:
pass
return out
# ── 讀寫名冊 ────────────────────────────────────────────────
# ⚠️ 這個檔是 CRLF(實測 3,248 個)。整份讀成 bytes、只動要動的那幾個位元組,
# 存回去也是 bytes —— 絕對不要用文字模式讀寫,那會把換行改掉。
def read_roster(path):
"""讀名冊,回傳 (原始位元組, 文字, 換行字元, 每一行, 表頭對照表)。
名冊長這樣:第一行是表頭,46 格「欄號 空白 欄位名」;之後一位球員一行,
**第一格是這一列的識別碼**(9 位十六進位字串,像 `0f58f3c1b`,這一格沒有欄號),
第一格之後才是 46 個「欄號 空白 值」的格子,全部用逗號隔開,行尾再一個「,;」。
這個形狀本站在三份名冊上量過:測試機那份 attrib.dat 3,247 位球員、
剛安裝好的原版英文版與中文版各 2,921 位。三份都是「識別碼 + 46 格 + ,;」,
表頭的欄號都是 0 到 45,識別碼都是 9 個十六進位字元。
所以「跑壘速度」不是固定在第幾格,而是要先從表頭查出它的**欄號**,
再去每一行裡找那個欄號 —— 這也是 `set_field` 要逐格比對的原因。
`hdr` 就是那張對照表:{'playerattrib_speed': 22, ...} 這種形狀。
22 是在本站測試機那份 attrib.dat(上面量到 3,247 位球員的那一份)量到的,
**不要把欄號記起來**:剛安裝好的原版(英文版與中文版都量過)是第 23 欄,
而第 33 欄在本站測試機那份其實是 playerattrib_elbowguard(護肘)。
兩份名冊 46 個欄位裡有 27 個的編號不一樣,對照表見
https://toniliumvp.github.io/MVPBaseball/reference/player-fields.html
所以底下一律照欄位「名字」查,不寫死數字。
回傳裡把 raw / txt / nl / lines 全部一起帶出來,是因為寫回去時
要用**原來那個換行字元**重組,不能讓 Python 自己決定。
"""
# 用 bytes 讀進來再以 latin-1 解碼:latin-1 是「一個位元組換一個字元」的雙向對應,
# 任何位元組都不會丟例外,而且 encode 回去必定一模一樣。
# 這裡要的不是「看懂編碼」,是「原封不動地搬運」。
raw = open(path, 'rb').read()
txt = raw.decode('latin-1')
# 換行字元自己認,認到什麼就用什麼寫回去。實測這個檔是 CRLF(3,248 行)。
nl = '\r\n' if '\r\n' in txt else '\n'
lines = txt.split(nl)
# 從表頭建欄名 → 欄號的對照表。rstrip(';,') 是為了吃掉行尾多餘的分隔符號。
hdr = {}
for cell in lines[0].rstrip(';,').split(','):
a = cell.strip().split(None, 1)
if len(a) == 2 and a[0].isdigit():
hdr[a[1]] = int(a[0])
# 表頭裡連名字欄都找不到,代表指到的根本不是名冊(或它被別的工具改壞了)。
# 這種情況要在任何寫入動作之前就停住。
if 'first_name' not in hdr or 'last_name' not in hdr:
raise Stop('這個 attrib.dat 的表頭看不懂 —— 第一行找不到 first_name / last_name。\n'
' 你指到的可能不是名冊,或者它被別的工具改壞了。')
return raw, txt, nl, lines, hdr
def parse_row(line):
"""回傳 {欄號: 值}。空值是 '' 不是 None。
只是把一行拆開來看,**不會拿來寫回去** —— 寫回去走 `set_field`,
它是在原本那一行上動最少的位元組。這樣分開的用意是:
讀的時候可以隨便解析,寫的時候絕不重組整行。
空值統一成 '' 而不是 None,是為了讓下面「新值跟舊值一不一樣」
那種比較不必到處判斷 None。
⚠️ 每一行的**第一格是這一列的識別碼,它沒有欄號**,這裡是靠
「這一格不是純數字」把它濾掉的。這句話有例外:識別碼是十六進位字串,
偶爾整串剛好都是數字(本站測試機那份 3,247 位裡有 10 位,剛安裝好的
原版英文版與中文版 2,921 位裡各有 59 位),那一格就會被當成欄號收進這張表。
實測這三份名冊裡一個都沒撞到真的欄位:那些整串是數字的識別碼換算成十進位,
最小的是 50041,而真正的欄號只到 45。就算哪天真的撞到也不會讀錯,
因為識別碼排在最前面,真正那一格接在後面會把它蓋掉;而 `set_field`
會數到 2 個命中,直接 raise Stop 不寫。
但要拿識別碼請走 `line.split(',', 1)[0]`(`cmd_export` 就是這樣拿的),
不要從這張表裡撈。
"""
d = {}
for cell in line.split(','):
# split(None, 1) 依任意空白切成最多兩段:欄號、值。
# 值本身含空白時(名字就會)不會被切壞。
a = cell.strip().split(None, 1)
if a and a[0].isdigit():
d[int(a[0])] = a[1] if len(a) > 1 else ''
return d
def set_field(line, idx, value):
"""把某一行裡「<欄號> <值>」那一格換成新值,其他位元組一個都不動。
⚠️ 不可以用 str.replace —— 「2 0」在一行裡會出現在很多地方。
要逐格走,只換欄號相符的那一格。
"""
cells = line.split(',')
hit = 0
for i, cell in enumerate(cells):
stripped = cell.strip()
a = stripped.split(None, 1)
if a and a[0].isdigit() and int(a[0]) == idx:
# 把這一格原本的前導空白留著再接回去。名冊是對齊排版的,
# 空白吃掉不會壞掉,但檔案會跟原版長得不一樣,之後想比對就麻煩了。
lead = cell[:len(cell) - len(cell.lstrip())]
cells[i] = '%s%d %s' % (lead, idx, value)
hit += 1
# 命中數必須**剛好 1**。0 表示這一行沒有這一欄(行壞了或指錯行),
# 2 以上表示我對這個格式的理解是錯的 —— 兩種都不可以繼續寫。
if hit != 1:
raise Stop('找不到第 %d 欄(或找到 %d 個),不敢動。' % (idx, hit))
return ','.join(cells)
def find_players(lines, hdr, query):
"""用名字的一部分找球員,回傳 [(行號, 全名, 那一行的欄位)]。
行號是**在 lines 裡的索引**,不是球員 id —— 之後要改哪一行就靠它,
所以 enumerate 從 1 開始(第 0 行是表頭)。這個對應關係一旦錯掉,
就會改到別人身上,是這支腳本最需要小心的一個數字。
比對一律轉小寫,讓使用者不必管大小寫。
"""
fn, ln = hdr['first_name'], hdr['last_name']
q = query.strip().lower()
out = []
for i, line in enumerate(lines[1:], start=1):
if not line.strip():
continue
d = parse_row(line)
name = ('%s %s' % (d.get(fn, ''), d.get(ln, ''))).strip()
# 沒有名字的行(檔尾空白行、或格式不同的行)直接跳過,不要讓它進選單。
if not name:
continue
if q in name.lower():
out.append((i, name, d))
return out
# ── 輸入檢查 ────────────────────────────────────────────────
# 這一小張表**不是**拼音系統,只是二十個最常見的姓,用來在使用者打了中文時
# 舉一個他自己的例子(「你打的『陳』,拼音是 Chen」)。
# 舉得出例子的錯誤訊息,比只說「不可以」有用得多。
ROMAN = {
'陳': 'Chen', '林': 'Lin', '王': 'Wang', '張': 'Chang', '李': 'Lee',
'黃': 'Huang', '吳': 'Wu', '劉': 'Liu', '蔡': 'Tsai', '楊': 'Yang',
'許': 'Hsu', '鄭': 'Cheng', '謝': 'Hsieh', '郭': 'Kuo', '洪': 'Hung',
'曾': 'Tseng', '廖': 'Liao', '賴': 'Lai', '周': 'Chou', '徐': 'Hsu',
}
def check_name(s):
"""名字只能是英文字母、空白、點、連字號、撇號。
2026-08-29 實測:名冊 3,247 位球員的名字用到 56 種字元,**非 ASCII 0 種**。
也就是說這個檔存不了中文 —— 打中文進去不是「顯示成問號」,是把檔案弄壞。
"""
s = s.strip()
if not s:
raise Stop('名字不能空白。')
# 這道門是**分別**套在「名」與「姓」上的(cmd_import 的欄位迴圈與互動模式
# 都是名、姓各過一次),所以 22 守的是**單一欄位**,不是全名。
# 而 22 對不上任何一份名冊的單一欄位:本站量過手上九份相異的 attrib.dat,
# 單一欄位最長只有 15 個字元(tools/Taiwan_20091110 那份的 Martinez-Esteve);
# 本站測試機那份 MVP2026/data 是 12(Chisholm Jr.),
# 剛安裝好的原版英文版那份是 14(Van Benschoten)。
# 22 剛好等於「名接姓」的最長字數(九份都不超過 22;MVP2026/data 那份是
# Juan Daniel 加 Encarnacion,11 加 11),但那只是數字對得上,
# 本站沒有證據說當初就是這樣訂的。
# 留在 22 不收緊:門開得比資料寬只是少擋人,收到 15 以下才會開始擋掉
# 名冊裡本來就有的名字。遊戲程式真正的上限本站沒有反組譯去確認。
if len(s) > 22:
raise Stop('名字太長了(最多 22 個字元,你打了 %d 個)。\n'
' 這是「名」或「姓」單獨一格的上限,不是全名的上限。\n'
' 本站量過的九份名冊裡,單一欄位最長是 15 個字元。' % len(s))
# 中文要**單獨挑出來講**,不能跟下面那條「只能用英文字母」合併 ——
# 打中文的人需要的是「這個檔存不了中文,你要拼音」加一個例子,
# 不是「格式不符」。這是這一課最常見的第一個錯誤。
bad = [c for c in s if not c.isascii()]
if bad:
hint = ''
for c in s:
if c in ROMAN:
hint = '\n 例如「%s」的拼音是 %s。' % (c, ROMAN[c])
break
raise Stop('名冊裡**存不了中文**,只能用英文拼音。\n'
' 你打的「%s」裡有 %d 個中文字。%s\n'
' 陳金鋒在遊戲裡就叫 Chin-Feng Chen,曹錦輝是 Chin-Hui Tsao ——\n'
' 照那個樣子拼就對了。' % (s, len(bad), hint))
# 白名單而不是黑名單。這五類字元是名冊實際用到的(Chin-Feng、O'Brien、Jr. 這些),
# 其餘一律擋掉 —— 尤其**逗號會直接把那一行切壞**,而換行會多切出一位球員。
if not re.fullmatch(r"[A-Za-z .'\-]+", s):
raise Stop('名字只能用英文字母、空白、點、連字號、撇號。\n'
' 你打的是「%s」。' % s)
return s
def check_num(s, lo, hi, what):
"""數字欄位的守門:必須是純數字,而且在範圍內。回傳正規化後的字串。
回傳字串而不是整數,是因為要直接填回名冊那一格;
先 int 再 str 順便把「007」正規化成「7」。
"""
s = s.strip()
# 只認 ASCII 的 0-9,**不用 str.isdigit()**:
# · 「²」這種上標 isdigit 回 True,而 int() 吃不下 —— 會丟一個
# 沒有人接得住的 ValueError(不是 Stop),互動模式的人看到英文 traceback,
# 批次模式則整支中斷(cmd_import 只接得住 Stop)。
# · 全形「5」與阿拉伯-印度數字「٣」是 isdigit 與 int() 都吃得下的,
# 會被悄悄正規化成 5 / 3 才寫進名冊。
# 一律要求 ASCII 數字,順便擋掉負號、小數點與空字串 ——
# 名冊這幾欄本來就沒有負數與小數。
if not re.fullmatch(r'[0-9]+', s):
raise Stop('%s要填數字,你打的是「%s」。' % (what, s))
n = int(s)
if not (lo <= n <= hi):
raise Stop('%s要在 %d 到 %d 之間,你打的是 %d。' % (what, lo, hi, n))
return str(n)
# ── 備份與原子寫入 ──────────────────────────────────────────
# 2026-09-05 第二輪:這一整段被重寫過一次,起因是一個真的被重現出來的洞。
#
# 舊版的暫存檔名是**猜得到的**:備份寫成 `attrib.dat.playerbak.part`、
# 寫入寫成 `attrib.dat.tmp`。誰先在名冊旁邊放一個同名的**符號連結**指到別的檔,
# `shutil.copy2()` / `open(..., 'wb')` 就會**跟著連結**把那個檔截斷再倒資料進去。
# 後面的 `os.replace()` 換掉的只是連結本身,但外面那個檔在那之前就毀了。
# 本站實測(2026-09-05,在複本上):事先放一個 21 bytes 的
# `attrib.dat.playerbak.part` 連結指到資料夾外的檔,跑一次 `--import … --apply`,
# 外面那個檔變成 840,643 bytes 的名冊內容,而畫面上印的是「✅ 都寫好了」。
#
# 三個改法,底下三個函式各守一段:
# · 暫存檔一律 `tempfile.mkstemp(dir=目的資料夾)` —— 名字是隨機的,
# 而且它用 O_CREAT|O_EXCL 開檔,先佔位的人佔不到,也不會跟著任何連結。
# · 目的檔與備份檔寫之前先看 `os.path.islink` —— 是連結就拒絕。
# ⚠️ 不可以用 `os.path.exists()` 判斷:連結指到的東西不存在時它回 False,
# 那種「斷掉的連結」正是最好用的餌。要用 `lstat` / `lexists` 這一族。
# · **換名之前**先把暫存檔讀回來逐位元組比對。換名之後才發現不對,正本已經沒了。
#
# 2026-09-06 第三輪再補一件事:**「換名」與「登記換過了」中間不可以有縫。**
# 舊版是「先設旗標、再 os.replace」—— 那是寧可多說一句的權宜寫法,
# 縫還在,只是掉進去的人被多告知一次。現在改成三態 + 不可中斷段,見 `_NoInterrupt`。
class _NoInterrupt(object):
"""把「os.replace + 登記」包成不可中斷的一段。
這段期間收到的 Ctrl-C 先記下來,離開這一段之後再照常丟出 KeyboardInterrupt。
這樣收尾看到的登記一定跟磁碟上的狀態一致,不會出現
「名冊已經換掉了,程式卻還以為沒換」的那一瞬間。
⚠️ `signal.signal` 只有主執行緒裝得上,裝不上就退回原本的行為 ——
不會比以前更糟,而且外面那個 'replacing' 狀態就是留給這種情形的:
真的落在縫裡,收尾會誠實說「換好了沒有無法確定」,不會說「什麼都沒有動到」。
"""
def __enter__(self):
self._pending = False
self._old = None
try:
self._old = signal.signal(signal.SIGINT, self._remember)
except (ValueError, OSError): # 非主執行緒等情況:退回原本行為
self._old = None
return self
def _remember(self, signum, frame):
# 只記著,不做事。真正丟出 KeyboardInterrupt 是 __exit__ 的工作。
self._pending = True
def __exit__(self, exc_type, exc, tb):
if self._old is not None:
try:
signal.signal(signal.SIGINT, self._old)
except (ValueError, OSError):
pass
# 區塊裡本來就有例外的話不要蓋掉它(那個例外比較重要);
# 沒有例外而期間按過 Ctrl-C,現在才照常丟出去。
if self._pending and exc_type is None:
raise KeyboardInterrupt
return False
def _quiet_remove(path):
"""刪掉殘骸,刪不掉也不要蓋掉真正的錯誤。"""
try:
os.remove(path)
except OSError:
pass
def _refuse_symlink(path, what):
"""目的檔是符號連結就拒絕。**不看它指到哪裡,是連結就不寫。**
`os.path.islink` 走的是 lstat,所以「斷掉的連結」(指到不存在的東西)
照樣認得出來 —— 那正是 `os.path.exists()` 會回 False 而漏掉的那一種。
"""
if os.path.islink(path):
raise Stop('%s 是一個符號連結(捷徑),不敢往它寫 —— 那會改到它指到的那個檔。\n'
' 請把 %s 刪掉或改名,再跑一次。'
% (what, os.path.basename(path)))
def _read_bytes(path):
"""整份讀回來。
寫入前後的複驗**都經過這一個口子**,所以 `--selftest` 只要換掉這一個函式,
就能演出「複驗讀到的東西不對」那一種故障,證明「換名之前真的有比對」。
"""
with open(path, 'rb') as f:
return f.read()
# 「正式的替換到底做了沒有」。Ctrl-C 落在寫入之後、複驗之前的時候,
# 檔尾那個 except 就是靠這個登記決定要說「什麼都沒有動到」還是「檔案已經改了」。
# 起手一律「還沒動」;真的動到名冊的只有 atomic_write 那條路。
#
# 2026-09-06 從兩態改成**三態**:
# idle 還沒動過 → 可以說「什麼都沒有動到」
# replacing 正在換(登記在換名之前) → 只能說「換好了沒有無法確定」
# replaced 換過了 → 要說「已經寫進去了」並印還原的路
# 'replacing' 是**保險**:`_NoInterrupt` 裝得上的話(主執行緒都裝得上)
# 幾乎不可能停在這一格,因為那一段期間的 Ctrl-C 會被押到登記完才丟出來。
# `done` 這個鍵留著沒改名,因為整支腳本與頁面上都用它指「名冊換過了沒有」。
APPLIED = {'done': False, 'phase': 'idle', 'path': None}
def _atomic_put(path, data, mode_src=None, mark_applied=False):
"""把 data 整份放進 path:唯一暫存檔 → fsync → 套權限 → **讀回來比對** → 換名。
失敗的每一條路都保證兩件事:暫存檔被刪掉、**正本一個位元組都沒有變**。
正本只在最後那一行 `os.replace` 的瞬間換過去,而那一行在同一個檔案系統上是原子的。
三條路都走這裡:寫名冊、寫備份、匯出 CSV。
`mode_src` 是「權限要照誰的」:備份要照名冊的權限,寫入要照原檔自己的;
匯出的 CSV 第一次寫時沒有原檔可照,那時補成「一般開檔會得到的權限」。
`mark_applied` **只有寫名冊那條路**會給 True,備份與匯出都不給 ——
那兩條寫壞了名冊還是原封不動,不該讓 Ctrl-C 的訊息說「檔案已經改了」。
"""
_refuse_symlink(path, os.path.basename(path))
d = os.path.dirname(os.path.abspath(path)) or '.'
prev = dict(APPLIED) # 確定沒換成時要把三態收回原狀
# mkstemp:名字隨機 + O_CREAT|O_EXCL + 權限 0600。先佔位佔不到,也不跟連結。
# 放在**目的檔同一個資料夾**,os.replace 才會是同一個檔案系統上的原子換名。
try:
fd, tmp = tempfile.mkstemp(dir=d, prefix='.' + os.path.basename(path) + '.tmp-')
except OSError as e:
# 資料夾唯讀、沒有權限、磁碟滿了都會落在這裡。**這時候一個位元組都還沒動過**,
# 所以要講的是「什麼都沒發生」加「去看哪裡」,不是丟一片英文 traceback。
raise Stop('在這個資料夾裡建不出暫存檔:%s\n'
' %s\n'
' %s 一個位元組都沒有被動到。\n'
' 常見原因:遊戲裝在需要管理員權限的地方(Program Files)、'
'磁碟滿了、或者外接碟是唯讀掛載。'
% (e.strerror or e, d, os.path.basename(path)))
try:
out = os.fdopen(fd, 'wb')
except BaseException:
# fdopen 沒接手成功的話 fd 還在我手上,自己關掉再清殘骸。
try:
os.close(fd)
except OSError:
pass
_quiet_remove(tmp)
raise
try:
with out: # 這個 with 會關檔,fd 不會漏
out.write(data)
out.flush()
os.fsync(out.fileno())
# 權限:mkstemp 給的是 0600,不補這一行的話原本 0700 的名冊換完會變 0600。
# 用 copymode 不用 copystat:copystat 連**修改時間**都搬,把新檔的時間往回調
# 會讓靠 mtime 判斷的同步工具漏掉這次修改。這裡只要權限。
try:
shutil.copymode(mode_src if mode_src is not None else path, tmp)
except OSError:
# 原檔不在(第一次寫,例如 --export 到一個還不存在的 CSV)。
# 這時**不可以就這樣算了**:mkstemp 給的是 0600,而使用者預期
# 匯出的 CSV 跟他自己用其他程式產生的檔一樣看得到、複製得走。
# 所以補成「一般開檔會得到的權限」= 0666 扣掉這台機器的 umask。
# (os.umask 只能用「設了再設回去」的方式讀出來;檔案系統不支援
# 權限位元的話 chmod 會丟 OSError,那種情況才是真的算了。
# ⚠️ 那兩行之間 umask 短暫是 0 —— 這支腳本是單執行緒的命令列工具,
# 那一瞬間不會有別人在建檔;要是哪天有人把它包進多執行緒,
# 這裡就得改成寫死一個權限,不能再問系統。)
try:
_um = os.umask(0)
os.umask(_um)
os.chmod(tmp, 0o666 & ~_um)
except OSError:
pass
# ⚠️ **換名之前**讀回來比對。長度先比再比內容 —— 兩個都要,
# 因為「短了一截」跟「內容不對」是兩種不同的故障。
back = _read_bytes(tmp)
if len(back) != len(data) or back != data:
raise Stop('寫暫存檔的時候出問題(寫了 %d bytes,讀回來 %d bytes)。\n'
' %s 一個位元組都沒有被動到,暫存檔已經刪掉了。\n'
' 多半是磁碟滿了或者外接碟出狀況,騰出空間再跑一次。'
% (len(data), len(back), os.path.basename(path)))
# 「正在換」刻意登記在 replace **之前**:Ctrl-C 剛好卡在這兩行之間的時候,
# 寧可多說一句「檔案可能已經改了,可以還原」,也不要漏說。
# 說錯的代價是使用者白跑一次還原(還原自己有五道把關,不會弄壞東西),
# 漏說的代價是他以為沒事就進遊戲。
#
# 然後「換名 + 登記換過了」被 `_NoInterrupt` 包成**不可中斷的一段**:
# 這一段期間按下的 Ctrl-C 會先被記著,等 'replaced' 登記完才丟出去 ——
# 所以收尾看到的登記跟磁碟上的狀態一定一致,不會有「換掉了卻說沒換」的縫。
if mark_applied:
APPLIED.update({'phase': 'replacing', 'path': path})
with _NoInterrupt():
os.replace(tmp, path)
if mark_applied:
APPLIED.update({'done': True, 'phase': 'replaced', 'path': path})
except BaseException as e:
# 用 BaseException 才連 Ctrl-C 都收得到 —— 按 Ctrl-C 一樣要清乾淨殘骸。
_quiet_remove(tmp)
if mark_applied and not APPLIED['done'] and isinstance(e, Exception):
# 一般的例外(磁碟滿了、權限不足…)代表 `os.replace` **沒有**成功
#('replaced' 是換名之後才登記的,所以 done 還是 False 就等於沒換成),
# 把三態收回原狀,免得之後的訊息說「檔案可能改了」而其實沒有。
# ⚠️ Ctrl-C 那一類(BaseException 但不是 Exception)**不收回** ——
# `_NoInterrupt` 萬一裝不上(非主執行緒),它可能剛好落在換名的
# 那一瞬間,那時狀態停在 'replacing',收尾會說「無法確定」,
# 不會說「什麼都沒有動到」。**確定沒換成才可以撤銷登記。**
APPLIED.update(prev)
# 磁碟/權限那一類的錯翻成人話再丟(這一課的讀者看不懂 traceback);
# **其餘一律原樣丟出去,絕不吞掉**。
if isinstance(e, OSError):
raise Stop('寫 %s 的時候出問題:%s\n'
' %s **沒有被動到**(換名之前就失敗了,暫存檔已經刪掉)。\n'
' 常見原因:磁碟滿了、外接碟被拔掉、或者沒有寫入權限。'
% (os.path.basename(path), e.strerror or e,
os.path.basename(path)))
raise
def atomic_backup(src, dst):
"""備份要嘛完整、要嘛不存在。中斷只會留下一個隨機名字的暫存檔,不會被誤認成備份。
為什麼要這麼講究:上層是用「`.playerbak` 存不存在」來決定要不要備份的。
如果複製到一半留下一個叫得出名字的半截檔,下一次執行會認為
「備份已經有了」而繼續改名冊,之後還原就會拿那個半截檔覆蓋掉正本。
"""
# 備份檔本身是連結的話,寫下去會改到連結指到的那個檔;而且下一次還原
# 會從那個檔讀資料蓋回名冊。兩頭都不能接受,所以是連結就直接拒絕。
_refuse_symlink(dst, '備份檔 %s' % os.path.basename(dst))
_atomic_put(dst, _read_bytes(src), mode_src=src)
def atomic_write(path, data):
"""把 data 整份寫進 path。**寫入與還原兩個方向都走這一條。**
2026-09-05 之前只有寫入這樣做,還原是直接 `shutil.copy2(備份, 名冊)` ——
那是開檔就把正本截成 0 再往裡面倒,中途被打斷(Ctrl-C、當機、外接碟拔掉)
正本就停在半截。
真正做事的是 `_atomic_put`。這裡多做的只有一件:標記「名冊已經被替換過了」,
好讓 Ctrl-C 的訊息說得出實話。
"""
_atomic_put(path, data, mark_applied=True)
def _print_interrupt_state(interrupted=True):
"""中斷的時候印出「到底有沒有動到檔案」。**這句話不可以是寫死的。**
2026-09-05 第二輪之前,不管中斷發生在哪裡,這支腳本都印
「什麼都沒有動到」然後 `sys.exit(0)` —— 而 Ctrl-C 完全可能落在
`os.replace()` 之後、複驗之前:名冊那時已經是新的了,畫面卻說沒動、
結束碼還說成功。使用者不會去還原,直接就進遊戲。
所以改成看 `APPLIED`(那個登記在 `_atomic_put` 裡做,「正在換」刻意登記在
換名之前 —— 寧可多說一句可以還原,不要漏說)。
2026-09-06 補上第三種狀態。三句話對三態,**沒有一句是寫死的**:
replaced 換過了 → 「名冊可能已經寫進去了」+ 兩條還原的路
replacing 正在換,結果不確定 → 「換好了沒有無法確定」+ 同樣兩條路
(`_NoInterrupt` 裝得上就幾乎到不了這裡)
idle 還沒動 → 「什麼都沒有動到」
2026-09-11 多一個 `interrupted` 參數:同樣三態,還有另一種到得了這裡的路 ——
**不是你按 Ctrl-C,是這支工具自己出了狀況**(防毒軟體鎖住檔案、外接碟被拔掉,
寫完之後才發作那一種)。那時三態要說的事情一模一樣,只有開頭那半句不能說
「你中斷了」—— 使用者沒有中斷,講錯會讓他以為是自己弄的。
`interrupted=True` 是原本的行為,一個字都沒有改。
"""
if APPLIED['done'] or APPLIED['phase'] == 'replacing':
if APPLIED['done']:
print(' ⚠️ 你中斷了,但名冊**可能已經寫進去了**:' if interrupted
else ' ⚠️ 名冊**可能已經寫進去了**:')
else:
# 只有「換名 + 登記」那一段的保險裝不上時才會走到這裡。
# 不確定就說不確定,不可以猜一個好聽的。
print(' ⚠️ 你中斷了,中斷時**正在替換名冊**,換好了沒有無法確定:' if interrupted
else ' ⚠️ 出狀況的時候**正在替換名冊**,換好了沒有無法確定:')
print(' %s' % (APPLIED['path'] or ''))
print()
print(' 要回到動手前的樣子,兩個方法選一個:')
print(' · 再跑一次這支工具(雙擊就好),它會問你要不要還原,打 r')
print(' · 或者打指令:python3 mvp_player.py "〔你的遊戲資料夾〕" --restore')
print(' 備份還在名冊旁邊,檔名是 attrib.dat%s。' % BAK_SUFFIX)
if not APPLIED['done']:
print(' 不想還原的話,也可以拿備份跟名冊比對,看它到底換過了沒有。')
else:
print(' 好,先不改。什麼都沒有動到。' if interrupted
else ' 名冊什麼都沒有動到。')
def roster_row_count(lines, what):
"""驗名冊的形狀,順便回傳資料列的列數。形狀不對就 raise Stop。
這是 `_restore_check` 用來分辨「完整的名冊」與「被截斷的半截檔」的尺。
量的是本站手上十一份相異的 attrib.dat(測試機那份、剛安裝好的原版英文版
與中文版、以及八份社群模組名冊),十一份的形狀完全一致:
表頭 47 格(46 個欄位 + 行尾那個「;」)
資料列 48 格(識別碼 + 46 個欄位 + 行尾那個「;」)
所以判準有兩條,都不假設欄位叫什麼名字:
· 每一列的欄數要一樣
· 表頭要剛好比資料列少一格(少的就是最前面那個識別碼)
截斷會被抓到,是因為**截斷只會讓逗號變少**:斷在半行中間的那一列
一定湊不滿 48 格。斷得剛好落在換行邊界的那種,這裡看不出來 ——
那一種靠 `_restore_check` 的「列數要跟正本一樣」抓。
"""
ncell = len(lines[0].split(','))
rows = 0
for i, l in enumerate(lines[1:], start=2):
if not l.strip():
continue
rows += 1
if len(l.split(',')) != ncell + 1:
raise Stop('%s 第 %d 行的格式不對(那一行有 %d 格,表頭有 %d 格,\n'
' 正常的資料列應該是 %d 格)。這個檔是半截的,或者根本不是名冊。'
% (what, i, len(l.split(',')), ncell, ncell + 1))
if rows == 0:
raise Stop('%s 裡面一位球員都沒有。' % what)
return rows
# ── 畫面 ────────────────────────────────────────────────────
def line(ch='─', n=54):
"""畫一條分隔線。54 是配合下面那些提示文字寬度挑的,沒有別的意思。"""
print(ch * n)
def ask(prompt, default=None):
"""問一句話,回傳去掉頭尾空白的答案。
**Ctrl-C 與 Ctrl-D 在這裡就直接乾淨離開**,不往上丟例外 ——
因為這支腳本是給雙擊的人用的,任何一個問句都應該可以隨時退出。
退出時印的那句話由 `_print_interrupt_state()` 決定:**它會先看檔案動了沒有**,
不是無條件說「什麼都沒有動到」。結束碼一律 130(中斷的慣例),不是 0 ——
0 的意思是「做完了而且成功」,中斷不是那件事。
"""
try:
v = input(prompt).strip()
except (EOFError, KeyboardInterrupt):
print()
print()
_print_interrupt_state()
sys.exit(130)
return v if v else (default or '')
# ── 批次模式(給做整包模組的人)────────────────────────────
# 為什麼要有這個(2026-08-29,一位 43 歲的老玩家來試聽時指出的):
# 他要做「台灣之光」模組,得接收二三十個位置。
# 互動問答一次一位 = 回答兩百多次提問。他五分鐘就能編好一張表。
# 所以同一支工具開第二道門,互動那道完全不動。
# CSV 只有五欄:一把配對用的鑰匙 + 四個可以改的欄位。
# 刻意不把 46 欄全倒出去 —— 表越寬,使用者不小心動到不該動的那一格的機會就越大。
CSV_COLS = ('id', 'first_name', 'last_name', 'jersey', 'speed')
def cmd_export(path, out):
"""--export:把名冊裡的五欄倒成一張 CSV。**完全不寫遊戲檔。**
這是批次那道門的第一步。id 是每一行的第一格,也是等一下 --import
用來對回同一位球員的唯一憑據 —— 所以匯出時要原樣帶出去。
"""
# 輸出檔名的守門。這條路沒有備份可以救 —— 沒跑過 --apply 的人
# 連 .playerbak 都沒有,寫錯地方就是直接把遊戲檔蓋掉。
# 實測(2026-09-05):`--export <遊戲>/data/database/attrib.dat` 會把
# 840,643 bytes 的名冊變成一張 98,784 bytes 的 CSV,還印四行成功訊息。
if os.path.realpath(out) == os.path.realpath(path):
raise Stop('--export 後面那個檔名不能是名冊本身,那會把名冊蓋掉。\n'
' 換一個名字,例如 players.csv。')
# 已經存在、而且結尾不是 .csv 的檔一律不覆蓋(`.playerbak` 也在這一條裡)。
# 只擋「已經存在」的,所以重複匯出到同一張 players.csv 照樣可以。
if os.path.exists(out) and not out.lower().endswith('.csv'):
raise Stop('%s 已經存在,而且它不是 .csv —— 不敢覆蓋。\n'
' --export 只往 .csv 寫。換一個名字,例如 players.csv。' % out)
# 匯出的目的檔也是「要寫的檔」,一樣不跟著符號連結走(2026-09-06 補)。
# ⚠️ 上面那一道用的是 `os.path.exists`,對**斷掉的**連結會回 False ——
# 所以它擋不住連結,必須另外用 `os.path.islink` 看一次。
# 實測(2026-09-06,在暫存複本上):補這一道之前,把 players.csv 先做成
# 指向資料夾外某個檔的連結,`--export` 會把那個檔整個蓋成 CSV,
# 畫面上照樣印「匯出 N 位球員」。這一條跟名冊、備份是同一種洞。
_refuse_symlink(out, '匯出的檔案 %s' % os.path.basename(out))
_raw, _txt, nl, lines, hdr = read_roster(path)
# 先把四個欄位的欄號查出來。名冊的欄位順序不保證每個版本都一樣,
# 所以一律從表頭查,不寫死數字。
fn, ln = hdr['first_name'], hdr['last_name']
jn, sp = hdr['playerattrib_jerseynum'], hdr['playerattrib_speed']
n = 0
# 先在記憶體裡把整張表排好,再一次原子寫出去(2026-09-06 改)。
#
# 舊版是 `open(out, 'w')` 直接開檔 —— 那有兩個問題:
# (一)`open(..., 'w')` 會**跟著符號連結**,而且是「先把那個檔截成 0
# 再往裡面倒」。上面新加的那道 islink 已經擋掉這一種。
# (二)就算不是連結,寫到一半斷掉會留下半截的 CSV,而它看起來跟
# 完整的一樣(CSV 沒有檔尾標記),使用者拿它去 --import 只會匯到一半。
# 現在改走名冊那條同一條路(`_atomic_put`):隨機名字的暫存檔 → fsync →
# 讀回來逐位元組比對 → 換名。斷在中途只會留下一個暫存檔,`out` 還是舊那份。
#
# newline='' 是 csv 模組的規定,少了它在 Windows 上會多出空白行 ——
# 寫進 StringIO 時同樣要照規矩來,csv 自己會用 \r\n 當行尾。
# 用 UTF-8 編出去,Excel 與記事本都讀得動。
_buf = io.StringIO(newline='')
w = csv.writer(_buf)
w.writerow(CSV_COLS)
for line_ in lines[1:]:
if not line_.strip():
continue
d = parse_row(line_)
# id 是這一行的第一格,原樣抄出去。它是 --import 的鑰匙,
# 不做任何正規化 —— 改動它就等於改動配對關係。
pid = line_.split(',', 1)[0].strip()
w.writerow([pid, d.get(fn, ''), d.get(ln, ''), d.get(jn, ''), d.get(sp, '')])
n += 1
# 資料夾不存在是這條路最常見的錯(檔名打錯、路徑打錯)。先自己看一眼,
# 才講得出「檢查一下那個資料夾在不在」這句話 —— `_atomic_put` 只知道
# 「建不出暫存檔」,講不出這麼具體的下一步。
_outdir = os.path.dirname(os.path.abspath(out)) or '.'
if not os.path.isdir(_outdir):
raise Stop('寫不出 %s:找不到資料夾 %s\n'
' 檢查一下那個資料夾在不在、檔名有沒有打錯。' % (out, _outdir))
# mark_applied 不給:這條路一個位元組都不會動到遊戲檔,
# 匯出失敗不該讓 Ctrl-C 的訊息說「名冊可能改了」。
_atomic_put(out, _buf.getvalue().encode('utf-8'))
print(' 匯出 %d 位球員 → %s' % (n, out))
print(' 用 Excel 或記事本改那張表,只改 first_name / last_name / jersey / speed 四欄。')
print(' ⚠️ id 那一欄是配對用的鑰匙,**絕對不要動**。')
print(' ⚠️ 名字只能用英文拼音(名冊存不了中文)。')
return 0
def cmd_import(path, src, apply_it):
"""--import:把 CSV 的內容寫回名冊。`apply_it` 是 False 就只預覽。
這個函式的順序是刻意的,而且**順序本身就是安全網**:
讀名冊 → 讀 CSV → 驗欄位 → 逐格算出「要改什麼」→
有任何一格不合格就整張擋下(一個位元組都不寫)→
沒有 --apply 就印預覽結束 → 有 --apply 才備份、寫暫存、換名、複驗
「整張擋下」是重點:一張二十幾個位置的表如果邊驗邊寫,
寫到第十五個才發現第十六格打錯,名冊就停在改了一半的狀態。
這裡是全部算完、全部通過,才開始動檔案。
"""
raw, _txt, nl, lines, hdr = read_roster(path)
fn, ln = hdr['first_name'], hdr['last_name']
jn, sp = hdr['playerattrib_jerseynum'], hdr['playerattrib_speed']
# 先建「id → 行號」的索引。配對靠 id 不靠行號順序,
# 所以使用者在 Excel 裡把列排序過、或刪掉不相干的列,都不影響配對。
byid = {}
for i, line_ in enumerate(lines[1:], start=1):
if line_.strip():
byid[line_.split(',', 1)[0].strip()] = i
# utf-8-sig:Excel 存 CSV 會在最前面加 BOM,不吃掉的話第一個欄名會變成
# 「id」而對不上,然後報一個看不懂的「少了 id 欄」。
# 檔名打錯是批次模式最常見的錯。不接住的話讀者看到的是一整片
# 英文 traceback,而不是 Stop 那段承諾的一句中文。
try:
with open(src, encoding='utf-8-sig', newline='') as f:
rows = list(csv.DictReader(f))
except OSError as e:
raise Stop('讀不到 %s:%s\n'
' 檢查一下檔名有沒有打錯、那張表是不是在你現在的資料夾裡。'
% (src, e.strerror or e))
if not rows:
raise Stop('%s 是空的,或第一行不是欄位名。' % src)
# 欄位缺一個就先停,而且把缺的全部列出來 —— 不要讓使用者改一次跑一次。
missing = [c for c in CSV_COLS if c not in rows[0]]
if missing:
raise Stop('CSV 少了這幾欄:%s\n 需要的欄位:%s'
% ('、'.join(missing), '、'.join(CSV_COLS)))
# 一趟走完整張表,把「要改什麼」跟「哪裡有問題」分別收集起來。
# 這一趟**完全不寫任何東西**,只是在算。
changes, errors = [], []
for i, r in enumerate(rows, start=2): # 第 1 行是欄名
pid = (r.get('id') or '').strip()
# id 空白的列直接略過,不算錯 —— 使用者常在表尾留空行。
if not pid:
continue
if pid not in byid:
errors.append('第 %d 行:找不到 id %s' % (i, pid))
continue
cur = parse_row(lines[byid[pid]])
for col, idx, checker in (('first_name', fn, 'name'), ('last_name', ln, 'name'),
('jersey', jn, 'num'), ('speed', sp, 'num')):
new = (r.get(col) or '').strip()
# 空白 = 不改這一格(不是「改成空白」);跟現值一樣也不算改動。
# 少了這一條,預覽會塞滿「Chen → Chen」這種沒有意義的列。
if new == '' or new == cur.get(idx, ''):
continue
try:
val = check_name(new) if checker == 'name' else check_num(
new, 0, 99, '背號' if col == 'jersey' else '跑壘速度')
except Stop as e:
# 這裡把 Stop **接住當成一筆錯誤**而不是讓它中止整支程式,
# 是為了一次把整張表的問題全部列給使用者,而不是修一格跑一次。
# 只取第一行:那些訊息後面還有給互動模式看的多行說明,列表裡會太長。
errors.append('第 %d 行 %s:%s' % (i, col, str(e).split(chr(10))[0]))
continue
# (行號, 欄號, 新值, id, 欄名, 舊值)—— 前三個給寫入用,後三個給預覽與複驗用。
changes.append((byid[pid], idx, val, pid, col, cur.get(idx, '')))
# 有錯就到此為止。**一個位元組都不寫**,回傳 1 讓呼叫者知道失敗了。
if errors:
print(' 🔴 這張表有 %d 個問題,一個位元組都不會寫:' % len(errors))
for e in errors[:20]:
print(' %s' % e)
if len(errors) > 20:
print(' …還有 %d 個' % (len(errors) - 20))
return 1
if not changes:
print(' 這張表跟名冊完全一樣,沒有要改的。')
return 0
who = len({c[3] for c in changes})
print(' 要改 %d 位球員、%d 個欄位:' % (who, len(changes)))
for lineno, idx, val, pid, col, old in changes[:40]:
print(' %s %-11s %-14s → %s' % (pid, col, old or '(空的)', val))
if len(changes) > 40:
print(' …還有 %d 個' % (len(changes) - 40))
if not apply_it:
print()
print(' 這是預覽,一個位元組都沒有寫。確定的話再跑一次,最後加上 --apply。')
return 0
backup = path + BAK_SUFFIX
# 動任何一根手指之前先擋掉符號連結。放在備份**之前**是刻意的:
# 名冊是連結的話,連備份都不該做 —— 那份備份會是連結指到的那個檔的內容,
# 之後還原就會把它蓋回一個不相干的地方。
_refuse_symlink(path, os.path.basename(path))
_refuse_symlink(backup, '備份檔 %s' % os.path.basename(backup))
if not os.path.exists(backup):
atomic_backup(path, backup)
print(' 已先備份成 %s' % os.path.basename(backup))
else:
print(' 備份已經有了,保留最早那一份。')
# 在**行的副本**上動手。同一位球員可能有好幾格要改,
# 所以是把上一次的結果再丟進 set_field,一格一格疊上去。
out = list(lines)
for lineno, idx, val, _pid, _col, _old in changes:
out[lineno] = set_field(out[lineno], idx, val)
# 用原本那個換行字元重組,再以 latin-1 編回位元組 ——
# 跟讀進來時完全對稱,沒有被改到的行連一個位元組都不會變。
# 先寫暫存檔、fsync、套回原檔權限、再換名(見 atomic_write)。
atomic_write(path, nl.join(out).encode('latin-1'))
# 複驗:讀回來逐項確認
# 刻意**重新從磁碟讀一次**,不拿記憶體裡的 out 來比 ——
# 拿自己剛算出來的東西驗自己,驗不出任何東西。
_r, _t, _n, lines2, _h = read_roster(path)
bad = []
for lineno, idx, val, pid, col, _old in changes:
if parse_row(lines2[lineno]).get(idx, '') != val:
bad.append((pid, col, val))
if bad:
print(' 🔴 寫進去了,但讀回來有 %d 個對不上:' % len(bad))
for b in bad[:10]:
print(' %s %s 應該是 %s' % b)
print(' 跑 --restore 還原,然後回報給班主任。')
return 1
print(' ✅ %d 位球員、%d 個欄位都寫好了,讀回來逐項相符。' % (who, len(changes)))
return 0
def cmd_restore_cli(path):
"""--restore:把 `.playerbak` 蓋回名冊。備份留著不刪。
備份不刪是刻意的 —— 還原之後使用者常常想再改一次,
刪掉的話下一輪就會拿「已經改過的名冊」當成新的備份基準。
"""
backup = path + BAK_SUFFIX
if not os.path.exists(backup):
raise Stop('找不到備份 %s —— 你還沒用這支工具改過這份名冊。'
% os.path.basename(backup))
_restore_check(backup, path)
print(' ✅ 已從 %s 還原。備份留著沒刪。' % os.path.basename(backup))
return 0
def _restore_check(bak, dst):
"""還原前擋掉壞掉的備份,過了才由這個函式自己把備份寫回去。
**這是唯一的還原入口。** `--restore` 與互動模式「打 r 還原」都走這裡 ——
2026-09-05 之前互動那條是自己呼叫 `shutil.copy2`,一道把關都沒走:
同一份截成前 1/8 的備份,`--restore` 擋得下來,雙擊那條卻直接蓋上去,
840,643 bytes 的名冊當場變成 105,080,畫面上還印
「✅ 已經還原成第一次改之前的樣子」。而雙擊那條正是這一課
**唯一教給不會打指令的讀者**的路。
五道把關,分成兩組:
只看備份自己(正本壞了也照驗)
1. 不是 0 bytes
3. 表頭讀得懂(`read_roster`,擋的是「指錯檔」)
4. 形狀:每一列欄數一致、表頭剛好少一格(`roster_row_count`)
拿正本當尺(**正本自己讀不出來就整組跳過**)
2. 不到正本的一半(粗篩,給最常見的那種中斷一句好懂的話)
5. 列數跟正本一樣 —— 這支工具是原地改,列數不會變
第 5 道不能省:截在**換行邊界**的備份形狀是完好的,第 1、3、4 道全過。
實測截到 60% 且對齊 CRLF 的備份,舊版四道全放行、印 ✅,
名冊從 3,247 位球員變成 1,947 位。
⚠️ 第 5 道的代價寫在檔頭安全網第 6 條:備份之後換了一份球員人數不同的
名冊模組,再來還原會被擋。那時請自己在檔案總管/Finder 裡複製。
⚠️ 反過來,正本已經壞到讀不出來時**不可以擋** —— 那正是最需要還原的時刻。
"""
# ── 把關之前先擋符號連結 ────────────────────────────────
# 備份是連結 → 讀到的是別人的內容;名冊是連結 → 寫下去會改到別的檔。
# 兩頭都是「不看它指到哪裡,是連結就不做」。
_refuse_symlink(bak, '備份檔 %s' % os.path.basename(bak))
_refuse_symlink(dst, os.path.basename(dst))
# ── 只驗備份自己的三道(1 / 3 / 4)──────────────────────
# 第 1 道:0 bytes。備份到一半被中斷最常見的樣子。
n = os.path.getsize(bak)
if n == 0:
raise Stop('備份是 0 bytes,不敢拿它覆蓋 %s。' % dst)
# 第 3 道:表頭解得開嗎。這一道擋的是「指錯檔」,不是截斷。
_braw, _btxt, _bnl, blines, _bhdr = read_roster(bak) # 表頭看不懂就會丟 Stop
# 第 4 道:形狀。每一列的欄數要一致,表頭要剛好少一格。
# 斷在半行中間的備份湊不滿一列,這裡就會亮。
brows = roster_row_count(blines, '備份 %s' % os.path.basename(bak))
# ── 拿正本當尺的兩道(2 / 5)────────────────────────────
# ⚠️ **正本自己讀不出來的時候這兩道一律跳過,不可以擋。**
# 正本被別的工具改壞了、或上一次還原到一半斷電 —— 那正是最需要還原的時刻,
# 而且壞掉的尺量出來的數字沒有意義。備份已經自己過了上面三道,是可信的那一份。
lrows = None
if os.path.exists(dst):
try:
_lraw, _ltxt, _lnl, llines, _lhdr = read_roster(dst)
lrows = roster_row_count(llines, os.path.basename(dst))
except (Stop, OSError):
lrows = None
print(' ⓘ 現在這份 %s 讀不出來(壞了),那就不拿它當尺了 ——'
% os.path.basename(dst))
print(' 備份自己已經過了前面三道把關,直接拿它蓋回去。')
if lrows is not None:
# 第 2 道:大小地板。這支腳本是原地改(改幾個字,大小幾乎不變),
# 所以備份不到正本的一半就一定出事了。
# ⚠️ 它排在第 4 道**後面**,所以「被截斷的備份」實際上是先被第 4 道
# (形狀)接走的,不會走到這裡 —— 實測前 1/8 的半截備份印的是
# 「第 406 行的格式不對」。這一道現在守的是「形狀完好但小得離譜」
# 的那種(例如只有一百位球員的名冊),那種第 5 道也擋得下,
# 留著是因為它講得出「差太多了」這句最好懂的話。
live = os.path.getsize(dst)
if live > 0 and n * 2 < live:
raise Stop('這份備份只有 %d bytes,而 %s 有 %d bytes —— 差太多了'
'(不到一半),不敢拿它覆蓋。多半是備份途中被中斷。'
% (n, os.path.basename(dst), live))
# 第 5 道:列數要跟正本一樣。斷在換行邊界的備份形狀是完好的,只有列數會少。
if brows != lrows:
raise Stop('這份備份有 %d 位球員,而 %s 有 %d 位 —— 對不上,不敢拿它覆蓋。\n'
' 兩種可能:(一)備份是半截的;(二)你在備份之後換過一份\n'
' 球員人數不同的名冊模組。如果是第二種而你確定要還原,\n'
' 請自己在檔案總管/Finder 裡把 %s 複製成 %s。'
% (brows, os.path.basename(dst), lrows,
os.path.basename(bak), os.path.basename(dst)))
# 五道全過才真的覆蓋,而且覆蓋也是先寫暫存檔再換名(見 atomic_write)——
# 直接 copy2 是「開檔就把正本截成 0 再往裡面倒」,中途被打斷正本就停在半截。
atomic_write(dst, _braw)
# 寫完把整份讀回來逐位元組比對。**不可以用 zip()** —— 它會在短的那一邊停,
# 而「短了一截」正是這裡要抓的東西。長度先比,再比內容。
back = open(dst, 'rb').read()
if len(back) != len(_braw) or back != _braw:
raise Stop('還原寫進去了,但讀回來跟備份不一樣(備份 %d bytes,讀回來 %d bytes)。\n'
' 先不要進遊戲。備份 %s 還在,請回報給班主任。'
% (len(_braw), len(back), os.path.basename(bak)))
# ── 自我測試 ────────────────────────────────────────────────
def _fake_roster(names, nl='\r\n'):
"""造一份最小的名冊,形狀跟真的一樣:表頭 47 格、資料列 48 格、CRLF、行尾「,;」。
測試一律在這種自己造的名冊上跑,**絕對不碰任何真的遊戲檔**。
形狀是照 `roster_row_count` 那一段量到的十一份 attrib.dat 抄的。
"""
cols = ['first_name', 'last_name', 'playerattrib_jerseynum'] + \
['playerattrib_f%d' % i for i in range(3, 45)] + ['playerattrib_speed']
assert len(cols) == 46, '表頭要剛好 46 欄'
head = ','.join('%d %s' % (i, c) for i, c in enumerate(cols)) + ',;'
rows = []
for k, (fn, ln) in enumerate(names):
cells = ['%09x' % (0xf58f3c1b + k)]
for i, _c in enumerate(cols):
v = fn if i == 0 else (ln if i == 1 else ('%d' % (i + k)))
cells.append('%d %s' % (i, v))
rows.append(','.join(cells) + ',;')
return (nl.join([head] + rows) + nl).encode('latin-1')
def selftest():
"""--selftest:自己造一份最小的名冊來測,**完全不碰任何遊戲檔**。
測的是這支工具對讀者的承諾,而且**正向與反向都要有**:
只有反向餌的話,「整支壞掉」也會讓每個餌都「成功地失敗」;
只有正向的話,防線壞掉了測試照樣全綠(2026-08-29 本站踩過)。
正向(證明正常流程真的做到承諾的事):
1. 造出來的名冊讀得出來,表頭四個欄位查得到
2. `--export` 一個位元組都不動名冊,而且匯出的列數等於球員數
3. `--import` 沒加 --apply 時名冊逐位元組不變
4. `--import --apply` 真的改到、備份出現、**沒點名的行逐位元組不變**
5. `--restore` 之後逐位元組回到原狀
6. `set_field` 只換那一格,同一行其他格不動
反向餌(每一道新守門各一個,失敗不了才是問題):
A. `<備份>.part` 先被放成指向資料夾外的符號連結 → 外面那個檔不可以被動到
(這一條是 2026-09-05 真的重現出來的洞,舊版會把外面那個檔寫成整份名冊)
B. 名冊本身是符號連結 → 兩道門都要被擋下,連結指到的檔不可以被動到,
而且批次那道門**連備份都不可以做**(備份會是連結指到的那個檔的內容)
C. 備份檔是**斷掉的**符號連結 → 要被擋下(`os.path.exists()` 對它回 False,
所以這一條同時證明了「不能用 exists 判斷」)
D. 換名之前的複驗讀到不對的東西 → 要被擋下,正本逐位元組不變、不留殘骸
E. `os.replace` 當場丟例外(還原做到一半失敗)→ 正本逐位元組不變、不留殘骸,
而且錯誤訊息要是人話、要說得出「正本沒有被動到」
E2. 資料夾唯讀,連暫存檔都建不出來 → 同上(Windows 與 root 底下測不出來,跳過)
F. 中斷登記:寫成功之前是「還沒動」,寫成功之後是「換過了」;
而且「正在換」要**登記在換名之前**、三態各自講的那句話都不可以講錯
(檔尾那句「什麼都沒有動到」就是靠它,說錯了會害人不去還原)
G. 截斷的備份 → `_restore_check` 要擋下,正本不變
H. 中文名字、超出範圍的數字 → `check_name` / `check_num` 要擋下
I. `--export` 的目的檔是指向資料夾外的符號連結 → 要被擋下,
外面那個檔一個位元組都不可以變(2026-09-06 補這道門之前它會被寫穿)
J. `_NoInterrupt` 真的接管了 SIGINT、期間的中斷會被押到離開才丟、
離開之後把原本的處理器裝回去(「不可中斷的一段」不可以是空話)
K. **沒有人預料到的例外**(不是 Stop、也不是 Ctrl-C)不可以只丟一片英文
traceback:名冊換過了就要說換過了、要印還原的路,原本那個例外也不可以
被吞掉;一個位元組都沒寫的時候則要說「什麼都沒有動到」。結束碼都是 1
⚠️ 這支測試靠 `assert` 站著,所以 `python -O` 底下**拒跑**(見第一行)——
-O 會把 assert 整個拿掉,每一道餌都會安靜地「通過」。
"""
# -O 會把 assert 全部拿掉,上面那些餌有一半是靠 assert 站著的,
# 在 -O 下會一路走到「全部通過」而其實什麼都沒驗。寧可不跑也不要假綠。
if sys.flags.optimize:
print('--selftest 不能在 python -O 下跑:-O 會把 assert 全部拿掉,測試會假綠')
return 2
# 兩個數字分開數(正向 / 反向餌),不要寫死在最後那句話裡 ——
# 寫死的數字加了一道測試就過期,而沒有人會發現。
n = {'fwd': 0, 'rev': 0}
def eq(a, b, msg):
assert a == b, '%s(得到 %r,應該是 %r)' % (msg, a, b)
d = tempfile.mkdtemp(prefix='mvp_player_selftest_')
gd = os.path.join(d, 'game')
dbdir = os.path.join(gd, 'data', 'database')
os.makedirs(dbdir)
path = os.path.join(dbdir, 'attrib.dat')
backup = path + BAK_SUFFIX
origin = _fake_roster([('Cory', 'Abbott'), ('Chin-Feng', 'Chen'), ('Dai-Kang', 'Yang')])
open(path, 'wb').write(origin)
outside = os.path.join(d, 'precious.txt')
open(outside, 'wb').write(b'PRECIOUS-DO-NOT-TOUCH\n')
def leftovers():
"""資料夾裡有沒有留下暫存殘骸。每一條失敗的路走完都要是 0。"""
return [f for f in os.listdir(dbdir) if '.tmp-' in f or '.part' in f]
# ── 正向 1:讀得出來 ────────────────────────────────────
raw, _txt, nl, lines, hdr = read_roster(path)
eq(raw, origin, '讀進來的位元組跟寫下去的不一樣')
eq(nl, '\r\n', '換行字元認錯了')
for c in ('first_name', 'last_name', 'playerattrib_jerseynum', 'playerattrib_speed'):
assert c in hdr, '表頭查不到 %s' % c
eq(roster_row_count(lines, 'test'), 3, '球員數不對')
n['fwd'] += 4
# ── 正向 6:set_field 只動那一格 ────────────────────────
one = set_field(lines[1], hdr['playerattrib_speed'], '88')
eq(len(one.split(',')), len(lines[1].split(',')), 'set_field 把格數改掉了')
eq(parse_row(one)[hdr['playerattrib_speed']], '88', 'set_field 沒改到那一格')
eq(parse_row(one)[hdr['first_name']], 'Cory', 'set_field 動到別格了')
n['fwd'] += 3
# ── 正向 2:--export 不動名冊 ───────────────────────────
csvp = os.path.join(d, 'players.csv')
import io as _io
import contextlib as _ctx
with _ctx.redirect_stdout(_io.StringIO()):
eq(cmd_export(path, csvp), 0, '--export 應該回 0')
eq(open(path, 'rb').read(), origin, '--export 動到名冊了')
with open(csvp, encoding='utf-8-sig', newline='') as f:
exported = list(csv.DictReader(f))
eq(len(exported), 3, '匯出的列數不對')
# 匯出的 CSV 權限要跟「一般開檔寫出來的檔」一樣。暫存檔是 0600,
# 忘了補權限的話匯出的表會變成只有自己看得到 —— 而使用者要拿它去 Excel,
# 也常常複製給別人。拿一個真的用 open() 建出來的檔當尺,不自己算數字。
#(Windows 的權限位元不是這一套,那邊跳過 —— 測不出來就別假裝測過了。)
if os.name != 'nt':
moderef = os.path.join(d, 'mode_ref.csv')
open(moderef, 'w').close()
eq(os.stat(csvp).st_mode & 0o777, os.stat(moderef).st_mode & 0o777,
'匯出的 CSV 權限跟一般開檔建出來的不一樣(暫存檔的 0600 漏出去了)')
n['fwd'] += 1
n['fwd'] += 3
# ── 反向餌 I:--export 的目的檔是符號連結 ────────────────
# 2026-09-06 補這道門之前,`open(out, 'w')` 會**跟著連結**把資料夾外
# 那個檔整個蓋成 CSV,而畫面上照樣印「匯出 N 位球員」——
# 本站在暫存複本上重現過。跟名冊、備份是同一種洞,只是它沒有備份可以救。
baitcsv = os.path.join(d, 'exported.csv')
os.symlink(outside, baitcsv)
try:
with _ctx.redirect_stdout(_io.StringIO()):
cmd_export(path, baitcsv)
except Stop:
pass
else:
raise AssertionError('餌 I 失效:--export 對著符號連結竟然照樣寫')
eq(open(outside, 'rb').read(), b'PRECIOUS-DO-NOT-TOUCH\n',
'餌 I 失效:資料夾外那個檔被寫穿了')
assert os.path.islink(baitcsv), '餌 I 失效:那個連結被動過'
os.remove(baitcsv)
# 斷掉的連結也是連結。這一半同時證明「上面那道 os.path.exists 擋不住它」:
# 對斷掉的連結 exists 回 False,所以非得另外用 islink 看一次不可。
dangcsv = os.path.join(d, 'dangling.csv')
os.symlink(os.path.join(d, 'no-such-file'), dangcsv)
assert not os.path.exists(dangcsv), 'exists() 對斷掉的連結應該回 False'
try:
with _ctx.redirect_stdout(_io.StringIO()):
cmd_export(path, dangcsv)
except Stop:
pass
else:
raise AssertionError('餌 I 失效:--export 對著斷掉的連結竟然照樣寫')
assert not os.path.exists(os.path.join(d, 'no-such-file')), \
'餌 I 失效:連結指到的位置被建出來了'
os.remove(dangcsv)
eq(open(path, 'rb').read(), origin, '餌 I 失效:被擋下了還是動到名冊')
n['rev'] += 5
# ── 正向 3:--import 預覽不寫 ───────────────────────────
editp = os.path.join(d, 'edit.csv')
with open(editp, 'w', encoding='utf-8', newline='') as f:
w = csv.writer(f)
w.writerow(CSV_COLS)
w.writerow([exported[1]['id'], 'Chieh-Hsien', 'Wang', '77', '88'])
with _ctx.redirect_stdout(_io.StringIO()):
eq(cmd_import(path, editp, False), 0, '預覽應該回 0')
eq(open(path, 'rb').read(), origin, '預覽動到名冊了')
assert not os.path.lexists(backup), '預覽不該產生備份'
n['fwd'] += 3
# ── 反向餌 A:`<備份>.part` 是指向資料夾外的符號連結 ────
# 舊版會 shutil.copy2(src, part) 跟著連結,把外面那個檔寫成整份名冊。
bait = backup + '.part'
os.symlink(outside, bait)
with _ctx.redirect_stdout(_io.StringIO()):
eq(cmd_import(path, editp, True), 0, '--apply 應該回 0')
eq(open(outside, 'rb').read(), b'PRECIOUS-DO-NOT-TOUCH\n',
'餌 A 失效:資料夾外那個檔被寫穿了')
assert os.path.islink(bait), '餌 A 失效:那個連結被動過'
os.remove(bait)
n['rev'] += 2
# ── 正向 4:真的改到、備份出現、沒點名的行不動 ──────────
assert os.path.isfile(backup) and not os.path.islink(backup), '備份沒出現'
eq(open(backup, 'rb').read(), origin, '備份的內容不是動手前那一份')
_r2, _t2, _n2, lines2, _h2 = read_roster(path)
eq(parse_row(lines2[2])[hdr['first_name']], 'Chieh-Hsien', '名字沒寫進去')
eq(parse_row(lines2[2])[hdr['playerattrib_speed']], '88', '速度沒寫進去')
eq(lines2[1], lines[1], '沒點名的第 1 行被動到了')
eq(lines2[3], lines[3], '沒點名的第 3 行被動到了')
eq(len(open(path, 'rb').read().split(b'\r\n')), len(origin.split(b'\r\n')),
'CRLF 的數量變了')
n['fwd'] += 6
# ── 反向餌 F:三態登記,以及它講的那三句話 ───────────────
assert APPLIED['done'] is True, '餌 F 失效:寫過名冊了,登記卻還是「還沒動」'
eq(APPLIED['phase'], 'replaced', '餌 F 失效:寫過名冊了,三態卻不是 replaced')
eq(APPLIED['path'], path, '餌 F 失效:登記記錯檔案')
# 「正在換」必須登記在**換名之前**。登記在後面的話那道縫還在,
# 而這一格正是為那道縫留的保險。把 os.replace 換成一個會回頭看登記的版本,
# 就量得出兩者的先後 —— 只讀狀態、不改行為,原本那個照樣呼叫。
seen = {}
real_replace0 = os.replace
def _watch_replace(a, b):
seen['phase'] = APPLIED['phase']
return real_replace0(a, b)
os.replace = _watch_replace
try:
atomic_write(path, open(path, 'rb').read()) # 內容原封不動地再寫一次
finally:
os.replace = real_replace0
eq(seen.get('phase'), 'replacing', '餌 F 失效:換名的當下還沒登記「正在換」')
# 三態各自要說對話。登記對了而話說錯,對使用者是一樣的傷害 ——
# 他看到的只有這三句,不是那個字典。
def _said(phase, done):
keep_ = dict(APPLIED)
APPLIED.update({'phase': phase, 'done': done, 'path': path})
buf = _io.StringIO()
with _ctx.redirect_stdout(buf):
_print_interrupt_state()
APPLIED.update(keep_)
return buf.getvalue()
said = _said('idle', False)
assert '什麼都沒有動到' in said, '餌 F 失效:還沒動卻沒說「什麼都沒有動到」'
said = _said('replacing', False)
assert '什麼都沒有動到' not in said, \
'餌 F 失效:正在換的時候竟然說「什麼都沒有動到」'
assert '無法確定' in said and '--restore' in said, \
'餌 F 失效:正在換的時候沒說「無法確定」,或沒印還原的路'
said = _said('replaced', True)
assert '什麼都沒有動到' not in said and '--restore' in said, \
'餌 F 失效:換過了卻沒印還原的路'
n['rev'] += 8
# ── 正向 5:--restore 逐位元組回到原狀 ──────────────────
with _ctx.redirect_stdout(_io.StringIO()):
eq(cmd_restore_cli(path), 0, '--restore 應該回 0')
eq(open(path, 'rb').read(), origin, '--restore 沒有逐位元組還原')
eq(leftovers(), [], '--restore 留下殘骸')
n['fwd'] += 3
# ── 反向餌 D:換名之前的複驗讀到不對的東西 ──────────────
# _read_bytes 是複驗唯一的口子,換掉它就等於演出「寫下去的跟讀回來的不一樣」。
before = open(path, 'rb').read()
real_read = globals()['_read_bytes']
globals()['_read_bytes'] = lambda p: b'WRONG'
try:
atomic_write(path, origin.replace(b'Cory', b'Kory'))
except Stop:
pass
else:
raise AssertionError('餌 D 失效:複驗讀到不對的東西竟然照樣換名')
finally:
globals()['_read_bytes'] = real_read
eq(open(path, 'rb').read(), before, '餌 D 失效:正本被動到了')
eq(leftovers(), [], '餌 D 失效:留下殘骸')
n['rev'] += 3
# ── 反向餌 E:os.replace 當場失敗(還原做到一半)────────
real_replace = os.replace
def boom(*_a, **_k):
raise OSError(5, 'Input/output error')
os.replace = boom
# 換名**失敗**的時候登記不可以留在「換過了」—— 不然下一次 Ctrl-C 會謊報
# 「檔案可能改了」,害人白跑一趟還原。這裡先把三態歸零才測得出來
#(前面幾道正向測試已經把它推到 replaced 了)。
keep = dict(APPLIED)
APPLIED.update({'done': False, 'phase': 'idle', 'path': None})
try:
_restore_check(backup, path)
except Stop as e:
# 磁碟那一類的錯要翻成人話(這一課的讀者看不懂 traceback),
# 而且那句話必須說「沒有被動到」—— 說錯了他會跑去做不必要的還原。
assert '沒有被動到' in str(e), '餌 E 失效:訊息沒說清楚正本沒事:%s' % e
else:
raise AssertionError('餌 E 失效:換名失敗了卻沒有丟出來')
finally:
os.replace = real_replace
assert APPLIED['done'] is False, '餌 E 失效:換名失敗了,登記卻停在「換過了」'
# 三態也要跟著收回「還沒動」。停在 'replacing' 的話,收尾會說
# 「換好了沒有無法確定」—— 而這裡是**確定**沒換成的,說不確定就是說謊。
eq(APPLIED['phase'], 'idle', '餌 E 失效:確定沒換成,三態卻沒收回「還沒動」')
APPLIED.update(keep)
eq(open(path, 'rb').read(), before, '餌 E 失效:換名失敗卻動到正本')
eq(leftovers(), [], '餌 E 失效:留下殘骸')
n['rev'] += 5
# ── 反向餌 E2:資料夾唯讀,連暫存檔都建不出來 ────────────
# (Windows 上 chmod 不擋寫入、root 也擋不住,那兩種情況跳過這一塊 ——
# 測不出來就別假裝測過了。)
if os.name != 'nt' and getattr(os, 'geteuid', lambda: 1)() != 0:
os.chmod(dbdir, 0o500)
try:
atomic_write(path, origin)
except Stop as e:
assert '沒有被動到' in str(e), '餌 E2 失效:訊息沒說清楚正本沒事:%s' % e
else:
raise AssertionError('餌 E2 失效:資料夾唯讀竟然還寫得進去')
finally:
os.chmod(dbdir, 0o700)
eq(open(path, 'rb').read(), before, '餌 E2 失效:資料夾唯讀卻動到正本')
eq(leftovers(), [], '餌 E2 失效:留下殘骸')
n['rev'] += 3
# ── 反向餌 B:名冊本身是符號連結 ────────────────────────
d2 = os.path.join(d, 'linked')
os.makedirs(d2)
linkdst = os.path.join(d2, 'attrib.dat')
os.symlink(outside, linkdst)
try:
atomic_write(linkdst, origin)
except Stop:
pass
else:
raise AssertionError('餌 B 失效:目的檔是符號連結竟然照樣寫')
eq(open(outside, 'rb').read(), b'PRECIOUS-DO-NOT-TOUCH\n',
'餌 B 失效:連結指到的檔被寫穿了')
# 同一件事走批次那道門,而且**這個餌要下對**:連結必須指向一份
# **讀得懂的名冊**。指向垃圾的話,`read_roster` 會先擋下來,
# 那時測到的是「垃圾檔擋得住」,不是「符號連結擋得住」——
# 2026-09-05 第一版的餌就下錯在這裡,反向測試(把守門拿掉)照樣全綠。
real2 = os.path.join(d2, 'other_roster.dat')
open(real2, 'wb').write(origin)
linkdst2 = os.path.join(d2, 'attrib2.dat')
os.symlink(real2, linkdst2)
linkbak = linkdst2 + BAK_SUFFIX
try:
with _ctx.redirect_stdout(_io.StringIO()):
cmd_import(linkdst2, editp, True)
except Stop:
pass
else:
raise AssertionError('餌 B 失效:--apply 對著符號連結名冊竟然照樣寫')
assert not os.path.lexists(linkbak), \
'餌 B 失效:名冊是連結卻做了備份(那份備份是別人的內容)'
eq(open(real2, 'rb').read(), origin, '餌 B 失效:批次那道門把連結指到的名冊改掉了')
n['rev'] += 5
# ── 反向餌 C:備份檔是斷掉的符號連結 ────────────────────
# 這一條同時證明「不能用 os.path.exists() 判斷」:對斷掉的連結它回 False。
dangling = os.path.join(d2, 'attrib.dat' + BAK_SUFFIX)
os.symlink(os.path.join(d, 'nothing-here'), dangling)
assert not os.path.exists(dangling), 'exists() 對斷掉的連結應該回 False'
assert os.path.lexists(dangling), 'lexists() 對斷掉的連結應該回 True'
try:
atomic_backup(path, dangling)
except Stop:
pass
else:
raise AssertionError('餌 C 失效:備份檔是斷掉的連結竟然照樣寫')
assert not os.path.exists(os.path.join(d, 'nothing-here')), \
'餌 C 失效:連結指到的位置被建出來了'
n['rev'] += 4
# ── 反向餌 G:截斷的備份要被 _restore_check 擋下 ─────────
open(backup, 'wb').write(origin[:len(origin) // 3])
try:
_restore_check(backup, path)
except Stop:
pass
else:
raise AssertionError('餌 G 失效:截斷的備份竟然蓋得上去')
eq(open(path, 'rb').read(), before, '餌 G 失效:被擋下了還是動到正本')
eq(leftovers(), [], '餌 G 失效:留下殘骸')
n['rev'] += 3
# ── 反向餌 J:「不可中斷的一段」不可以是空話 ──────────────
# 這裡**不真的殺自己一次**:os.kill 在 Windows 上的語義跟 POSIX 不同,
# 而且真的丟一個 SIGINT 進來會弄亂後面幾道測試。改成驗三件看得見的事 ——
# 進去之後 SIGINT 的處理器換成 `_NoInterrupt` 自己的、期間「收到訊號」
# 不會當場打斷 body、離開之後把原本的處理器裝回去並且補丟出來。
# (真的按下 Ctrl-C 的那一種另外用變體檔對真實的 --apply 測過,
# 結果寫在這一輪的回報裡;那種測試會殺掉整個行程,放不進這裡。)
before_handler = signal.getsignal(signal.SIGINT)
ran = []
try:
with _NoInterrupt() as _ni:
assert signal.getsignal(signal.SIGINT) is not before_handler, \
'餌 J 失效:_NoInterrupt 沒有真的接管 SIGINT,「押後」是假的'
_ni._remember(signal.SIGINT, None) # 等同「這一刻按了 Ctrl-C」
ran.append('body 跑完了') # 沒被打斷才到得了這一行
except KeyboardInterrupt:
ran.append('離開之後才丟出來')
eq(ran, ['body 跑完了', '離開之後才丟出來'], '餌 J 失效:押後中斷沒有照約定發生')
assert signal.getsignal(signal.SIGINT) is before_handler, \
'餌 J 失效:離開之後沒有把原本的 SIGINT 處理器裝回去'
n['rev'] += 3
# ── 反向餌 H:輸入的兩道門 ──────────────────────────────
for bad_name in ('陳金鋒', 'Wang,Chien-Ming', 'A' * 23):
try:
check_name(bad_name)
except Stop:
pass
else:
raise AssertionError('餌 H 失效:check_name 放行了「%s」' % bad_name)
for bad_num in ('100', '-1', '5', '7.5', ''):
try:
check_num(bad_num, 0, 99, '測試')
except Stop:
pass
else:
raise AssertionError('餌 H 失效:check_num 放行了「%s」' % bad_num)
eq(check_num('007', 0, 99, '測試'), '7', 'check_num 沒有正規化 007')
n['rev'] += 9
# ── 反向餌 K:沒人接住的例外不可以只丟一片英文 ────────────
# 守的是**收尾**那一段。寫進去之後才出狀況(防毒軟體鎖住檔案、外接碟被拔掉)
# 的時候,畫面第一句必須說得出名冊動了沒有、還要印還原的路;
# 原本那個例外也不可以被吞掉,不然班主任查不出原因。
# 做法:把 `run_cli` 換成一個「就是要爆炸」的版本,讓 `_cli_entry` 真的
# 走一次那條收尾 —— 測的是正式那一段程式,不是另外抄一份來測。
# 順便把 `input` 換掉:Stop 那條路會停下來等按鍵,測試不可以卡在那裡。
import builtins as _bi
def _crashed(phase, done, err_):
"""讓 `_cli_entry` 在指定的三態下吃到 err_,回傳 (結束碼, 螢幕, 英文那段)。"""
keep_ = dict(APPLIED)
APPLIED.update({'phase': phase, 'done': done, 'path': path})
real_run_cli, real_input = run_cli, _bi.input
def _boom(_argv):
raise err_
globals()['run_cli'] = _boom
_bi.input = lambda *a, **k: ''
buf, errbuf = _io.StringIO(), _io.StringIO()
try:
with _ctx.redirect_stdout(buf), _ctx.redirect_stderr(errbuf):
code_ = _cli_entry(['mvp_player.py', 'x', '--restore'])
except Exception as _e: # 收尾自己沒接住 = 這一格就是壞的
raise AssertionError('餌 K 失效:例外一路衝出收尾沒人接住(%r)' % (_e,))
finally:
globals()['run_cli'] = real_run_cli
_bi.input = real_input
APPLIED.update(keep_)
return code_, buf.getvalue(), errbuf.getvalue()
# (一)名冊已經換過了才出狀況 —— 最危險的那一種,漏說他就直接進遊戲了
code_, said, errtxt = _crashed('replaced', True, PermissionError(13, 'Permission denied'))
eq(code_, 1, '餌 K 失效:沒人接住的例外結束碼不是 1')
assert '什麼都沒有動到' not in said, '餌 K 失效:名冊換過了卻說「什麼都沒有動到」'
assert '可能已經寫進去了' in said and '--restore' in said, \
'餌 K 失效:名冊換過了卻沒說,也沒印還原的路'
assert '你中斷了' not in said, '餌 K 失效:使用者沒有中斷,不可以說是他中斷的'
assert 'PermissionError' in errtxt, '餌 K 失效:原本的例外被吞掉了,班主任查不到原因'
# (二)一個位元組都還沒寫 —— 這時要說清楚沒動到,不要害他白跑一次還原
code_, said, errtxt = _crashed('idle', False, RuntimeError('boom'))
eq(code_, 1, '餌 K 失效:沒人接住的例外結束碼不是 1')
assert '什麼都沒有動到' in said, '餌 K 失效:一個位元組都沒寫卻沒說「什麼都沒有動到」'
assert 'RuntimeError' in errtxt, '餌 K 失效:原本的例外被吞掉了'
# (三)Stop 也一樣:名冊換過之後才丟的 Stop,不可以只印那一句就算了
code_, said, errtxt = _crashed('replaced', True, Stop('假裝寫完之後才發現的問題'))
eq(code_, 1, 'Stop 的結束碼不是 1')
assert '假裝寫完之後才發現的問題' in said, '餌 K 失效:Stop 那句話不見了'
assert '可能已經寫進去了' in said and '--restore' in said, \
'餌 K 失效:名冊換過之後才丟的 Stop 沒有把還原的路一起講出來'
# (四)`--selftest` 自己不可以把「名冊換過了」的登記留給收尾 —— 它只動假名冊,
# 留下去會讓一道測試沒過長得像「你的遊戲檔可能被改了」。
# 拿一個「弄髒登記之後就爆炸」的假 selftest 去跑真的 `run_cli`。
# ⚠️ 進去之前要先把登記**壓成 idle**:跑到這裡時它本來就是 'replaced'
# (前面那些正向測試真的寫過假名冊),不壓的話「進去前 == 出來後」
# 會自動成立,這個餌就永遠不會紅 —— 種餌種在對照組上等於沒種。
keep_applied = dict(APPLIED)
APPLIED.update({'done': False, 'phase': 'idle', 'path': None})
real_selftest = selftest
def _dirty_selftest():
APPLIED.update({'done': True, 'phase': 'replaced', 'path': 'X'})
raise RuntimeError('假裝測試沒過')
globals()['selftest'] = _dirty_selftest
try:
run_cli(['mvp_player.py', '--selftest'])
except RuntimeError:
pass
finally:
globals()['selftest'] = real_selftest
eq(APPLIED['phase'], 'idle', '餌 K 失效:--selftest 把三態登記留給了收尾')
eq(APPLIED['done'], False, '餌 K 失效:--selftest 把「換過了」留給了收尾')
APPLIED.update(keep_applied)
n['rev'] += 13
shutil.rmtree(d, ignore_errors=True)
print(' ✅ --selftest 全過:%d 道(正向 %d 道、反向餌 %d 道)。'
% (n['fwd'] + n['rev'], n['fwd'], n['rev']))
print(' 全程只用自己造的名冊,一個遊戲檔都沒有碰。')
return 0
def run_cli(argv):
"""有參數就走這裡。回傳 None 表示「不是批次模式」,讓它掉回互動模式。
這是「兩道門」的分岔點,而且**回傳 None 這件事就是那道岔**:
只給一個資料夾路徑而沒給 --export / --import / --restore 的話,
它會回 None,檔尾就改去跑互動模式 —— 使用者把資料夾拖到腳本上也能用。
手寫參數解析而不用 argparse:這一課的雙擊入口(.command / .bat)
要的是「沒有參數時完全不像命令列工具」,argparse 會在
參數不對時自己印出英文用法並直接結束,那對這一課的讀者是反效果。
"""
args = [a for a in argv[1:] if a.strip()]
if not args:
return None
gamedir = None
mode = None
target = None
apply_it = False
i = 0
while i < len(args):
a = args[i]
if a == '--export' or a == '--import':
mode = a[2:]
i += 1
if i >= len(args):
raise Stop('%s 後面要接一個 CSV 檔名。' % a)
target = args[i]
elif a == '--apply':
apply_it = True
elif a == '--restore':
mode = 'restore'
elif a == '--selftest':
# 自我測試。不需要遊戲資料夾,也不碰任何遊戲檔 ——
# 所以下面「猜遊戲資料夾」那一段之前就會先分岔出去。
mode = 'selftest'
elif a in ('-h', '--help'):
print(__doc__)
return 0
elif a.startswith('-'):
raise Stop('不認得的參數:%s\n 用 --help 看用法。' % a)
else:
gamedir = a
i += 1
# 沒有任何一個模式旗標 = 不是批次模式,交還給互動模式。
if mode is None:
return None
# --selftest 在「找遊戲資料夾」之前就分岔出去:它自己造一份名冊,
# 不需要遊戲,也**不可以**碰到任何遊戲檔。
if mode == 'selftest':
# 跑完(或跑壞)之後把三態登記收回原狀。selftest 全程只動自己造的假名冊,
# 那個「換過了」不可以留給收尾看 —— 不然一道測試沒過會長得像
#「你的遊戲檔可能被改了,快去還原」,而它指的其實是暫存資料夾裡的假名冊。
# (2026-09-11 補;沒有這一段的樣子由反向餌 K 的第四小段擋著)
_keep = dict(APPLIED)
try:
return selftest()
finally:
APPLIED.update(_keep)
# 批次模式**只接受「剛好猜到一份」**。猜到零份或多份都要使用者明講 ——
# 這條路沒有人在旁邊看,猜錯就是改到別份遊戲。
if not gamedir:
dirs = guess_gamedirs()
if len(dirs) != 1:
raise Stop('批次模式要明確指定遊戲資料夾:\n'
' python3 mvp_player.py "<遊戲資料夾>" %s' % ('--' + mode))
gamedir = dirs[0]
path = os.path.join(gamedir, 'data', 'database', 'attrib.dat')
if not os.path.isfile(path):
raise Stop('%s 底下找不到 data/database/attrib.dat。' % gamedir)
if mode == 'export':
return cmd_export(path, target)
if mode == 'import':
return cmd_import(path, target, apply_it)
return cmd_restore_cli(path)
def main():
"""互動模式:雙擊之後一句一句問。七個步驟,寫死的順序。
1. 找遊戲 2. 問要不要還原 3. 找球員 4. 問要改什麼
5. 印預覽並確認 6. 備份 + 寫入 7. 讀回來複驗
⚠️ **改球員這條路**在第 5 步之前一個位元組都沒有寫過。這一課的讀者不會打指令,
所以「不小心按到就改掉了」是這支腳本最需要防的事,
而防法就是把「改球員」那個寫入動作關在「打 y」後面。
⚠️ 例外是**第 2 步那條還原分支,它會寫**。打 r 再按 Enter 就把備份寫回
名冊然後 return,第 3 步以後不會跑到。蓋回去的是「第一次改之前」那份名冊
(第 6 步已經有備份就保留最早那一份)。
這一步**跟 --restore 走同一個 _restore_check**(五道把關 + 原子寫入 +
寫完逐位元組比對),備份要是壞的會被擋下來,不會蓋掉正本。
2026-09-05 之前這裡是自己呼叫 shutil.copy2,一道都沒走 —— 那個洞已經補掉。
"""
print()
line('=')
print(' ⚾ 把一位球員改成你想要的球員')
line('=')
print(' 這支工具會一句一句問你,不用打指令。')
print(' 隨時按 Ctrl + C 就離開,還沒確認之前什麼都不會改。')
print()
# 1. 找遊戲
# 三條分支:猜不到就請人把資料夾拖進來、剛好一份就直接用、
# 多份就列出來讓人選。互動模式**可以**問,所以不像批次模式那樣直接拒絕。
dirs = guess_gamedirs()
if not dirs:
print(' 找不到遊戲資料夾。')
print(' 請把**遊戲資料夾**拖進這個視窗,再按 Enter:')
print(' (那個資料夾裡面要有一個叫 data 的資料夾)')
# 把路徑左右的引號剝掉:終端機在拖曳含空白的資料夾時會自動加上引號,
# 不剝的話 os.path.isfile 一定找不到,而使用者完全看不出自己哪裡做錯。
g = ask(' > ').strip().strip('"').strip("'")
if not os.path.isfile(os.path.join(g, 'data', 'database', 'attrib.dat')):
raise Stop('那個資料夾裡找不到 data\\database\\attrib.dat。\n'
' 你可能拖錯資料夾了。')
gamedir = g
elif len(dirs) == 1:
gamedir = dirs[0]
print(' 找到遊戲了:%s' % gamedir)
else:
print(' 找到不只一份遊戲,你要改哪一份?')
for i, d in enumerate(dirs, 1):
print(' %d. %s' % (i, d))
c = check_num(ask(' 打數字再按 Enter > '), 1, len(dirs), '選項')
gamedir = dirs[int(c) - 1]
path = os.path.join(gamedir, 'data', 'database', 'attrib.dat')
backup = path + BAK_SUFFIX
# 符號連結的檢查放在**問問題之前**:不能寫的話,不該讓人先回答一輪問題。
_refuse_symlink(path, os.path.basename(path))
_refuse_symlink(backup, '備份檔 %s' % os.path.basename(backup))
raw, txt, nl, lines, hdr = read_roster(path)
print(' 名冊讀好了,裡面有 %d 位球員。' % (len([l for l in lines[1:] if l.strip()])))
print()
# 2. 還原?
# 備份存在 = 這個人以前改過。**還原要排在「找球員」之前**,
# 因為想還原的人不該被逼著先挑一位球員才看得到出口。
if os.path.exists(backup):
line()
print(' ⓘ 這份名冊你以前改過,備份還在。')
print(' 要「還原成沒改過的樣子」的話,打 r 再按 Enter。')
print(' 要繼續改別的球員,直接按 Enter。')
if ask(' > ').lower() == 'r':
# 兩條還原路徑走同一組把關:_restore_check 五道全過了,
# 才由它自己用 atomic_write 寫回去,並逐位元組比對。
# 任何一道不過都會丟 Stop,底下這兩行印不出來。
_restore_check(backup, path)
print('\n ✅ 已經還原成第一次改之前的樣子。')
print(' 備份留著沒刪(%s)。' % os.path.basename(backup))
return 0
print()
# 3. 找球員
line()
print(' 你想改哪一位球員?')
print(' 打他名字的一部分就好,要用**英文**。')
print(' (名冊裡沒有中文。陳金鋒在遊戲裡叫 Chin-Feng Chen)')
# 問到問出一個「不多不少」的結果為止。
# 上限 12 是刻意的:一次列超過十幾個選項,人反而挑不到,
# 而挑錯就是改到別人身上。與其列一長串,不如請他多打兩個字母。
while True:
q = ask(' > ')
if not q:
continue
hits = find_players(lines, hdr, q)
if not hits:
print(' 找不到名字含「%s」的球員。換個拼法,或只打兩三個字母。' % q)
continue
if len(hits) > 12:
print(' 找到 %d 位,太多了。多打幾個字母縮小範圍。' % len(hits))
continue
break
fn, ln, jn = hdr['first_name'], hdr['last_name'], hdr['playerattrib_jerseynum']
sp = hdr['playerattrib_speed']
print()
for i, (li, name, d) in enumerate(hits, 1):
print(' %d. %-24s 背號 %-3s 跑壘速度 %s'
% (i, name, d.get(jn, '?'), d.get(sp, '?')))
print()
idx = int(check_num(ask(' 要改哪一個?打數字再按 Enter > '), 1, len(hits), '選項'))
lineno, oldname, row = hits[idx - 1]
# 4. 要改什麼
line()
print(' 你選的是:%s(背號 %s,跑壘速度 %s)'
% (oldname, row.get(jn, '?'), row.get(sp, '?')))
print()
print(' 要改什麼?可以一次改好幾個,不改的直接按 Enter 跳過。')
print()
# 四個問句,每一句都可以直接按 Enter 跳過。
# 值一填進來就當場檢查(check_name / check_num),不合格立刻丟 Stop ——
# 互動模式下當場講比較有用,不必像批次模式那樣攢到最後一起報。
changes = []
v = ask(' 新的名(first name,例如 Chieh-Hsien)> ')
if v:
changes.append((fn, check_name(v), '名'))
v = ask(' 新的姓(last name,例如 Chen)> ')
if v:
changes.append((ln, check_name(v), '姓'))
v = ask(' 新的背號(0-99)> ')
if v:
changes.append((jn, check_num(v, 0, 99, '背號'), '背號'))
v = ask(' 新的跑壘速度(0-99,現在是 %s)> ' % row.get(sp, '?'))
if v:
changes.append((sp, check_num(v, 0, 99, '跑壘速度'), '跑壘速度'))
# 填了但跟原本一樣的,不算改動 —— 預覽裡不該出現「Chen → Chen」
same = [(i, v, lab) for i, v, lab in changes if row.get(i, '') == v]
changes = [c for c in changes if c not in same]
for _i, _v, lab in same:
print(' (%s 本來就是 %s,不用改)' % (lab, _v))
if not changes:
print('\n 沒有任何一項需要改。什麼都沒有動到。')
return 0
# 5. 預覽
print()
line()
print(' 這是**預覽**,還沒有動到任何檔案:')
print()
for i, val, label in changes:
print(' %-10s %s → %s' % (label, row.get(i, '(空的)') or '(空的)', val))
print()
print(' 改完之後他會變成:')
newfn = next((v for i, v, _ in changes if i == fn), row.get(fn, ''))
newln = next((v for i, v, _ in changes if i == ln), row.get(ln, ''))
newjn = next((v for i, v, _ in changes if i == jn), row.get(jn, ''))
print(' %s 背號 %s' % (('%s %s' % (newfn, newln)).strip(), newjn))
print()
# **只有 y 算同意**,其他任何輸入(含直接按 Enter)都當取消。
# 這是整支互動流程唯一的閘門,所以判準要嚴,不可以做成「按 Enter 等於 yes」。
print(' 確定要改嗎?打 y 再按 Enter。打別的都算取消。')
if ask(' > ').lower() != 'y':
print('\n 取消了,什麼都沒有動到。')
return 0
# 6. 備份 + 寫入
# 備份已經有了就保留最早那一份 —— 那才是「動手前的名冊」。
if not os.path.exists(backup):
atomic_backup(path, backup)
print('\n 已經先備份成 %s' % os.path.basename(backup))
else:
print('\n 備份已經有了,保留最早那一份(%s)' % os.path.basename(backup))
# 只重寫**那一行**。名冊裡其餘每一行連碰都沒碰過,
# 用原本的換行字元接回去、以 latin-1 編回位元組,整份檔案的其餘部分逐位元組不變。
newline = lines[lineno]
for i, val, _ in changes:
newline = set_field(newline, i, val)
lines2 = list(lines)
lines2[lineno] = newline
out = nl.join(lines2).encode('latin-1')
# 一樣走 atomic_write:先寫暫存、fsync、套回原檔權限、再換名。
atomic_write(path, out)
# 7. 複驗:讀回來確認真的改到了
# 從磁碟重新讀一次,不看記憶體裡的 out。
# 「印了成功」跟「真的寫進去了」是兩件事,這一段就是在分辨那兩件事。
_r2, _t2, _nl2, lines3, hdr3 = read_roster(path)
check = parse_row(lines3[lineno])
bad = [(lab, val, check.get(i, '')) for i, val, lab in changes if check.get(i, '') != val]
print()
line('=')
if bad:
print(' 🔴 寫進去了,但讀回來對不上:')
for lab, want, got in bad:
print(' %s 應該是 %s,讀回來是 %s' % (lab, want, got))
print(' 請跑一次這支工具選 r 還原,然後回報給班主任。')
return 1
print(' ✅ 改好了!')
print(' %s 背號 %s' % (('%s %s' % (newfn, newln)).strip(), newjn))
print()
print(' 進遊戲看看吧。想改回來的話,再跑一次這支工具,')
print(' 它會問你要不要還原。')
line('=')
print()
return 0
def _cli_entry(argv):
"""整支腳本的收尾:分兩道門進去,四種方式出來。回傳行程的結束碼。
2026-09-11 從 `if __name__ == '__main__':` 底下抽成函式,**為了讓它測得到**。
寫在 `if __name__` 底下的程式沒有任何辦法在 `--selftest` 裡跑到,
而「出狀況的時候有沒有說實話」正是這支腳本最需要被測到的一段
(反向餌 K 就是拿這個函式真的走一次那條收尾)。抽出來的過程沒有改行為:
原本 `sys.exit(X)` 的地方現在 `return X`,由底下那一行 `sys.exit` 送出去。
四種出口:
正常 run_cli 給的結束碼;它回 None 就是「沒給模式旗標」,改跑互動模式
Stop 自己丟的、講人話的錯 → 印一句中文,結束碼 1
沒人接住的例外 這支工具自己出狀況 → **先講檔案動了沒有**,再把英文丟給班主任,結束碼 1
Ctrl-C 照三態講實話,結束碼 130
"""
# 兩道門的分岔就在這一行:run_cli 回 None 代表「沒有給模式旗標」,
# 那就跑互動模式;回了數字就是批次模式的結束碼,直接拿去當行程結束碼。
#
# Stop 統一在這裡接住,印成一句中文,再**等使用者按 Enter** ——
# Windows 上雙擊執行時視窗會在程式結束的瞬間關掉,
# 沒有這一行的話錯誤訊息只會閃一下,使用者什麼都看不到。
try:
_r = run_cli(argv)
return main() if _r is None else _r
except Stop as e:
print()
print(' ✗ %s' % e)
# 這個 Stop 要是在名冊換過之後才丟出來的(寫進去了才發現讀不回來那一種),
# 光印那句話會讓人以為什麼都沒發生。**動了就要說**,而且要把還原的路一起給他。
# 沒動到的時候這一段不會印任何東西,所以平常看到的畫面一個字都沒有變。
if APPLIED['done'] or APPLIED['phase'] == 'replacing':
print()
_print_interrupt_state(interrupted=False)
print()
try:
input(' 按 Enter 關閉這個視窗。')
except (EOFError, KeyboardInterrupt):
pass
return 1
except Exception:
# ⚠️ 這一段守的是「**沒有人預料到**的例外」。2026-09-11 之前這裡沒有東西接,
# 所以任何一個沒被翻成 Stop 的錯(最現實的是防毒軟體或同步工具在
# `os.replace()` 之後那一瞬間鎖住名冊,複驗那一次 open 就丟 PermissionError)
# 會直接噴一片英文 traceback 出去 —— 而那時**名冊已經換過了**,
# 畫面上卻一個字都沒提「你可以還原」。這一課的讀者看不懂 traceback,
# 他會以為只是壞掉了,然後就進遊戲。
# 本站實測(2026-09-11,在測試複本上):在複驗那一行種一個 PermissionError,
# 修補前印的是三行英文 traceback、結束碼 1,而名冊的 sha256 真的變了。
# 順序刻意是「先講檔案、再給英文」:讀者要的是第一句,班主任要的是後面那段。
# 這裡**不再多等一次按鍵** —— 雙擊的兩個入口(.bat 的 pause 與 .command 的
# read)自己會把視窗停住,而多一個等待會讓自動跑的檢查器卡在這裡。
print()
print(' ✗ 這支工具自己出狀況了 —— 不是你打錯什麼。')
print()
_print_interrupt_state(interrupted=False)
print()
print(' 下面這段英文是給班主任看的,請整段複製起來回報:')
print(' https://toniliumvp.github.io/MVPBaseball/report.html')
print()
# 英文那一段是寫到 stderr 的,中文這幾行是 stdout。畫面上兩邊是同一個地方,
# 但**把輸出導進檔案**的時候 stdout 會先被存起來、離開才吐出去 ——
# 不先 flush 的話存下來的檔案裡英文會跑到中文前面,回報的人看了會錯亂。
try:
sys.stdout.flush()
except (OSError, ValueError):
pass
traceback.print_exc()
return 1
except KeyboardInterrupt:
# ⚠️ 這裡**不可以**無條件說「什麼都沒有動到」。Ctrl-C 可能落在
# os.replace() 之後、複驗之前 —— 那時名冊已經是新的了。
# _print_interrupt_state() 會看 APPLIED 旗標決定要說哪一句。
# 結束碼一律 130:中斷不是成功,不可以回 0。
print()
print()
_print_interrupt_state()
return 130
if __name__ == '__main__':
sys.exit(_cli_entry(sys.argv))
# ─────────────────────────────────────────────────────────────
# MIT License
#
# Copyright (c) 2026 toni
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
# ─────────────────────────────────────────────────────────────
想更進一步
- 修改球員跑壘速度 —— 這一欄是怎麼找出來的
- 球員資料檔全欄位清單 —— 46 欄各是什麼
- 備份與還原 SOP —— 出事怎麼救
- 那一年做臉的人 —— 2009 年台灣社群怎麼做的