From 71ee8b8dd234ccb129d69365c6d148c63313ad1c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:38:50 +0000 Subject: [PATCH] =?UTF-8?q?feat(ops):=20=E4=BA=BA=E5=8A=9B=E7=9F=AD?= =?UTF-8?q?=E7=BC=BA/=E7=BD=B7=E5=B7=A5=E4=BA=8B=E4=BB=B6=E5=AF=A6?= =?UTF-8?q?=E6=95=88=E5=8C=96=20+=20=E8=BB=B8=E6=89=BF=E5=82=99=E5=93=81?= =?UTF-8?q?=E6=AD=A3=E5=90=8D(=E5=82=B3=E5=8B=95=E8=BB=B8=E6=89=BF?= =?UTF-8?q?=E7=B5=84=20T3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 補上兩處「只影響展示、不影響決策」的邏輯完整性缺口: 人力短缺/罷工實效化(#crew): - crew_shortage(-3)/strike(-6) 原本只改展示用 techAvail,OPS_DISPATCH/ OPS_INSPECT 完全不讀 → 事件承諾後果卻不兌現。 - 新增 crewShortfallJobs/effectiveJobCapOf:缺工直接折抵「可同時開的現場 作業面」= max(1, jobCapOf − ⌈(techTotal−techAvail)/CREW_PER_JOB⌉)。 - 滿編不懲罰(以缺額計,不影響頂級船隊)、下限 1(短手仍可派一組、遠端重啟 不占名額、主線工單不受此限)、每日休整回復。 - 開局改滿編 30/30;tech 升級同步 techAvail(+2)避免憑空缺額。 - 戰情室新增「可出勤班組」統計 + 短手 👷 橫幅(vesselCap→cap)+ 成因區分。 軸承備品正名(#bearing): - 主軸承振動(bearing)與發電機振動(gen_vibration)原權宜共用「變槳軸承」 (pitch_bearing)。新增 Tier 3 專屬「傳動軸承組(發電機/主軸)」 (drive_bearing, ◎130萬)並重新指派,與 Tier 4 主軸承全換(main_bearing) 語意區隔(振動維修級 vs 重吊更換)。 - 新增 pitch_bearing_wear(變槳軸承磨耗)事件,讓 pitch_bearing 保有真實 消費端(呼應 cs_pitch_bearing_wear 案例)、不成孤兒。 測試/文件: - test/run.mjs +5 測試(159 passed):crewShortfall/effectiveJobCapOf、 OPS_DISPATCH 缺工受阻 vs 滿編可派、事件連上機制、開局滿編/升級不生 假缺額、drive_bearing 正名與消費端一致性。 - GAME_DESIGN §18、ROADMAP、STATUS.yaml 對齊。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AC7ERcxjL7dhsYv9PncydP --- STATUS.yaml | 6 ++-- docs/GAME_DESIGN.md | 12 ++++++++ docs/ROADMAP.md | 6 ++-- src/state/game.ts | 14 ++++++--- src/state/incidents.ts | 3 +- src/ui/FleetOpsModal.tsx | 18 ++++++++++-- src/ui/data.ts | 1 + src/ui/faults.ts | 2 +- test/run.mjs | 62 ++++++++++++++++++++++++++++++++++++++++ 9 files changed, 109 insertions(+), 15 deletions(-) diff --git a/STATUS.yaml b/STATUS.yaml index 3df6ab6..4a7e55a 100644 --- a/STATUS.yaml +++ b/STATUS.yaml @@ -3,8 +3,8 @@ name_zh: 離岸風場・運維傳說 category: edtech progress: 100 status: active -last_updated: "2026-07-08" -next_milestone: "出海決策支援與學習迴路閉環已完成:出海前工期預估vs天氣窗(航線/登船/檢查/維修+保留餘裕,含磨耗/疲勞/擇日/缺料提醒)、審慎返港再規劃(不計安全事件)+半途成果保留(#carry)、⚡加班搶修風險抉擇(#rush,快vs穩三選一)、任務復盤星級+一句takeaway、診斷連對streak(封頂XP加成+🔥里程碑)、錯題本主動回想(檢索練習)、無障礙(減少動態:系統偏好+遊戲內開關;色覺友善雙重編碼)、戰情室SLA季末風險+母港預算續航提醒、Pages部署來源鎖定GitHub Actions(configure-pages)。內容系統全面查核與修正(事件/案例/任務/故障×消費端交叉驗證):每日任務維持型項目改日結算(deferred,修正開局白拿)、安全事件數夾0(堵刷分漏洞)、備品資料誤植修正(感測器/變槳故障)、交易所主線防卡(進行中工單備品永遠可見)、移除3組重複任務模板、案例研究資料誤植修正;判斷型任務引擎擴充至192題。先前:循序漸進難度與長線動機已完成:運維層級 Tier 系統(依進度自動推進,故障/備品/經濟壓力分層)、入門引導+UI 漸進揭露、每日任務(遊戲內日曆)、每週主題挑戰(風暴/斷料週等,綁戰情室事件池)、情境包匯入(教師自訂題組)、真實度深化(jack-up 安裝船動員前置期+計畫性定期保養)、故障/備品分層擴充(25 故障/33 備品)、真實案例研究事件(經查核的真實風場事故,具名+出處/去識別兩類,Tier 偶發+母港案例檔)。併發壓力測試通過(npm run stress);雲端後端 v2.1 加併發鎖(LockService);PWA 可安裝/離線(手機網頁並存);教師面板 CSV 匯出;營運趨勢儀表板/賽後復盤;登入畫面教師入口;存檔版本化遷移;code-split(初始包 653KB→268KB);知識點掌握度(答題正確率+弱點補強建議);HSE 科別加厚(LOTO 能量隔離/高處墜落防護/SIMOPS 同時作業,含圖鑑+鑑別診斷+戰情室事件);直升機進場與電網限電真實運維權衡判斷任務;真實/擬真案例擴充至 24 則,並可在自由營運中心任務清單以『案例演練』自然出現(略過 Tier、答後揭示 O&M 教訓並收錄圖鑑);手機直向專屬精簡版面(母港+交易所+出海+維修+所有彈窗,自動偵測裝置;PWA 快取版本化+錯誤邊界+啟動安全網防更新後空白);學習成效分析(各科別能力雷達圖 + 錯題本/維修檢討反思);母港建設視覺成長(用獲利升級碼頭/倉儲/起重機/燈塔,母港畫面隨之長大,純視覺不影響計分);新手友善漸進式揭露(Tier 1 隱藏三日預報/技師疲勞細節,Tier 2 解鎖)+ 圖鑑生活化比喻 + 戲劇性非技術事件(環團/媒體/社區/稽查)。後續延伸候選:bearing類故障備品命名/Tier重新設計、crew_shortage/strike事件實效化、掌握度同步雲端供教師端個別鑽取、獨立測驗模式(Exam Mode)、母港視覺疊到實景背景" +last_updated: "2026-07-09" +next_milestone: "出海決策支援與學習迴路閉環已完成:出海前工期預估vs天氣窗(航線/登船/檢查/維修+保留餘裕,含磨耗/疲勞/擇日/缺料提醒)、審慎返港再規劃(不計安全事件)+半途成果保留(#carry)、⚡加班搶修風險抉擇(#rush,快vs穩三選一)、任務復盤星級+一句takeaway、診斷連對streak(封頂XP加成+🔥里程碑)、錯題本主動回想(檢索練習)、無障礙(減少動態:系統偏好+遊戲內開關;色覺友善雙重編碼)、戰情室SLA季末風險+母港預算續航提醒、Pages部署來源鎖定GitHub Actions(configure-pages)。內容系統全面查核與修正(事件/案例/任務/故障×消費端交叉驗證):每日任務維持型項目改日結算(deferred,修正開局白拿)、安全事件數夾0(堵刷分漏洞)、備品資料誤植修正(感測器/變槳故障)、交易所主線防卡(進行中工單備品永遠可見)、移除3組重複任務模板、案例研究資料誤植修正;判斷型任務引擎擴充至192題。先前:循序漸進難度與長線動機已完成:運維層級 Tier 系統(依進度自動推進,故障/備品/經濟壓力分層)、入門引導+UI 漸進揭露、每日任務(遊戲內日曆)、每週主題挑戰(風暴/斷料週等,綁戰情室事件池)、情境包匯入(教師自訂題組)、真實度深化(jack-up 安裝船動員前置期+計畫性定期保養)、故障/備品分層擴充(25 故障/33 備品)、真實案例研究事件(經查核的真實風場事故,具名+出處/去識別兩類,Tier 偶發+母港案例檔)。併發壓力測試通過(npm run stress);雲端後端 v2.1 加併發鎖(LockService);PWA 可安裝/離線(手機網頁並存);教師面板 CSV 匯出;營運趨勢儀表板/賽後復盤;登入畫面教師入口;存檔版本化遷移;code-split(初始包 653KB→268KB);知識點掌握度(答題正確率+弱點補強建議);HSE 科別加厚(LOTO 能量隔離/高處墜落防護/SIMOPS 同時作業,含圖鑑+鑑別診斷+戰情室事件);直升機進場與電網限電真實運維權衡判斷任務;真實/擬真案例擴充至 24 則,並可在自由營運中心任務清單以『案例演練』自然出現(略過 Tier、答後揭示 O&M 教訓並收錄圖鑑);手機直向專屬精簡版面(母港+交易所+出海+維修+所有彈窗,自動偵測裝置;PWA 快取版本化+錯誤邊界+啟動安全網防更新後空白);學習成效分析(各科別能力雷達圖 + 錯題本/維修檢討反思);母港建設視覺成長(用獲利升級碼頭/倉儲/起重機/燈塔,母港畫面隨之長大,純視覺不影響計分);新手友善漸進式揭露(Tier 1 隱藏三日預報/技師疲勞細節,Tier 2 解鎖)+ 圖鑑生活化比喻 + 戲劇性非技術事件(環團/媒體/社區/稽查)。本輪續作(邏輯完整性):人力短缺/罷工事件實效化(缺工直接折抵戰情室「可同時開的現場作業面」effectiveJobCapOf,滿編不懲罰、下限1、遠端重啟不占名額、每日休整回復;開局改滿編30/30、戰情室顯示缺工折抵——事件從純展示變成先遠端重啟/批次搶修/靠港補人的真實取捨)、軸承備品正名(新增Tier3「傳動軸承組」drive_bearing,主軸承振動/發電機振動改指專屬備品、與Tier4主軸承全換區隔,補pitch_bearing_wear事件避免孤兒備品;見GAME_DESIGN §18)。後續延伸候選:掌握度同步雲端供教師端個別鑽取、獨立測驗模式(Exam Mode)、母港視覺疊到實景背景" technologies: - React - TypeScript @@ -16,4 +16,4 @@ outputs: - presentation description: "An Uncharted-Waters-style educational game for offshore wind O&M training." description_zh: "大航海時代風格、以離岸風電運維為核心的教學遊戲。" -key_metrics: "四畫面UI+三模式背景(模擬/實境/漫畫)+俯瞰60°、192題判斷型任務引擎(7類,含直升機進場/電網限電權衡)+情境包匯入、雙層架構(計分主線+無限沙盒)、運維層級Tier循序漸進難度、每日任務(維持型日結算)+每週主題挑戰、出海決策支援(工期預估/半途成果保留/加班搶修)、25故障/33備品分層(含HSE:LOTO/墜落防護/SIMOPS)、jack-up動員+計畫保養、多風場+突發事件+安全KPI、知識點掌握度(正確率+弱點補強)+錯題本主動回想、無障礙(減少動態+色覺友善)、學號帳號+通關碼(後端驗證)+雲端為主存檔/紀錄、15項成就+個人檔案頁、教師檢視面板、雲端班級排行榜(Apps Script,併發壓測通過)、每週開放、中英雙語" +key_metrics: "四畫面UI+三模式背景(模擬/實境/漫畫)+俯瞰60°、192題判斷型任務引擎(7類,含直升機進場/電網限電權衡)+情境包匯入、雙層架構(計分主線+無限沙盒)、運維層級Tier循序漸進難度、每日任務(維持型日結算)+每週主題挑戰、出海決策支援(工期預估/半途成果保留/加班搶修)、25故障/34備品分層(含HSE:LOTO/墜落防護/SIMOPS;傳動軸承組正名)、人力短缺/罷工實效化(缺工折抵現場作業面)、jack-up動員+計畫保養、多風場+突發事件+安全KPI、知識點掌握度(正確率+弱點補強)+錯題本主動回想、無障礙(減少動態+色覺友善)、學號帳號+通關碼(後端驗證)+雲端為主存檔/紀錄、15項成就+個人檔案頁、教師檢視面板、雲端班級排行榜(Apps Script,併發壓測通過)、每週開放、中英雙語" diff --git a/docs/GAME_DESIGN.md b/docs/GAME_DESIGN.md index 994aa71..442d1d7 100644 --- a/docs/GAME_DESIGN.md +++ b/docs/GAME_DESIGN.md @@ -122,6 +122,8 @@ | **任務復盤(星級+takeaway)+診斷連對 streak** | ✅ 已實作(見 §17)| | **錯題本主動回想(檢索練習)** | ✅ 已實作(見 §17)`MistakeLog`| | **無障礙(減少動態+色覺友善雙重編碼)** | ✅ 已實作(見 §17)`useReducedMotion`| +| **人力短缺/罷工實效化(缺工折抵現場作業面)** | ✅ 已實作(見 §18)`effectiveJobCapOf`| +| **軸承備品正名(傳動軸承組 T3,與主軸承全換區隔)** | ✅ 已實作(見 §18)`drive_bearing`| | CC0 實體音樂 / 每機獨立健康度 | ⏳ 規劃中(見 §14)| > 兩層架構:**評量層**=全班相同的主線 7 關(教師按週開放、計成績);**自由營運層**=沙盒無限判斷型任務(永遠開放、只進排行榜)。 @@ -244,3 +246,13 @@ scores(user_id, score, availability, generation_mwh, missions_done, day, updated - **主線防卡**:交易所預設依 Tier 過濾備品,但進行中工單的必備備品**永遠可見**(不受過濾),避免主線卡在「找不到料」。 - **移除 3 組重複任務模板**(`b_harmonics`/`g_medical_evac`/`c_dehumidify` 與既有題重複,避免同題雙倍抽中率);`caseStudies.ts` 一則案例「正解」誤植安全事件(全內容唯一反例)已修正。 +## 18. 人力短缺實效化・軸承備品正名(本輪新增) +接續 §17 的內容查核,補上兩處**邏輯完整性**缺口——把「只影響展示、不影響決策」的事件接上實際機制,並修正一組權宜共用的備品命名。 + +- **人力短缺/罷工事件實效化(#crew,`crewShortfallJobs`/`effectiveJobCapOf`)**:`crew_shortage`(−3)、`strike`(−6) 事件原本只調整展示用 `techAvail`,`OPS_DISPATCH`/`OPS_INSPECT` 完全不讀 → 事件承諾了後果卻不兌現。現在缺工**直接折抵「可同時開的現場作業面」**:`crewShortfallJobs = ⌈(techTotal − techAvail) / CREW_PER_JOB⌉`(`CREW_PER_JOB=4`),有效上限 `effectiveJobCapOf = max(1, jobCapOf − crewShortfallJobs)`。設計要點: + - **滿編不懲罰**:以「缺額」而非「絕對人數」計,`techAvail=techTotal` 時 shortfall=0 → 不影響任何船隊(含頂級母船);缺工愈多、可並行的作業面愈少。 + - **下限 1**:極端缺工仍可派一組(短手應變),且**遠端重啟不占名額、主線工單不受此限** → 不會硬鎖遊戲;`techAvail` 每日休整 +1 逐步回復,讓「罷工」有明確的止血與恢復節奏。 + - **開局滿編 30/30**(原 24/30)+ tech 升級同步 `techAvail`(+2):避免「憑空缺額」在開局或擴編後誤觸折抵。 + - **戰情室透明化**(`FleetOpsModal`):新增「可出勤班組」統計;短手時顯示 👷 橫幅(`vesselCap → cap`、少 N 面)與「現場已滿」時的成因區分(人力 vs 船舶)。事件不再是純展示,而是「先遠端重啟軟故障、批次搶修、靠港補人」的真實取捨。 +- **軸承備品正名(#bearing,`drive_bearing`)**:主軸承振動事件(`bearing`)與發電機振動圖鑑故障(`gen_vibration`)原本權宜共用「變槳軸承」(`pitch_bearing`)備品(規避 Tier 閘門)。新增 Tier 3 專屬備品**「傳動軸承組(發電機/主軸)」**(`drive_bearing`, ◎130 萬)並重新指派兩者——與 Tier 4「主軸承」全換(`main_bearing`)語意區隔(振動維修級 vs 重吊更換)。同時新增 `pitch_bearing_wear`(變槳軸承磨耗)事件,讓 `pitch_bearing` 備品保有真實消費端(與既有 `cs_pitch_bearing_wear` 案例研究呼應),不成孤兒。 + diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 521d5fd..ad68580 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -27,7 +27,7 @@ - 已查核確認乾淨:quiz 索引、跨檔 id 引用、i18n 完整性、Tier 閘門、派工守衛一致性、事件權重、案例去重。 - **Pages 部署硬化**:`deploy.yml` 新增 `actions/configure-pages`(鎖定發佈來源為 GitHub Actions,避免誤切回「Deploy from a branch」導致線上端出未 build 原始碼);`concurrency.cancel-in-progress` 改 false。 -**已知留待下次的候選(需設計決定,見下方「進行中或下一步」)**:bearing 類故障共用「變槳軸承」備品的命名/Tier 取捨;`crew_shortage`/`strike` 事件目前只影響展示用 `techAvail`,尚未接入實際派工邏輯。 +**本輪續作已完成(原留待下次的兩項)**:bearing 類故障備品正名(新增 Tier 3「傳動軸承組」`drive_bearing`,主軸承振動/發電機振動不再權宜共用「變槳軸承」,並補 `pitch_bearing_wear` 事件避免孤兒備品);`crew_shortage`/`strike` 事件實效化(缺工直接折抵戰情室「可同時開的現場作業面」,滿編不懲罰、下限 1、可休整回復)。詳見 [GAME_DESIGN.md](GAME_DESIGN.md) §18。 --- @@ -79,8 +79,8 @@ - **✅ 營運趨勢儀表板 / 賽後復盤** — 已完成。每次推進日累積 KPI 歷史,母港「📈 營運趨勢·賽後復盤」顯示妥善率/健康度/收入vs支出/淨額/累積發電的時間序列圖 + 期間摘要(平均/最低妥善率、總收入/支出/淨額、發電增量),作教學覆盤工具。 - **✅ 登入畫面教師入口** — 已完成。登入畫面提供教師入口(免先登入遊戲),輸入班級碼+教師碼即可開啟唯讀教師檢視。 - **✅ 出海決策支援・學習迴路收斂・無障礙**(本輪) — 見上方「本輪已完成」與 [GAME_DESIGN.md](GAME_DESIGN.md) §17。 -- **bearing 類故障備品命名/Tier 重新設計** — 主軸承振動(`bearing`)與發電機軸承磨損(`gen_vibration`)目前共用「變槳軸承」(`pitch_bearing`)備品(權宜規避 Tier 閘門,見本輪查核)。建議新增一個 Tier 3「發電機/主軸軸承」備品並重新指派,需與設計者確認 Tier 平衡影響。 -- **crew_shortage / strike 事件實效化** — 目前這兩個事件只調整展示用 `techAvail` 數字,未接入任何派工/派船的實際限制(`OPS_DISPATCH` 不讀取此欄位)。建議接上技師疲勞加成或派工人力上限檢查,讓事件真正影響決策而非純展示。 +- **✅ bearing 類故障備品命名/Tier 重新設計** — 已完成。新增 Tier 3「傳動軸承組(發電機/主軸)」(`drive_bearing`, ◎130 萬),主軸承振動(`bearing`)與發電機振動(`gen_vibration`)改指專屬備品,與 Tier 4「主軸承」全換(`main_bearing`)語意區隔;並新增 `pitch_bearing_wear`(變槳軸承磨耗)事件,讓變槳軸承備品保有真實消費端(呼應 `cs_pitch_bearing_wear` 案例)、不成孤兒。見 [GAME_DESIGN.md](GAME_DESIGN.md) §18。 +- **✅ crew_shortage / strike 事件實效化** — 已完成。缺工(`techTotal − techAvail`)直接折抵戰情室「可同時開的現場作業面」(`effectiveJobCapOf = max(1, jobCapOf − ⌈缺額/CREW_PER_JOB⌉)`):滿編不懲罰(以缺額計)、下限 1(短手仍可派一組、遠端重啟不占名額、主線不受限)、每日休整回復;開局改滿編 30/30、tech 升級同步 techAvail、戰情室顯示缺工折抵。事件從純展示變成「先遠端重啟軟故障/批次搶修/靠港補人」的真實取捨。見 [GAME_DESIGN.md](GAME_DESIGN.md) §18。 - **試玩微調與平衡回測** — 依 `npm run sim` 持續校正故障率/經濟手感;本輪修正備品資料與每日任務發獎時機後,建議重跑一次完整回測。 - **戰情室停機折抵「現金」收入的選項** — 目前停機已折抵淨發電;提供把戰情室層也接入售電現金的設定開關(需與設計者確認)。 - **文件持續對齊** — README / 手冊 / 攻略隨數值變動同步維護;`docs/TEST_REPORT.md`、`docs/MANUAL.zh-TW.md`、`docs/WALKTHROUGH.md` 內的測試數/任務題數等統計為較早版本快照,下次更新時一併校正為 `npm test`/`TASKS.length` 實跑值。 diff --git a/src/state/game.ts b/src/state/game.ts index 7d09b7b..cea7b72 100644 --- a/src/state/game.ts +++ b/src/state/game.ts @@ -97,6 +97,12 @@ export function activeVesselSpec(s: { activeVessel?: VesselClass; ownsSOV?: bool } export const seaTolOf = (s: { activeVessel?: VesselClass; ownsSOV?: boolean }): number => activeVesselSpec(s).seaTol; export const jobCapOf = (s: { activeVessel?: VesselClass; ownsSOV?: boolean; vesselLevel: number }): number => activeVesselSpec(s).jobCap + s.vesselLevel; +// 人力短缺/罷工事件實效化(#crew):可出勤班組(techAvail)缺額會折抵「可同時開的現場作業面」。 +// 滿編(techAvail=techTotal)時 shortfall=0 → 不影響頂級船隊;缺工愈多、可並行的作業面愈少,讓 crew_shortage/strike 真正影響決策而非純展示。 +export const CREW_PER_JOB = 4; // 每少 4 名可出勤班組 → 少 1 個現場作業面(tune:crew_shortage −3 折 1 面、strike −6 折 2 面) +export const crewShortfallJobs = (s: { techAvail: number; techTotal: number }): number => Math.ceil(Math.max(0, s.techTotal - s.techAvail) / CREW_PER_JOB); +// 有效現場工單上限 = 船舶上限 − 人力缺額折抵,下限 1(短手仍可派一組;遠端重啟不占用、且主線工單不受此限)。 +export const effectiveJobCapOf = (s: { activeVessel?: VesselClass; ownsSOV?: boolean; vesselLevel: number; techAvail: number; techTotal: number }): number => Math.max(1, jobCapOf(s) - crewShortfallJobs(s)); export const sortieCostOf = (s: { activeVessel?: VesselClass; ownsSOV?: boolean }): number => activeVesselSpec(s).sortieCost; export const vesselWearOf = (s: { activeVessel?: VesselClass; ownsSOV?: boolean }): number => activeVesselSpec(s).wearRate; export const windowBonusOf = (s: { activeVessel?: VesselClass; ownsSOV?: boolean }): number => activeVesselSpec(s).windowBonus; @@ -432,7 +438,7 @@ export const INITIAL: GameData = { budget: 84_200_000, xp: 0, day: 21, - techAvail: 24, + techAvail: 30, // 開局滿編(=techTotal),缺工由事件造成、隨休整回復(#crew 人力折抵作業面) techTotal: 30, availability: fleetUptime(INITIAL_FLEET), // 妥善率 = 機隊運轉比例(單一真實來源,#3) seaState: "workable", @@ -894,7 +900,7 @@ export function reducer(s: GameData, a: Action): GameData { if (!inc || eng.discipline !== inc.discipline) return s; // 需對應科別技師 if (fatigueOf(eng) >= FATIGUE_LIMIT) return s; // 過勞不可派 if (engineerBusy(s.opsJobs, eng.id)) return s; // 該技師已在執行工單 - if (onsiteJobCount(s.opsJobs) >= jobCapOf(s)) return s; // 船舶現場工單已達上限(依目前作業船,#4) + if (onsiteJobCount(s.opsJobs) >= effectiveJobCapOf(s)) return s; // 現場工單上限:船舶上限 − 人力缺額折抵(#4/#crew) if (SEA_INDEX[s.seaState] > seaTolOf(s)) return s; // 海象超過目前作業船耐受度,無法派船(可改遠端重啟或等天氣窗) if ((s.inventory[inc.part] ?? 0) <= 0) return s; // 缺必備備品,無法現場維修(接上真實備品價格) const inv = { ...s.inventory, [inc.part]: (s.inventory[inc.part] ?? 0) - 1 }; @@ -918,7 +924,7 @@ export function reducer(s: GameData, a: Action): GameData { if (!eng) return s; if (fatigueOf(eng) >= FATIGUE_LIMIT) return s; // 過勞不可派 if (engineerBusy(s.opsJobs, eng.id)) return s; // 已在執行工單 - if (onsiteJobCount(s.opsJobs) >= jobCapOf(s)) return s; // 船舶現場工單上限(依目前作業船,#4) + if (onsiteJobCount(s.opsJobs) >= effectiveJobCapOf(s)) return s; // 現場工單上限:船舶上限 − 人力缺額折抵(#4/#crew) if (SEA_INDEX[s.seaState] > seaTolOf(s)) return s; // 海象超過目前作業船耐受度,無法派船 const job: OpsJob = { id: "ins_" + Math.random().toString(36).slice(2, 9), turbine: "__sweep__", engineerId: eng.id, discipline: eng.discipline, daysLeft: INSPECT_DAYS, kind: "inspect" }; const newSortie = onsiteJobCount(s.opsJobs) === 0; // 定檢也算一趟出海 @@ -1016,7 +1022,7 @@ export function reducer(s: GameData, a: Action): GameData { if (a.cost > s.budget) return s; const adv = advance(s, 1); // 設施升級/訓練耗時 1 天(Phase B) const patch = - a.kind === "vessel" ? { vesselLevel: s.vesselLevel + 1 } : a.kind === "tech" ? { techLevel: s.techLevel + 1, techTotal: s.techTotal + 2 } : { toolLevel: s.toolLevel + 1 }; + a.kind === "vessel" ? { vesselLevel: s.vesselLevel + 1 } : a.kind === "tech" ? { techLevel: s.techLevel + 1, techTotal: s.techTotal + 2, techAvail: s.techAvail + 2 } : { toolLevel: s.toolLevel + 1 }; // 招募擴編:新進即到位(techAvail 同步 +2),避免升級後憑空產生人力缺額 return { ...s, ...adv, budget: Math.max(0, (adv.budget ?? s.budget) - a.cost), ...patch }; } case "NEXT_QUEST": { diff --git a/src/state/incidents.ts b/src/state/incidents.ts index 1d83e16..03ac320 100644 --- a/src/state/incidents.ts +++ b/src/state/incidents.ts @@ -25,7 +25,7 @@ export const INCIDENTS: IncidentType[] = [ { id: "pitch", name: { zh: "變槳故障", en: "Pitch fault" }, discipline: "control", repairDays: 2, part: "pitch_battery", weight: 4, minTier: 2 }, // 修:控制側變槳故障耗後備電池;液壓根因由 pitch_hyd(hydraulic_valve)負責 { id: "yaw", name: { zh: "偏航失準", en: "Yaw misalignment" }, discipline: "control", repairDays: 1, resettable: true, part: "yaw_motor", weight: 4, minTier: 1 }, { id: "converter", name: { zh: "變流器跳脫", en: "Converter trip" }, discipline: "electrical", repairDays: 1, resettable: true, part: "converter", weight: 3, minTier: 2 }, - { id: "bearing", name: { zh: "主軸承振動", en: "Main-bearing vibration" }, discipline: "mechanical", repairDays: 3, part: "pitch_bearing", weight: 2, minTier: 3 }, + { id: "bearing", name: { zh: "主軸承振動", en: "Main-bearing vibration" }, discipline: "mechanical", repairDays: 3, part: "drive_bearing", weight: 2, minTier: 3 }, // 修:改用專屬「傳動軸承組」(原權宜共用變槳軸承);與 Tier4 主軸承全換區隔 { id: "cable", name: { zh: "海纜絕緣告警", en: "Cable insulation alarm" }, discipline: "electrical", repairDays: 2, part: "cable_joint", weight: 2, minTier: 3 }, { id: "blade", name: { zh: "葉片損傷", en: "Blade damage" }, discipline: "structural", repairDays: 2, part: "gfrp_blade", weight: 1, minTier: 4 }, // ── 擴充故障池(對應更多元的備品/科別;維持「耗材常見、大型罕見」的真實分布)── @@ -41,6 +41,7 @@ export const INCIDENTS: IncidentType[] = [ { id: "tower_bolt", name: { zh: "塔基螺栓鬆動", en: "Tower bolt loosening" }, discipline: "structural", repairDays: 2, part: "bolt_m36", weight: 2, minTier: 2 }, { id: "lift", name: { zh: "升降機/安全系統", en: "Service lift fault" }, discipline: "hse", repairDays: 1, part: "lift_part", weight: 2, minTier: 2 }, { id: "main_bearing", name: { zh: "主軸承磨耗", en: "Main-bearing wear" }, discipline: "mechanical", repairDays: 3, part: "main_bearing", weight: 1, minTier: 4 }, + { id: "pitch_bearing_wear", name: { zh: "變槳軸承磨耗", en: "Pitch-bearing wear" }, discipline: "mechanical", repairDays: 3, part: "pitch_bearing", weight: 1, minTier: 3 }, // 變槳軸承專屬故障(與 cs_pitch_bearing_wear 案例呼應),使變槳軸承備品有真實消費端 // ── #82 分層擴充:基礎防蝕(結構/Tier2)、變壓器套管(電氣/Tier3)、海纜接頭過熱(電氣/Tier3) ── { id: "corrosion", name: { zh: "基礎/塔基防蝕劣化", en: "Foundation corrosion" }, discipline: "structural", repairDays: 2, part: "corrosion_anode", weight: 2, minTier: 2 }, { id: "bushing", name: { zh: "變壓器套管劣化", en: "Transformer bushing fault" }, discipline: "electrical", repairDays: 2, part: "transformer_bushing", weight: 1, minTier: 3 }, diff --git a/src/ui/FleetOpsModal.tsx b/src/ui/FleetOpsModal.tsx index 0002cd3..f52bd61 100644 --- a/src/ui/FleetOpsModal.tsx +++ b/src/ui/FleetOpsModal.tsx @@ -9,7 +9,7 @@ import { DISC } from "./disc"; import { FARMS } from "../state/farms"; import { incidentAt } from "../state/incidents"; import { PARTS } from "./data"; -import { fleetUptime, engineerBusy, fatigueOf, FATIGUE_LIMIT, jobCapOf, onsiteJobCount, INSPECT_DAYS, SEA_INDEX, seaTolOf, activeVesselSpec, SEA_LABEL, dailyPayroll, toWan, sortieCostOf, QUARTER_DAYS, SLA_FLOOR } from "../state/game"; +import { fleetUptime, engineerBusy, fatigueOf, FATIGUE_LIMIT, jobCapOf, effectiveJobCapOf, crewShortfallJobs, onsiteJobCount, INSPECT_DAYS, SEA_INDEX, seaTolOf, activeVesselSpec, SEA_LABEL, dailyPayroll, toWan, sortieCostOf, QUARTER_DAYS, SLA_FLOOR } from "../state/game"; import { LedgerView } from "./Ledger"; const STATUS_COLOR: Record = { ok: "#3f7d52", fault: "#c0463a", repair: "#cf9a35" }; @@ -51,7 +51,9 @@ export default function FleetOpsModal({ open, onClose }: { open: boolean; onClos const selT = sel ? fleet.find((t) => t.id === sel) : null; const inc = selT ? incidentAt(selT.faultId) : undefined; const candidates = inc ? data.engineers.filter((e) => e.discipline === inc.discipline && !engineerBusy(data.opsJobs, e.id) && fatigueOf(e) < FATIGUE_LIMIT) : []; - const cap = jobCapOf(data); + const vesselCap = jobCapOf(data); // 船舶本身的現場工單上限(未計人力) + const crewShort = crewShortfallJobs(data); // 人力缺額折抵的作業面數(#crew) + const cap = effectiveJobCapOf(data); // 有效上限 = 船舶上限 − 人力缺額折抵(下限 1) const onsite = onsiteJobCount(data.opsJobs); const atCap = onsite >= cap; const vSpec = activeVesselSpec(data); @@ -86,6 +88,7 @@ export default function FleetOpsModal({ open, onClose }: { open: boolean; onClos = 90 ? C.green : uptime >= 75 ? C.amber : C.red} /> 0 ? C.red : C.green} /> + 0 ? C.amber : C.mist2} /> @@ -103,6 +106,13 @@ export default function FleetOpsModal({ open, onClose }: { open: boolean; onClos : ""} + {/* 人力短缺/罷工實效化(#crew):缺工折抵可同時開的現場作業面 —— 只在短手時顯示 */} + {crewShort > 0 && ( +
+ 👷 {t({ zh: `人力短缺:可出勤班組 ${data.techAvail}/${data.techTotal} → 現場作業面上限 ${vesselCap} 降為 ${cap}(少 ${crewShort} 面)。可先遠端重啟軟故障、批次搶修,靠港休整後人力逐日回復。`, en: `Crew shortage: ${data.techAvail}/${data.techTotal} on duty → on-site slots cut ${vesselCap}→${cap} (−${crewShort}). Remote-restart soft faults, batch repairs; crew recovers daily with rest.` })} +
+ )} + {/* 海象限制派船(接上天氣預報) */} {!seaOk && (
@@ -185,7 +195,9 @@ export default function FleetOpsModal({ open, onClose }: { open: boolean; onClos {!seaOk ? (
{t({ zh: `海象「${SEA_LABEL[data.seaState].zh}」無法派船——等可作業天氣窗,或升級/購置 SOV(可在更高海象出航)。`, en: `Seas "${SEA_LABEL[data.seaState].en}" — can't deploy. Wait for a workable window or get an SOV.` })}
) : atCap ? ( -
{t({ zh: `船舶現場工單已滿(${onsite}/${cap})。等工單完成、升級整備或購置 SOV 以提高並行數。`, en: `On-site jobs full (${onsite}/${cap}). Wait, upgrade the vessel or buy an SOV to raise concurrency.` })}
+
{crewShort > 0 + ? t({ zh: `現場工單已滿(${onsite}/${cap})——人力短缺折抵 ${crewShort} 面(船舶上限 ${vesselCap})。等工單完成、靠港休整回復人力,或改遠端重啟。`, en: `On-site jobs full (${onsite}/${cap}) — crew shortage cuts ${crewShort} slot(s) (vessel cap ${vesselCap}). Wait for jobs to finish, rest crew, or use remote restart.` }) + : t({ zh: `船舶現場工單已滿(${onsite}/${cap})。等工單完成、升級整備或購置 SOV 以提高並行數。`, en: `On-site jobs full (${onsite}/${cap}). Wait, upgrade the vessel or buy an SOV to raise concurrency.` })}
) : (data.inventory[inc.part] ?? 0) <= 0 ? (
{t({ zh: "缺必備備品,無法現場維修——先去備品交易所採購(或先遠端重啟可重啟的故障)。", en: "Missing the required part — buy it at the Parts Market first (or remote-restart resettable faults)." })}
) : candidates.length === 0 ? ( diff --git a/src/ui/data.ts b/src/ui/data.ts index 8482b1e..e03609f 100644 --- a/src/ui/data.ts +++ b/src/ui/data.ts @@ -19,6 +19,7 @@ export const PARTS: Part[] = [ { id: "main_bearing", n: { zh: "主軸承", en: "Main Bearing" }, stars: 3, idx: 118, qty: 24, price: "3,500,000", minTier: 4 }, { id: "converter", n: { zh: "變流器模組", en: "Converter Module" }, stars: 3, idx: 106, qty: 81, price: "2,600,000", minTier: 2 }, { id: "pitch_bearing", n: { zh: "變槳軸承", en: "Pitch Bearing" }, stars: 3, idx: 122, qty: 65, price: "1,800,000", minTier: 3 }, + { id: "drive_bearing", n: { zh: "傳動軸承組(發電機/主軸)", en: "Drivetrain Bearing Set (gen/main-shaft)" }, stars: 3, idx: 110, qty: 30, price: "1,300,000", minTier: 3 }, // 傳動鏈滾動軸承(發電機/主軸振動維修級);與 Tier4 主軸承全換(main_bearing)區隔 { id: "igbt_module", n: { zh: "IGBT 功率模組", en: "IGBT Power Module" }, stars: 3, idx: 112, qty: 40, price: "1,200,000", minTier: 3 }, // ── 中型組件(數十~數百萬)── { id: "main_controller", n: { zh: "主控制器 PLC", en: "Main Controller (PLC)" }, stars: 3, idx: 99, qty: 28, price: "900,000", minTier: 2 }, diff --git a/src/ui/faults.ts b/src/ui/faults.ts index 8cffc55..6bdb0ea 100644 --- a/src/ui/faults.ts +++ b/src/ui/faults.ts @@ -105,7 +105,7 @@ export const FAULTS: Record = { { zh: "復歸測試並回報 SCADA", en: "Reset test & report to SCADA" }, ], knowledge_point: "vibration_bpfi", - part: "pitch_bearing", + part: "drive_bearing", // 修:發電機軸承改用專屬「傳動軸承組」(原權宜共用變槳軸承) discipline: "mechanical", }, pitch_fault: { diff --git a/test/run.mjs b/test/run.mjs index ea2c7df..dd531d0 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -455,6 +455,68 @@ test("remote resets do not count toward vessel cap", () => { eq(g.onsiteJobCount(s.opsJobs), 1, "crew dispatch still allowed"); }); +// ───────────── 人力短缺/罷工事件實效化(#crew):缺工折抵現場作業面 ───────────── +test("crew shortfall folds into on-site cap; full crew never penalized; floor 1 (#crew)", () => { + eq(g.crewShortfallJobs({ techAvail: 30, techTotal: 30 }), 0, "full crew: no shortfall"); + eq(g.crewShortfallJobs({ techAvail: 27, techTotal: 30 }), 1, "-3 crew → -1 slot (crew_shortage)"); + eq(g.crewShortfallJobs({ techAvail: 24, techTotal: 30 }), 2, "-6 crew → -2 slots (strike)"); + const sov = { activeVessel: "sov", ownsSOV: true, vesselLevel: 0 }; + eq(g.effectiveJobCapOf({ ...sov, techAvail: 30, techTotal: 30 }), g.jobCapOf(sov), "full crew: eff cap == vessel cap (no nerf)"); + eq(g.effectiveJobCapOf({ ...sov, techAvail: 24, techTotal: 30 }), g.jobCapOf(sov) - 2, "strike cuts 2 slots on SOV"); + eq(g.effectiveJobCapOf({ activeVessel: "ctv", ownsSOV: false, vesselLevel: 0, techAvail: 0, techTotal: 30 }), 1, "floor at 1 even fully short-handed"); +}); +test("OPS_DISPATCH respects crew-reduced cap; full crew allows more (#crew)", () => { + const idxs = I.fleet.map((t, i) => (t.status === "ok" ? i : -1)).filter((i) => i >= 0).slice(0, 3); + const fleet = I.fleet.map((t, i) => (idxs.includes(i) ? { ...t, status: "fault", faultId: "gearbox" } : t)); + const engineers = ["a", "b", "c"].map((id) => ({ id, name: id, discipline: "mechanical", level: 1, fatigue: 0 })); + const sovBase = { fleet, engineers, ownsSOV: true, ownedVessels: ["ctv", "sov"], activeVessel: "sov", vesselLevel: 0, inventory: { gearbox_oil: 9 } }; + // 缺工 22/30 → shortfall 2 → SOV 有效上限 4−2=2:派 2 可、第 3 受阻 + let s = { ...I, ...sovBase, techAvail: 22, techTotal: 30 }; + eq(g.effectiveJobCapOf(s), 2, "SOV cap 4 − 2 crew shortfall = 2"); + s = R(s, { type: "OPS_DISPATCH", turbine: fleet[idxs[0]].id, engineerId: "a" }); + s = R(s, { type: "OPS_DISPATCH", turbine: fleet[idxs[1]].id, engineerId: "b" }); + eq(g.onsiteJobCount(s.opsJobs), 2, "two on-site jobs under crew-reduced cap"); + const before = s.opsJobs.length; + s = R(s, { type: "OPS_DISPATCH", turbine: fleet[idxs[2]].id, engineerId: "c" }); + eq(s.opsJobs.length, before, "third dispatch blocked by crew shortfall"); + // 對照:滿編 30/30 → 有效上限 4,同一 SOV 可派到 3 + let full = { ...I, ...sovBase, techAvail: 30, techTotal: 30 }; + full = R(full, { type: "OPS_DISPATCH", turbine: fleet[idxs[0]].id, engineerId: "a" }); + full = R(full, { type: "OPS_DISPATCH", turbine: fleet[idxs[1]].id, engineerId: "b" }); + full = R(full, { type: "OPS_DISPATCH", turbine: fleet[idxs[2]].id, engineerId: "c" }); + eq(g.onsiteJobCount(full.opsJobs), 3, "full crew allows the third dispatch (SOV cap 4)"); +}); +test("crew_shortage/strike events reduce techAvail → fewer slots, not just HUD (#crew)", () => { + const cs = events.EVENTS.find((e) => e.id === "crew_shortage"); + const st = events.EVENTS.find((e) => e.id === "strike"); + ok(cs && st, "both crew events exist"); + const base = { techAvail: 30, techTotal: 30 }; + eq(cs.apply(base).techAvail, 27, "crew_shortage -3"); + eq(st.apply(base).techAvail, 24, "strike -6"); + const sov = { activeVessel: "sov", ownsSOV: true, vesselLevel: 0, techTotal: 30 }; + ok(g.effectiveJobCapOf({ ...sov, techAvail: st.apply(base).techAvail }) < g.effectiveJobCapOf({ ...sov, techAvail: 30 }), "strike lowers effective on-site cap (wired to mechanic)"); +}); +test("INITIAL starts full-crew; tech upgrade keeps crew full — no phantom shortfall (#crew)", () => { + eq(I.techAvail, I.techTotal, "new game starts fully crewed"); + eq(g.crewShortfallJobs(I), 0, "no shortfall at start"); + const up = R({ ...I, budget: 999_999_999 }, { type: "UPGRADE", kind: "tech", cost: 0 }); + eq(up.techTotal, I.techTotal + 2, "tech upgrade raises headcount"); + eq(up.techAvail, up.techTotal, "new hires immediately available"); + eq(g.crewShortfallJobs(up), 0, "still no shortfall after upgrade"); +}); +test("bearing parts: drive_bearing (T3) replaces the pitch_bearing workaround (#bearing)", () => { + const db = data.PARTS.find((p) => p.id === "drive_bearing"); + ok(db, "drive_bearing part exists"); + eq(db.minTier, 3, "drive_bearing is Tier 3"); + eq(inc.incidentAt("bearing").part, "drive_bearing", "main-bearing vibration → drive_bearing (not pitch_bearing)"); + eq(flt.FAULTS.gen_vibration.part, "drive_bearing", "generator vibration codex → drive_bearing"); + // pitch_bearing 仍有真實消費端(pitch_bearing_wear 事件),未變孤兒;與 cs_pitch_bearing_wear 案例呼應 + ok(inc.INCIDENTS.some((x) => x.part === "pitch_bearing"), "pitch_bearing still consumed by an incident (no orphan)"); + ok(cs.CASE_STUDIES.some((c) => c.id === "cs_pitch_bearing_wear"), "pitch-bearing-wear case study still present"); + // 沿用不變式:drive_bearing 消費端的 part tier ≤ incident tier(買得到) + for (const x of inc.INCIDENTS.filter((i) => i.part === "drive_bearing")) ok((db.minTier ?? 1) <= (x.minTier ?? 1), `incident ${x.id}: part tier <= incident tier`); +}); + // ───────────────────────── C2:預防性定檢 ───────────────────────── test("OPS_INSPECT creates an inspect job; completion sets fault-rate buff", () => { let s = { ...I, engineers: [{ id: "k", name: "k", discipline: "control", level: 1, fatigue: 0 }] };