diff --git a/STATUS.yaml b/STATUS.yaml index 4a7e55a..6f2c527 100644 --- a/STATUS.yaml +++ b/STATUS.yaml @@ -3,8 +3,8 @@ name_zh: 離岸風場・運維傳說 category: edtech progress: 100 status: active -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)、母港視覺疊到實景背景" +last_updated: "2026-07-10" +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)。再一輪(收尾三延伸候選,見GAME_DESIGN §19):獨立測驗模式(#exam,exam.ts/ExamModal —— 跨科別均衡抽題、無提示單次作答、A–F等第+各類別對錯+錯題覆盤;RECORD_EXAM計入掌握度與錯題本但不動xp/streak,最佳成績bestExam存紀錄;入口在課程模式)、掌握度雲端同步供教師端個別鑽取(#mastery-cloud,存檔夾帶掌握度精簡摘要、後端Code.gs v2.2存/回mastery、教師面板點列展開各科別正確率鑽取,向後相容)、母港建設疊實境/漫畫背景(#port,PortScene依背景模式疊harbor.jpg/comic_harbor.jpg)。後續延伸候選:每機獨立健康度/RUL預測性維護、內容編輯器讓教師資料驅動新增故障" 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故障/34備品分層(含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/CLOUD_SETUP.md b/docs/CLOUD_SETUP.md index 345655d..58118c3 100644 --- a/docs/CLOUD_SETUP.md +++ b/docs/CLOUD_SETUP.md @@ -23,6 +23,11 @@ > 🆕 **v2.1(併發加固)**:本版所有寫入加上 `LockService` 序列化,杜絕多人同時寫入時的 > 競態覆蓋/重複建號;`findRow` 改為只讀 key 欄以降低讀取量。**改用此版務必「新版本」重部署**。 +> +> 🆕 **v2.2(掌握度雲端同步)**:`saves` 分頁新增**第 9 欄 `mastery`**(知識點掌握度精簡摘要 JSON), +> 隨存檔一併寫入;`teacher` 端回傳每位學生的 `mastery`,供**教師檢視面板「個別鑽取」**各科別/類別正確率。 +> **改用此版務必「新版本」重部署**;既有 `saves` 舊列第 9 欄留空即可(教師端會優雅降級,其餘欄位不受影響)。 +> 未升級後端時,前端一切照舊運作,只是教師面板不顯示掌握度鑽取資料。 ## 併發與容量(單班 ~50 人) - 後端為**單一試算表 + Apps Script Web App**。單班約 50 人課堂操作可負荷:存檔去抖 1.5 秒、 diff --git a/docs/GAME_DESIGN.md b/docs/GAME_DESIGN.md index 442d1d7..b138c18 100644 --- a/docs/GAME_DESIGN.md +++ b/docs/GAME_DESIGN.md @@ -124,6 +124,9 @@ | **無障礙(減少動態+色覺友善雙重編碼)** | ✅ 已實作(見 §17)`useReducedMotion`| | **人力短缺/罷工實效化(缺工折抵現場作業面)** | ✅ 已實作(見 §18)`effectiveJobCapOf`| | **軸承備品正名(傳動軸承組 T3,與主軸承全換區隔)** | ✅ 已實作(見 §18)`drive_bearing`| +| **獨立測驗模式(跨科別評量+錯題覆盤)** | ✅ 已實作(見 §19)`exam.ts`/`ExamModal`| +| **掌握度雲端同步・教師端個別鑽取** | ✅ 已實作(見 §19,後端需 v2.2)`masterySummary`| +| **母港建設疊實境/漫畫背景** | ✅ 已實作(見 §19)`PortScene mode`| | CC0 實體音樂 / 每機獨立健康度 | ⏳ 規劃中(見 §14)| > 兩層架構:**評量層**=全班相同的主線 7 關(教師按週開放、計成績);**自由營運層**=沙盒無限判斷型任務(永遠開放、只進排行榜)。 @@ -256,3 +259,10 @@ scores(user_id, score, availability, generation_mwh, missions_done, day, updated - **戰情室透明化**(`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` 案例研究呼應),不成孤兒。 +## 19. 獨立測驗模式・掌握度雲端鑽取・母港疊實景(本輪新增) +收尾三項延伸候選,把既有「判斷型任務/掌握度/母港建設」延展為評量、教師洞察與視覺收束。 + +- **獨立測驗模式(#exam,`state/exam.ts` + `ExamModal`)**:把判斷型題庫抽成一份正式評量——固定 10/20 題、跨科別均衡抽題(各類別洗牌後 round-robin)、**排除需輔助圖者**以確保純文字公平作答;**作答當下不給提示、不揭示對錯**,結束才公布 `pct`、A–F 等第、各類別對錯與**錯題覆盤**(揭示正解+教訓)。純函式引擎 `buildExam`(可決定性種子)/`gradeExam` 集中於 `exam.ts`;作答經 **`RECORD_EXAM`** 批次計入知識點掌握度與錯題本(供主動回想),但**不動 xp/answerStreak** → 評量與遊戲經濟隔離。最佳成績 `bestExam` 存學習紀錄(只增不減、雲端聯集取大),個人檔案頁顯示。入口在 ⚙ 課程模式。 +- **掌握度雲端同步・教師端個別鑽取(#mastery-cloud)**:存檔上傳時夾帶**掌握度精簡摘要**(`masterySummary`:只帶 n>0 的格、[n,ok] 元組);後端 `Code.gs` v2.2 於 `saves` 新增第 9 欄 `mastery`,`teacher` 端回傳。教師檢視面板可**點任一學生列展開鑽取**:以 `parseMasterySummary` 還原後,沿用個人檔案頁同一套 `masteryRows(disc/cat)` 顯示各科別/類別正確率條與最弱項。全程**向後相容**:舊後端不回傳 `mastery` → 教師端優雅降級並提示需升級 v2.2(見 [CLOUD_SETUP.md](CLOUD_SETUP.md))。 +- **母港建設疊實境/漫畫背景(#port)**:`PortScene` 依全域背景模式(模擬/實境/漫畫)選底——實境→`harbor.jpg`、漫畫→`comic_harbor.jpg`,母港建設 sprite(碼頭/倉庫/起重機/燈塔/貨櫃/停泊船)疊其上,加下半暗化 scrim 保可讀;模擬模式維持原 CSS 漸層(向後相容)。呼應 §16「後續可把 PortScene 疊到母港實景背景」的收尾。 + diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ad68580..76960fd 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -98,8 +98,8 @@ - **新手友善・漸進式揭露強化** — ✅ 已完成(對應 WM3 報告建議 1.2)。Tier 1(見習運維員)隱藏進階資訊以專注核心循環:三日天氣預報、技師疲勞細節改於 Tier 2 解鎖(母港與出海畫面皆然),並附「Tier 2 解鎖」提示降低認知負荷。 - **生活化比喻** — ✅ 已完成(對應 WM3 報告建議 1.2)。圖鑑為入門常見故障(齒輪箱過熱/偏航/變槳/變流器/發電機過溫·振動)加上「💡 生活化比喻」(汽車引擎、腳踏車輪軸、電腦風扇等),幫無背景玩家秒懂。 - **戲劇性(非技術)事件** — ✅ 已完成(對應 WM3 報告建議 2.2)。事件池新增環保團體抗議、負面/正面媒體報導、地方社區支持、主管機關稽查,以聲望/合規成本呈現,增加張力與代入感(不需新機制)。 -- **母港建設・視覺成長** — ✅ 已完成(對應 WM3 評估報告建議 2.2)。新增「🏗 母港建設」設施:用獲利升級碼頭/倉儲/起重機/燈塔(各 3 級),母港即時預覽畫面隨之長大(更多停泊船、貨櫃、起重機、點亮燈塔招牌),把數值成長轉化為視覺成就感(純視覺、不影響計分)。後續可把 PortScene 疊到母港實景背景。 -- **學習成效分析(能力雷達圖 + 錯題本)** — ✅ 已完成(對應 WM3 評估報告建議 3.1/3.3)。個人檔案頁新增**各科別能力雷達圖**(視覺化既有知識點掌握度),與**錯題本**:答錯自動收錄情境/你的選擇/正解/教訓,可寫「維修檢討」反思(形成性評量)後標記已複習。純本機,免後端。後續可把掌握度同步雲端 → 教師端個別學生鑽取(報告建議 3.1)、以及獨立「測驗模式」(報告建議 3.2,需後端)。 +- **母港建設・視覺成長** — ✅ 已完成(對應 WM3 評估報告建議 2.2)。新增「🏗 母港建設」設施:用獲利升級碼頭/倉儲/起重機/燈塔(各 3 級),母港即時預覽畫面隨之長大(更多停泊船、貨櫃、起重機、點亮燈塔招牌),把數值成長轉化為視覺成就感(純視覺、不影響計分)。**✅ 後續已完成:PortScene 依全域背景模式疊到母港實境(`harbor.jpg`)/漫畫(`comic_harbor.jpg`)背景**(見 [GAME_DESIGN.md](GAME_DESIGN.md) §19)。 +- **學習成效分析(能力雷達圖 + 錯題本)** — ✅ 已完成(對應 WM3 評估報告建議 3.1/3.3)。個人檔案頁新增**各科別能力雷達圖**(視覺化既有知識點掌握度),與**錯題本**:答錯自動收錄情境/你的選擇/正解/教訓,可寫「維修檢討」反思(形成性評量)後標記已複習。純本機,免後端。**✅ 後續已完成:掌握度同步雲端 → 教師端個別學生鑽取(#mastery-cloud,後端 v2.2)、以及獨立「測驗模式」(#exam,`exam.ts`/`ExamModal`,跨科別評量+錯題覆盤,計入掌握度但不動遊戲分數)**(見 [GAME_DESIGN.md](GAME_DESIGN.md) §19)。 - **在地化擴充** — 介面字串抽離與第三語言(如英文以外)擴充流程整理;既有雙語架構已就緒。 - **效能與測試覆蓋率提升** — 擴大 state 層測試、加入關鍵 UI 流程的回歸測試,整理大型畫面元件。 diff --git a/docs/leaderboard-appsscript/Code.gs b/docs/leaderboard-appsscript/Code.gs index d0f0e6c..3a2f92e 100644 --- a/docs/leaderboard-appsscript/Code.gs +++ b/docs/leaderboard-appsscript/Code.gs @@ -12,9 +12,13 @@ * * 分頁(自動建立): * accounts: key | studentId | classCode | nickname | pinHash | createdAt - * saves: key | savedAt | state | score | day | availability | generation | nickname + * saves: key | savedAt | state | score | day | availability | generation | nickname | mastery * records: key | updatedAt | record * Sheet1(排行,沿用):時間 | 暱稱 | 班級碼 | 績效分 | 可用率 | 發電量 | 天數 | 學號 + * + * v2.2 變更(#mastery-cloud):saves 新增第 9 欄 mastery(掌握度精簡摘要 JSON),隨存檔一併寫入; + * teacher 端回傳每位學生的 mastery,供教師面板「個別鑽取」各科別正確率。改檔後務必「新版本」重部署; + * 既有 saves 分頁的舊列第 9 欄留空即可(教師端優雅降級,不影響原有欄位)。 */ var SIGN_SECRET = 'wfg-2026-oandm'; // 與前端 src/cloud/sheet.ts 一致(排行送分簽章) @@ -120,7 +124,8 @@ function doGet(e) { nickname: String(row2[7] || ''), savedAt: Number(row2[1]) || 0, updatedAt: Number(row2[1]) || 0, score: Number(row2[3]) || 0, day: Number(row2[4]) || 0, - availability: Number(row2[5]) || 0, generation: Number(row2[6]) || 0 + availability: Number(row2[5]) || 0, generation: Number(row2[6]) || 0, + mastery: String(row2[8] || '') // #mastery-cloud:掌握度精簡摘要,供教師端個別鑽取 }); } out.sort(function (a, b) { return b.score - a.score; }); @@ -159,10 +164,11 @@ function doPost(e) { var key = keyOf(cls, sid); var nick = (getAccount(cls, sid) || {}).nickname || ''; var state = String(p.state || '').slice(0, 90000); + var mastery = String(p.mastery || '').slice(0, 4000); // #mastery-cloud:掌握度精簡摘要(第 9 欄) var rowVals = [key, Number(p.savedAt) || (new Date()).getTime(), state, - clampInt(p.score, 0, 9e8), clampInt(p.day, 0, 3650), clampInt(p.availability, 0, 100), clampInt(p.generation, 0, 9e8), nick]; + clampInt(p.score, 0, 9e8), clampInt(p.day, 0, 3650), clampInt(p.availability, 0, 100), clampInt(p.generation, 0, 9e8), nick, mastery]; var row = findRow(sh, key); - if (row > 0) sh.getRange(row, 1, 1, 8).setValues([rowVals]); + if (row > 0) sh.getRange(row, 1, 1, 9).setValues([rowVals]); else sh.appendRow(rowVals); return json({ ok: true }); }); diff --git a/src/App.tsx b/src/App.tsx index 2da905f..b5ec515 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,6 +31,7 @@ const TrendsModal = lazy(() => import("./ui/TrendsModal")); const ProfileModal = lazy(() => import("./ui/ProfileModal")); const TeacherModal = lazy(() => import("./ui/TeacherModal")); const PortModal = lazy(() => import("./ui/PortModal")); +const ExamModal = lazy(() => import("./ui/ExamModal")); import { GameProvider } from "./state/GameContext"; import { DialogueProvider } from "./state/DialogueContext"; import { TutorialProvider } from "./state/TutorialContext"; @@ -54,6 +55,7 @@ export default function App() { const [showBuild, setShowBuild] = useState(false); const [showProfile, setShowProfile] = useState(false); const [showTeacher, setShowTeacher] = useState(false); + const [showExam, setShowExam] = useState(false); const [showCaseFile, setShowCaseFile] = useState(false); const [showTrends, setShowTrends] = useState(false); const [showPort, setShowPort] = useState(false); @@ -126,7 +128,7 @@ export default function App() { // 彈窗:打開時才掛載 → 對應 chunk 才下載(code-split)。手機上由 .wfg-modal-panel 響應式縮放。 const modals = ( - {showCourse && setShowCourse(false)} week={week} onSetWeek={changeWeek} onTeacher={() => { setShowCourse(false); setShowTeacher(true); }} />} + {showCourse && setShowCourse(false)} week={week} onSetWeek={changeWeek} onTeacher={() => { setShowCourse(false); setShowTeacher(true); }} onExam={() => { setShowCourse(false); setShowExam(true); }} />} {showDispatch && setShowDispatch(false)} />} {showOps && setShowOps(false)} />} {showFleet && setShowFleet(false)} />} @@ -134,9 +136,10 @@ export default function App() { {facility && setFacility(null)} />} {showCaseFile && setShowCaseFile(false)} />} {showTrends && setShowTrends(false)} />} - {showPort && setShowPort(false)} />} + {showPort && setShowPort(false)} mode={mode} />} {showProfile && setShowProfile(false)} />} {showTeacher && setShowTeacher(false)} />} + {showExam && setShowExam(false)} />} ); const trackers = (<>); diff --git a/src/cloud/api.ts b/src/cloud/api.ts index 579be4f..b5cbee7 100644 --- a/src/cloud/api.ts +++ b/src/cloud/api.ts @@ -84,6 +84,7 @@ export interface ProgressSummary { day: number; availability: number; generation: number; + mastery?: string; // 知識點掌握度精簡摘要(#mastery-cloud):供教師端個別鑽取;後端存欄、teacher 端回傳 } export async function loadStateCloud(p: CloudIdentity): Promise { const j = await getJson<{ ok?: boolean; state?: string | null; savedAt?: number }>({ do: "load", studentId: p.studentId, classCode: p.classCode, pinHash: p.pinHash }); @@ -120,6 +121,7 @@ export interface ClassRow { availability: number; generation: number; updatedAt: number; + mastery?: string; // 掌握度精簡摘要(#mastery-cloud);舊後端未回傳時為 undefined → 教師端優雅降級 } export interface TeacherResult { ok: boolean; diff --git a/src/state/GameContext.tsx b/src/state/GameContext.tsx index 7f1e947..dae0ea8 100644 --- a/src/state/GameContext.tsx +++ b/src/state/GameContext.tsx @@ -3,6 +3,7 @@ import { INITIAL, reducer, migrateSave, computeScore, type Action, type GameData import { getProfile, saveKeyFor, isAuthed } from "./profile"; import { SAVE_SYNC, loadCloudState, saveCloudState, CLOUD_FIRST } from "../cloud/sheet"; import { cloudEnabled, identityOf, loadStateCloud, saveStateCloud, pickNewer, type SaveEnvelope } from "../cloud/api"; +import { masterySummary } from "./mastery"; // 依登入身分取得存檔 key —— 每位使用者資料獨立,互不沿用 function saveKey(): string { @@ -88,6 +89,7 @@ export function GameProvider({ children }: { children: ReactNode }) { day: data.day, availability: data.availability, generation: data.generationMWh, + mastery: masterySummary(data.mastery ?? {}), // 掌握度摘要隨存檔上傳,供教師端個別鑽取(#mastery-cloud) }); }, 1500); return () => window.clearTimeout(id); diff --git a/src/state/exam.ts b/src/state/exam.ts new file mode 100644 index 0000000..890e7d2 --- /dev/null +++ b/src/state/exam.ts @@ -0,0 +1,101 @@ +// 獨立測驗模式(#exam):把既有「判斷型任務」抽成一份正式評量 —— 固定題數、無提示、單次作答, +// 結束才揭曉總分與各類別對錯。純資料/純函式集中於此(可決定性測試);UI 見 ui/ExamModal.tsx。 +import { TASKS, type TaskTemplate, type TaskCat } from "./tasks"; +import type { I18n } from "../game/systems/types"; + +export const EXAM_LENGTHS = [10, 20] as const; +export const DEFAULT_EXAM_LENGTH = 10; + +// 測驗題池:引用判斷型任務,但排除「需輔助圖(chart)」者 → 確保純文字即可作答、對所有人公平。 +export const EXAM_POOL: TaskTemplate[] = TASKS.filter((tk) => !tk.chart); + +// 可重現的偽隨機(mulberry32):測試給固定種子可決定性驗證;實際遊玩以隨機種子開場 → 每次題目不同。 +function rng(seed: number): () => number { + let x = (seed || 1) >>> 0; + return () => { + x = (x + 0x6d2b79f5) >>> 0; + let t = Math.imul(x ^ (x >>> 15), 1 | x); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} +function shuffle(arr: T[], r: () => number): T[] { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(r() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + +// 抽 n 題,盡量「跨類別均衡」:各類別各自洗牌後 round-robin 輪流取一題,湊滿 n。 +// 回傳題目模板陣列(不足 n 時回全部可用題)。 +export function buildExam(seed: number, n: number = DEFAULT_EXAM_LENGTH, pool: TaskTemplate[] = EXAM_POOL): TaskTemplate[] { + const r = rng(seed); + const byCat = new Map(); + for (const tk of pool) { + const g = byCat.get(tk.cat) ?? []; + g.push(tk); + byCat.set(tk.cat, g); + } + for (const g of byCat.values()) shuffle(g, r); + const cats = shuffle([...byCat.keys()], r); + const out: TaskTemplate[] = []; + let progressed = true; + while (out.length < n && progressed) { + progressed = false; + for (const c of cats) { + const g = byCat.get(c)!; + if (g.length) { + out.push(g.pop()!); + progressed = true; + if (out.length >= n) break; + } + } + } + return out; +} + +// 某題是否答對:所選選項為 good(可能有多個 good 皆算對)。pick = -1(未答)算錯。 +export function isCorrect(tpl: TaskTemplate, pick: number): boolean { + return pick >= 0 && pick < tpl.choices.length && !!tpl.choices[pick].good; +} +// 取某題的「正解」選項(第一個 good),供覆盤揭示。 +export const goodChoiceOf = (tpl: TaskTemplate) => tpl.choices.find((c) => c.good); + +export interface ExamGrade { key: string; label: I18n; color: string; } +export function examGrade(pct: number): ExamGrade { + if (pct >= 90) return { key: "A", label: { zh: "優異 A", en: "Excellent A" }, color: "#7fce8e" }; + if (pct >= 80) return { key: "B", label: { zh: "良好 B", en: "Good B" }, color: "#8fc7a0" }; + if (pct >= 70) return { key: "C", label: { zh: "及格 C", en: "Pass C" }, color: "#e3ad42" }; + if (pct >= 60) return { key: "D", label: { zh: "待加強 D", en: "Marginal D" }, color: "#e0995a" }; + return { key: "F", label: { zh: "不及格 F", en: "Fail F" }, color: "#d96a5a" }; +} + +export interface ExamResult { + total: number; + correct: number; + pct: number; // 0–100 + byCat: Record; // 各類別對錯(教學覆盤/掌握度) + grade: ExamGrade; + wrong: { tpl: TaskTemplate; picked: number }[]; // 錯題(供覆盤揭示正解) +} + +// 判分(純函式):items = 抽出的題;picks = 每題所選選項索引(-1 未答)。 +export function gradeExam(items: TaskTemplate[], picks: number[]): ExamResult { + const byCat: Record = {}; + const wrong: { tpl: TaskTemplate; picked: number }[] = []; + let correct = 0; + items.forEach((tpl, i) => { + const pick = picks[i] ?? -1; + const ok = isCorrect(tpl, pick); + const cell = byCat[tpl.cat] ?? { n: 0, correct: 0 }; + cell.n += 1; + if (ok) cell.correct += 1; + byCat[tpl.cat] = cell; + if (ok) correct += 1; + else wrong.push({ tpl, picked: pick }); + }); + const total = items.length; + const pct = total ? Math.round((correct / total) * 100) : 0; + return { total, correct, pct, byCat, grade: examGrade(pct), wrong }; +} diff --git a/src/state/game.ts b/src/state/game.ts index cea7b72..ad07261 100644 --- a/src/state/game.ts +++ b/src/state/game.ts @@ -738,6 +738,7 @@ export type Action = | { type: "ROLL_WEEKLY"; weekly: WeeklyState } // 產生當週主題挑戰(#79,由 WeeklyTracker 於週推進時派發) | { type: "CLAIM_WEEKLY"; xp: number; cash: number } // 發放本週挑戰獎勵(#79) | { type: "RECORD_ANSWER"; keys: string[]; correct: boolean } // 記錄一次作答(知識點掌握度 #mastery) + | { type: "RECORD_EXAM"; answers: { keys: string[]; correct: boolean }[]; mistakes: Omit[] } // 獨立測驗(#exam):批次計入掌握度+錯題本,不動 xp/streak(評量與遊戲經濟隔離) | { type: "RECORD_MISTAKE"; mk: Omit } // 答錯→記入錯題本 | { type: "REVIEW_MISTAKE"; id: string; reflection: string } // 複習錯題並寫反思 | { type: "UPGRADE_PORT"; id: string; cost: number } // 母港建設升級(#port):扣預算、設施 +1 級 @@ -1098,6 +1099,15 @@ export function reducer(s: GameData, a: Action): GameData { const id = `${a.mk.day}_${(s.mistakes ?? []).length}`; return { ...s, mistakes: addMistake(s.mistakes ?? [], { ...a.mk, id }) }; } + case "RECORD_EXAM": { + // 測驗批次計入:各題折入掌握度(#mastery),錯題收入錯題本;不動 xp/answerStreak → 評量不干擾遊戲經濟。 + let mastery = s.mastery ?? {}; + for (const ans of a.answers) mastery = recordAnswer(mastery, ans.keys, ans.correct); + let mistakes = s.mistakes ?? []; + const base = mistakes.length; + a.mistakes.forEach((mk, i) => { mistakes = addMistake(mistakes, { ...mk, id: `ex${mk.day}_${base + i}` }); }); + return { ...s, mastery, mistakes }; + } case "REVIEW_MISTAKE": return { ...s, mistakes: reviewMistake(s.mistakes ?? [], a.id, a.reflection) }; case "UPGRADE_PORT": { diff --git a/src/state/mastery.ts b/src/state/mastery.ts index bc00289..7c863f0 100644 --- a/src/state/mastery.ts +++ b/src/state/mastery.ts @@ -36,6 +36,33 @@ export function weakest(rows: MasteryRow[], minN = 2): MasteryRow | null { // 總作答數(判斷是否已有足夠資料可顯示)。 export const totalAnswered = (m: Mastery): number => Object.values(m || {}).reduce((a, c) => a + (c.n || 0), 0); +// ───────── 掌握度雲端摘要(#mastery-cloud):隨存檔上傳,供教師端個別鑽取 ───────── +// 壓成精簡字串:只帶有作答(n>0)的格,值以 [n, ok] 元組節省長度(十來個格,體積極小)。 +export function masterySummary(m: Mastery): string { + const out: Record = {}; + for (const k of Object.keys(m || {})) { + const c = (m || {})[k]; + if (c && c.n > 0) out[k] = [c.n, c.ok]; + } + return JSON.stringify(out); +} +// 解析雲端摘要回 Mastery(容錯:接受 [n,ok] 元組或 {n,ok} 物件;壞資料回 {})。 +export function parseMasterySummary(s: string | undefined | null): Mastery { + if (!s) return {}; + try { + const raw = JSON.parse(s) as Record; + const out: Mastery = {}; + for (const k of Object.keys(raw || {})) { + const v = raw[k]; + if (Array.isArray(v)) out[k] = { n: Number(v[0]) || 0, ok: Number(v[1]) || 0 }; + else if (v && typeof v === "object") out[k] = { n: Number(v.n) || 0, ok: Number(v.ok) || 0 }; + } + return out; + } catch { + return {}; + } +} + // ───────── 錯題本(#mistake-log):答錯即記錄情境/你的選擇/正解/教訓,供事後複習與反思 ───────── export interface Mistake { id: string; // 唯一 id(reducer 指派) diff --git a/src/state/records.ts b/src/state/records.ts index 61726e8..5aef353 100644 --- a/src/state/records.ts +++ b/src/state/records.ts @@ -44,10 +44,11 @@ export interface RecordData { bestMissions: number; // 最多完成任務數 bestCatalog: number; // 最多圖鑑數 bestResolved: number; // 最多戰情室修復 + bestExam?: number; // 獨立測驗最佳成績(%)(#exam);選填→舊存檔相容 updatedAt: number; } -export const emptyRecord = (): RecordData => ({ unlocked: {}, bestScore: 0, bestDay: 0, bestGeneration: 0, bestMissions: 0, bestCatalog: 0, bestResolved: 0, updatedAt: 0 }); +export const emptyRecord = (): RecordData => ({ unlocked: {}, bestScore: 0, bestDay: 0, bestGeneration: 0, bestMissions: 0, bestCatalog: 0, bestResolved: 0, bestExam: 0, updatedAt: 0 }); // 目前狀態下「已滿足」的成就 id(純函式) export function evaluateAchievements(d: GameData): string[] { @@ -76,6 +77,7 @@ export function mergeRecord(prev: RecordData, d: GameData, now: number): { rec: bestMissions: Math.max(prev.bestMissions, d.missionsDone), bestCatalog: Math.max(prev.bestCatalog, d.seenFaults?.length ?? 0), bestResolved: Math.max(prev.bestResolved, d.fleetResolved ?? 0), + bestExam: prev.bestExam ?? 0, // 測驗成績非源自 GameData → 沿用既有紀錄(由 ExamModal 直接更新) updatedAt: newly.length || score > prev.bestScore ? now : prev.updatedAt, }; return { rec, newly }; @@ -95,6 +97,7 @@ export function unionRecord(a: RecordData, b: RecordData): RecordData { bestMissions: Math.max(a.bestMissions, b.bestMissions), bestCatalog: Math.max(a.bestCatalog, b.bestCatalog), bestResolved: Math.max(a.bestResolved, b.bestResolved), + bestExam: Math.max(a.bestExam ?? 0, b.bestExam ?? 0), updatedAt: Math.max(a.updatedAt, b.updatedAt), }; } diff --git a/src/ui/CourseModal.tsx b/src/ui/CourseModal.tsx index 01be645..63dd28a 100644 --- a/src/ui/CourseModal.tsx +++ b/src/ui/CourseModal.tsx @@ -19,7 +19,7 @@ function toI18n(v: unknown, fallback: I18n): I18n { } // 課程模式(#6):一鍵把某週故障指派為下一筆工單,或匯入自訂任務 JSON。 -export default function CourseModal({ open, onClose, week = 1, onSetWeek, onTeacher }: { open: boolean; onClose: () => void; week?: number; onSetWeek?: (w: number) => void; onTeacher?: () => void }) { +export default function CourseModal({ open, onClose, week = 1, onSetWeek, onTeacher, onExam }: { open: boolean; onClose: () => void; week?: number; onSetWeek?: (w: number) => void; onTeacher?: () => void; onExam?: () => void }) { useLang(); const { dispatch } = useGame(); const { start: startTutorial } = useTutorial(); @@ -107,6 +107,21 @@ export default function CourseModal({ open, onClose, week = 1, onSetWeek, onTeac + {/* 獨立測驗模式(#exam):正式評量,跨科別抽題、無提示、單次作答,計入掌握度/錯題本 */} + {onExam && ( + + )} + {/* 教師檢視:班級碼 + 教師碼 → 唯讀檢視全班進度(雲端) */} {onTeacher && ( + ))} + + + )} + + {/* ── 作答頁 ── */} + {phase === "quiz" && cur && ( + <> +
+ {t({ zh: "第", en: "Q" })} {idx + 1} / {items.length} + {t(CAT_LABEL[cur.cat])} +
+
+
+
+
{t(cur.title)}
+
{t(cur.scenario)}
+
+ {cur.choices.map((c, i) => ( + + ))} +
+ + )} + + {/* ── 結果頁 ── */} + {phase === "result" && result && ( + <> +
+
{result.pct}%
+
+
{t(result.grade.label)}
+
{t({ zh: "答對", en: "Correct" })} {result.correct} / {result.total}{result.pct >= best ? ` · 🏅 ${t({ zh: "個人最佳", en: "personal best" })}` : ""}
+
+
+ + {/* 各類別對錯(掌握度覆盤) */} +
{t({ zh: "各類別表現", en: "By category" })}
+
+ {Object.keys(result.byCat).map((cat) => { + const cell = result.byCat[cat]; + const pct = cell.n ? Math.round((cell.correct / cell.n) * 100) : 0; + const col = pct >= 80 ? C.green : pct >= 60 ? C.amber : C.red; + return ( +
+ {t(CAT_LABEL[cat as keyof typeof CAT_LABEL] ?? { zh: cat, en: cat })} +
+
+
+ {cell.correct}/{cell.n} +
+ ); + })} +
+ + {/* 錯題覆盤:揭示正解與教訓(已同步收入錯題本) */} + {result.wrong.length > 0 && ( + <> +
📕 {t({ zh: "錯題覆盤", en: "Review misses" })} ({result.wrong.length})
+
+ {result.wrong.map((w, i) => { + const good = goodChoiceOf(w.tpl); + return ( +
+
{t(w.tpl.title)}
+
{t(w.tpl.scenario)}
+
✓ {t({ zh: "正解", en: "Correct" })}: {t(good?.label ?? { zh: "—", en: "—" })}
+ {good?.feedback &&
{t(good.feedback)}
} +
+ ); + })} +
+ + )} +
{t({ zh: "錯題已收入「錯題本」,可到個人檔案主動回想複習。", en: "Missed items were added to your Mistake Log for active-recall review in your profile." })}
+ +
+ + +
+ + )} +
+
+ + ); +} diff --git a/src/ui/PortModal.tsx b/src/ui/PortModal.tsx index 0cd2380..5fe77f7 100644 --- a/src/ui/PortModal.tsx +++ b/src/ui/PortModal.tsx @@ -5,11 +5,12 @@ import { useGame } from "../state/GameContext"; import { toWan } from "../state/game"; import { Sfx } from "../audio/sfx"; import { toast } from "./toast"; -import PortScene from "./PortScene"; +import PortScene, { type PortBgMode } from "./PortScene"; import { PORT_FACILITIES, portFacLevel, nextPortCost, portLevel, PORT_MAX_LEVEL } from "../state/port"; // 母港建設(#port):用獲利升級母港設施,即時預覽視覺成長。永遠開放(沙盒)。 -export default function PortModal({ open, onClose }: { open: boolean; onClose: () => void }) { +// mode 跟隨全域背景切換(模擬/實境/漫畫):實境/漫畫時建設成果疊在母港實景照片上。 +export default function PortModal({ open, onClose, mode = "sim" }: { open: boolean; onClose: () => void; mode?: PortBgMode }) { useLang(); const { data, dispatch } = useGame(); if (!open) return null; @@ -29,8 +30,11 @@ export default function PortModal({ open, onClose }: { open: boolean; onClose: ( {t({ zh: "用營運獲利美化、擴建母港 —— 每次升級都會讓港區畫面成長,把數值變成看得見的成就。", en: "Reinvest your profits to expand and beautify the home port — each upgrade visibly grows the harbour, turning numbers into achievement." })} - {/* 即時預覽:會隨升級長大 */} - + {/* 即時預覽:會隨升級長大;實境/漫畫背景模式時疊在母港照片上 */} + +
+ {t({ zh: `背景:${mode === "real" ? "實境" : mode === "comic" ? "漫畫" : "模擬"}(於母港右上「🎬 背景」切換)`, en: `Backdrop: ${mode === "real" ? "Realistic" : mode === "comic" ? "Comic" : "Simulated"} (toggle via 🎬 on the Hub)` })} +
{t({ zh: "母港等級", en: "Port level" })} diff --git a/src/ui/PortScene.tsx b/src/ui/PortScene.tsx index 8093c2f..757db26 100644 --- a/src/ui/PortScene.tsx +++ b/src/ui/PortScene.tsx @@ -2,13 +2,20 @@ import { C, FONT_SERIF } from "./tokens"; import { t } from "../game/systems/i18n"; import { portFacLevel, portLevel, PORT_MAX_LEVEL, type PortUpgrades } from "../state/port"; -// 母港視覺成長場景(#port):純 CSS,依各設施等級「長大」——碼頭/倉庫/貨櫃/起重機/燈塔。 -export default function PortScene({ u, height = 200 }: { u: PortUpgrades; height?: number }) { +// 母港建設疊到實境/漫畫背景(#port):依全域背景模式,選對應母港實景照片作底,建設 sprite 疊其上。 +export type PortBgMode = "sim" | "real" | "comic"; +const PORT_PHOTO: Record = { sim: null, real: "harbor.jpg", comic: "comic_harbor.jpg" }; + +// 母港視覺成長場景(#port):依各設施等級「長大」——碼頭/倉庫/貨櫃/起重機/燈塔。 +// mode = sim → 純 CSS 漸層天海(預設);real/comic → 母港實景/漫畫照片作底,建設 sprite 疊其上(對應 GAME_DESIGN §16 收尾)。 +export default function PortScene({ u, height = 200, mode = "sim" }: { u: PortUpgrades; height?: number; mode?: PortBgMode }) { const quay = portFacLevel(u, "quay"); const wh = portFacLevel(u, "warehouse"); const crane = portFacLevel(u, "crane"); const beacon = portFacLevel(u, "beacon"); const lv = portLevel(u); + const photo = PORT_PHOTO[mode]; + const photoUrl = photo ? `${import.meta.env.BASE_URL}assets/scenes/${photo}` : null; const deckW = [58, 72, 86, 100][quay]; // 碼頭延伸寬度(%) const whW = [34, 46, 58, 70][wh]; // 倉庫寬 @@ -21,12 +28,22 @@ export default function PortScene({ u, height = 200 }: { u: PortUpgrades; height const cont = ["#3f8f6e", "#c07a2e", "#8a5fb0", "#3f6f9f", "#b04a4a", "#6f8a3f"]; return ( -
- {/* 夕陽光暈 */} -
- {/* 海 */} -
-
+
+ {photoUrl ? ( + /* 實境/漫畫:母港照片作底 + 下半暗化,讓建設 sprite 讀得清楚 */ + <> +
+
+ + ) : ( + <> + {/* 夕陽光暈 */} +
+ {/* 海 */} +
+
+ + )} {/* 燈塔(右) */}
diff --git a/src/ui/ProfileModal.tsx b/src/ui/ProfileModal.tsx index 43d67d2..d0823f2 100644 --- a/src/ui/ProfileModal.tsx +++ b/src/ui/ProfileModal.tsx @@ -38,6 +38,7 @@ export default function ProfileModal({ open, onClose }: { open: boolean; onClose { label: { zh: "故障圖鑑", en: "Fault catalog" }, value: String(data.seenFaults?.length ?? 0), sub: { zh: `最多 ${rec.bestCatalog}`, en: `max ${rec.bestCatalog}` } }, { label: { zh: "戰情室修復", en: "Ops resolved" }, value: String(data.fleetResolved ?? 0), sub: { zh: `最多 ${rec.bestResolved}`, en: `max ${rec.bestResolved}` } }, { label: { zh: "營運風場", en: "Farms" }, value: String(data.farmsOwned) }, + { label: { zh: "測驗最佳", en: "Exam best" }, value: rec.bestExam ? `${rec.bestExam}%` : "—", sub: { zh: "獨立測驗模式", en: "Exam Mode" } }, { label: { zh: "安全事件", en: "Safety incidents" }, value: String(data.safetyIncidents ?? 0) }, ]; diff --git a/src/ui/TeacherModal.tsx b/src/ui/TeacherModal.tsx index b163e7c..6aae86d 100644 --- a/src/ui/TeacherModal.tsx +++ b/src/ui/TeacherModal.tsx @@ -1,10 +1,13 @@ -import { useState } from "react"; +import { useState, Fragment } from "react"; import { C, FONT_SERIF, primaryBg, panel } from "./tokens"; import { t } from "../game/systems/i18n"; import { useLang } from "./useLang"; import { getProfile } from "../state/profile"; import { Sfx } from "../audio/sfx"; import { cloudEnabled, fetchClassProgress, classRowsToCsv, type ClassRow } from "../cloud/api"; +import { parseMasterySummary, masteryRows, weakest, totalAnswered } from "../state/mastery"; +import { DISC } from "./disc"; +import { CAT_LABEL } from "../state/tasks"; type Status = "form" | "loading" | "ok" | "error"; @@ -17,6 +20,7 @@ export default function TeacherModal({ open, onClose }: { open: boolean; onClose const [status, setStatus] = useState("form"); const [rows, setRows] = useState([]); const [err, setErr] = useState(""); + const [openId, setOpenId] = useState(null); // 個別學生鑽取展開(#mastery-cloud) if (!open) return null; const reset = () => { setStatus("form"); setRows([]); setErr(""); }; @@ -99,6 +103,13 @@ export default function TeacherModal({ open, onClose }: { open: boolean; onClose )}
+ {rows.length > 0 && ( +
totalAnswered(parseMasterySummary(r.mastery)) > 0) ? C.mist : C.amber2, marginBottom: 8, lineHeight: 1.5 }}> + {rows.some((r) => totalAnswered(parseMasterySummary(r.mastery)) > 0) + ? t({ zh: "💡 點任一列可展開該生「知識點掌握度」個別鑽取(各科別/類別正確率)。", en: "💡 Click a row to drill into that student's per-topic mastery (accuracy by discipline/category)." }) + : t({ zh: "ℹ 尚無掌握度資料 —— 需學生以新版更新存檔、且後端已更新至 v2.2(見 CLOUD_SETUP.md)。", en: "ℹ No mastery data yet — students must re-sync on the new build and the backend must be updated to v2.2 (see CLOUD_SETUP.md)." })} +
+ )} {rows.length === 0 ? (
{t({ zh: "此班尚無學生存檔資料。", en: "No student saves for this class yet." })}
) : ( @@ -116,18 +127,31 @@ export default function TeacherModal({ open, onClose }: { open: boolean; onClose - {rows.map((r, i) => ( - - {i + 1} - {r.studentId} - {r.nickname || "—"} - {(r.score || 0).toLocaleString()} - {Math.max(0, (r.day || 0) - 21)} - {r.availability || 0}% - {(r.generation || 0).toLocaleString()} - {fmtDate(r.updatedAt)} - - ))} + {rows.map((r, i) => { + const isOpen = openId === r.studentId; + const hasM = totalAnswered(parseMasterySummary(r.mastery)) > 0; + return ( + + { Sfx.click(); setOpenId(isOpen ? null : r.studentId); }} style={{ cursor: "pointer", background: isOpen ? "rgba(217,164,65,.08)" : undefined }}> + {hasM ? (isOpen ? "▾" : "▸") : ""} {i + 1} + {r.studentId} + {r.nickname || "—"} + {(r.score || 0).toLocaleString()} + {Math.max(0, (r.day || 0) - 21)} + {r.availability || 0}% + {(r.generation || 0).toLocaleString()} + {fmtDate(r.updatedAt)} + + {isOpen && ( + + + + + + )} + + ); + })} )} @@ -138,3 +162,33 @@ export default function TeacherModal({ open, onClose }: { open: boolean; onClose
); } + +// 個別學生「知識點掌握度」鑽取(#mastery-cloud):把雲端摘要解回 Mastery,沿用 ProfileModal 同一套統計顯示。 +function MasteryDrill({ row }: { row: ClassRow }) { + const m = parseMasterySummary(row.mastery); + const discRows = masteryRows(m, "disc", DISC).filter((r) => r.n > 0); + const catRows = masteryRows(m, "cat", CAT_LABEL).filter((r) => r.n > 0); + const weak = weakest([...masteryRows(m, "disc", DISC), ...masteryRows(m, "cat", CAT_LABEL)]); + if (totalAnswered(m) === 0) return
{t({ zh: "此學生尚無作答資料。", en: "No answer data for this student yet." })}
; + const bar = (key: string, label: string, acc: number, n: number, ok: number) => { + const col = acc >= 80 ? C.green : acc >= 60 ? C.amber : C.red; + return ( +
+ {label} +
+
+
+ {acc}% ({ok}/{n}) +
+ ); + }; + return ( +
+
{t({ zh: "科別掌握度", en: "By discipline" })}
+ {discRows.length ? discRows.map((r) => bar("d" + r.key, t(r.label), r.acc, r.n, r.ok)) :
} + {catRows.length > 0 &&
{t({ zh: "任務類別", en: "By category" })}
} + {catRows.map((r) => bar("c" + r.key, t(r.label), r.acc, r.n, r.ok))} + {weak &&
⚠ {t({ zh: "最弱項", en: "Weakest" })}: {t(weak.label)}({weak.acc}%)
} +
+ ); +} diff --git a/test/run.mjs b/test/run.mjs index dd531d0..89979f7 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -59,6 +59,7 @@ const trends = await load("src/state/trends.ts"); const mastery = await load("src/state/mastery.ts"); const port = await load("src/state/port.ts"); const events = await load("src/state/events.ts"); +const exam = await load("src/state/exam.ts"); const R = g.reducer, I = g.INITIAL; // ───────────────────────── INITIAL 不變量 ───────────────────────── @@ -1229,6 +1230,68 @@ test("records: unionRecord merges unlocks (earliest) and max bests", () => { eq(u.bestGeneration, 1000, "max generation"); }); +// ───────────── 獨立測驗模式(#exam) + 掌握度雲端摘要(#mastery-cloud) ───────────── +test("exam: buildExam deterministic, right length, chartless pool, category spread", () => { + const a = exam.buildExam(12345, 10); + const b = exam.buildExam(12345, 10); + eq(a.length, 10, "right length"); + eq(a.map((x) => x.id).join(","), b.map((x) => x.id).join(","), "same seed → same questions"); + ok(a.every((x) => !x.chart), "exam questions are chartless (text-only → fair)"); + eq(new Set(a.map((x) => x.id)).size, a.length, "no duplicate questions"); + ok(new Set(a.map((x) => x.cat)).size >= 4, "spread across ≥4 categories"); + ok(a.map((x) => x.id).join(",") !== exam.buildExam(999, 10).map((x) => x.id).join(","), "different seed → different draw"); + ok(exam.EXAM_POOL.length >= 40 && exam.EXAM_POOL.every((t) => !t.chart), "EXAM_POOL is sizeable & chartless"); +}); +test("exam: gradeExam scores good choices; byCat sums; grade thresholds", () => { + const items = exam.buildExam(42, 6); + const allGood = items.map((tpl) => tpl.choices.findIndex((c) => c.good)); + const perfect = exam.gradeExam(items, allGood); + eq(perfect.correct, items.length, "all-good picks → all correct"); + eq(perfect.pct, 100, "100%"); + eq(perfect.grade.key, "A", "A grade"); + eq(perfect.wrong.length, 0, "no wrong entries"); + const allBad = items.map((tpl) => tpl.choices.findIndex((c) => !c.good)); + const bad = exam.gradeExam(items, allBad); + ok(bad.pct < perfect.pct, "non-good picks score lower"); + eq(Object.values(perfect.byCat).reduce((s, c) => s + c.n, 0), items.length, "byCat n sums to total"); + eq(exam.isCorrect(items[0], -1), false, "skip counts wrong"); + eq(exam.isCorrect(items[0], 999), false, "out-of-range counts wrong"); + eq(exam.examGrade(95).key, "A"); eq(exam.examGrade(85).key, "B"); eq(exam.examGrade(75).key, "C"); eq(exam.examGrade(65).key, "D"); eq(exam.examGrade(30).key, "F"); +}); +test("exam: RECORD_EXAM folds into mastery + mistakes, leaves xp/streak untouched", () => { + const before = { ...I, xp: 100, answerStreak: 4, mastery: {}, mistakes: [] }; + const answers = [{ keys: ["cat:A"], correct: true }, { keys: ["cat:A"], correct: false }, { keys: ["cat:D"], correct: true }]; + const mistakes = [{ topic: "cat:A", question: { zh: "q", en: "q" }, chosen: { zh: "x", en: "x" }, correct: { zh: "y", en: "y" }, day: 21 }]; + const after = R(before, { type: "RECORD_EXAM", answers, mistakes }); + eq(after.mastery["cat:A"].n, 2, "cat:A answered twice"); + eq(after.mastery["cat:A"].ok, 1, "cat:A one correct"); + eq(after.mastery["cat:D"].ok, 1, "cat:D correct"); + eq(after.mistakes.length, 1, "one mistake logged"); + ok(after.mistakes[0].id, "mistake got an id"); + eq(after.xp, 100, "xp untouched (exam isolated from game economy)"); + eq(after.answerStreak, 4, "streak untouched"); +}); +test("mastery-cloud: masterySummary compacts n>0 cells; parse round-trips + tolerant", () => { + const m = { "disc:mechanical": { n: 5, ok: 4 }, "cat:A": { n: 3, ok: 1 }, "disc:hse": { n: 0, ok: 0 } }; + const s = mastery.masterySummary(m); + ok(s.length < 120, "summary is compact"); + const back = mastery.parseMasterySummary(s); + eq(back["disc:mechanical"].n, 5); eq(back["disc:mechanical"].ok, 4); + eq(back["cat:A"].ok, 1, "cat:A round-trips"); + ok(!("disc:hse" in back), "n=0 cells dropped"); + eq(Object.keys(mastery.parseMasterySummary("not json")).length, 0, "bad json → empty"); + eq(Object.keys(mastery.parseMasterySummary("")).length, 0, "empty → empty"); + eq(Object.keys(mastery.parseMasterySummary(undefined)).length, 0, "undefined → empty"); + // 容錯:亦接受 {n,ok} 物件形式 + eq(mastery.parseMasterySummary('{"cat:B":{"n":2,"ok":2}}')["cat:B"].n, 2, "accepts object form too"); +}); +test("records: bestExam default 0, carried by merge, max by union", () => { + eq(recs.emptyRecord().bestExam, 0, "default 0"); + const { rec } = recs.mergeRecord({ ...recs.emptyRecord(), bestExam: 80 }, { ...I }, 1000); + eq(rec.bestExam, 80, "mergeRecord carries bestExam through (not derived from GameData)"); + eq(recs.unionRecord({ ...recs.emptyRecord(), bestExam: 60 }, { ...recs.emptyRecord(), bestExam: 90 }).bestExam, 90, "union takes max exam best"); +}); + // ───────────────────────── 每日任務(#78) ───────────────────────── test("daily: pickDailyIds deterministic, right length, unique", () => { const a = daily.pickDailyIds(30, "seedX");