icoa-cli 2.19.348 → 2.19.349
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/ai4ctf.js +1 -1
- package/dist/commands/arena-eai.js +1 -1
- package/dist/commands/connect.js +1 -1
- package/dist/commands/ctf.js +1 -1
- package/dist/commands/ctf4ai-demo.js +1 -1
- package/dist/commands/ctf4vla.js +1 -1
- package/dist/commands/exam.js +1 -1
- package/dist/index.js +355 -1
- package/dist/lib/access.js +184 -1
- package/dist/lib/arena-submit.js +21 -1
- package/dist/lib/budget.js +6 -1
- package/dist/lib/challenge-dir.js +16 -1
- package/dist/lib/comms.js +212 -1
- package/dist/lib/config.js +93 -1
- package/dist/lib/country-lang.js +39 -1
- package/dist/lib/demo-exam.js +478 -1
- package/dist/lib/demo-flags.js +27 -1
- package/dist/lib/demo-stats.js +62 -1
- package/dist/lib/demo2-progress.js +102 -1
- package/dist/lib/exam-client.js +54 -1
- package/dist/lib/exam-state.js +273 -1
- package/dist/lib/gemini.js +247 -1
- package/dist/lib/integrity-snapshot.js +88 -1
- package/dist/lib/interactive-spawn.js +55 -1
- package/dist/lib/ipynb-input.js +65 -1
- package/dist/lib/kernel-protocol.js +88 -1
- package/dist/lib/kernel.js +146 -2
- package/dist/lib/learn-curricula.js +309 -1
- package/dist/lib/learn-i18n.js +184 -1
- package/dist/lib/log-sync.js +155 -1
- package/dist/lib/logger.js +49 -1
- package/dist/lib/open-file.js +55 -1
- package/dist/lib/paper-upgrade.js +119 -1
- package/dist/lib/render-card.js +112 -1
- package/dist/lib/repl-asker.js +67 -1
- package/dist/lib/sample-runner.js +227 -1
- package/dist/lib/shell-split.js +69 -1
- package/dist/lib/sim-cooldown.js +75 -1
- package/dist/lib/theme.js +119 -1
- package/dist/lib/token-format.js +74 -1
- package/dist/lib/toolset-hash.js +48 -1
- package/dist/lib/translations-fetcher.js +95 -1
- package/dist/lib/ui.js +99 -1
- package/dist/lib/version.js +24 -1
- package/dist/postinstall.js +48 -1
- package/dist/repl.js +2251 -1
- package/dist/types/index.js +63 -1
- package/package.json +1 -1
|
@@ -1 +1,102 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* demo2 progress persistence — opt-in resume + replay hooks.
|
|
3
|
+
*
|
|
4
|
+
* State lives in `~/.icoa/demo2-progress.json`. Records the last card
|
|
5
|
+
* index the user finished plus a completedAt timestamp when they reach
|
|
6
|
+
* the outro. Auto-clears anything older than 7 days so re-runs after a
|
|
7
|
+
* gap feel like a fresh start (no manual cleanup needed — see
|
|
8
|
+
* feedback_no_manual_rm).
|
|
9
|
+
*
|
|
10
|
+
* Out of scope: scores, answers, language history. Those are run-scoped
|
|
11
|
+
* and re-initialised every call to runDemo2Once().
|
|
12
|
+
*/
|
|
13
|
+
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
const PROGRESS_FILE = join(homedir(), '.icoa', 'demo2-progress.json');
|
|
17
|
+
// Auto-expire stale partial progress so coming back next month re-greets
|
|
18
|
+
// the user fresh instead of dangling on card 5/10.
|
|
19
|
+
const STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
|
|
20
|
+
function ensureDir() {
|
|
21
|
+
try {
|
|
22
|
+
mkdirSync(join(homedir(), '.icoa'), { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Non-fatal — caller's writeFileSync will surface the real failure
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Load progress, auto-clearing anything stale. Returns null if no usable
|
|
30
|
+
* record exists.
|
|
31
|
+
*/
|
|
32
|
+
export function loadDemo2Progress() {
|
|
33
|
+
let raw;
|
|
34
|
+
try {
|
|
35
|
+
raw = readFileSync(PROGRESS_FILE, 'utf-8');
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
let parsed;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(raw);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
clearDemo2Progress();
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
if (typeof parsed.nextCardIndex !== 'number' ||
|
|
49
|
+
typeof parsed.totalCards !== 'number' ||
|
|
50
|
+
typeof parsed.lang !== 'string' ||
|
|
51
|
+
typeof parsed.startedAt !== 'number') {
|
|
52
|
+
clearDemo2Progress();
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const anchor = parsed.completedAt ?? parsed.startedAt;
|
|
56
|
+
if (typeof anchor === 'number' && Date.now() - anchor > STALE_AFTER_MS) {
|
|
57
|
+
clearDemo2Progress();
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
nextCardIndex: parsed.nextCardIndex,
|
|
62
|
+
totalCards: parsed.totalCards,
|
|
63
|
+
lang: parsed.lang,
|
|
64
|
+
startedAt: parsed.startedAt,
|
|
65
|
+
completedAt: typeof parsed.completedAt === 'number' ? parsed.completedAt : undefined,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function saveDemo2Progress(p) {
|
|
69
|
+
ensureDir();
|
|
70
|
+
try {
|
|
71
|
+
writeFileSync(PROGRESS_FILE, JSON.stringify(p));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Non-fatal — progress is convenience, not correctness
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function clearDemo2Progress() {
|
|
78
|
+
try {
|
|
79
|
+
unlinkSync(PROGRESS_FILE);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Already absent — fine
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Convenience: record that the user finished card N (0-based). */
|
|
86
|
+
export function markCardDone(cardIndex, totalCards, lang, startedAt) {
|
|
87
|
+
saveDemo2Progress({
|
|
88
|
+
nextCardIndex: cardIndex + 1,
|
|
89
|
+
totalCards,
|
|
90
|
+
lang,
|
|
91
|
+
startedAt,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
export function markDemo2Complete(totalCards, lang, startedAt) {
|
|
95
|
+
saveDemo2Progress({
|
|
96
|
+
nextCardIndex: totalCards,
|
|
97
|
+
totalCards,
|
|
98
|
+
lang,
|
|
99
|
+
startedAt,
|
|
100
|
+
completedAt: Date.now(),
|
|
101
|
+
});
|
|
102
|
+
}
|
package/dist/lib/exam-client.js
CHANGED
|
@@ -1 +1,54 @@
|
|
|
1
|
-
export class ExamClient
|
|
1
|
+
export class ExamClient {
|
|
2
|
+
baseUrl;
|
|
3
|
+
token;
|
|
4
|
+
constructor(baseUrl, token) {
|
|
5
|
+
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
|
6
|
+
this.token = token;
|
|
7
|
+
}
|
|
8
|
+
async request(method, path, body) {
|
|
9
|
+
// Try nginx proxy first, fallback to direct port
|
|
10
|
+
const urls = [`${this.baseUrl}/api/icoa/exams${path}`, `${this.baseUrl}:9090/api/icoa/exams${path}`];
|
|
11
|
+
let lastError = null;
|
|
12
|
+
for (const url of urls) {
|
|
13
|
+
try {
|
|
14
|
+
return await this._fetch(method, url, body);
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
lastError = e;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
throw lastError || new Error('Exam API unreachable');
|
|
21
|
+
}
|
|
22
|
+
async _fetch(method, url, body) {
|
|
23
|
+
const res = await fetch(url, {
|
|
24
|
+
method,
|
|
25
|
+
headers: {
|
|
26
|
+
Authorization: `Token ${this.token}`,
|
|
27
|
+
'Content-Type': 'application/json',
|
|
28
|
+
},
|
|
29
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
30
|
+
signal: AbortSignal.timeout(10000),
|
|
31
|
+
});
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
const text = await res.text().catch(() => 'Unknown error');
|
|
34
|
+
throw new Error(`Exam API error (${res.status}): ${text}`);
|
|
35
|
+
}
|
|
36
|
+
const json = (await res.json());
|
|
37
|
+
if (json.success === false) {
|
|
38
|
+
throw new Error(json.message || 'Exam API error');
|
|
39
|
+
}
|
|
40
|
+
return json.data;
|
|
41
|
+
}
|
|
42
|
+
async getExams() {
|
|
43
|
+
return this.request('GET', '');
|
|
44
|
+
}
|
|
45
|
+
async startExam(examId) {
|
|
46
|
+
return this.request('POST', `/${examId}/start`);
|
|
47
|
+
}
|
|
48
|
+
async submitExam(examId, answers) {
|
|
49
|
+
return this.request('POST', `/${examId}/submit`, { answers });
|
|
50
|
+
}
|
|
51
|
+
async getResult(examId) {
|
|
52
|
+
return this.request('GET', `/${examId}/result`);
|
|
53
|
+
}
|
|
54
|
+
}
|
package/dist/lib/exam-state.js
CHANGED
|
@@ -1 +1,273 @@
|
|
|
1
|
-
import{readFileSync
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync, chmodSync } from 'node:fs';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { getIcoaDir } from './config.js';
|
|
6
|
+
/**
|
|
7
|
+
* Write a practical question's attachedData blob to the contestant's
|
|
8
|
+
* workspace so they can use shell / python / editor tooling on it without
|
|
9
|
+
* pasting the whole base64 back in on the command line.
|
|
10
|
+
*
|
|
11
|
+
* Because `!shell` / `!python3` inside the REPL runs in the sandbox
|
|
12
|
+
* container (which only mounts its own challenges volume, not the host's
|
|
13
|
+
* workspace), we also mirror the file into the sandbox at
|
|
14
|
+
* /home/competitor/challenges/q<n>.txt via `docker cp` when the sandbox
|
|
15
|
+
* is running. Returns the path students should reference —
|
|
16
|
+
* sandbox-visible when the sandbox is up, else host — so the
|
|
17
|
+
* "Ways to solve" block points at a file that actually exists wherever
|
|
18
|
+
* `!command` executes.
|
|
19
|
+
*
|
|
20
|
+
* Silent on failure: null means no workspace file was materialized, and
|
|
21
|
+
* callers skip the solve-paths guidance.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Map a practical question's TYPE to the AI chat command a contestant should
|
|
25
|
+
* enter for it. The practical section is interleaved (standard paper:
|
|
26
|
+
* Q38 ctf4ai, Q39 ai4ctf, Q40 ctf4ai; Paper E: Q37-46 ai4ctf, Q47-48 ctf4ai),
|
|
27
|
+
* so routing by question NUMBER misroutes questions to the wrong engine — the
|
|
28
|
+
* root cause behind both the old printSectionIntro bug and the practical
|
|
29
|
+
* answer-nudge in repl.ts. Always route by type.
|
|
30
|
+
*
|
|
31
|
+
* Returns null for MCQ / short-answer / unknown types, and (when not a real
|
|
32
|
+
* exam, i.e. the demo) since the demo has no live AI chat sections.
|
|
33
|
+
*/
|
|
34
|
+
export function practicalChatCommand(qType, isReal) {
|
|
35
|
+
if (!isReal)
|
|
36
|
+
return null;
|
|
37
|
+
if (qType === 'ctf4ai')
|
|
38
|
+
return 'ctf4ai';
|
|
39
|
+
if (qType === 'ai4ctf')
|
|
40
|
+
return 'ai4ctf';
|
|
41
|
+
if (qType === 'vla')
|
|
42
|
+
return 'ctf4eai';
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
export function materializeAttachedData(qNum, data) {
|
|
46
|
+
if (!data)
|
|
47
|
+
return null;
|
|
48
|
+
let hostPath;
|
|
49
|
+
try {
|
|
50
|
+
// Prefer the active exam's per-exam tmpdir (L1 anti-cheat workspace) so
|
|
51
|
+
// contestants see their question file in the same dir their `!cmd`
|
|
52
|
+
// invocations land in. Falls back to ~/icoa-workspace pre-start.
|
|
53
|
+
const state = getRealExamState();
|
|
54
|
+
const examWorkspace = state?.session?.workspaceDir;
|
|
55
|
+
const workspace = examWorkspace && existsSync(examWorkspace) ? examWorkspace : join(homedir(), 'icoa-workspace');
|
|
56
|
+
mkdirSync(workspace, { recursive: true });
|
|
57
|
+
hostPath = join(workspace, `q${qNum}.txt`);
|
|
58
|
+
writeFileSync(hostPath, data, 'utf-8');
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
// If the sandbox container is up, copy the file inside so `!command`
|
|
64
|
+
// invocations can reach it at a stable path. Uses execFileSync (no
|
|
65
|
+
// shell) because qNum is numeric and hostPath is trusted, but we still
|
|
66
|
+
// avoid string concatenation into a shell. A no-op when the container
|
|
67
|
+
// isn't running — we just swallow any error and fall back to host.
|
|
68
|
+
try {
|
|
69
|
+
execFileSync('docker', ['cp', hostPath, `icoa-sandbox:/home/competitor/challenges/q${qNum}.txt`], {
|
|
70
|
+
stdio: 'ignore',
|
|
71
|
+
timeout: 3000,
|
|
72
|
+
});
|
|
73
|
+
return `challenges/q${qNum}.txt`;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return hostPath;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Demo and real exam use separate state files so they don't block each other
|
|
80
|
+
function stateFile() {
|
|
81
|
+
return join(getIcoaDir(), 'exam-state.json');
|
|
82
|
+
}
|
|
83
|
+
function demoStateFile() {
|
|
84
|
+
return join(getIcoaDir(), 'demo-state.json');
|
|
85
|
+
}
|
|
86
|
+
// Internal: pick the right file based on examId
|
|
87
|
+
function resolveStateFile(examId) {
|
|
88
|
+
return examId === 'demo-free' ? demoStateFile() : stateFile();
|
|
89
|
+
}
|
|
90
|
+
export function getRealExamState() {
|
|
91
|
+
const f = stateFile();
|
|
92
|
+
if (!existsSync(f))
|
|
93
|
+
return null;
|
|
94
|
+
try {
|
|
95
|
+
const state = JSON.parse(readFileSync(f, 'utf-8'));
|
|
96
|
+
// Pre-v2.19.45 versions stored demo state in exam-state.json with
|
|
97
|
+
// examId='demo-free'. After v2.19.45 demo moved to demo-state.json.
|
|
98
|
+
// Ignore stale demo-tagged content found in the real-exam file —
|
|
99
|
+
// it's contamination from an old install. Also auto-clean the file.
|
|
100
|
+
if (state?.session?.examId === 'demo-free') {
|
|
101
|
+
try {
|
|
102
|
+
unlinkSync(f);
|
|
103
|
+
}
|
|
104
|
+
catch { }
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return state;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export function getDemoState() {
|
|
114
|
+
const f = demoStateFile();
|
|
115
|
+
if (!existsSync(f))
|
|
116
|
+
return null;
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(readFileSync(f, 'utf-8'));
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export function getExamState() {
|
|
125
|
+
// Real exam ALWAYS takes priority over demo when both exist.
|
|
126
|
+
// Reasoning: real exam is token-gated, has a timer, and represents a serious
|
|
127
|
+
// commitment. Demo is casual practice. If a real exam is in progress, all
|
|
128
|
+
// shared commands (exam q N, exam answer, ai4ctf, ctf4ai) must target the
|
|
129
|
+
// real exam — never silently fall back to demo questions, even if demo was
|
|
130
|
+
// run more recently. (Demo→Exam→Finals progression principle, exam.md §0)
|
|
131
|
+
const real = getRealExamState();
|
|
132
|
+
if (real)
|
|
133
|
+
return real;
|
|
134
|
+
return getDemoState();
|
|
135
|
+
}
|
|
136
|
+
export function saveExamState(state) {
|
|
137
|
+
const f = resolveStateFile(state.session.examId);
|
|
138
|
+
writeFileSync(f, JSON.stringify(state, null, 2));
|
|
139
|
+
// V16 (v2.19.181): exam-state.json holds session.token in plaintext.
|
|
140
|
+
// 0o600 so other users on shared hosts can't read it.
|
|
141
|
+
try {
|
|
142
|
+
chmodSync(f, 0o600);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
/* ignore Windows / restricted FS */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export function clearExamState(examId) {
|
|
149
|
+
if (examId) {
|
|
150
|
+
const f = resolveStateFile(examId);
|
|
151
|
+
if (existsSync(f))
|
|
152
|
+
unlinkSync(f);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
// Clear the currently active exam
|
|
156
|
+
const state = getExamState();
|
|
157
|
+
if (state) {
|
|
158
|
+
const f = resolveStateFile(state.session.examId);
|
|
159
|
+
if (existsSync(f))
|
|
160
|
+
unlinkSync(f);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** Pure: compute the deadline for a given exam state, or null if the
|
|
165
|
+
* session has no time limit. Shared by getExamDeadline (priority-aware)
|
|
166
|
+
* and getRealExamDeadline (real-exam file only). */
|
|
167
|
+
function computeDeadline(state) {
|
|
168
|
+
if (!state.session.durationMinutes)
|
|
169
|
+
return null; // 0 = no time limit
|
|
170
|
+
// Preferred path: server-time sync succeeded at exam start. deadlineServerMs
|
|
171
|
+
// is the authoritative server-time moment of expiry. Convert back to local
|
|
172
|
+
// clock domain so existing callers (which compare against Date.now()) stay
|
|
173
|
+
// correct even if the local clock drifts from server.
|
|
174
|
+
const { deadlineServerMs, clockOffsetMs } = state.session;
|
|
175
|
+
if (typeof deadlineServerMs === 'number' && typeof clockOffsetMs === 'number') {
|
|
176
|
+
return new Date(deadlineServerMs - clockOffsetMs);
|
|
177
|
+
}
|
|
178
|
+
// Fallback: local-clock-only (pre-v2.19.85 behavior). Accurate as long as
|
|
179
|
+
// client and server clocks agree; off by the clock skew otherwise.
|
|
180
|
+
const startTime = state.session.confirmedAt || state.session.startedAt;
|
|
181
|
+
const start = new Date(startTime).getTime();
|
|
182
|
+
return new Date(start + state.session.durationMinutes * 60 * 1000);
|
|
183
|
+
}
|
|
184
|
+
export function getExamDeadline() {
|
|
185
|
+
const state = getExamState();
|
|
186
|
+
return state ? computeDeadline(state) : null;
|
|
187
|
+
}
|
|
188
|
+
/** Deadline read from the real-exam state file specifically, bypassing
|
|
189
|
+
* getExamState()'s real-over-demo priority. Used by `exam demo` to
|
|
190
|
+
* detect a parked expired real exam that would otherwise block demo. */
|
|
191
|
+
export function getRealExamDeadline() {
|
|
192
|
+
const state = getRealExamState();
|
|
193
|
+
return state ? computeDeadline(state) : null;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Back-fill missing attachedData / description on practical questions by
|
|
197
|
+
* re-fetching from the server. Older state files (saved before v2.19.104)
|
|
198
|
+
* may have questions whose attachedData was dropped or never stored, leaving
|
|
199
|
+
* the contestant stuck on Q31-37 ("where is the attached file?"). This runs
|
|
200
|
+
* at most once per REPL session and is a no-op if:
|
|
201
|
+
* - no session token is stored (can't authenticate the refetch)
|
|
202
|
+
* - all practical questions already have attachedData
|
|
203
|
+
* - the network is unreachable
|
|
204
|
+
* Returns the (possibly updated) state so callers can use it directly.
|
|
205
|
+
*/
|
|
206
|
+
let _resyncAttempted = false;
|
|
207
|
+
export async function refetchQuestionDataIfStale() {
|
|
208
|
+
const state = getRealExamState();
|
|
209
|
+
if (!state)
|
|
210
|
+
return null;
|
|
211
|
+
if (_resyncAttempted)
|
|
212
|
+
return state;
|
|
213
|
+
const token = state.session.token;
|
|
214
|
+
if (!token)
|
|
215
|
+
return state;
|
|
216
|
+
// Only refetch if at least one practical question references attachedData
|
|
217
|
+
// but doesn't have it locally — otherwise we'd burn a server round-trip
|
|
218
|
+
// on every ai4ctf entry.
|
|
219
|
+
const needsRefetch = state.questions.some((q) => {
|
|
220
|
+
const isPractical = q.type === 'ai4ctf' || q.type === 'ctf4ai';
|
|
221
|
+
if (!isPractical)
|
|
222
|
+
return false;
|
|
223
|
+
const desc = String(q.description || '');
|
|
224
|
+
const mentionsAttached = desc.includes('attachedData') || /base64 blob/i.test(desc);
|
|
225
|
+
return mentionsAttached && !q.attachedData;
|
|
226
|
+
});
|
|
227
|
+
if (!needsRefetch)
|
|
228
|
+
return state;
|
|
229
|
+
_resyncAttempted = true;
|
|
230
|
+
try {
|
|
231
|
+
const { getConfig } = await import('./config.js');
|
|
232
|
+
const config = getConfig();
|
|
233
|
+
const serverUrl = config.ctfdUrl || 'https://practice.icoa2026.au';
|
|
234
|
+
const lang = config.language || 'en';
|
|
235
|
+
const res = await fetch(`${serverUrl}/api/icoa/exam-token`, {
|
|
236
|
+
method: 'POST',
|
|
237
|
+
headers: { 'Content-Type': 'application/json' },
|
|
238
|
+
body: JSON.stringify({ token, deviceHash: '', lang }),
|
|
239
|
+
signal: AbortSignal.timeout(8000),
|
|
240
|
+
});
|
|
241
|
+
if (!res.ok)
|
|
242
|
+
return state;
|
|
243
|
+
const json = (await res.json());
|
|
244
|
+
const fresh = json?.data?.questions || [];
|
|
245
|
+
if (fresh.length === 0)
|
|
246
|
+
return state;
|
|
247
|
+
// Merge: for every local question, if the fresh copy has attachedData or
|
|
248
|
+
// a longer description, copy those fields in. Keep local answer state /
|
|
249
|
+
// eliminated options etc. untouched — we only patch read-only content.
|
|
250
|
+
const byNum = new Map();
|
|
251
|
+
for (const fq of fresh)
|
|
252
|
+
byNum.set(fq.number, fq);
|
|
253
|
+
let patched = 0;
|
|
254
|
+
for (const q of state.questions) {
|
|
255
|
+
const fq = byNum.get(q.number);
|
|
256
|
+
if (!fq)
|
|
257
|
+
continue;
|
|
258
|
+
if (fq.attachedData && !q.attachedData) {
|
|
259
|
+
q.attachedData = fq.attachedData;
|
|
260
|
+
patched++;
|
|
261
|
+
}
|
|
262
|
+
if (fq.description && (!q.description || fq.description.length > q.description.length)) {
|
|
263
|
+
q.description = fq.description;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (patched > 0)
|
|
267
|
+
saveExamState(state);
|
|
268
|
+
return state;
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return state;
|
|
272
|
+
}
|
|
273
|
+
}
|