- {/* 夕陽光暈 */}
-
- {/* 海 */}
-
-
+
+ {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");