From 391960c53568ca7140ae152123336f70761d0ea0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 02:07:02 +0000 Subject: [PATCH 01/11] =?UTF-8?q?feat(ux):=20=E9=81=8A=E6=88=B2=E5=85=A7?= =?UTF-8?q?=E3=80=8C=E6=B8=9B=E5=B0=91=E5=8B=95=E6=85=8B=E3=80=8D=E9=96=8B?= =?UTF-8?q?=E9=97=9C=20+=20=E5=AE=8C=E5=B7=A5/=E9=80=A3=E5=B0=8D=E5=8D=B3?= =?UTF-8?q?=E6=99=82=E5=9B=9E=E9=A5=8B(juice)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 設定(無障礙): - useReducedMotion 支援 localStorage 手動覆寫(+body class 讓 CSS 停用 動畫規則同步生效);開機同步、事件通知即時切換。 - 個人檔案新增「🎬 減少動態」開關:影片改靜態圖、停用動畫;預設跟隨 系統偏好,教室/無法改 OS 設定的裝置可手動開啟。 即時回饋(juice): - 完工跳出「💰 +◎獎勵 ⭐ +XP」toast,強化成就感。 - 診斷連對 3 題與每 5 題跳 🔥 里程碑慶祝。 - TopBar 預算增減短暫變色(綠=進帳/紅=支出);純顏色變化,減少動態下也安全。 151 tests / typecheck / build 全綠。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NF4jqbu1VJU5iS4pHEeDrn --- src/index.css | 11 +++++++++- src/main.tsx | 4 ++++ src/ui/ProfileModal.tsx | 16 ++++++++++++++ src/ui/TopBar.tsx | 14 ++++++++++-- src/ui/screens/RepairScreen.tsx | 10 ++++++++- src/ui/useReducedMotion.ts | 38 +++++++++++++++++++++++++-------- 6 files changed, 80 insertions(+), 13 deletions(-) diff --git a/src/index.css b/src/index.css index de01ad5..7a08f27 100644 --- a/src/index.css +++ b/src/index.css @@ -74,7 +74,8 @@ body { } /* 無障礙:尊重「減少動態」偏好 → 停用非必要動畫/轉場(前庭敏感/暈動友善)。 - 自動播放的場景影片另在 SceneVideo 以 poster 靜態首幀取代。 */ + 自動播放的場景影片另在 SceneVideo 以 poster 靜態首幀取代。 + body.wfg-reduced = 遊戲內手動開啟(個人檔案 → 設定),與系統偏好等效。 */ @media (prefers-reduced-motion: reduce) { *, *::before, @@ -85,3 +86,11 @@ body { scroll-behavior: auto !important; } } +body.wfg-reduced *, +body.wfg-reduced *::before, +body.wfg-reduced *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; +} diff --git a/src/main.tsx b/src/main.tsx index f15933c..837c7d3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,8 +1,12 @@ import ReactDOM from "react-dom/client"; import App from "./App"; import ErrorBoundary from "./ui/ErrorBoundary"; +import { syncBodyClass } from "./ui/useReducedMotion"; import "./index.css"; +// 無障礙:開機同步「減少動態」手動覆寫(body class),讓 CSS 停用動畫規則生效 +syncBodyClass(); + // 不使用 StrictMode:其開發期重複掛載會與 Phaser 遊戲生命週期競態(重複建立/銷毀 game)。 ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/src/ui/ProfileModal.tsx b/src/ui/ProfileModal.tsx index ebcf929..43d67d2 100644 --- a/src/ui/ProfileModal.tsx +++ b/src/ui/ProfileModal.tsx @@ -13,6 +13,7 @@ import { DISC } from "./disc"; import { CAT_LABEL } from "../state/tasks"; import { CLOUD_FIRST } from "../cloud/sheet"; import { cloudEnabled } from "../cloud/api"; +import { useReducedMotion, getReducedOverride, setReducedOverride } from "./useReducedMotion"; import type { I18n } from "../game/systems/types"; // 個人檔案頁(階段 3):身分、關鍵數據、最佳紀錄與成就牆。資料來源 = 即時遊戲狀態 + 學習紀錄。 @@ -20,6 +21,7 @@ export default function ProfileModal({ open, onClose }: { open: boolean; onClose useLang(); const { data } = useGame(); const profile = getProfile(); + const reduced = useReducedMotion(); // 設定:減少動態(跟隨系統 or 手動開啟) // 開啟時讀一次紀錄(含本回合已累積的最佳值) const rec = useMemo(() => loadRecord(profile), [open, profile]); if (!open) return null; @@ -68,6 +70,20 @@ export default function ProfileModal({ open, onClose }: { open: boolean; onClose + {/* 設定:減少動態(無障礙)——影片改靜態圖、停用動畫;跟隨系統偏好,也可在此手動開啟 */} +
+
+
🎬 {t({ zh: "減少動態(影片改靜態圖、停用動畫)", en: "Reduce motion (static scenes, no animations)" })}
+
{t({ zh: "暈動/前庭敏感/投影友善;預設跟隨系統偏好", en: "Motion-sensitivity friendly; follows OS preference by default" })}
+
+ +
+ {/* 數據格 */}
{stats.map((s, i) => ( diff --git a/src/ui/TopBar.tsx b/src/ui/TopBar.tsx index 2e9dfc3..e11d9b3 100644 --- a/src/ui/TopBar.tsx +++ b/src/ui/TopBar.tsx @@ -1,4 +1,4 @@ -import { useState, type CSSProperties, type ReactNode } from "react"; +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { C, FONT_SERIF, FONT_CINZEL, chip } from "./tokens"; import { Sfx } from "../audio/sfx"; import { Bgm } from "../audio/bgm"; @@ -56,6 +56,16 @@ export default function TopBar({ const { data } = useGame(); const sea = SEA[data.seaState as SeaState]; const profile = getProfile(); + // 預算變動即時回饋(juice):金額增減時短暫變色(綠=進帳/紅=支出);純顏色變化,減少動態偏好下也安全 + const prevBudget = useRef(data.budget); + const [flash, setFlash] = useState(null); + useEffect(() => { + if (prevBudget.current === data.budget) return; + setFlash(data.budget > prevBudget.current ? "up" : "down"); + prevBudget.current = data.budget; + const id = window.setTimeout(() => setFlash(null), 900); + return () => window.clearTimeout(id); + }, [data.budget]); return (
- + {toWan(data.budget)} {t(S.hud.wan)}
diff --git a/src/ui/screens/RepairScreen.tsx b/src/ui/screens/RepairScreen.tsx index 025987b..ccc9e2f 100644 --- a/src/ui/screens/RepairScreen.tsx +++ b/src/ui/screens/RepairScreen.tsx @@ -13,7 +13,8 @@ import { FAULTS, LOCATION_LABEL, locationOf, isMajorFault } from "../faults"; import RepairScene from "../RepairScene"; import { FallbackImg } from "../SceneVideo"; import { useReducedMotion } from "../useReducedMotion"; -import { workWindowMax, sopStepCost, type RepairState } from "../../state/game"; +import { workWindowMax, sopStepCost, toWan, type RepairState } from "../../state/game"; +import { toast } from "../toast"; import { PARTS } from "../data"; import { missionInstance } from "../campaign"; import type { Screen } from "../../App"; @@ -112,6 +113,8 @@ export default function RepairScreen({ setScreen, mode = "sim", mobile = false } } Sfx.success(); dispatch({ type: "FINISH_REPAIR", quest, part: need, discipline: fault.discipline }); + // 完工即時回饋(juice):跳出本次獎勵,強化成就感 + toast({ zh: `💰 +◎${toWan(quest.rewardBudget)} 萬 ⭐ +${quest.rewardXp} XP`, en: `💰 +◎${toWan(quest.rewardBudget)}M ⭐ +${quest.rewardXp} XP` }); const m = data.customQuest ? null : missionInstance(data.campaignIndex); // 任務復盤(#debrief):量化本次出海的決策品質 → 星級 + 一句 takeaway(把每次出海變成可檢討的學習點) const misses = rp?.misses ?? 0; @@ -337,6 +340,11 @@ export default function RepairScreen({ setScreen, mode = "sim", mobile = false } dispatch({ type: "RECORD_ANSWER", keys: [`disc:${fault.discipline}`], correct: i === q.correct }); // 錯題本(#mistake-log):第一次就答錯 → 記錄情境/你的選擇/正解/解析,供事後複習 if (i !== q.correct) dispatch({ type: "RECORD_MISTAKE", mk: { topic: `disc:${fault.discipline}`, question: q.question, chosen: q.options[i], correct: q.options[q.correct], lesson: q.ok, day: data.day } }); + // 連對里程碑(juice):3 連對與每 5 連對跳慶祝,強化「先思考再作答」的正循環 + if (i === q.correct) { + const ns = (data.answerStreak ?? 0) + 1; + if (ns === 3 || (ns >= 5 && ns % 5 === 0)) toast({ zh: `🔥 診斷連對 ${ns} 題!XP 加成中`, en: `🔥 ${ns}-answer streak! XP bonus active` }); + } } // 答錯多耗作業窗(可重新作答,但每次扣時段);累計答錯次數供任務復盤(#debrief) saveRepair({ pick: i, win: Math.max(0, win - (i === q.correct ? 1 : 3)), misses: (rp?.misses ?? 0) + (i === q.correct ? 0 : 1) }); diff --git a/src/ui/useReducedMotion.ts b/src/ui/useReducedMotion.ts index e3027be..c5b5c46 100644 --- a/src/ui/useReducedMotion.ts +++ b/src/ui/useReducedMotion.ts @@ -1,17 +1,37 @@ import { useEffect, useState } from "react"; -// 無障礙:尊重使用者「減少動態」偏好(prefers-reduced-motion)。 +// 無障礙:尊重使用者「減少動態」偏好(prefers-reduced-motion), +// 並支援遊戲內手動開啟(localStorage 覆寫,供無法改作業系統設定的裝置/教室環境)。 // 用於關閉自動播放的場景影片與非必要動畫,對前庭敏感/暈動使用者更友善。 +const KEY = "wfg-reduced-motion"; // "1" = 手動強制開啟;未設 = 跟隨系統 +const EVT = "wfg-rm-change"; + +export const getReducedOverride = (): boolean => { + try { return localStorage.getItem(KEY) === "1"; } catch { return false; } +}; +export const setReducedOverride = (on: boolean) => { + try { on ? localStorage.setItem(KEY, "1") : localStorage.removeItem(KEY); } catch { /* ignore */ } + syncBodyClass(); + window.dispatchEvent(new Event(EVT)); +}; + +// 手動覆寫時在 body 加 class,讓 index.css 的「停用動畫」規則也生效(與系統偏好等效) +export const syncBodyClass = () => { + try { document.body.classList.toggle("wfg-reduced", getReducedOverride()); } catch { /* ignore */ } +}; + +const systemReduced = (): boolean => + typeof window !== "undefined" && !!window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + export function useReducedMotion(): boolean { - const [reduced, setReduced] = useState(() => - typeof window !== "undefined" && !!window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches - ); + const [reduced, setReduced] = useState(() => systemReduced() || getReducedOverride()); useEffect(() => { - if (typeof window === "undefined" || !window.matchMedia) return; - const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); - const on = () => setReduced(mq.matches); - mq.addEventListener?.("change", on); - return () => mq.removeEventListener?.("change", on); + if (typeof window === "undefined") return; + const on = () => setReduced(systemReduced() || getReducedOverride()); + const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)"); + mq?.addEventListener?.("change", on); + window.addEventListener(EVT, on); + return () => { mq?.removeEventListener?.("change", on); window.removeEventListener(EVT, on); }; }, []); return reduced; } From 8cfd848b9a60e4717ea044f0d4c5cb871fd782c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 02:09:46 +0000 Subject: [PATCH 02/11] =?UTF-8?q?feat(gameplay):=20=E5=8D=8A=E9=80=94?= =?UTF-8?q?=E6=88=90=E6=9E=9C=E4=BF=9D=E7=95=99(#carry)=20=E2=80=94=20?= =?UTF-8?q?=E5=AF=A9=E6=85=8E=E8=BF=94=E6=B8=AF=E4=B8=8D=E5=86=8D=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E6=AD=B8=E9=9B=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - REPLAN_RETURN 改為「保留」已完成的診斷與 SOP 步驟(工作交接紀錄): 擇日再出海只需重新登塔、重擲作業窗續修。被迫撤離(FAIL_REPAIR)維持 進度全失+計安全事件 → 明確獎勵「及時止損」勝過「硬撐到窗關」。 - 登船畫面顯示「♻ 上次進度已保留(診斷/SOP n/m)」。 - 出海前工期預估只算「剩餘」工作(已診斷→檢查 0;SOP 只算剩餘步驟), 並標示已保留進度 → 再出發的餘裕評估更準。 - 測試改寫涵蓋新語意(保留 pick/steps、boarded 重置、窗重擲、無安全事件; FAIL_REPAIR 對照全失+事件)。151 tests / typecheck / build 全綠。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NF4jqbu1VJU5iS4pHEeDrn --- src/state/game.ts | 6 ++++-- src/ui/screens/RepairScreen.tsx | 11 +++++++++-- src/ui/screens/SailScreen.tsx | 15 +++++++++++---- test/run.mjs | 17 ++++++++++++----- 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/state/game.ts b/src/state/game.ts index 0889af9..7ba709c 100644 --- a/src/state/game.ts +++ b/src/state/game.ts @@ -844,9 +844,11 @@ export function reducer(s: GameData, a: Action): GameData { } case "REPLAN_RETURN": { // 審慎返港再規劃:維修中途、作業窗吃緊時的安全決策。空耗 1 天,但「不計」安全事件(與 FAIL_REPAIR 撤離區隔); - // questStage 維持 active、清空維修進度 → 回母港可重新規劃船機/時機、擇日再出海(獎勵「看準天氣窗、及時止損」的判斷)。 + // questStage 維持 active;已完成的診斷/SOP 步驟「保留」(工作交接紀錄,#carry)——擇日再出海只需重新登塔、 + // 重擲作業窗續修。被迫撤離(FAIL_REPAIR)則進度全失+計安全事件 → 獎勵「及時止損」勝過「硬撐到窗關」。 const adv = advance(s, 1); - return { ...s, ...adv, jobPhase: "office", repair: null }; + const kept = s.repair ? { ...s.repair, boarded: false, win: 0 } : null; + return { ...s, ...adv, jobPhase: "office", repair: kept }; } case "REST": { const adv = advance(s, 1); diff --git a/src/ui/screens/RepairScreen.tsx b/src/ui/screens/RepairScreen.tsx index ccc9e2f..e2ac808 100644 --- a/src/ui/screens/RepairScreen.tsx +++ b/src/ui/screens/RepairScreen.tsx @@ -78,11 +78,12 @@ export default function RepairScreen({ setScreen, mode = "sim", mobile = false } setScreen("hub"); }; - // Part B — 審慎返港再規劃(維修不利時的安全選擇):進 1 天、不計安全事件、工單保留可改天再來 + // Part B — 審慎返港再規劃(維修不利時的安全選擇):進 1 天、不計安全事件、工單保留可改天再來; + // 已完成的診斷/SOP 進度「保留」(#carry),擇日只需重新登塔續修。 const replan = () => { Sfx.click(); dispatch({ type: "REPLAN_RETURN" }); - say({ speaker: "veteran_sailor", line: { zh: "作業窗吃緊、別硬拚。先回港重新規劃船機與時機,擇日再來——工單還在。", en: "Window's tight — don't force it. Head back and re-plan the vessel and timing; the order stays open." } }); + say({ speaker: "veteran_sailor", line: { zh: "作業窗吃緊、別硬拚。已完成的步驟都記錄在案,回港重新規劃、擇日登塔續修!", en: "Window's tight — don't force it. Completed steps are logged; re-plan in port and resume another day." } }); setScreen("hub"); }; @@ -206,6 +207,12 @@ export default function RepairScreen({ setScreen, mode = "sim", mobile = false } : t({ zh: "海象平穩,可安全登船,前往作業地點。", en: "Calm seas — board safely and proceed to the work area." })}
{t({ zh: "作業地點", en: "Work area" })}:{t(LOCATION_LABEL[location])}
+ {/* 半途成果保留(#carry):上次審慎返港所留的診斷/SOP 進度,登塔後直接續修 */} + {rp && (quizCorrect || steps.filter(Boolean).length > 2) && ( +
+ ♻ {t({ zh: `上次進度已保留:診斷${quizCorrect ? "✓" : "未完成"} · SOP ${steps.filter(Boolean).length}/${steps.length} 步——登塔後續修即可。`, en: `Progress kept: diagnosis ${quizCorrect ? "done" : "pending"} · SOP ${steps.filter(Boolean).length}/${steps.length} — resume after boarding.` })} +
+ )} diff --git a/src/ui/screens/SailScreen.tsx b/src/ui/screens/SailScreen.tsx index ac0f871..8c1b46c 100644 --- a/src/ui/screens/SailScreen.tsx +++ b/src/ui/screens/SailScreen.tsx @@ -74,9 +74,15 @@ export default function SailScreen({ setScreen, accent, mode = "sim", mobile = f const roughSea = data.seaState !== "workable"; const stepCost = sopStepCost(data.toolLevel); const sopClickable = fault ? Math.max(0, fault.sop.length - 2) : 3; // 前兩步預設完成、其餘需逐步完成 + // 半途成果保留(#carry):上次審慎返港所留的診斷/SOP 進度 → 工期預估只算「剩餘」工作 + const repairKey = `${data.customQuest ? "c" : data.campaignIndex}:${quest.id}`; + const rp = data.repair && data.repair.key === repairKey ? data.repair : null; + const diagDone = !!rp && !!fault && rp.pick === fault.quiz.correct; + const stepsLeft = rp ? rp.steps.filter((v) => !v).length : sopClickable; + const carried = !!rp && (diagDone || rp.steps.filter(Boolean).length > 2); const estBoard = roughSea ? 3 : 0; // 登船(頂浪 −3;平穩不耗窗) - const estInspect = 1; // 診斷題(答對一次;答錯每次 −3) - const estRepair = sopClickable * stepCost; // 維修 SOP + const estInspect = diagDone ? 0 : 1; // 診斷題(答對一次;答錯每次 −3;已診斷則免) + const estRepair = stepsLeft * stepCost; // 維修 SOP(僅剩餘步驟) const estOnsite = estBoard + estInspect + estRepair; // 上塔作業小計(耗天氣窗) const reserve = winMax - estOnsite; // 保留餘裕 const transitH = 4; // 單程航程 ~4h(與航行 ETA 一致) @@ -193,8 +199,9 @@ export default function SailScreen({ setScreen, accent, mode = "sim", mobile = f
{t({ zh: "上塔作業(耗天氣窗 · 時段)", en: "On-site (spends window · slots)" })}
- - + + + {carried &&
♻ {t({ zh: "已保留上次返港前的進度,只需完成剩餘工作", en: "Progress from the last trip is kept — only remaining work counted" })}
}
diff --git a/test/run.mjs b/test/run.mjs index 30e0643..9465b0e 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -768,17 +768,24 @@ test("repair progress persists in state, resets on quest lifecycle (#33)", () => // 明確清空 eq(R(s1, { type: "SET_REPAIR", repair: null }).repair, null); }); -test("REPLAN_RETURN: 審慎返港 → 清進度、回 office、進 1 天、不計安全事件、工單保留(Part B)", () => { - const r = { key: "0:m1", boarded: true, pick: null, steps: [true, true, false, false, false], win: 2 }; +test("REPLAN_RETURN: 審慎返港 → 保留診斷/SOP 進度(#carry)、回 office、進 1 天、不計安全事件", () => { + const r = { key: "0:m1", boarded: true, pick: 1, steps: [true, true, true, false, false], win: 2, misses: 1 }; const base = { ...I, questStage: "active", jobPhase: "onsite", repair: r, safetyIncidents: 0 }; seed(3); const s = R(base, { type: "REPLAN_RETURN" }); - eq(s.repair, null, "repair cleared"); + ok(s.repair, "repair progress kept (not cleared)"); + eq(s.repair.key, "0:m1", "same work order key"); + eq(s.repair.pick, 1, "diagnosis pick kept"); + eq(s.repair.steps.filter(Boolean).length, 3, "completed SOP steps kept"); + eq(s.repair.boarded, false, "must re-board next trip"); + eq(s.repair.win, 0, "window re-rolled at next boarding (not carried)"); eq(s.jobPhase, "office", "returned to office"); eq(s.questStage, "active", "work order stays open (re-plannable)"); eq(s.safetyIncidents, 0, "no safety incident (distinct from FAIL_REPAIR)"); eq(s.day, base.day + 1, "advances one day"); - // 對照:FAIL_REPAIR(撤離) 會計一次安全事件 - seed(3); eq(R(base, { type: "FAIL_REPAIR" }).safetyIncidents, 1, "FAIL_REPAIR counts a safety incident"); + // 對照:FAIL_REPAIR(被迫撤離) 進度全失 + 計一次安全事件 + seed(3); const f = R(base, { type: "FAIL_REPAIR" }); + eq(f.repair, null, "FAIL_REPAIR clears progress"); + eq(f.safetyIncidents, 1, "FAIL_REPAIR counts a safety incident"); }); test("answer streak: 連續首答正確 → streak+1 與封頂 XP 加成;答錯歸零(#streak)", () => { let s = R(I, { type: "RECORD_ANSWER", keys: ["disc:mechanical"], correct: true }); From 2ab219741c0c2d7c6254fd08f5c9a3c60f694c45 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 02:12:13 +0000 Subject: [PATCH 03/11] =?UTF-8?q?feat(gameplay):=20=E2=9A=A1=20=E5=8A=A0?= =?UTF-8?q?=E7=8F=AD=E6=90=B6=E4=BF=AE(#rush)=20=E2=80=94=20=E4=BD=9C?= =?UTF-8?q?=E6=A5=AD=E7=AA=97=E5=90=83=E7=B7=8A=E6=99=82=E7=9A=84=E3=80=8C?= =?UTF-8?q?=E5=BF=AB=20vs=20=E7=A9=A9=E3=80=8D=E9=A2=A8=E9=9A=AA=E6=8A=89?= =?UTF-8?q?=E6=93=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 RUSH_SOP action:已登塔且窗未關時,剩餘 SOP 一次趕完、 總耗時減半(rushCost = ceil(剩餘×每步/2),下限 1); 25%(RUSH_RISK)機率安全近失事件 → 計 safetyIncidents、扣績效。 風險由 UI 擲骰後傳入,reducer 保持確定性(可測、fuzz 友善)。 - 僅在「維修不利」(tightWindow)時出現 —— 是緊急槓桿,不是常規最佳解; 與「繼續作業 / 回港再規劃」並列成三選一決策: 搶修(快、有風險) vs 續作(穩、可能窗關) vs 返港(慢、保進度)。 - 成功/出事各有工程師/工安對話回饋,把「快歸快,程序不能省」教進遊戲。 - 測試:完成/減半/事件/三守衛 + fuzz 動作池;152 tests / typecheck / build 全綠。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NF4jqbu1VJU5iS4pHEeDrn --- src/state/game.ts | 21 +++++++++++++++++++ src/ui/screens/RepairScreen.tsx | 37 +++++++++++++++++++++++++++------ test/run.mjs | 16 +++++++++++++- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/state/game.ts b/src/state/game.ts index 7ba709c..0283c1b 100644 --- a/src/state/game.ts +++ b/src/state/game.ts @@ -373,6 +373,12 @@ export const workWindowMax = (s: { seaState: SeaState; vesselLevel: number; vess // 每個可點擊 SOP 步驟耗費的作業窗時段(工坊等級越高越省,下限 1)。RepairScreen.completeStep 與出海預估共用。 export const sopStepCost = (toolLevel: number): number => Math.max(1, 2 - toolLevel); +// 加班搶修(#rush):作業窗吃緊時的風險取捨——剩餘 SOP 一次趕完、總耗時減半(無條件進位、下限 1), +// 但有 RUSH_RISK 機率發生安全近失事件(計入 safetyIncidents、扣績效)。「快」與「穩」的抉擇。 +export const RUSH_RISK = 0.25; +export const rushCost = (remainingSteps: number, toolLevel: number): number => + Math.max(1, Math.ceil((remainingSteps * sopStepCost(toolLevel)) / 2)); + // 多風場每日基準發電總和(owned 座風場的 genPerDay 加總) function ownedFarmGen(farmsOwned: number): number { let g = 0; @@ -705,6 +711,7 @@ export type Action = | { type: "ARRIVE" } // 抵達 → onsite(#25) | { type: "FAIL_REPAIR" } // 天氣窗關閉、撤離(#17) | { type: "REPLAN_RETURN" } // 審慎返港再規劃:維修中途、作業窗吃緊時的安全決策(進 1 天、不計安全事件、工單保留) + | { type: "RUSH_SOP"; incident: boolean } // 加班搶修(#rush):剩餘 SOP 一次趕完、耗時減半;風險由 UI 擲骰後傳入(reducer 保持可測) | { type: "REST" } // 靠港休整:進日 + 重新擲海象(#18) | { type: "REMOTE_CHECK" } // 每日遠端 SCADA 巡檢(Phase A #2):消耗 1 天、累積 XP、早期偵測微幅回復健康度 | { type: "OPS_DISPATCH"; turbine: string; engineerId: string } // 戰情室派工:指派技師維修某故障機組(Phase C) @@ -850,6 +857,20 @@ export function reducer(s: GameData, a: Action): GameData { const kept = s.repair ? { ...s.repair, boarded: false, win: 0 } : null; return { ...s, ...adv, jobPhase: "office", repair: kept }; } + case "RUSH_SOP": { + // 加班搶修(#rush):已登塔、有剩餘步驟、窗未關才可趕工;一次完成剩餘 SOP、耗 rushCost 時段; + // incident=true 時計 1 次安全近失事件(趕工的代價 —— 教「快」不等於「好」)。 + const rp = s.repair; + if (!rp || !rp.boarded || rp.win <= 0) return s; + const remaining = rp.steps.filter((v) => !v).length; + if (remaining === 0) return s; + const cost = rushCost(remaining, s.toolLevel); + return { + ...s, + repair: { ...rp, steps: rp.steps.map(() => true), win: Math.max(0, rp.win - cost) }, + safetyIncidents: s.safetyIncidents + (a.incident ? 1 : 0), + }; + } case "REST": { const adv = advance(s, 1); // 可用率由機隊重算(#3,adv 已設定);休整僅回復技師疲勞與機組健康度。 diff --git a/src/ui/screens/RepairScreen.tsx b/src/ui/screens/RepairScreen.tsx index e2ac808..a11135e 100644 --- a/src/ui/screens/RepairScreen.tsx +++ b/src/ui/screens/RepairScreen.tsx @@ -13,7 +13,7 @@ import { FAULTS, LOCATION_LABEL, locationOf, isMajorFault } from "../faults"; import RepairScene from "../RepairScene"; import { FallbackImg } from "../SceneVideo"; import { useReducedMotion } from "../useReducedMotion"; -import { workWindowMax, sopStepCost, toWan, type RepairState } from "../../state/game"; +import { workWindowMax, sopStepCost, toWan, rushCost, RUSH_RISK, type RepairState } from "../../state/game"; import { toast } from "../toast"; import { PARTS } from "../data"; import { missionInstance } from "../campaign"; @@ -78,6 +78,20 @@ export default function RepairScreen({ setScreen, mode = "sim", mobile = false } setScreen("hub"); }; + // 加班搶修(#rush) — 作業窗吃緊時的風險取捨:剩餘 SOP 一次趕完、耗時減半,但 25% 機率安全近失事件。 + const stepsRemaining = steps.filter((v) => !v).length; + const rushSlots = rushCost(stepsRemaining, data.toolLevel); + const rush = () => { + const incident = Math.random() < RUSH_RISK; // UI 擲骰、reducer 保持確定性(可測) + (incident ? Sfx.error : Sfx.success)(); + dispatch({ type: "RUSH_SOP", incident }); + say( + incident + ? { speaker: "safety_officer", expr: "alert", line: { zh: "趕工出狀況!有人差點失足——列入安全近失紀錄。快歸快,程序不能省!", en: "Rushing went wrong — a near-miss on the ladder. Logged as a safety incident. Fast isn't free!" } } + : { speaker: "repair_eng", expr: "confident", line: { zh: "加班趕上了!所有步驟完成——這次運氣站在我們這邊。", en: "Overtime paid off — all steps done. Luck was on our side this time." } } + ); + }; + // Part B — 審慎返港再規劃(維修不利時的安全選擇):進 1 天、不計安全事件、工單保留可改天再來; // 已完成的診斷/SOP 進度「保留」(#carry),擇日只需重新登塔續修。 const replan = () => { @@ -409,12 +423,23 @@ export default function RepairScreen({ setScreen, mode = "sim", mobile = false } ) : ( <> - {/* Part B —「維修不利」提示:作業窗吃緊時,明確給出「繼續作業 / 回港再規劃」兩條路 */} + {/* Part B —「維修不利」提示:作業窗吃緊時,給三條路「繼續作業 / ⚡加班搶修(風險) / 回港再規劃」 */} {tightWindow && ( -
- - {t({ zh: `作業窗吃緊(剩 ${win} 時段,估計還需 ${remainingCost})——可繼續作業,或回港再規劃、擇日再來。`, en: `Window's tight (${win} slots left, ~${remainingCost} needed) — keep going, or return to port and re-plan.` })} -
+ <> +
+ + {t({ zh: `作業窗吃緊(剩 ${win} 時段,估計還需 ${remainingCost})——可繼續作業、加班搶修(有風險),或回港再規劃。`, en: `Window's tight (${win} slots left, ~${remainingCost} needed) — keep going, rush (risky), or return & re-plan.` })} +
+ {stepsRemaining > 0 && win >= rushSlots && ( + + )} + )} {roughBoarding && ( )}
From 84bbacde3be4fe25072dae101656ab61da2d5457 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 12:37:12 +0000 Subject: [PATCH 06/11] =?UTF-8?q?feat(ux):=20=E6=AF=8D=E6=B8=AF=E5=B7=A5?= =?UTF-8?q?=E5=96=AE=E9=A1=AF=E7=A4=BA=E3=80=8C=E2=99=BB=20=E4=B8=8A?= =?UTF-8?q?=E6=AC=A1=E9=80=B2=E5=BA=A6=E5=B7=B2=E4=BF=9D=E7=95=99=E3=80=8D?= =?UTF-8?q?=E2=80=94=E2=80=94=E8=BF=94=E6=B8=AF=E5=BE=8C=E7=9F=A5=E9=81=93?= =?UTF-8?q?=E5=8E=BB=E7=BA=8C=E4=BF=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 半途成果保留(#carry)的資訊閉環:審慎返港後,母港「待處理工單」 顯示已保留的診斷/SOP 進度與「出海續修即可」提示,玩家不會誤以為 要從頭來過。152 tests / typecheck / build 全綠。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NF4jqbu1VJU5iS4pHEeDrn --- src/ui/screens/HubScreen.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/ui/screens/HubScreen.tsx b/src/ui/screens/HubScreen.tsx index 1f45a03..a1e425c 100644 --- a/src/ui/screens/HubScreen.tsx +++ b/src/ui/screens/HubScreen.tsx @@ -363,6 +363,19 @@ export default function HubScreen({ setScreen, accent, onDispatch, onFacility, s
{t(quest.title)}
{quest.unit} · {fault ? t(fault.name) : "—"} · {t(stage === "available" ? S.status.available : stage === "active" ? S.status.active : S.status.done)}
{stage === "active" &&
{t({ zh: "⚠ 停機中:每天約損失 3 萬", en: "⚠ Down: ~30k/day lost" })}
} + {/* 半途成果保留(#carry):返港後在工單上顯示已保留的進度,提示出海續修 */} + {stage === "active" && !data.overhaul && (() => { + const rk = `${data.customQuest ? "c" : data.campaignIndex}:${quest.id}`; + const rp = data.repair && data.repair.key === rk && !data.repair.boarded ? data.repair : null; + const diagOk = !!rp && !!fault && rp.pick === fault.quiz.correct; + const doneSteps = rp ? rp.steps.filter(Boolean).length : 0; + if (!rp || (!diagOk && doneSteps <= 2)) return null; + return ( +
+ ♻ {t({ zh: `上次進度已保留(診斷${diagOk ? "✓" : "未完成"} · SOP ${doneSteps}/${rp.steps.length})——出海續修即可`, en: `Progress kept (diag ${diagOk ? "done" : "pending"} · SOP ${doneSteps}/${rp.steps.length}) — sail to resume` })} +
+ ); + })()} {/* 多回合大修(#4):需連續可作業天氣窗,惡劣海象停滯 + 船舶待命費 */} {stage === "active" && data.overhaul && (oh => (oh.mobilizeLeft ?? 0) > 0 ? ( // 安裝船(jack-up)動員/航行中(#81):尚未到場,僅倒數,不收待命費 From 0855219fdb67135991d7b7026234c35e32e4806f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 12:39:01 +0000 Subject: [PATCH 07/11] =?UTF-8?q?fix(ux):=20=E7=87=9F=E9=81=8B=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E3=80=8C=E5=AE=89=E5=85=A8=20+N=E3=80=8D=E6=AD=A3?= =?UTF-8?q?=E5=90=8D=E7=82=BA=E3=80=8C=E2=9A=A0=20=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=20+N=E3=80=8D+=20=E9=80=A3=E5=B0=8D=E5=9B=9E?= =?UTF-8?q?=E9=A5=8B=E4=B8=80=E8=87=B4=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 小細節抓蟲: - 任務選項效果標籤「安全 +1」其實是 safetyIncidents +1(壞事、扣績效), 顯示卻像加分 → 改為「⚠ 安全事件 +N」,決策後果一目了然。 - 營運中心作答同樣累計連對 streak,但原本沒有回饋 → 補上與維修診斷 一致的 🔥 徽章與里程碑 toast(3 題/每 5 題)。 152 tests / typecheck / build 全綠。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NF4jqbu1VJU5iS4pHEeDrn --- src/ui/OpsCenterModal.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/ui/OpsCenterModal.tsx b/src/ui/OpsCenterModal.tsx index 04e0efa..e05a07e 100644 --- a/src/ui/OpsCenterModal.tsx +++ b/src/ui/OpsCenterModal.tsx @@ -8,6 +8,7 @@ import { toWan, DIAG_COST } from "../state/game"; import { Sfx } from "../audio/sfx"; import { CAT_LABEL, generateTask, type TaskChoice, type TaskInstance } from "../state/tasks"; import { randomCaseDrill, visibleSources, type CaseStudy, type CaseChoice } from "../state/caseStudies"; +import { toast } from "./toast"; import type { I18n } from "../game/systems/types"; const CAT_COLOR: Record = { A: "#dc6450", B: "#5fa8d9", C: "#7fce8e", D: "#e3ad42", E: "#b08adf", F: "#e89a5b", G: "#d98ac0" }; @@ -157,6 +158,11 @@ export default function OpsCenterModal({ open, onClose }: { open: boolean; onClo if (picked !== null) return; (c.good ? Sfx.success : Sfx.error)(); setPicked(ci); + // 連對里程碑(juice):與維修診斷一致的 🔥 回饋(RECORD_ANSWER 於下方派發,先以現值+1 判斷) + if (c.good) { + const ns = (data.answerStreak ?? 0) + 1; + if (ns === 3 || (ns >= 5 && ns % 5 === 0)) toast({ zh: `🔥 判斷連對 ${ns} 題!XP 加成中`, en: `🔥 ${ns}-answer streak! XP bonus active` }); + } if (draw.kind === "case") { const cs = draw.cs; const dHealth = c.good ? 2 : -3; @@ -192,7 +198,8 @@ export default function OpsCenterModal({ open, onClose }: { open: boolean; onClo if (c.eff.a) parts.push(`${t({ zh: "可用率", en: "Avail" })} ${c.eff.a > 0 ? "+" : ""}${c.eff.a}`); if (c.eff.b) parts.push(`◎ ${c.eff.b > 0 ? "+" : ""}${Math.round(c.eff.b / 10000)}萬`); if (c.eff.g) parts.push(`${c.eff.g > 0 ? "+" : ""}${c.eff.g} MWh`); - if (c.eff.s) parts.push(`${t({ zh: "安全", en: "Safety" })} +${c.eff.s}`); + // 小細節:dSafety 是「安全事件」次數(越多越糟),明確標示避免看起來像加分 + if (c.eff.s) parts.push(`⚠ ${t({ zh: "安全事件", en: "Incidents" })} +${c.eff.s}`); return parts.join(" · "); }; @@ -200,6 +207,11 @@ export default function OpsCenterModal({ open, onClose }: { open: boolean; onClo
{t({ zh: "風場日常各種狀況的判斷練習,結算計入排行榜績效分(不影響計分週任務)。本場次已處理:", en: "Judgment practice on day-to-day farm situations; results count toward leaderboard score (not the graded weekly tasks). Resolved this session: " })}{count} + {(data.answerStreak ?? 0) >= 2 && ( + + 🔥 {t({ zh: `連對 ${data.answerStreak}`, en: `Streak ${data.answerStreak}` })} + + )}
{/* 進階檢測解鎖(#scada):付費後 SCADA 圖顯示投影/門檻/讀數,判讀更清晰 */} From 8ab3d4337049a8976eae56f5412bf956aa71d9aa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 12:40:41 +0000 Subject: [PATCH 08/11] =?UTF-8?q?fix(balance):=20=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E6=95=B8=E5=A4=BE=200=20=E2=80=94=20?= =?UTF-8?q?=E5=A0=B5=E4=BD=8F=E3=80=8C=E8=B2=A0=E4=BA=8B=E4=BB=B6=E5=88=B7?= =?UTF-8?q?=E5=88=86=E3=80=8D=E6=BC=8F=E6=B4=9E=20+=20=E6=95=88=E6=9E=9C?= =?UTF-8?q?=E6=A8=99=E7=B1=A4=E6=AD=A3=E8=B2=A0=E8=99=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RESOLVE_TASK 的 dSafety 負值(改善安全的選項,s:-1 共 4 處)原本可把 safetyIncidents 打成負數 → computeScore 的 −20×事件數反而變成加分, 重複選這類選項可無限刷績效。改為 Math.max(0, ...) 夾底。 - 效果標籤同步修正:原「安全 +-1」錯誤顯示;現在正值標「⚠ 安全事件 +N」、 負值標「安全事件 -N」(改善),符號與語意都正確。 - 新增 clamp 測試(0 夾底 / 正常遞減)。153 tests / typecheck / build 全綠。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NF4jqbu1VJU5iS4pHEeDrn --- src/state/game.ts | 2 +- src/ui/OpsCenterModal.tsx | 4 ++-- test/run.mjs | 6 ++++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/state/game.ts b/src/state/game.ts index ffca329..7d09b7b 100644 --- a/src/state/game.ts +++ b/src/state/game.ts @@ -1058,7 +1058,7 @@ export function reducer(s: GameData, a: Action): GameData { availability: fleetUptime(taskFleet), budget: Math.max(0, (adv.budget ?? s.budget) + a.dBudget), generationMWh: Math.max(0, (adv.generationMWh ?? s.generationMWh) + a.dGen), - safetyIncidents: s.safetyIncidents + a.dSafety, + safetyIncidents: Math.max(0, s.safetyIncidents + a.dSafety), // 夾 0:s:-1 的選項(降事件)不可把事件數打成負值 → 否則變成刷分漏洞(負事件反加績效) fleetHealth: clampN((adv.fleetHealth ?? s.fleetHealth) + a.dHealth, 0, 100), xp: s.xp + a.xp, missionsDone: s.missionsDone + 1, diff --git a/src/ui/OpsCenterModal.tsx b/src/ui/OpsCenterModal.tsx index e05a07e..2550c34 100644 --- a/src/ui/OpsCenterModal.tsx +++ b/src/ui/OpsCenterModal.tsx @@ -198,8 +198,8 @@ export default function OpsCenterModal({ open, onClose }: { open: boolean; onClo if (c.eff.a) parts.push(`${t({ zh: "可用率", en: "Avail" })} ${c.eff.a > 0 ? "+" : ""}${c.eff.a}`); if (c.eff.b) parts.push(`◎ ${c.eff.b > 0 ? "+" : ""}${Math.round(c.eff.b / 10000)}萬`); if (c.eff.g) parts.push(`${c.eff.g > 0 ? "+" : ""}${c.eff.g} MWh`); - // 小細節:dSafety 是「安全事件」次數(越多越糟),明確標示避免看起來像加分 - if (c.eff.s) parts.push(`⚠ ${t({ zh: "安全事件", en: "Incidents" })} +${c.eff.s}`); + // 小細節:dSafety 是「安全事件」次數(正=更糟,負=改善),帶正確符號並明確標示,避免「安全 +1」看起來像加分 + if (c.eff.s) parts.push(`${c.eff.s > 0 ? "⚠ " : ""}${t({ zh: "安全事件", en: "Incidents" })} ${c.eff.s > 0 ? "+" : ""}${c.eff.s}`); return parts.join(" · "); }; diff --git a/test/run.mjs b/test/run.mjs index b31d5c5..1be22c6 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -787,6 +787,12 @@ test("REPLAN_RETURN: 審慎返港 → 保留診斷/SOP 進度(#carry)、回 offi eq(f.repair, null, "FAIL_REPAIR clears progress"); eq(f.safetyIncidents, 1, "FAIL_REPAIR counts a safety incident"); }); +test("RESOLVE_TASK: dSafety 負值(降事件)夾 0 — 不可刷成負事件數換績效", () => { + seed(9); const s = R({ ...I, safetyIncidents: 0 }, { type: "RESOLVE_TASK", dAvail: 0, dBudget: 0, dSafety: -1, dGen: 0, dHealth: 0, xp: 10 }); + eq(s.safetyIncidents, 0, "clamped at 0 (no negative incidents)"); + seed(9); const s2 = R({ ...I, safetyIncidents: 2 }, { type: "RESOLVE_TASK", dAvail: 0, dBudget: 0, dSafety: -1, dGen: 0, dHealth: 0, xp: 10 }); + eq(s2.safetyIncidents, 1, "normal decrement still works"); +}); test("RUSH_SOP: 剩餘步驟一次完成、耗時減半;incident 計安全事件;守衛不動作(#rush)", () => { const r = { key: "0:m1", boarded: true, pick: 0, steps: [true, true, true, false, false], win: 4 }; const base = { ...I, toolLevel: 0, repair: r, safetyIncidents: 0 }; From 6186ee9f07e2dd91a7645ec4da596aa925b89fbf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 12:42:25 +0000 Subject: [PATCH 09/11] =?UTF-8?q?fix(ux):=20Toaster=20=E6=94=B9=E7=82=BA?= =?UTF-8?q?=E5=B0=8F=E4=BD=87=E5=88=97=20=E2=80=94=20=E5=A4=9A=E5=89=87?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E4=B8=8D=E5=86=8D=E4=BA=92=E7=9B=B8=E8=93=8B?= =?UTF-8?q?=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原本單一插槽:新 toast 立即取代舊的 → 完工獎勵 toast 會在 ~0.9 秒內 被「每日任務達成」通知蓋掉(FINISH_REPAIR 進日 → 每日任務隨即發獎)。 改為佇列依序各顯示 1.8 秒(上限 5 則防洪)。typecheck / build 全綠。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NF4jqbu1VJU5iS4pHEeDrn --- src/ui/Toaster.tsx | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/ui/Toaster.tsx b/src/ui/Toaster.tsx index 4ae115c..a001e50 100644 --- a/src/ui/Toaster.tsx +++ b/src/ui/Toaster.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { C, FONT_SERIF } from "./tokens"; import { t } from "../game/systems/i18n"; import { useLang } from "./useLang"; @@ -7,18 +7,16 @@ import type { I18n } from "../game/systems/types"; export default function Toaster() { useLang(); - const [msg, setMsg] = useState(null); - const timer = useRef(undefined); + // 小佇列:多則通知(完工獎勵/每日任務/連對里程碑)依序各顯示 1.8 秒,不再互相蓋掉;上限 5 則防洪 + const [queue, setQueue] = useState([]); + const msg = queue[0] ?? null; - useEffect( - () => - onToast((m) => { - setMsg(m); - window.clearTimeout(timer.current); - timer.current = window.setTimeout(() => setMsg(null), 1800); - }), - [] - ); + useEffect(() => onToast((m) => setQueue((q) => (q.length >= 5 ? q : [...q, m]))), []); + useEffect(() => { + if (!msg) return; + const id = window.setTimeout(() => setQueue((q) => q.slice(1)), 1800); + return () => window.clearTimeout(id); + }, [msg]); if (!msg) return null; return ( From 72b662334ef32f72610cb7eefdb2974a4aa61ae1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 12:43:26 +0000 Subject: [PATCH 10/11] =?UTF-8?q?feat(ux):=20=E8=8E=89=E8=8E=89=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E6=B6=B5=E8=93=8B=E6=96=B0=E7=B3=BB=E7=B5=B1=20?= =?UTF-8?q?=E2=80=94=20=E9=80=B2=E5=BA=A6=E4=BF=9D=E7=95=99=E6=8F=90?= =?UTF-8?q?=E9=86=92=20+=20=E4=B8=89=E9=81=B8=E4=B8=80/=E9=80=A3=E5=B0=8D?= =?UTF-8?q?=E6=95=99=E5=AD=B8=E5=B0=8F=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 動態提示:有保留進度的工單時提醒「出海續修」。 - 常駐輪播新增:作業窗吃緊三選一(繼續/搶修/返港)與 🔥 連對機制說明。 153 tests / typecheck / build 全綠。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NF4jqbu1VJU5iS4pHEeDrn --- src/ui/HubAdvisor.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ui/HubAdvisor.tsx b/src/ui/HubAdvisor.tsx index 28ce288..47e8a97 100644 --- a/src/ui/HubAdvisor.tsx +++ b/src/ui/HubAdvisor.tsx @@ -42,6 +42,9 @@ function hubTips(d: GameData): Tip[] { // 過勞技師 if (d.engineers.some((e) => fatigueOf(e) >= 80)) tips.push({ expr: "worried", line: { zh: "有技師快過勞了,適時『靠港休整』讓他們回復,別硬派。", en: "An engineer's near burnout — rest in port to recover before dispatching again." } }); + // 半途成果保留(#carry):有保留進度的工單 → 提醒出海續修 + if (d.questStage === "active" && d.repair && !d.repair.boarded) tips.push({ expr: "happy", line: { zh: "上次返港前的維修進度已保留!挑個好天氣窗出海,登塔就能續修。", en: "Your repair progress was saved! Pick a good weather window, sail out, and resume where you left off." } }); + // 常駐教學小提示(提供穩定可輪播的內容) tips.push( { expr: "smile", line: { zh: "小提醒:售電收入=實際運轉的機組,管理好機隊就是賺錢。", en: "Tip: revenue = turbines actually running — managing the fleet is how you earn." } }, @@ -49,6 +52,8 @@ function hubTips(d: GameData): Tip[] { { expr: "smile", line: { zh: "答對診斷題、完成 SOP 才算修好;答錯會多耗作業窗喔。", en: "A correct diagnosis + full SOP completes a repair; wrong answers cost work-window time." } }, { expr: "happy", line: { zh: `目前綜合績效分要靠:高可用率、完成任務、少安全事件。預算 ◎${toWan(d.budget)} 萬。`, en: `Score comes from high uptime, missions done, and few safety incidents. Budget ◎${toWan(d.budget)}M.` } }, { expr: "smile", line: { zh: "缺料就先到『備品交易所』下單,注意有到貨前置期。", en: "Out of parts? Order at the Parts Market — mind the delivery lead time." } }, + { expr: "thinking", line: { zh: "作業窗吃緊時有三條路:繼續作業(穩)、加班搶修(快但有風險)、回港再規劃(保留進度)。", en: "When the window's tight: keep working (steady), rush (fast but risky), or return to port (progress kept)." } }, + { expr: "happy", line: { zh: "診斷第一次就答對會累積 🔥 連對,XP 加成越疊越高——出手前先想清楚!", en: "First-try correct diagnoses build a 🔥 streak with growing XP bonus — think before you answer!" } }, ); return tips; } From 49cef4a916fa48f2baf93b816ee164fec143e9dd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 12:53:08 +0000 Subject: [PATCH 11/11] =?UTF-8?q?fix(content):=20=E5=85=A7=E5=AE=B9?= =?UTF-8?q?=E7=B3=BB=E7=B5=B1=E5=85=A8=E9=9D=A2=E6=9F=A5=E6=A0=B8=20?= =?UTF-8?q?=E2=80=94=20=E6=AF=8F=E6=97=A5=E4=BB=BB=E5=8B=99=E7=99=BD?= =?UTF-8?q?=E6=8B=BF=E3=80=81=E5=82=99=E5=93=81=E8=AA=A4=E6=A4=8D=E3=80=81?= =?UTF-8?q?=E9=87=8D=E8=A4=87=E9=A1=8C=E3=80=81=E6=A1=88=E4=BE=8B=E8=B3=87?= =?UTF-8?q?=E6=96=99=E8=AA=A4=E6=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 深度查核(事件/案例/任務/故障內容 × 消費端交叉驗證)後修正: 每日任務(HIGH): - 「維持型」任務(零事件/健康度/妥善率,3/6 種)開局當下條件即真 → roll 的瞬間就自動發獎(白拿 ◎12-15 萬 + streak 灌水)。新增 deferred 語意:維持型改於「隔日 roll 前」結算,撐過一整天才算達成; DailyTracker 先結算昨日、再擲今日(連勝判定含剛結算項)。 - 修 DailyTracker 效果重跑造成的重複音效/通知(排程去重)。 備品/故障資料(MED): - sensor(感測器誤報)誤耗「發電機碳刷」→ 改耗「風速計」。 - pitch(變槳故障,控制側)quiz/SOP 都在講後備電池,卻誤耗「液壓油」 → 改耗「變槳後備電池」(faults.ts 與 incidents.ts 同步;液壓根因 另由 pitch_hyd/pitch_hydraulic_leak 負責)。 - yaw_motor/gen_brush 展示庫存 0 → 修正(Tier1 必需品看似永遠缺貨)。 - 進行中工單的必備備品「永遠可見」於交易所(即使高於目前 Tier 的 預設過濾),主線不再卡在找不到料(m3 → pitch_bearing 案例)。 案例/任務內容: - cs_blade_bonding_quality_escape「正解」誤植 s:1(全內容唯一 好選項計安全事件)→ 移除;成就 safety_clean 改 <=0 防禦。 - 移除 3 組重複任務模板(b_harmonics/g_medical_evac/c_dehumidify), 避免同題雙倍抽中率。 查核亦確認乾淨:quiz 索引、跨檔 id 引用、i18n 完整性、Tier 閘門、 派工守衛一致性、事件權重、案例去重。 154 tests(新增 deferred 結算測試)/ typecheck / build / sim 梯度健康。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NF4jqbu1VJU5iS4pHEeDrn --- src/state/caseStudies.ts | 2 +- src/state/dailyTasks.ts | 26 +++++++++++++++++++++----- src/state/incidents.ts | 4 ++-- src/state/records.ts | 2 +- src/state/tasks.ts | 12 +++--------- src/ui/DailyTracker.tsx | 32 +++++++++++++++++++++++++------- src/ui/data.ts | 4 ++-- src/ui/faults.ts | 2 +- src/ui/screens/MarketScreen.tsx | 15 +++++++++++---- src/ui/tutorialSteps.ts | 4 ++-- test/run.mjs | 15 +++++++++++++++ 11 files changed, 84 insertions(+), 34 deletions(-) diff --git a/src/state/caseStudies.ts b/src/state/caseStudies.ts index 67c6c7c..99be9bb 100644 --- a/src/state/caseStudies.ts +++ b/src/state/caseStudies.ts @@ -107,7 +107,7 @@ export const CASE_STUDIES: CaseStudy[] = [ scenario: { zh: "某北美外海風場一支約 107m 大型葉片在『高振動』警報停機約六小時後斷裂解體,玻纖與泡棉碎片入海漂上岸。事後回溯安裝葉片的製程數據,找出更多膠合不足的同廠葉片,主管機關要求移除該廠所有葉片。據調查屬製造/NDT 檢驗逃逸,非設計缺陷。", en: "At a North American offshore site, a ~107m blade fractured ~6 hours after a 'high vibration' alarm tripped the unit, scattering fiberglass and foam into the sea and onto beaches. Re-mining factory process data found more under-bonded blades from the same plant; the regulator ordered removal of all that factory's blades. Per the investigation, a manufacturing/NDT quality escape, not a design flaw." }, lesson: { zh: "製造品質逃逸會潛伏到在役才以災難性失效現形;高振動警報確實正確跳機,驗證 CMS 是最後防線,但無法阻止碎片入海。正解是用『全機隊製程數據鑑識』(依工廠/序號回溯)界定系列缺陷,並在葉片出廠前加嚴並獨立稽核 NDT/膠合線驗收;事前備妥外海碎片圍堵與環境應變程序。", en: "Quality escapes lie dormant until catastrophic in-service failure; the high-vibration trip validated CMS as a last line of defense but can't prevent debris release. The fix: fleet-wide manufacturing-data forensics (by factory/serial) to scope a serial defect, tightened & independently audited NDT/bond-line acceptance before blades ship, and pre-planned offshore debris containment." }, choices: [ - { label: { zh: "依製程數據回溯全機隊同廠葉片並停機檢查", en: "Re-mine process data; ground & inspect all same-factory blades" }, good: true, feedback: { zh: "✓ 系列缺陷要用製程鑑識界定範圍,不是逐台等壞。", en: "✓ A serial defect is scoped by process forensics, not by waiting unit-by-unit." }, eff: { a: 4, b: -1500000, s: 1 } }, + { label: { zh: "依製程數據回溯全機隊同廠葉片並停機檢查", en: "Re-mine process data; ground & inspect all same-factory blades" }, good: true, feedback: { zh: "✓ 系列缺陷要用製程鑑識界定範圍,不是逐台等壞。", en: "✓ A serial defect is scoped by process forensics, not by waiting unit-by-unit." }, eff: { a: 4, b: -1500000 } }, // 修:原 s:1 誤把「正解」記一次安全事件(全內容唯一反例,資料誤植) { label: { zh: "視為單一葉片偶發,換掉就好", en: "Treat as a one-off blade and just replace it" }, good: false, feedback: { zh: "✗ 同廠序號可能共享膠合不足,漏掉會再斷。", en: "✗ Same-factory serials may share the defect — missing them means another break." }, eff: { a: -6, s: 2 } }, ], relatesTo: { incidentId: "blade", faultId: "blade_crack" }, diff --git a/src/state/dailyTasks.ts b/src/state/dailyTasks.ts index 73eade0..acbde73 100644 --- a/src/state/dailyTasks.ts +++ b/src/state/dailyTasks.ts @@ -14,6 +14,9 @@ export interface DailyDef { cash: number; // 達成獎勵 ◎ // 是否達成:以「自今日起始的增量(base)」或「當前狀態」判定 met: (d: GameData, base: DailyState["base"]) => boolean; + // 維持型任務(#daily-fix):條件在開局當下即可能為真(零事件/健康度/妥善率), + // 若即時發獎會變成「開局白拿」。deferred=true → 僅在「隔日 roll 前」結算(撐過一整天才算達成)。 + deferred?: boolean; } export const DAILY_GEN_TARGET = 100; // 今日淨發電增量目標 (MWh) @@ -24,9 +27,9 @@ export const DAILY_DEFS: DailyDef[] = [ { id: "resolve2", desc: { zh: "今日於戰情室修復 2 台機組", en: "Resolve 2 turbines in Fleet Ops today" }, xp: 30, cash: 200_000, met: (d, b) => (d.fleetResolved ?? 0) - b.resolved >= 2 }, { id: "mission1", desc: { zh: "今日完成 1 件工單/任務", en: "Complete 1 work order/task today" }, xp: 25, cash: 150_000, met: (d, b) => d.missionsDone - b.missions >= 1 }, { id: "gen", desc: { zh: `今日淨發電 ≥ ${DAILY_GEN_TARGET} MWh`, en: `Generate ≥ ${DAILY_GEN_TARGET} MWh today` }, xp: 25, cash: 150_000, met: (d, b) => d.generationMWh - b.gen >= DAILY_GEN_TARGET }, - { id: "noincident", desc: { zh: "今日零安全事件", en: "Zero safety incidents today" }, xp: 20, cash: 120_000, met: (d, b) => (d.safetyIncidents ?? 0) - b.safety <= 0 }, - { id: "health", desc: { zh: "維持機組健康度 ≥ 60%", en: "Keep fleet health ≥ 60%" }, xp: 20, cash: 120_000, met: (d) => d.fleetHealth >= 60 }, - { id: "uptime", desc: { zh: "維持機隊妥善率 ≥ 85%", en: "Keep fleet uptime ≥ 85%" }, xp: 25, cash: 150_000, met: (d) => fleetUptime(d.fleet) >= 85 }, + { id: "noincident", desc: { zh: "今日零安全事件", en: "Zero safety incidents today" }, xp: 20, cash: 120_000, deferred: true, met: (d, b) => (d.safetyIncidents ?? 0) - b.safety <= 0 }, + { id: "health", desc: { zh: "維持機組健康度 ≥ 60%", en: "Keep fleet health ≥ 60%" }, xp: 20, cash: 120_000, deferred: true, met: (d) => d.fleetHealth >= 60 }, + { id: "uptime", desc: { zh: "維持機隊妥善率 ≥ 85%", en: "Keep fleet uptime ≥ 85%" }, xp: 25, cash: 150_000, deferred: true, met: (d) => fleetUptime(d.fleet) >= 85 }, ]; export const dailyDef = (id: string): DailyDef | undefined => DAILY_DEFS.find((x) => x.id === id); @@ -61,11 +64,24 @@ export function rollDailyState(day: number, seed: string, d: GameData, prev: Dai }; } -// 目前「已達成但尚未發獎」的任務 id(DailyTracker 據此自動 claim) +// 目前「已達成但尚未發獎」的任務 id(DailyTracker 據此自動 claim);維持型(deferred)不在此列,於隔日結算 export function dueDailyClaims(d: GameData): string[] { const dl = d.daily; if (!dl) return []; - return dl.ids.filter((id) => !dl.claimed.includes(id) && (dailyDef(id)?.met(d, dl.base) ?? false)); + return dl.ids.filter((id) => { + const def = dailyDef(id); + return !!def && !def.deferred && !dl.claimed.includes(id) && def.met(d, dl.base); + }); +} + +// 日終結算(#daily-fix):隔日 roll「前」呼叫,回傳撐過一整天且達成的維持型任務 id +export function dueDeferredClaims(d: GameData): string[] { + const dl = d.daily; + if (!dl) return []; + return dl.ids.filter((id) => { + const def = dailyDef(id); + return !!def && !!def.deferred && !dl.claimed.includes(id) && def.met(d, dl.base); + }); } // 全部完成? diff --git a/src/state/incidents.ts b/src/state/incidents.ts index 5da14fa..1d83e16 100644 --- a/src/state/incidents.ts +++ b/src/state/incidents.ts @@ -21,8 +21,8 @@ export interface IncidentType { // Tier 4:最大組件更換(葉片/主軸承磨耗) export const INCIDENTS: IncidentType[] = [ { id: "gearbox", name: { zh: "齒輪箱過熱", en: "Gearbox overheat" }, discipline: "mechanical", repairDays: 2, part: "gearbox_oil", weight: 5, minTier: 1 }, - { id: "sensor", name: { zh: "感測器誤報", en: "Sensor false alarm" }, discipline: "control", repairDays: 1, resettable: true, part: "gen_brush", weight: 5, minTier: 1 }, - { id: "pitch", name: { zh: "變槳故障", en: "Pitch fault" }, discipline: "control", repairDays: 2, part: "hydraulic_oil", weight: 4, minTier: 2 }, + { id: "sensor", name: { zh: "感測器誤報", en: "Sensor false alarm" }, discipline: "control", repairDays: 1, resettable: true, part: "anemometer", weight: 5, minTier: 1 }, // 修:原誤用發電機碳刷,感測器工單應耗感測器備品 + { 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 }, diff --git a/src/state/records.ts b/src/state/records.ts index 966e45d..61726e8 100644 --- a/src/state/records.ts +++ b/src/state/records.ts @@ -27,7 +27,7 @@ export const ACHIEVEMENTS: Achievement[] = [ { id: "fleet_master", icon: "🎯", name: { zh: "戰情室高手", en: "Ops Master" }, desc: { zh: "戰情室累積修復 20 台機組", en: "Resolve 20 turbines in Ops Center" }, test: (d) => (d.fleetResolved ?? 0) >= 20 }, { id: "multi_farm", icon: "🌊", name: { zh: "拓展版圖", en: "Fleet Expansion" }, desc: { zh: "同時營運 2 座以上風場", en: "Operate 2+ wind farms" }, test: (d) => d.farmsOwned >= 2 }, { id: "two_vessels", icon: "🚢", name: { zh: "多元船隊", en: "Diverse Fleet" }, desc: { zh: "擁有 2 種以上作業船", en: "Own 2+ vessel types" }, test: (d) => (d.ownedVessels?.length ?? 0) >= 2 }, - { id: "safety_clean", icon: "🦺", name: { zh: "零事故 30 天", en: "30 Days Incident-Free" }, desc: { zh: "營運滿 30 天且零安全事件", en: "30+ days operated with zero safety incidents" }, test: (d) => d.day - 21 >= 30 && (d.safetyIncidents ?? 0) === 0 }, + { id: "safety_clean", icon: "🦺", name: { zh: "零事故 30 天", en: "30 Days Incident-Free" }, desc: { zh: "營運滿 30 天且零安全事件", en: "30+ days operated with zero safety incidents" }, test: (d) => d.day - 21 >= 30 && (d.safetyIncidents ?? 0) <= 0 }, // <=0 防禦:舊存檔若殘留負值不致永久卡死成就 { id: "sla_keeper", icon: "📈", name: { zh: "達標守門員", en: "SLA Keeper" }, desc: { zh: "撐過一季且無 SLA 違約", en: "Clear a quarter with no SLA breach" }, test: (d) => d.quarter >= 2 && (d.slaPenalties ?? 0) === 0 }, { id: "score_500", icon: "⭐", name: { zh: "績效新星", en: "Rising Star" }, desc: { zh: "綜合績效分達 500", en: "Reach a performance score of 500" }, test: (_d, s) => s >= 500 }, { id: "score_1500", icon: "🌟", name: { zh: "績效王者", en: "Performance Ace" }, desc: { zh: "綜合績效分達 1,500", en: "Reach a performance score of 1,500" }, test: (_d, s) => s >= 1500 }, diff --git a/src/state/tasks.ts b/src/state/tasks.ts index 2ca4671..46c04e9 100644 --- a/src/state/tasks.ts +++ b/src/state/tasks.ts @@ -665,17 +665,13 @@ export const TASKS: TaskTemplate[] = [ { id: "b_data_gap", cat: "B", xp: 60, title: { zh: "SCADA 資料缺漏", en: "SCADA data gaps" }, scenario: { zh: "某機組 SCADA 資料出現大量缺漏,趨勢無法判讀。", en: "A unit's SCADA data is full of gaps; trends can't be read." }, choices: [ { label: { zh: "修復資料採集與通訊鏈路", en: "Fix data acquisition & comms link" }, good: true, feedback: { zh: "✓ 沒有可信資料就沒有預知保養。", en: "✓ No trustworthy data, no predictive maintenance." }, eff: { a: 2, b: -80000 } }, { label: { zh: "用其他機組資料推估", en: "Estimate from other units" }, good: false, feedback: { zh: "△ 代用資料掩蓋本機真實狀態。", en: "△ Proxy data hides this unit's true state." }, eff: { a: -1 } } ] }, - { id: "b_harmonics", cat: "B", xp: 70, chart: "spectrum", title: { zh: "諧波失真上升", en: "Harmonic distortion rising" }, scenario: { zh: "併網點電流總諧波失真(THD)持續升高。", en: "Current total-harmonic-distortion at the grid point keeps rising." }, choices: [ - { label: { zh: "檢查濾波器與變流器調變", en: "Check filters & converter modulation" }, good: true, feedback: { zh: "✓ THD 過高違反併網規範且傷設備。", en: "✓ Excess THD breaches grid code and stresses equipment." }, eff: { a: 2, b: -150000 } }, - { label: { zh: "在限值內就不理會", en: "Within limits — ignore" }, good: false, feedback: { zh: "△ 上升趨勢終將越限受罰。", en: "△ The rising trend will breach limits and incur penalties." }, eff: { b: -100000 } } ] }, + // (b_harmonics 已移除:與 b_harmonic 情境重複,避免同題雙倍抽中率) // ── C 預防保養 ── { id: "c_torque_audit", cat: "C", xp: 50, title: { zh: "螺栓抽驗稽核", en: "Bolt torque audit" }, scenario: { zh: "品保要求對連接螺栓做抽樣扭力稽核。", en: "QA requires a sample torque audit on connection bolts." }, choices: [ { label: { zh: "依抽樣計畫稽核並記錄", en: "Audit per sampling plan & record" }, good: true, feedback: { zh: "✓ 抽驗能及早發現系統性鬆動。", en: "✓ Sampling catches systematic loosening early." }, eff: { a: 1, b: -50000 } }, { label: { zh: "上次沒問題就免了", en: "Skip — last time was fine" }, good: false, feedback: { zh: "✗ 預緊力會隨運轉持續衰減。", en: "✗ Preload keeps relaxing with operation." }, eff: { s: 1 } } ] }, - { id: "c_dehumidify", cat: "C", xp: 40, title: { zh: "機艙除濕排水", en: "Nacelle dehumidification" }, scenario: { zh: "機艙濕度偏高、底部積水,電氣件受潮風險升高。", en: "High nacelle humidity with pooled water raises moisture risk to electronics." }, choices: [ - { label: { zh: "檢修除濕機與排水、密封", en: "Service dehumidifier, drains & seals" }, good: true, feedback: { zh: "✓ 受潮是離岸電氣故障主因之一。", en: "✓ Moisture is a top cause of offshore electrical faults." }, eff: { a: 2, b: -60000 } }, - { label: { zh: "開門通風就好", en: "Just air it out" }, good: false, feedback: { zh: "△ 海上高鹽濕,通風治標。", en: "△ Salty sea air makes airing a stopgap." }, eff: { a: -1 } } ] }, + // (c_dehumidify 已移除:與 c_dehumidifier 情境重複,避免同題雙倍抽中率) { id: "c_escape_kit", cat: "C", xp: 40, title: { zh: "逃生與緊急照明檢查", en: "Escape & emergency-light check" }, scenario: { zh: "塔內緊急照明與逃生裝備到期須檢查。", en: "Tower emergency lighting & escape kit are due for inspection." }, choices: [ { label: { zh: "逐項檢查並更換失效件", en: "Check each item & replace failures" }, good: true, feedback: { zh: "✓ 緊急時刻這些是保命裝備。", en: "✓ In an emergency these are life-saving kit." }, eff: { b: -40000 } }, { label: { zh: "外觀完好就跳過", en: "Looks ok — skip" }, good: false, feedback: { zh: "✗ 失效要到逃生時才發現就太遲。", en: "✗ Finding failures during an escape is too late." }, eff: { s: 1 } } ] }, @@ -741,9 +737,7 @@ export const TASKS: TaskTemplate[] = [ { id: "g_lightning_hit", cat: "G", xp: 70, title: { zh: "運轉中遭雷擊", en: "Lightning strike in operation" }, scenario: { zh: "一台運轉中機組剛遭雷擊,接閃系統告警、可能起火或損傷。", en: "An operating unit just took a lightning strike; the LPS alarms and fire/damage is possible." }, choices: [ { label: { zh: "遠端停機、確認無火後派員檢查", en: "Remote-stop, confirm no fire, then inspect" }, good: true, feedback: { zh: "✓ 先確保無火再進場,避免人員涉險。", en: "✓ Confirm no fire before entry to keep crews safe." }, eff: { a: 1, b: -100000 } }, { label: { zh: "立即派員登塔查看", en: "Send crew up immediately" }, good: false, feedback: { zh: "✗ 雷擊後恐有殘餘起火與帶電風險。", en: "✗ Post-strike there may be residual fire & live hazards." }, eff: { s: 2 } } ] }, - { id: "g_medical_evac", cat: "G", xp: 80, title: { zh: "海上醫療急救", en: "Offshore medical emergency" }, scenario: { zh: "一名技師在機艙突發疑似心臟不適。", en: "A technician suffers suspected cardiac distress in the nacelle." }, choices: [ - { label: { zh: "啟動急救與醫療後送程序", en: "Start first aid & medevac procedure" }, good: true, feedback: { zh: "✓ 黃金時間搶救,立即後送。", en: "✓ Act in the golden window — evacuate at once." }, eff: { a: -1 } }, - { label: { zh: "讓他休息等下班一起回", en: "Let him rest, return at shift end" }, good: false, feedback: { zh: "✗ 延誤醫療可能致命。", en: "✗ Delaying care can be fatal." }, eff: { s: 3 } } ] }, + // (g_medical_evac 已移除:與 g_medical 情境重複,避免同題雙倍抽中率) { id: "g_unauth_vessel", cat: "G", xp: 50, title: { zh: "不明船舶闖入", en: "Unauthorised vessel intrusion" }, scenario: { zh: "不明船舶進入風場安全區、逼近作業船。", en: "An unknown vessel enters the safety zone near work boats." }, choices: [ { label: { zh: "通報海巡並廣播警示", en: "Notify the coastguard & broadcast a warning" }, good: true, feedback: { zh: "✓ 安全區管制須即時通報。", en: "✓ Safety-zone control requires prompt reporting." }, eff: {} }, { label: { zh: "自行驅離", en: "Chase it off yourself" }, good: false, feedback: { zh: "✗ 自行處置恐釀碰撞與衝突。", en: "✗ Confronting it risks collision & escalation." }, eff: { s: 1 } } ] }, diff --git a/src/ui/DailyTracker.tsx b/src/ui/DailyTracker.tsx index ee3a14f..a272d2c 100644 --- a/src/ui/DailyTracker.tsx +++ b/src/ui/DailyTracker.tsx @@ -1,29 +1,47 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useGame } from "../state/GameContext"; import { accountSeed } from "./campaign"; -import { rollDailyState, dueDailyClaims, dailyDef } from "../state/dailyTasks"; +import { rollDailyState, dueDailyClaims, dueDeferredClaims, dailyDef } from "../state/dailyTasks"; import { toWan } from "../state/game"; import { toast } from "./toast"; import { Sfx } from "../audio/sfx"; -// 每日任務追蹤(#78):監看遊戲內日 →(1)日推進時產生當日任務、(2)達成時自動發獎並通知。 -// 與排行/紀錄追蹤同為 App 內常駐的 headless 元件。 +// 每日任務追蹤(#78):監看遊戲內日 →(1)日推進時「先結算昨日維持型任務、再產生當日任務」、 +// (2)增量型任務達成時自動發獎並通知。與排行/紀錄追蹤同為 App 內常駐的 headless 元件。 export default function DailyTracker() { const { data, dispatch } = useGame(); + // 已排程發獎的去重鍵(day:id):效果會因 data 變動重跑,避免同一任務重複排 setTimeout → 重複音效/通知(#daily-fix) + const scheduled = useRef>(new Set()); - // 日推進(或首次掛載)→ 產生當日每日任務(baseline = 當前累積值) + // 日推進(或首次掛載)→ 先結算「昨日維持型任務」(撐過一整天才發獎),再產生當日任務 useEffect(() => { if (!data.daily || data.daily.day !== data.day) { - dispatch({ type: "ROLL_DAILY", daily: rollDailyState(data.day, accountSeed(), data, data.daily) }); + const settled: string[] = data.daily ? dueDeferredClaims(data) : []; + for (const id of settled) { + const def = dailyDef(id); + if (!def) continue; + dispatch({ type: "CLAIM_DAILY", id, xp: def.xp, cash: def.cash }); + Sfx.success(); + toast({ + zh: `✅ 每日任務達成:${def.desc.zh}(+◎${toWan(def.cash)} 萬 ・ +${def.xp} XP)`, + en: `✅ Daily done: ${def.desc.en} (+◎${toWan(def.cash)}M ・ +${def.xp} XP)`, + }); + } + // 連勝判定要看「含剛結算」的完成數 → 用本地補丁後的 prev 再 roll + const prev = data.daily ? { ...data.daily, claimed: [...data.daily.claimed, ...settled] } : null; + dispatch({ type: "ROLL_DAILY", daily: rollDailyState(data.day, accountSeed(), data, prev) }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [data.day]); - // 狀態變更 → 自動發放已達成的每日任務獎勵(reducer 冪等,重複派發不重複計) + // 狀態變更 → 自動發放已達成的「增量型」每日任務獎勵(reducer 冪等,重複派發不重複計) useEffect(() => { const due = dueDailyClaims(data); if (!due.length) return; due.forEach((id, i) => { + const key = `${data.daily?.day}:${id}`; + if (scheduled.current.has(key)) return; // 已排程過(效果重跑) → 不重複音效/通知 + scheduled.current.add(key); const def = dailyDef(id); if (!def) return; window.setTimeout(() => { diff --git a/src/ui/data.ts b/src/ui/data.ts index 08925d1..8482b1e 100644 --- a/src/ui/data.ts +++ b/src/ui/data.ts @@ -24,7 +24,7 @@ export const PARTS: Part[] = [ { id: "main_controller", n: { zh: "主控制器 PLC", en: "Main Controller (PLC)" }, stars: 3, idx: 99, qty: 28, price: "900,000", minTier: 2 }, { id: "cable_joint", n: { zh: "海纜接頭", en: "Subsea Cable Joint" }, stars: 2, idx: 105, qty: 243, price: "900,000", minTier: 3 }, { id: "transformer_bushing", n: { zh: "變壓器套管", en: "Transformer Bushing" }, stars: 2, idx: 97, qty: 26, price: "350,000", minTier: 3 }, - { id: "yaw_motor", n: { zh: "偏航電機", en: "Yaw Motor" }, stars: 2, idx: 70, qty: 0, price: "700,000", minTier: 1 }, + { id: "yaw_motor", n: { zh: "偏航電機", en: "Yaw Motor" }, stars: 2, idx: 70, qty: 6, price: "700,000", minTier: 1 }, // 修:原 qty 0 讓 Tier1 必需品看似缺貨(qty 僅展示) { id: "yaw_gear", n: { zh: "偏航齒輪組", en: "Yaw Gear Set" }, stars: 2, idx: 92, qty: 18, price: "600,000", minTier: 2 }, { id: "pitch_controller", n: { zh: "變槳控制器", en: "Pitch Controller" }, stars: 2, idx: 101, qty: 30, price: "500,000", minTier: 2 }, { id: "slip_ring", n: { zh: "集電環", en: "Slip Ring" }, stars: 2, idx: 88, qty: 22, price: "400,000", minTier: 2 }, @@ -42,7 +42,7 @@ export const PARTS: Part[] = [ { id: "bolt_m36", n: { zh: "螺栓組 M36", en: "Bolt Set M36" }, stars: 1, idx: 103, qty: 73, price: "50,000", minTier: 2 }, { id: "corrosion_anode", n: { zh: "犧牲陽極(防蝕)", en: "Sacrificial Anode" }, stars: 1, idx: 100, qty: 90, price: "55,000", minTier: 2 }, { id: "seal_kit", n: { zh: "油封組", en: "Seal Kit" }, stars: 1, idx: 99, qty: 160, price: "45,000", minTier: 1 }, - { id: "gen_brush", n: { zh: "發電機碳刷", en: "Generator Brush" }, stars: 1, idx: 81, qty: 0, price: "40,000", minTier: 1 }, + { id: "gen_brush", n: { zh: "發電機碳刷", en: "Generator Brush" }, stars: 1, idx: 81, qty: 24, price: "40,000", minTier: 1 }, // 修:原 qty 0 讓 Tier1 必需品看似缺貨(qty 僅展示) { id: "leading_edge_tape", n: { zh: "葉片前緣保護帶", en: "Leading-edge Tape" }, stars: 1, idx: 100, qty: 210, price: "35,000", minTier: 2 }, { id: "gearbox_filter", n: { zh: "齒輪箱濾芯", en: "Gearbox Filter" }, stars: 1, idx: 98, qty: 130, price: "30,000", minTier: 1 }, { id: "coolant", n: { zh: "冷卻液", en: "Coolant" }, stars: 1, idx: 101, qty: 180, price: "25,000", minTier: 1 }, diff --git a/src/ui/faults.ts b/src/ui/faults.ts index 4e3bfae..8cffc55 100644 --- a/src/ui/faults.ts +++ b/src/ui/faults.ts @@ -136,7 +136,7 @@ export const FAULTS: Record = { { zh: "順槳測試並回報 SCADA", en: "Feather test & report to SCADA" }, ], knowledge_point: "pitch_backup", - part: "hydraulic_oil", + part: "pitch_battery", // 修:quiz/SOP 都在講後備電池,原誤耗液壓油(液壓根因另有 pitch_hydraulic_leak) discipline: "control", }, converter_fault: { diff --git a/src/ui/screens/MarketScreen.tsx b/src/ui/screens/MarketScreen.tsx index cdb3115..2f86cda 100644 --- a/src/ui/screens/MarketScreen.tsx +++ b/src/ui/screens/MarketScreen.tsx @@ -28,8 +28,16 @@ export default function MarketScreen({ accent, mobile = false }: { accent: strin const [cart, setCart] = useState>({}); const [showAll, setShowAll] = useState(false); // #77 漸進揭露:預設只顯示已解鎖備品,可切換顯示全部 const tier = tierOf(data); - const buyParts = showAll ? PARTS : partsForTier(tier); // 依運維層級過濾(非阻擋:可切換全部) - const lockedCount = PARTS.length - partsForTier(tier).length; + // 判斷提醒/小細節:進行中工單的必備備品「永遠可見」——即使 minTier 高於目前層級,也不能讓主線卡在找不到料 + const activeQuest = data.customQuest ?? missionInstance(data.campaignIndex); + const activeNeed = data.questStage === "active" ? FAULTS[activeQuest.targetFault]?.part : undefined; + const tierParts = partsForTier(tier); + const buyParts = showAll + ? PARTS + : activeNeed && !tierParts.some((p) => p.id === activeNeed) + ? [...tierParts, ...PARTS.filter((p) => p.id === activeNeed)] + : tierParts; // 依運維層級過濾(非阻擋:可切換全部) + const lockedCount = PARTS.length - tierParts.length; const subtotal = PARTS.reduce((s, p) => s + priceNum(p) * (cart[p.id] ?? 0), 0); const total = Math.round(subtotal * (1 + TAX)); @@ -39,8 +47,7 @@ export default function MarketScreen({ accent, mobile = false }: { accent: strin const owned = PARTS.filter((p) => (data.inventory[p.id] ?? 0) > 0); // 判斷提醒:進行中工單的必備備品是否備齊;缺料時提示到貨前置期趕不趕得上出海。 - const quest = data.customQuest ?? missionInstance(data.campaignIndex); - const need = data.questStage === "active" ? FAULTS[quest.targetFault]?.part : undefined; + const need = activeNeed; const needPart = need ? PARTS.find((p) => p.id === need) : undefined; const needInStock = need ? (data.inventory[need] ?? 0) > 0 : false; const needInTransit = need ? data.pendingOrders.filter((o) => o.partId === need).reduce((a, o) => a + o.qty, 0) : 0; diff --git a/src/ui/tutorialSteps.ts b/src/ui/tutorialSteps.ts index f365b6a..e15abda 100644 --- a/src/ui/tutorialSteps.ts +++ b/src/ui/tutorialSteps.ts @@ -92,8 +92,8 @@ export const TUTORIAL_STEPS: CoachStep[] = [ screen: "repair", expr: "happy", text: { - zh: "海象平穩,可安全登船。點「登船登塔,開始作業」!", - en: "Calm seas — board safely. Tap 'Board & start work'!", + zh: "抵達後先登船登塔。海象平穩可直接登船;若浪高,「頂浪登船」會多耗作業窗——點登船按鈕繼續!", + en: "Time to board the tower. Calm seas board freely; in swell, boarding costs extra window time — tap the board button to continue!", }, gate: (d) => !!d.repair?.boarded, }, diff --git a/test/run.mjs b/test/run.mjs index 1be22c6..ea2c7df 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -1195,6 +1195,21 @@ test("daily: dueDailyClaims detects met increment goals", () => { const after = daily.dueDailyClaims({ ...I, daily: dl, missionsDone: 1 }); ok(after.includes("mission1"), "mission increment detected"); }); +test("daily: 維持型任務(deferred)不即時發獎、於日結算檢核(#daily-fix)", () => { + const base = { resolved: 0, missions: 0, gen: 0, safety: 0 }; + const dl = { day: I.day, ids: ["mission1", "noincident", "uptime"], base, claimed: [], streak: 0 }; + const d0 = { ...I, daily: dl, safetyIncidents: 0 }; + // 開局當下 noincident/uptime 條件即為真,但不得立即發獎(原本開局白拿 3/6 種任務) + const due = daily.dueDailyClaims(d0); + ok(!due.includes("noincident") && !due.includes("uptime"), "maintain-type not instantly claimable"); + // 日結算(隔日 roll 前):整天沒出事 → 通過 + ok(daily.dueDeferredClaims(d0).includes("noincident"), "deferred settles after holding all day"); + // 當天出過事 → 結算不通過 + const d1 = { ...I, daily: dl, safetyIncidents: 1 }; + ok(!daily.dueDeferredClaims(d1).includes("noincident"), "incident during the day blocks settlement"); + // 增量型不受影響 + ok(daily.dueDailyClaims({ ...d0, missionsDone: I.missionsDone + 1 }).includes("mission1"), "increment goals still instant"); +}); test("daily: CLAIM_DAILY grants reward, is idempotent, increments streak on full", () => { const dl = { day: I.day, ids: ["health", "uptime"], base: { resolved: 0, missions: 0, gen: 0, safety: 0 }, claimed: [], streak: 0 }; let s = { ...I, daily: dl, budget: 1_000_000, xp: 0 };