icoa-cli 2.19.19 → 2.19.20

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.
@@ -1,13 +1,108 @@
1
1
  import chalk from 'chalk';
2
2
  import { confirm } from '@inquirer/prompts';
3
3
  import { ExamClient } from '../lib/exam-client.js';
4
- import { getConfig } from '../lib/config.js';
4
+ import { getConfig, saveConfig } from '../lib/config.js';
5
5
  import { getExamState, saveExamState, clearExamState, getExamDeadline } from '../lib/exam-state.js';
6
+ import { getDemoStats, recordDemoAttempt, saveRetryQueue, getRetryQueue, clearRetryQueue } from '../lib/demo-stats.js';
6
7
  import { logCommand } from '../lib/logger.js';
7
8
  import { printSuccess, printError, printWarning, printInfo, printTable, printHeader, printKeyValue, createSpinner, formatCountdown, } from '../lib/ui.js';
8
9
  import { t } from '../lib/i18n.js';
9
10
  import { getDeviceFingerprint } from '../lib/access.js';
10
11
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
12
+ /**
13
+ * Skippable intro animation for first-time demo users.
14
+ * Resolves immediately on any keypress. Max 30s auto-advance.
15
+ */
16
+ async function playDemoIntro() {
17
+ let skipped = false;
18
+ const stdin = process.stdin;
19
+ const onKey = () => { skipped = true; };
20
+ const rawSupported = stdin.isTTY && typeof stdin.setRawMode === 'function';
21
+ if (rawSupported) {
22
+ stdin.setRawMode(true);
23
+ stdin.resume();
24
+ stdin.once('data', onKey);
25
+ }
26
+ const waitOrSkip = async (ms) => {
27
+ const step = 80;
28
+ let waited = 0;
29
+ while (waited < ms && !skipped) {
30
+ await sleep(step);
31
+ waited += step;
32
+ }
33
+ };
34
+ const cleanup = () => {
35
+ if (rawSupported) {
36
+ stdin.removeListener('data', onKey);
37
+ try {
38
+ stdin.setRawMode(false);
39
+ }
40
+ catch { }
41
+ stdin.pause();
42
+ }
43
+ };
44
+ try {
45
+ console.clear();
46
+ console.log();
47
+ console.log(chalk.gray(' ') + chalk.dim('(press any key to skip)'));
48
+ console.log();
49
+ console.log(chalk.bold.white(' ██╗ ██████╗ ██████╗ █████╗'));
50
+ console.log(chalk.bold.white(' ██║██╔════╝██╔═══██╗██╔══██╗'));
51
+ console.log(chalk.bold.white(' ██║██║ ██║ ██║███████║'));
52
+ console.log(chalk.bold.white(' ██║██║ ██║ ██║██╔══██║'));
53
+ console.log(chalk.bold.white(' ██║╚██████╗╚██████╔╝██║ ██║'));
54
+ console.log(chalk.bold.white(' ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝'));
55
+ console.log();
56
+ console.log(chalk.bold.yellow(' International Cyber Olympiad in AI'));
57
+ console.log(chalk.gray(' Sydney, Australia · Jun 27 – Jul 2, 2026'));
58
+ await waitOrSkip(2500);
59
+ if (skipped)
60
+ return;
61
+ console.log();
62
+ console.log(chalk.white(' What you\'ll do today:'));
63
+ console.log();
64
+ await waitOrSkip(600);
65
+ if (skipped)
66
+ return;
67
+ console.log(chalk.cyan(' ▣ ') + chalk.white('Answer 10 quick questions') + chalk.gray(' (now, ~5 min)'));
68
+ await waitOrSkip(700);
69
+ if (skipped)
70
+ return;
71
+ console.log(chalk.green(' ▣ ') + chalk.white('Team up with AI to solve CTFs') + chalk.gray(' (ai4ctf)'));
72
+ await waitOrSkip(700);
73
+ if (skipped)
74
+ return;
75
+ console.log(chalk.red(' ▣ ') + chalk.white('Hack the AI\'s guardrails') + chalk.gray(' (ctf4ai)'));
76
+ await waitOrSkip(900);
77
+ if (skipped)
78
+ return;
79
+ console.log();
80
+ console.log(chalk.bold.white(' Your terminal ') + chalk.bold.yellow('IS') + chalk.bold.white(' the competition.'));
81
+ console.log();
82
+ await waitOrSkip(1500);
83
+ if (skipped)
84
+ return;
85
+ console.log(chalk.gray(' · 109 CTF tools pre-configured'));
86
+ await waitOrSkip(400);
87
+ if (skipped)
88
+ return;
89
+ console.log(chalk.gray(' · 15 languages, real-time AI translation'));
90
+ await waitOrSkip(400);
91
+ if (skipped)
92
+ return;
93
+ console.log(chalk.gray(' · 15,000+ concurrent participants'));
94
+ await waitOrSkip(800);
95
+ if (skipped)
96
+ return;
97
+ console.log();
98
+ console.log(chalk.bold.cyan(' Press any key to start demo...'));
99
+ await waitOrSkip(4000);
100
+ }
101
+ finally {
102
+ cleanup();
103
+ console.clear();
104
+ }
105
+ }
11
106
  function drawProgress(percent, label) {
12
107
  const width = 30;
13
108
  const filled = Math.round((percent / 100) * width);
@@ -554,6 +649,14 @@ export function registerExamCommand(program) {
554
649
  const answered = Object.keys(state.answers).length;
555
650
  const total = state.session.questionCount;
556
651
  printSuccess(`Q${num}: ${c} ✓ (${answered}/${total} answered)`);
652
+ // Demo-only: gentle per-answer feedback (grey, non-intrusive)
653
+ if (state.session.examId === 'demo-free' && q.answer) {
654
+ const NICE_WORDS = ['Nice one.', 'Solid pick.', 'On the money.', 'Exactly.', 'Spot on.'];
655
+ const CLOSE_WORDS = ['Close — we\'ll revisit this one.', 'Hmm, worth a second look at the end.', 'Keep it in mind for review.'];
656
+ const pool = c === q.answer ? NICE_WORDS : CLOSE_WORDS;
657
+ const word = pool[Math.floor(Math.random() * pool.length)];
658
+ console.log(chalk.gray(` · ${word}`));
659
+ }
557
660
  // Auto-show next question and update _lastQ
558
661
  if (num < state.questions.length) {
559
662
  const nextQ = state.questions[num]; // 0-indexed: questions[num] = question num+1
@@ -732,19 +835,39 @@ export function registerExamCommand(program) {
732
835
  console.log(chalk.gray(' Sydney, Australia · Jun 27 - Jul 2, 2026'));
733
836
  console.log();
734
837
  console.log(chalk.cyan(' ═══════════════════════════════════════'));
735
- // Per-category breakdown
838
+ // Per-category breakdown with ASCII progress bars
736
839
  const catEntries = Object.entries(categoryStats);
737
840
  if (catEntries.length > 0) {
738
841
  console.log();
739
842
  console.log(chalk.bold.white(' By category'));
843
+ const BAR_WIDTH = 10;
740
844
  for (const [cat, s] of catEntries) {
741
845
  const pct = s.total > 0 ? Math.round(s.correct / s.total * 100) : 0;
846
+ const filled = s.total > 0 ? Math.round((s.correct / s.total) * BAR_WIDTH) : 0;
847
+ const empty = BAR_WIDTH - filled;
742
848
  const color = pct >= 80 ? chalk.green : pct >= 50 ? chalk.yellow : chalk.red;
743
- console.log(' ' + color(`${cat.padEnd(20)} ${s.correct}/${s.total} (${pct}%)`));
849
+ const bar = color('█'.repeat(filled)) + chalk.gray('░'.repeat(empty));
850
+ console.log(' ' + chalk.white(cat.padEnd(20)) + ' ' + bar + ' ' + color(`${s.correct}/${s.total}`) + chalk.gray(` (${pct}%)`));
744
851
  }
745
852
  console.log();
746
853
  console.log(chalk.cyan(' ─────────────────────────────────────────────'));
747
854
  }
855
+ // Record stats locally + save retry queue
856
+ const updatedStats = recordDemoAttempt(score, total);
857
+ const isNewBest = score === updatedStats.best && updatedStats.attempts > 1 && score > 0;
858
+ if (isNewBest) {
859
+ console.log();
860
+ console.log(chalk.bold.green(' ★ New personal best!'));
861
+ }
862
+ if (wrongQuestions.length > 0) {
863
+ const wrongSnapshots = questionsSnapshot
864
+ .filter((q) => wrongQuestions.includes(q.number))
865
+ .map((q) => ({ ...q }));
866
+ saveRetryQueue(wrongSnapshots);
867
+ }
868
+ else {
869
+ clearRetryQueue();
870
+ }
748
871
  // Show wrong answers with explanations
749
872
  if (wrongQuestions.length > 0) {
750
873
  console.log();
@@ -776,6 +899,11 @@ export function registerExamCommand(program) {
776
899
  console.log();
777
900
  console.log(chalk.green(` ${t('perfectScore')}`));
778
901
  }
902
+ // Offer retry wrong only
903
+ if (wrongQuestions.length > 0) {
904
+ console.log();
905
+ console.log(chalk.white(' 💪 Want to nail the ones you missed? Type: ') + chalk.bold.cyan('retry'));
906
+ }
779
907
  console.log();
780
908
  // ─── What is CTF + Dual-track introduction ───
781
909
  console.log(chalk.white(` ${t('theoryDone')}`));
@@ -966,6 +1094,12 @@ export function registerExamCommand(program) {
966
1094
  .action(async () => {
967
1095
  logCommand('exam demo');
968
1096
  const { pickDemoQuestions, getLocalizedDemoSession, DEMO_PICK_SIZE, DEMO_POOL_SIZE } = await import('../lib/demo-exam.js');
1097
+ // First-time intro animation (skippable)
1098
+ const cfg = getConfig();
1099
+ if (!cfg.demoIntroSeen) {
1100
+ await playDemoIntro();
1101
+ saveConfig({ demoIntroSeen: true });
1102
+ }
969
1103
  const DEMO_QUESTIONS = pickDemoQuestions(DEMO_PICK_SIZE);
970
1104
  const DEMO_SESSION = getLocalizedDemoSession();
971
1105
  const existing = getExamState();
@@ -985,6 +1119,14 @@ export function registerExamCommand(program) {
985
1119
  console.log();
986
1120
  console.log(chalk.white(' Free practice · No account needed · No time limit'));
987
1121
  console.log(chalk.white(` ${DEMO_PICK_SIZE} random questions from a pool of ${DEMO_POOL_SIZE} · Pick one answer per question`));
1122
+ // Best score tracker
1123
+ const stats = getDemoStats();
1124
+ if (stats.attempts > 0) {
1125
+ const bestPct = stats.bestPercentage;
1126
+ const bestColor = bestPct >= 80 ? chalk.green : bestPct >= 60 ? chalk.yellow : chalk.white;
1127
+ console.log();
1128
+ console.log(chalk.gray(' Your best: ') + bestColor(`${stats.best}/${DEMO_PICK_SIZE} (${bestPct}%)`) + chalk.gray(` · ${stats.attempts} attempts`));
1129
+ }
988
1130
  console.log();
989
1131
  printHowToPlay();
990
1132
  console.log();
@@ -1018,6 +1160,67 @@ export function registerExamCommand(program) {
1018
1160
  // Show first question
1019
1161
  printQuestion(DEMO_QUESTIONS[0]);
1020
1162
  });
1163
+ // ─── exam demo-retry ─── (runs demo with the wrong questions from last attempt)
1164
+ exam
1165
+ .command('demo-retry')
1166
+ .description('Retry only the questions you got wrong last demo attempt')
1167
+ .action(async () => {
1168
+ logCommand('exam demo-retry');
1169
+ const { getLocalizedDemoSession } = await import('../lib/demo-exam.js');
1170
+ const retryQueue = getRetryQueue();
1171
+ if (!retryQueue || retryQueue.length === 0) {
1172
+ console.log();
1173
+ console.log(chalk.yellow(' No wrong questions to retry. Run ') + chalk.bold.cyan('demo') + chalk.yellow(' first.'));
1174
+ console.log();
1175
+ return;
1176
+ }
1177
+ const existing = getExamState();
1178
+ if (existing) {
1179
+ if (existing.session.examId === 'demo-free') {
1180
+ clearExamState();
1181
+ }
1182
+ else {
1183
+ printWarning(`Exam "${existing.session.examName}" is in progress.`);
1184
+ printInfo('Submit it first: exam submit');
1185
+ return;
1186
+ }
1187
+ }
1188
+ // Reshuffle each question's options (keeps learning fresh), renumber 1..n
1189
+ const shuffleOpts = (q) => {
1190
+ if (!q.answer)
1191
+ return q;
1192
+ const correctText = q.options[q.answer];
1193
+ const values = ['A', 'B', 'C', 'D'].map((k) => q.options[k]);
1194
+ for (let i = values.length - 1; i > 0; i--) {
1195
+ const j = Math.floor(Math.random() * (i + 1));
1196
+ [values[i], values[j]] = [values[j], values[i]];
1197
+ }
1198
+ const newOptions = { A: values[0], B: values[1], C: values[2], D: values[3] };
1199
+ const newAnswer = ['A', 'B', 'C', 'D'].find((k) => newOptions[k] === correctText);
1200
+ return { ...q, options: newOptions, answer: newAnswer };
1201
+ };
1202
+ const retryQuestions = retryQueue.map((q, i) => ({
1203
+ ...shuffleOpts(q),
1204
+ number: i + 1,
1205
+ }));
1206
+ const baseSession = getLocalizedDemoSession();
1207
+ const session = {
1208
+ ...baseSession,
1209
+ examName: `${baseSession.examName} (Retry wrong only)`,
1210
+ questionCount: retryQuestions.length,
1211
+ startedAt: new Date().toISOString(),
1212
+ };
1213
+ console.log();
1214
+ printHeader('Demo Retry — Wrong Questions Only');
1215
+ console.log();
1216
+ console.log(chalk.white(` Practicing ${retryQuestions.length} question${retryQuestions.length === 1 ? '' : 's'} you got wrong.`));
1217
+ console.log(chalk.gray(' Options have been reshuffled.'));
1218
+ console.log();
1219
+ saveExamState({ session, questions: retryQuestions, answers: {} });
1220
+ printKeyValue('Questions', String(retryQuestions.length));
1221
+ printKeyValue('Duration', 'No time limit');
1222
+ printQuestion(retryQuestions[0]);
1223
+ });
1021
1224
  // Default action: show status or help
1022
1225
  exam.action(() => {
1023
1226
  logCommand('exam');
@@ -0,0 +1,16 @@
1
+ import type { ExamQuestion } from '../types/index.js';
2
+ export interface DemoStats {
3
+ best: number;
4
+ bestPercentage: number;
5
+ attempts: number;
6
+ lastFive: Array<{
7
+ score: number;
8
+ total: number;
9
+ at: string;
10
+ }>;
11
+ }
12
+ export declare function getDemoStats(): DemoStats;
13
+ export declare function recordDemoAttempt(score: number, total: number): DemoStats;
14
+ export declare function saveRetryQueue(questions: ExamQuestion[]): void;
15
+ export declare function getRetryQueue(): ExamQuestion[] | null;
16
+ export declare function clearRetryQueue(): void;
@@ -0,0 +1,65 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { getIcoaDir } from './config.js';
4
+ const STATS_FILE = () => join(getIcoaDir(), 'demo-stats.json');
5
+ const RETRY_FILE = () => join(getIcoaDir(), 'demo-retry.json');
6
+ const DEFAULT_STATS = {
7
+ best: 0,
8
+ bestPercentage: 0,
9
+ attempts: 0,
10
+ lastFive: [],
11
+ };
12
+ export function getDemoStats() {
13
+ try {
14
+ if (!existsSync(STATS_FILE()))
15
+ return { ...DEFAULT_STATS };
16
+ const data = JSON.parse(readFileSync(STATS_FILE(), 'utf-8'));
17
+ return { ...DEFAULT_STATS, ...data };
18
+ }
19
+ catch {
20
+ return { ...DEFAULT_STATS };
21
+ }
22
+ }
23
+ export function recordDemoAttempt(score, total) {
24
+ const current = getDemoStats();
25
+ const pct = total > 0 ? Math.round((score / total) * 100) : 0;
26
+ const next = {
27
+ best: Math.max(current.best, score),
28
+ bestPercentage: Math.max(current.bestPercentage, pct),
29
+ attempts: current.attempts + 1,
30
+ lastFive: [
31
+ { score, total, at: new Date().toISOString() },
32
+ ...current.lastFive,
33
+ ].slice(0, 5),
34
+ };
35
+ try {
36
+ writeFileSync(STATS_FILE(), JSON.stringify(next, null, 2));
37
+ }
38
+ catch { }
39
+ return next;
40
+ }
41
+ export function saveRetryQueue(questions) {
42
+ try {
43
+ writeFileSync(RETRY_FILE(), JSON.stringify({ questions, savedAt: new Date().toISOString() }, null, 2));
44
+ }
45
+ catch { }
46
+ }
47
+ export function getRetryQueue() {
48
+ try {
49
+ if (!existsSync(RETRY_FILE()))
50
+ return null;
51
+ const data = JSON.parse(readFileSync(RETRY_FILE(), 'utf-8'));
52
+ if (Array.isArray(data.questions) && data.questions.length > 0) {
53
+ return data.questions;
54
+ }
55
+ }
56
+ catch { }
57
+ return null;
58
+ }
59
+ export function clearRetryQueue() {
60
+ try {
61
+ if (existsSync(RETRY_FILE()))
62
+ writeFileSync(RETRY_FILE(), JSON.stringify({ questions: [] }, null, 2));
63
+ }
64
+ catch { }
65
+ }
package/dist/repl.js CHANGED
@@ -500,8 +500,8 @@ export async function startRepl(program, resumeMode) {
500
500
  }
501
501
  const cmd = input.split(/\s+/)[0].toLowerCase();
502
502
  // ─── Mode-based command filtering ───
503
- const selectionCommands = ['exam', 'demo', 'nations', 'next', 'prev', 'continue', 'setup', 'lang', 'ref', 'ai4ctf', 'ctf4ai'];
504
- const organizerCommands = ['join', 'exam', 'demo', 'next', 'prev', 'logout', 'setup', 'lang', 'ref', 'ctf'];
503
+ const selectionCommands = ['exam', 'demo', 'retry', 'nations', 'next', 'prev', 'continue', 'setup', 'lang', 'ref', 'ai4ctf', 'ctf4ai'];
504
+ const organizerCommands = ['join', 'exam', 'demo', 'retry', 'next', 'prev', 'logout', 'setup', 'lang', 'ref', 'ctf'];
505
505
  if (mode === 'selection' && !selectionCommands.includes(cmd)) {
506
506
  console.log(chalk.gray(' Not available in Selection mode.'));
507
507
  if (examState) {
@@ -537,7 +537,7 @@ export async function startRepl(program, resumeMode) {
537
537
  'scoreboard', 'sb', 'status', 'time', 'hint', 'hint-b', 'hint-c',
538
538
  'hint-budget', 'ref', 'shell', 'files', 'connect', 'note',
539
539
  'log', 'lang', 'setup', 'env', 'ai4ctf', 'model', 'ctf',
540
- 'exam', 'demo', 'nations', 'next', 'prev', 'continue', 'logout', 'ctf4ai',
540
+ 'exam', 'demo', 'retry', 'nations', 'next', 'prev', 'continue', 'logout', 'ctf4ai',
541
541
  ];
542
542
  if (!knownCommands.includes(cmd)) {
543
543
  // Block dangerous commands
@@ -681,6 +681,7 @@ function mapCommand(input) {
681
681
  const rest = parts.slice(1);
682
682
  const ctfShortcuts = {
683
683
  'demo': ['exam', 'demo'],
684
+ 'retry': ['exam', 'demo-retry'],
684
685
  'nations': ['exam', 'nations'],
685
686
  'next': ['exam', 'next'],
686
687
  'prev': ['exam', 'prev'],
@@ -104,6 +104,7 @@ export interface IcoaConfig {
104
104
  sessionCookie: string;
105
105
  country: string;
106
106
  mode: IcoaMode | '';
107
+ demoIntroSeen: boolean;
107
108
  }
108
109
  export type CompetitionState = 'pre_competition' | 'demo' | 'live' | 'finished' | 'unknown';
109
110
  export type IcoaMode = 'selection' | 'olympiad' | 'organizer';
@@ -32,5 +32,6 @@ export const DEFAULT_CONFIG = {
32
32
  sessionCookie: '',
33
33
  country: '',
34
34
  mode: '',
35
+ demoIntroSeen: false,
35
36
  };
36
37
  export const SUPPORTED_LANGUAGES = ['en', 'zh', 'ja', 'ko', 'es', 'ar', 'fr', 'pt', 'ru', 'hi', 'de', 'id', 'th', 'vi', 'tr'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icoa-cli",
3
- "version": "2.19.19",
3
+ "version": "2.19.20",
4
4
  "description": "ICOA CLI — The world's first CLI-native CTF competition terminal",
5
5
  "type": "module",
6
6
  "bin": {