Compare commits

..

2 Commits

Author SHA1 Message Date
vumas3r
accb26159e chore: release 2026.10.26.2
Bump VERSION to 2026.10.26.2 and date its CHANGELOG.md entry: Teach a
domain, from e852419.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:14:17 -06:00
vumas3r
e852419130 feat: teach a domain, its lessons back to back
Add runs of a domain's lessons: ./ksim lesson ARCH, the lobby's Teach
a domain card, or the Teach a domain session in the Start exam
dialog. A run is one lesson session over the domain's lessons not
done yet (--all: every one), each on its own host, all made before it
starts, so there is no waiting between lessons. The session keeps
the lesson you are on and each lesson's checks. A lesson's wrap-up
leads to the next, the list at the top of a lesson goes to any lesson
of the run, and ./ksim lesson next and ./ksim lesson OBJ do the same
in a terminal. Finishing the run sends you to the domain's drill.

Stamp a lesson done the moment its last step passes, not when the
session ends, so terminating a lesson or a run keeps what it taught.

Tested on a real STOR run in a copy of the repo: every host up before
step 1; lesson 1.1 done step by step through the CLI and the web API,
and stamped done as its last step passed; next, jump, a lesson
outside the run refused, and the finish report as expected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:14:08 -06:00
6 changed files with 300 additions and 65 deletions

View File

@ -4,6 +4,25 @@ Notable changes to kubestronaut-sim, newest first. A version is the `VERSION` st
(year.month.day.n); `./ksim help` and the top bar of the web page show the one you are running. (year.month.day.n); `./ksim help` and the top bar of the web page show the one you are running.
Every entry names the commit it came in. Every entry names the commit it came in.
## 2026.10.26.2 — 2026-10-26
Everything below came in `e852419`.
### Added
- **Teach a domain**: a domain's lessons you have not done, back to back in one session without
a clock - `./ksim lesson ARCH`, the lobby's *Teach a domain* card, or the *Teach a domain*
session in the Start exam dialog (`--all`, or a domain whose lessons are all done, brings every
one). Every lesson's host is made before the run starts, so there is no waiting between
lessons. A lesson's wrap-up leads to the next, the list at the top of a lesson goes to any lesson
of the run, and in a terminal `./ksim lesson next` and `./ksim lesson 4.3` do the same.
Finishing the run sends you to the domain's timed drill.
### Changed
- A lesson counts as done the moment its last step is checked, not when you finish it, so
terminating a lesson or a run keeps the lessons it completed.
## 2026.10.26.1 — 2026-10-26 ## 2026.10.26.1 — 2026-10-26
Everything below came in `cf4f5be`. Everything below came in `cf4f5be`.

View File

@ -136,6 +136,8 @@ host name such as `ckad0013` only resolves in the desktop's terminal: from your
./ksim objectives | next | focus # the curriculum as a path, one objective at a time against the clock ./ksim objectives | next | focus # the curriculum as a path, one objective at a time against the clock
./ksim notes 3.2 # an objective's notes: what to know, fastest commands, traps, docs ./ksim notes 3.2 # an objective's notes: what to know, fastest commands, traps, docs
./ksim lesson [OBJ] # the track's lessons, or start one: a guided lab, no clock (CKA: all 27 · CKAD: 2.1-2.4) ./ksim lesson [OBJ] # the track's lessons, or start one: a guided lab, no clock (CKA: all 27 · CKAD: 2.1-2.4)
./ksim lesson ARCH [--all] # teach a domain: its lessons not done yet (--all: every one), back to back
./ksim lesson next | lesson 4.3 # in a domain's run: the next lesson, or any lesson of the run
./ksim lesson step | check | hint | show [N] # inside a lesson; ./ksim end finishes it ./ksim lesson step | check | hint | show [N] # inside a lesson; ./ksim end finishes it
./ksim skip DESIGN | unskip DESIGN # "I am good with this domain (or objective 2.3)": off the path, and back ./ksim skip DESIGN | unskip DESIGN # "I am good with this domain (or objective 2.3)": off the path, and back
./ksim drill OBS:3 | next OBS:3 # three tasks of a domain, each from another objective - alone, or added ./ksim drill OBS:3 | next OBS:3 # three tasks of a domain, each from another objective - alone, or added
@ -158,10 +160,20 @@ untimed part of the simulator — they teach, the drills test — and they write
history, the statistics or the weak list; a lesson whose every step was checked is marked done on history, the statistics or the weak list; a lesson whose every step was checked is marked done on
its objective (`lesson ✓` in the objective list). Steps count in order. Start one from an its objective (`lesson ✓` in the objective list). Steps count in order. Start one from an
objective's **Lesson** button, **Lesson first** on the path card, the *Take a lesson* session in the objective's **Lesson** button, **Lesson first** on the path card, the *Take a lesson* session in the
Start exam dialog, or `./ksim lesson 2.1`. **CKA has a lesson for each of its 27 objectives** Start exam dialog, or `./ksim lesson 2.1`.
(`cka/lessons_*.py`, 135 steps), which also cover what a full CKA course teaches under them —
checked against the outline of KubeDojo's CKA course and written from scratch: projected volumes, **Teach a domain** runs a domain's lessons back to back: the ones you have not done, or every
volume expansion, snapshots and restores, eviction thresholds, debug containers, kube-proxy's one again once they all are (`--all` forces that). It is one session without a clock. Every
lesson's host is made before it starts, as many at a time as for any session (`KSIM_PARALLEL`),
so you never wait between lessons. Each lesson's wrap-up leads to the next, the list at the top
of a lesson goes to any lesson of the run, and a lesson counts as done the moment its last step
is checked, whether or not the run is finished. Finishing the run sends you to the domain's
timed drill. Start one from the **Teach a domain** card in the lobby, the *Teach a domain*
session in the Start exam dialog, or `./ksim lesson ARCH`.
**CKA has a lesson for each of its 27 objectives** (`cka/lessons_*.py`, 135 steps), which also
cover what a full CKA course teaches under them — checked against the outline of KubeDojo's CKA
course and written from scratch: projected volumes, volume expansion, snapshots and restores, eviction thresholds, debug containers, kube-proxy's
rules, encryption at rest, in-place resize, PodDisruptionBudgets, QoS classes, ServiceAccount rules, encryption at rest, in-place resize, PodDisruptionBudgets, QoS classes, ServiceAccount
tokens, a real upgrade from v1.36 to v1.37, EndpointSlices, an operator. Tasks 132–147 test those tokens, a real upgrade from v1.36 to v1.37, EndpointSlices, an operator. Tasks 132–147 test those
topics against the clock. The CKAD pilot topics against the clock. The CKAD pilot

View File

@ -5,6 +5,7 @@ Lessons write nothing to the history or the scores - only a "lesson done" mark o
./ksim lesson the lessons of this track, and which you have done ./ksim lesson the lessons of this track, and which you have done
./ksim lesson 1.1 start the lesson of objective 1.1 (id or key) ./ksim lesson 1.1 start the lesson of objective 1.1 (id or key)
./ksim lesson STOR a domain's lessons not done yet, back to back; ./ksim lesson next moves on
./ksim lesson check check the step you are on; the next one follows ./ksim lesson check check the step you are on; the next one follows
./ksim lesson audit [OBJ..] prove lessons: every step fails before its commands and passes after ./ksim lesson audit [OBJ..] prove lessons: every step fails before its commands and passes after

View File

@ -5,6 +5,7 @@ Lessons write nothing to the history or the scores - only a "lesson done" mark o
./ksim lesson the lessons of this track, and which you have done ./ksim lesson the lessons of this track, and which you have done
./ksim lesson 2.1 start the lesson of objective 2.1 (id or key) ./ksim lesson 2.1 start the lesson of objective 2.1 (id or key)
./ksim lesson DEPLOY the domain's lessons not done yet, back to back; ./ksim lesson next moves on
./ksim lesson check check the step you are on; the next one follows ./ksim lesson check check the step you are on; the next one follows
./ksim lesson audit [OBJ..] prove lessons: every step fails before its commands and passes after ./ksim lesson audit [OBJ..] prove lessons: every step fails before its commands and passes after

185
ksim
View File

@ -22,7 +22,7 @@ from collections.abc import Callable
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
VERSION = "2026.10.26.1" VERSION = "2026.10.26.2"
ROOT = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, ROOT) sys.path.insert(0, ROOT)
from sim import desktop, envs # noqa: E402 from sim import desktop, envs # noqa: E402
@ -930,21 +930,50 @@ def known_task(q, known):
# sends you to the objective's timed drill. It runs as a session of mode "lesson": no clock, its # sends you to the objective's timed drill. It runs as a session of mode "lesson": no clock, its
# steps checked one at a time and in order, nothing written to the history or the statistics; a # steps checked one at a time and in order, nothing written to the history or the statistics; a
# lesson whose every step was checked is stamped done in study-<track>.json. # lesson whose every step was checked is stamped done in study-<track>.json.
# A domain's run (./ksim lesson ARCH) is one such session over the domain's lessons, each on its
# host, all made before it starts: "at" is the lesson you are on, "steps" holds each one's checks.
def find_lesson(arg): def find_lesson(arg):
"""A lesson by its objective (2.1, or the objective's key) or by its id (9201); None if none fits.""" """A lesson by its objective (2.1, or the objective's key) or by its id (9201); None if none fits."""
o = find_objective(arg) o = find_objective(arg)
return next((l for l in LESSONS if (o and l["objective"] == o["id"]) or str(l["id"]) == str(arg)), None) return next((l for l in LESSONS if (o and l["objective"] == o["id"]) or str(l["id"]) == str(arg)), None)
def lesson_of(st): def lessons_of(st):
"""The lesson a session is, or None.""" """The lessons of a lesson session, in order: one, or a domain's run."""
if not st or st.get("mode") != "lesson": if not st or st.get("mode") != "lesson":
return None return []
return next((l for l in LESSONS if l["id"] in st.get("ids", [])), None) by_id = {l["id"]: l for l in LESSONS}
return [by_id[i] for i in st.get("ids", []) if i in by_id]
def lesson_of(st):
"""The lesson a session is on, or None."""
ls = lessons_of(st)
return next((l for l in ls if l["id"] == st.get("at")), ls[0] if ls else None)
def lessons_done(): def lessons_done():
"""objective id -> when its lesson was first done.""" """objective id -> when its lesson was first done."""
return load_progress()["lessons"] return load_progress()["lessons"]
def domain_lessons(d, everything=False):
"""A domain's lessons in curriculum order: those not done yet - all of them once every one is
done, or with `everything`."""
ls, done = [l for l in LESSONS if l["domain"] == d], lessons_done()
left = [l for l in ls if l["objective"] not in done]
return ls if everything or not left else left
def steps_of(st, l):
"""Step index -> its latest check, for lesson l of the session."""
return st.get("steps", {}).get(str(l["id"]), {})
def steps_checked(st, l):
return sum(1 for i in range(len(l["steps"])) if steps_of(st, l).get(str(i), {}).get("pass"))
def stamp_lesson(l):
"""Mark l's objective as having its lesson done, the first time."""
prog = load_progress()
if l["objective"] not in prog["lessons"]:
prog["lessons"][l["objective"]] = datetime.now().isoformat(timespec="minutes")
save_progress(prog)
def check_step(l, params, i): def check_step(l, params, i):
"""Run the check of step i on the lesson's host.""" """Run the check of step i on the lesson's host."""
try: try:
@ -955,13 +984,14 @@ def check_step(l, params, i):
def open_step(st, l): def open_step(st, l):
"""The first step not checked yet, or None when every one is.""" """The first step not checked yet, or None when every one is."""
done = st.get("steps", {}) done = steps_of(st, l)
return next((i for i in range(len(l["steps"])) if not done.get(str(i), {}).get("pass")), None) return next((i for i in range(len(l["steps"])) if not done.get(str(i), {}).get("pass")), None)
def lesson_check(st, i): def lesson_check(st, i):
"""Check step i of the lesson in progress and keep the result. Steps count in order - a step """Check step i of the lesson in progress and keep the result. Steps count in order - a step
builds on the one before - so only the first open step or one already done can be checked, and builds on the one before - so only the first open step or one already done can be checked, and
a pass stays a pass when a later step changes what an earlier check looked at. a pass stays a pass when a later step changes what an earlier check looked at. The check that
completes a lesson stamps it done, whatever then becomes of the session.
Returns (result, None) or (None, why not).""" Returns (result, None) or (None, why not)."""
l = lesson_of(st) l = lesson_of(st)
if not l or st.get("ended_at"): if not l or st.get("ended_at"):
@ -972,12 +1002,28 @@ def lesson_check(st, i):
if first is not None and i > first: if first is not None and i > first:
return None, f"Do step {first + 1} first: steps count in order." return None, f"Do step {first + 1} first: steps count in order."
r = check_step(l, session_params(st, l), i) r = check_step(l, session_params(st, l), i)
steps = st.setdefault("steps", {}) steps = st.setdefault("steps", {}).setdefault(str(l["id"]), {})
if r["pass"] or not steps.get(str(i), {}).get("pass"): if r["pass"] or not steps.get(str(i), {}).get("pass"):
steps[str(i)] = r steps[str(i)] = r
save_state(st) save_state(st)
if open_step(st, l) is None:
stamp_lesson(l)
return r, None return r, None
def move_to_lesson(st, l):
"""Go to lesson l of a domain's run - or, once it has ended, review it. Returns why not, or None."""
if l["id"] not in st.get("ids", []):
return f"Lesson {l['objective']} is not part of this run."
st["at"] = l["id"]
save_state(st)
return None
def next_lesson(st):
"""The lesson after the current one in a domain's run, or None."""
ls, l = lessons_of(st), lesson_of(st)
i = ls.index(l) if l in ls else -1
return ls[i + 1] if 0 <= i < len(ls) - 1 else None
def task_state(qid, agg): def task_state(qid, agg):
"""passed = the latest attempt against the clock was a full pass; failed = it was not; """passed = the latest attempt against the clock was a full pass; failed = it was not;
new = never attempted.""" new = never attempted."""
@ -1481,7 +1527,8 @@ def open_sessions_elsewhere():
pass pass
return out return out
def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg, hard=False, other_ok=None, objective=None): def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg, hard=False, other_ok=None, objective=None,
domain=None):
st = load_state() st = load_state()
if st and not st.get("ended_at") and not force: if st and not st.get("ended_at") and not force:
die("A session is already in progress. './ksim end' it first, or add --force.") die("A session is already in progress. './ksim end' it first, or add --force.")
@ -1522,7 +1569,9 @@ def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg,
"objective": objective} # the curriculum objective a drill or a lesson was started for, if any "objective": objective} # the curriculum objective a drill or a lesson was started for, if any
lesson = mode == "lesson" lesson = mode == "lesson"
if lesson: if lesson:
st["steps"] = {} # step index -> its latest check (a pass sticks) st["steps"] = {} # lesson id -> step index -> its latest check (a pass sticks)
st["at"] = qs[0]["id"]
st["domain"] = domain # the domain a run of lessons teaches (./ksim lesson ARCH), if it is one
save_state(st) save_state(st)
log("") log("")
log(bold("=" * 64)) log(bold("=" * 64))
@ -1530,7 +1579,9 @@ def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg,
+ ("no clock: take your time" if lesson else "clock started"))) + ("no clock: take your time" if lesson else "clock started")))
log(f" Hosts : {hosts[0]}" + (f" .. {len(hosts)} in all" if len(hosts) > 1 else "") log(f" Hosts : {hosts[0]}" + (f" .. {len(hosts)} in all" if len(hosts) > 1 else "")
+ f" (Kubernetes {st['k8s']}, as the real {TRACK_ID.upper()} runs {K8S})") + f" (Kubernetes {st['k8s']}, as the real {TRACK_ID.upper()} runs {K8S})")
if lesson: if lesson and domain:
log(f" Lessons : {DOMAINS[domain]} - {', '.join(l['objective'] for l in qs)}, {sum(len(l['steps']) for l in qs)} steps")
elif lesson:
log(f" Lesson : {objective} {qs[0]['title']} - {len(qs[0]['steps'])} steps") log(f" Lesson : {objective} {qs[0]['title']} - {len(qs[0]['steps'])} steps")
else: else:
log(f" Ends : {datetime.fromtimestamp(st['started_at'] + duration).strftime('%H:%M:%S')} ({duration // 60} min)") log(f" Ends : {datetime.fromtimestamp(st['started_at'] + duration).strftime('%H:%M:%S')} ({duration // 60} min)")
@ -1542,23 +1593,26 @@ def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg,
"the exam in the browser: task pane, remote desktop, clock\n") "the exam in the browser: task pane, remote desktop, clock\n")
+ (" ./ksim lesson step / check / hint / show, ./ksim end terminal workflow\n" if lesson else + (" ./ksim lesson step / check / hint / show, ./ksim end terminal workflow\n" if lesson else
" ./ksim list / show N / grade N / timer / end terminal workflow\n") " ./ksim list / show N / grade N / timer / end terminal workflow\n")
+ f" {'The lesson runs on' if lesson else 'Every task names its host:'} ssh {hosts[0]} from the desktop's terminal, then sudo -i when you need root.\n" + (" ./ksim lesson next the run's next lesson; ./ksim lesson OBJ any lesson of the run\n" if lesson and domain else "")
+ f" {'The lesson runs on' if lesson and not domain else 'Every lesson names its host:' if lesson else 'Every task names its host:'} ssh {hosts[0]} from the desktop's terminal, then sudo -i when you need root.\n"
+ f" Files go under {WS_DISPLAY}/ on the host.") + f" Files go under {WS_DISPLAY}/ on the host.")
return st return st
def finish_lesson(st): def finish_lesson(st):
"""End a lesson: nothing is graded or recorded in the history; a lesson whose every step was """End a lesson session: nothing is graded or recorded in the history; each lesson whose every
checked is stamped done on its objective (the first time). Returns the lines to show.""" step was checked is stamped done on its objective (the first time). Returns the lines to show."""
l = lesson_of(st)
st["ended_at"] = time.time() st["ended_at"] = time.time()
n, done = len(l["steps"]), sum(1 for i in range(len(l["steps"])) if st.get("steps", {}).get(str(i), {}).get("pass")) d, lines = st.get("domain"), []
if done == n: for l in lessons_of(st):
prog = load_progress() n, done = len(l["steps"]), steps_checked(st, l)
prog["lessons"].setdefault(l["objective"], datetime.now().isoformat(timespec="minutes")) if done == n:
save_progress(prog) stamp_lesson(l)
lines = [f"✓ Lesson {l['objective']} done - {n}/{n} steps checked. Now prove it against the clock: ./ksim drill {l['objective']}"] lines.append(f"✓ Lesson {l['objective']} done - {n}/{n} steps checked."
else: + ("" if d else f" Now prove it against the clock: ./ksim drill {l['objective']}"))
lines = [f"Lesson {l['objective']} finished with {done}/{n} steps checked - take it again any time: ./ksim lesson {l['objective']}"] else:
lines.append(f"Lesson {l['objective']} finished with {done}/{n} steps checked - take it again any time: ./ksim lesson {l['objective']}")
if d:
lines.append(f"Now prove {DOMAINS[d]} against the clock: ./ksim drill {d}")
st["milestones"] = lines st["milestones"] = lines
save_state(st) save_state(st)
return lines return lines
@ -1687,24 +1741,45 @@ def cli_md(text):
def print_step(l, params, i, st=None): def print_step(l, params, i, st=None):
s = l["steps"][i] s = l["steps"][i]
done = bool(st and st.get("steps", {}).get(str(i), {}).get("pass")) done = bool(st and steps_of(st, l).get(str(i), {}).get("pass"))
print(bold(f"Step {i + 1} of {len(l['steps'])}: {render(s['title'], params, l)}") + (green(" ✓ checked") if done else "")) print(bold(f"Step {i + 1} of {len(l['steps'])}: {render(s['title'], params, l)}") + (green(" ✓ checked") if done else ""))
print(cli_md(render(s["text"], params, l))) print(cli_md(render(s["text"], params, l)))
print(dim(" ./ksim lesson check when it is done ./ksim lesson hint / show if you are stuck")) print(dim(" ./ksim lesson check when it is done ./ksim lesson hint / show if you are stuck"))
def print_outro(l, params): def print_outro(l, params, st):
print(bold("Every step is checked.")); print("-" * 64) print(bold("Every step is checked.")); print("-" * 64)
print(cli_md(render(l["outro"], params, l))); print("-" * 64) print(cli_md(render(l["outro"], params, l))); print("-" * 64)
print(dim(" ./ksim end finishes the lesson")) nxt = next_lesson(st) if st.get("domain") else None
if nxt:
print(dim(f" ./ksim lesson next lesson {nxt['objective']}: {nxt['title']}"))
print(dim(f" ./ksim end finishes the {'run' if st.get('domain') else 'lesson'}"))
def print_lesson(st, l):
"""A lesson as you arrive at it: its host, its introduction, then the step you are on."""
params, ls, i = session_params(st, l), lessons_of(st), open_step(st, l)
where = f" lesson {ls.index(l) + 1} of {len(ls)} in {DOMAINS[st['domain']]}" if st.get("domain") else ""
print(); print(bold(f"Lesson {l['objective']} - {l['title']}") + dim(where)); print("-" * 64)
print(f"It runs on: ssh {host_of(l)}\n")
print(cli_md(render(l["intro"], params, l))); print("-" * 64)
return print_outro(l, params, st) if i is None else print_step(l, params, i, st)
def cmd_lesson(args): def cmd_lesson(args):
"""Lessons (<track>/lessons.py): guided labs on a host of your own, with no clock. """Lessons (<track>/lessons.py): guided labs on a host of your own, with no clock.
./ksim lesson | lesson OBJ | lesson step|check|hint|show [N] | lesson audit [OBJ..]""" ./ksim lesson | lesson OBJ | lesson DOMAIN [--all] | lesson next | lesson step|check|hint|show [N]
| lesson audit [OBJ..]"""
sub = args[0] if args else "" sub = args[0] if args else ""
if sub == "audit": if sub == "audit":
return lesson_audit(args[1:]) return lesson_audit(args[1:])
st = load_state() st = load_state()
live = lesson_of(st) if st and not st.get("ended_at") else None live = lesson_of(st) if st and not st.get("ended_at") else None
if sub == "next":
if not live or not st.get("domain"):
die("No run of lessons in progress. ./ksim lesson DOMAIN starts one: a domain's lessons, back to back.")
nxt = next_lesson(st)
if not nxt:
die(f"Lesson {live['objective']} is the last of the run. ./ksim end finishes it")
move_to_lesson(st, nxt)
return print_lesson(st, nxt)
if sub in ("check", "step", "hint", "show"): if sub in ("check", "step", "hint", "show"):
if not live: if not live:
die("No lesson in progress. ./ksim lesson lists them; ./ksim lesson 2.1 starts one.") die("No lesson in progress. ./ksim lesson lists them; ./ksim lesson 2.1 starts one.")
@ -1712,7 +1787,7 @@ def cmd_lesson(args):
num = [a for a in args[1:] if a.isdigit()] num = [a for a in args[1:] if a.isdigit()]
i = int(num[0]) - 1 if num else open_step(st, live) i = int(num[0]) - 1 if num else open_step(st, live)
if i is None: if i is None:
return print_outro(live, params) return print_outro(live, params, st)
if not 0 <= i < len(live["steps"]): if not 0 <= i < len(live["steps"]):
die(f"The lesson has steps 1 to {len(live['steps'])}.") die(f"The lesson has steps 1 to {len(live['steps'])}.")
s = live["steps"][i] s = live["steps"][i]
@ -1733,28 +1808,37 @@ def cmd_lesson(args):
print(dim(" ./ksim lesson hint a hint ./ksim lesson show the commands")) print(dim(" ./ksim lesson hint a hint ./ksim lesson show the commands"))
return return
print(green(f"✓ Step {i + 1} done: {render(s['title'], params, live)}")); print() print(green(f"✓ Step {i + 1} done: {render(s['title'], params, live)}")); print()
nxt = open_step(load_state(), live) st = load_state(); nxt = open_step(st, live)
return print_outro(live, params) if nxt is None else print_step(live, params, nxt) return print_outro(live, params, st) if nxt is None else print_step(live, params, nxt)
if sub.upper() in DOMAINS: # a domain's run: its lessons not done yet, every host made up front
d = sub.upper(); ls = domain_lessons(d, "--all" in args)
if not ls:
die(f"{DOMAINS[d]} has no lessons yet.")
st = begin_session(ls, "lesson", 0, "--force" in args, domain=d)
return print_lesson(st, ls[0])
if sub: if sub:
l = find_lesson(sub) l = find_lesson(sub)
if not l: if not l:
die(f"No lesson for '{sub}'. The {TRACK_ID.upper()} lessons: " die(f"No lesson for '{sub}'. The {TRACK_ID.upper()} lessons: "
+ (", ".join(f"{x['objective']} ({x['title']})" for x in LESSONS) or "none yet") + " (./ksim lesson lists them)") + (", ".join(f"{x['objective']} ({x['title']})" for x in LESSONS) or "none yet") + " (./ksim lesson lists them)")
if live and st.get("domain") and l["id"] in st["ids"]: # a lesson of the run in progress: go there
move_to_lesson(st, l)
return print_lesson(st, l)
st = begin_session([l], "lesson", 0, "--force" in args, objective=l["objective"]) st = begin_session([l], "lesson", 0, "--force" in args, objective=l["objective"])
params = session_params(st, l) return print_lesson(st, l)
print(); print(bold(f"Lesson {l['objective']} - {l['title']}")); print("-" * 64)
print(cli_md(render(l["intro"], params, l))); print("-" * 64)
print_step(l, params, 0, st)
return
if not LESSONS: if not LESSONS:
print(f"No lessons for {TRACK_ID.upper()} yet.") print(f"No lessons for {TRACK_ID.upper()} yet.")
return return
done = lessons_done() done, by_domain = lessons_done(), {}
print(bold(f"{TRACK['name']} - lessons") + dim(" guided labs on a host of your own, with no clock")) print(bold(f"{TRACK['name']} - lessons") + dim(" guided labs on a host of your own, with no clock"))
for l in LESSONS: for l in LESSONS:
print(f" {green('✓') if l['objective'] in done else dim('·')} {l['objective']:<5} {l['title']}" by_domain.setdefault(l["domain"], []).append(l)
here = live is not None and l["id"] == live["id"]
print(f" {cyan('→') if here else green('✓') if l['objective'] in done else dim('·')} {l['objective']:<5} {l['title']}"
+ dim(f" {len(l['steps'])} steps · {DOMAINS.get(l['domain'], l['domain'])}")) + dim(f" {len(l['steps'])} steps · {DOMAINS.get(l['domain'], l['domain'])}"))
runs = ", ".join(f"{d} {sum(x['objective'] in done for x in ls)}/{len(ls)}" for d, ls in by_domain.items())
print(dim(f"\n ./ksim lesson {LESSONS[0]['objective']} starts that lesson ./ksim lesson audit proves them on real clusters")) print(dim(f"\n ./ksim lesson {LESSONS[0]['objective']} starts that lesson ./ksim lesson audit proves them on real clusters"))
print(dim(f" ./ksim lesson {LESSONS[0]['domain']} a domain's lessons not done yet, back to back ({runs} done)"))
def lesson_audit(args): def lesson_audit(args):
"""Prove lessons on real clusters: each is seeded on a host of its own; then every step must """Prove lessons on real clusters: each is seeded on a host of its own; then every step must
@ -1922,7 +2006,8 @@ def status_line(st):
l = lesson_of(st) l = lesson_of(st)
if l: if l:
n = len(l["steps"]); done = n if open_step(st, l) is None else open_step(st, l) n = len(l["steps"]); done = n if open_step(st, l) is None else open_step(st, l)
return green("lesson - no clock") + dim(f" [{done}/{n} steps checked]") ls = lessons_of(st); run = f"{st['domain']} lesson {ls.index(l) + 1}/{len(ls)}: " if st.get("domain") else ""
return green("lesson - no clock") + dim(f" [{run}{l['objective']} {done}/{n} steps checked]")
r = remaining(st) r = remaining(st)
if st.get("paused_at"): if st.get("paused_at"):
return yellow(f"⏸ PAUSED - {fmt(r)} remaining") + dim(" ./ksim resume") return yellow(f"⏸ PAUSED - {fmt(r)} remaining") + dim(" ./ksim resume")
@ -1991,7 +2076,8 @@ def cmd_end(args):
if st.get("mode") == "lesson": # nothing to grade: the steps were checked as you went if st.get("mode") == "lesson": # nothing to grade: the steps were checked as you went
for line in finish_lesson(st): for line in finish_lesson(st):
print(green(line) if line.startswith("✓") else line) print(green(line) if line.startswith("✓") else line)
print(dim("A lesson writes nothing to the history or the scores. Its host stays up until the next session.")) print(dim("A lesson writes nothing to the history or the scores. " + ("Its hosts stay" if st.get("domain") else "Its host stays")
+ " up until the next session."))
return return
down = hosts_down(st) down = hosts_down(st)
if down: if down:
@ -2150,6 +2236,11 @@ class Api(http.server.BaseHTTPRequestHandler):
if not l: if not l:
die(f"No lesson for {body['lesson']}.") die(f"No lesson for {body['lesson']}.")
begin_session([l], "lesson", 0, True, log=ulog, other_ok=False, objective=l["objective"]) begin_session([l], "lesson", 0, True, log=ulog, other_ok=False, objective=l["objective"])
elif body.get("teach"): # a domain's run of lessons
d = str(body["teach"]).upper(); ls = domain_lessons(d, bool(body.get("all"))) if d in DOMAINS else []
if not ls:
die(f"No lessons for domain {body['teach']}.")
begin_session(ls, "lesson", 0, True, log=ulog, other_ok=False, domain=d)
elif body.get("sim") in SETS: elif body.get("sim") in SETS:
begin_session(set_questions(body["sim"]), "exam", EXAM_SECONDS, True, log=ulog, hard=True, other_ok=False) begin_session(set_questions(body["sim"]), "exam", EXAM_SECONDS, True, log=ulog, hard=True, other_ok=False)
elif mode == "exam": elif mode == "exam":
@ -2198,6 +2289,11 @@ class Api(http.server.BaseHTTPRequestHandler):
_, why = lesson_check(st, int(body.get("step", -1))) _, why = lesson_check(st, int(body.get("step", -1)))
if why: if why:
return self._json({"error": why}, 400) return self._json({"error": why}, 400)
elif p.path == "/api/lesson/at": # another lesson of the run in progress
l = find_lesson(str(body.get("lesson", "")))
why = move_to_lesson(st, l) if l else "No such lesson."
if why:
return self._json({"error": why}, 400)
elif p.path == "/api/pause": elif p.path == "/api/pause":
set_paused(st, bool(body.get("paused", True))) set_paused(st, bool(body.get("paused", True)))
elif p.path == "/api/restart": elif p.path == "/api/restart":
@ -2264,8 +2360,15 @@ class Api(http.server.BaseHTTPRequestHandler):
"domains": self.domain_scores(st), "server_time": time.time()} "domains": self.domain_scores(st), "server_time": time.time()}
l = lesson_of(st) l = lesson_of(st)
if l: if l:
payload["session"]["steps"] = st.get("steps", {}) payload["session"]["steps"] = steps_of(st, l) # the steps of the lesson you are on
payload["lesson"] = self.lesson_payload(l, session_params(st, l)) payload["lesson"] = self.lesson_payload(l, session_params(st, l))
if st.get("domain"): # a domain's run: every lesson of it, and how far each one is
payload["course"] = {"domain": st["domain"], "name": DOMAINS[st["domain"]], "at": l["id"],
"lessons": [{"id": x["id"], "objective": x["objective"], "title": x["title"], "host": host_of(x),
"steps": [{"title": render(s["title"], session_params(st, x), x).strip(),
"pass": bool(steps_of(st, x).get(str(i), {}).get("pass"))}
for i, s in enumerate(x["steps"])]}
for x in lessons_of(st)]}
return payload return payload
def lesson_payload(self, l: dict[str, Any], params: dict[str, Any]) -> dict[str, Any]: def lesson_payload(self, l: dict[str, Any], params: dict[str, Any]) -> dict[str, Any]:
@ -2580,6 +2683,8 @@ def cmd_help(args):
" drill OBJ an objective's tasks against the clock, those not yet passed first\n") " drill OBJ an objective's tasks against the clock, those not yet passed first\n")
objective_help += "" if not LESSONS else ( objective_help += "" if not LESSONS else (
f" lesson [OBJ] the track's lessons ({len(LESSONS)}), or start one: a guided lab on a host of your own, no clock\n" f" lesson [OBJ] the track's lessons ({len(LESSONS)}), or start one: a guided lab on a host of your own, no clock\n"
" lesson DOMAIN [--all] teach a domain: its lessons not done yet (--all: every one) back to back, hosts made up front\n"
" lesson next | OBJ in a domain's run: on to its next lesson, or to any lesson of it\n"
" lesson step|check|hint|show [N] the step you are on, check it, a hint, the commands; ./ksim end finishes\n" " lesson step|check|hint|show [N] the step you are on, check it, a hint, the commands; ./ksim end finishes\n"
" lesson audit [OBJ..] prove lessons: every step fails before its commands and passes after them\n") " lesson audit [OBJ..] prove lessons: every step fails before its commands and passes after them\n")
print(f"""usage: ./ksim [--track cka|ckad|cks] <command> (current track: {TRACK_ID}, {len(QUESTIONS)} tasks) print(f"""usage: ./ksim [--track cka|ckad|cks] <command> (current track: {TRACK_ID}, {len(QUESTIONS)} tasks)

View File

@ -290,6 +290,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
.lmark { font: 600 11px var(--sans); text-transform: uppercase; letter-spacing: .04em; padding: 1px 7px; border-radius: 50px; .lmark { font: 600 11px var(--sans); text-transform: uppercase; letter-spacing: .04em; padding: 1px 7px; border-radius: 50px;
border: 1px solid var(--line); color: var(--muted); margin-right: 6px; white-space: nowrap; } border: 1px solid var(--line); color: var(--muted); margin-right: 6px; white-space: nowrap; }
.lmark.done { border-color: var(--ok-text); color: var(--ok-text); } .lmark.done { border-color: var(--ok-text); color: var(--ok-text); }
.qhead select.select { width: auto; max-width: 100%; padding: .15rem .5rem; font-size: 13px; margin-left: 4px; }
.stepsum { margin: 0 0 14px; color: var(--muted); font-size: 13px; text-transform: uppercase; letter-spacing: .05em; } .stepsum { margin: 0 0 14px; color: var(--muted); font-size: 13px; text-transform: uppercase; letter-spacing: .05em; }
.checkrow { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 16px; font-size: 13px; } .checkrow { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 16px; font-size: 13px; }
.checks { margin-top: 14px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 6px; padding: 12px 14px; font-size: 14px; } .checks { margin-top: 14px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 6px; padding: 12px 14px; font-size: 14px; }
@ -404,6 +405,11 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
<div class="ladder" id="ladder"></div> <div class="ladder" id="ladder"></div>
<div class="pathgo"><button class="btn btn-primary" id="pathGo">Continue</button><button class="btn btn-success hidden" id="pathLesson">Lesson first</button><span class="why" id="pathWhy"></span></div> <div class="pathgo"><button class="btn btn-primary" id="pathGo">Continue</button><button class="btn btn-success hidden" id="pathLesson">Lesson first</button><span class="why" id="pathWhy"></span></div>
</section> </section>
<section class="card hidden" id="teachCard">
<h2 class="section-title">Teach a domain</h2>
<p class="section-sub">A domain's lessons you have not done, back to back and with no clock. Every lesson's host is made before you start, and a lesson counts as done the moment its last step is checked.</p>
<div class="dpills" id="teachPills"></div>
</section>
<section class="card" id="domainCard"> <section class="card" id="domainCard">
<h2 class="section-title">Drill a domain</h2> <h2 class="section-title">Drill a domain</h2>
<p class="section-sub">Five tasks of one domain, against the clock. ✓ drilled at least once, » marked known.</p> <p class="section-sub">Five tasks of one domain, against the clock. ✓ drilled at least once, » marked known.</p>
@ -515,6 +521,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
<div class="field"><label class="lbl" for="pkTrack">Certification</label><select class="select" id="pkTrack"></select></div> <div class="field"><label class="lbl" for="pkTrack">Certification</label><select class="select" id="pkTrack"></select></div>
<div class="field"><label class="lbl" for="pkSession">Session</label><select class="select" id="pkSession"></select></div> <div class="field"><label class="lbl" for="pkSession">Session</label><select class="select" id="pkSession"></select></div>
<div class="field hidden" id="pkLessonRow"><label class="lbl" for="pkLesson">Lesson</label><select class="select" id="pkLesson"></select></div> <div class="field hidden" id="pkLessonRow"><label class="lbl" for="pkLesson">Lesson</label><select class="select" id="pkLesson"></select></div>
<div class="field hidden" id="pkTeachRow"><label class="lbl" for="pkTeach">Domain</label><select class="select" id="pkTeach"></select></div>
<div class="field hidden" id="pkObjRow"><label class="lbl" for="pkObjective">Objective</label><select class="select" id="pkObjective"></select></div> <div class="field hidden" id="pkObjRow"><label class="lbl" for="pkObjective">Objective</label><select class="select" id="pkObjective"></select></div>
<div class="field hidden" id="pkDomRow"><label class="lbl" for="pkDomain">Domain</label><select class="select" id="pkDomain"></select></div> <div class="field hidden" id="pkDomRow"><label class="lbl" for="pkDomain">Domain</label><select class="select" id="pkDomain"></select></div>
<div class="field hidden" id="pkPickRow"><label class="lbl">Tasks</label><div class="picker" id="pkTasks"></div></div> <div class="field hidden" id="pkPickRow"><label class="lbl">Tasks</label><div class="picker" id="pkTasks"></div></div>
@ -682,13 +689,14 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
const ended = sess && sess.ended_at; const ended = sess && sess.ended_at;
$('#navResults').classList.toggle('hidden', !(ended && !sess.aborted)); $('#navResults').classList.toggle('hidden', !(ended && !sess.aborted));
const lessonLine = () => { const n = S.lesson.steps.length, c = Object.values(sess.steps || {}).filter(r => r.pass).length; const lessonLine = () => { const n = S.lesson.steps.length, c = Object.values(sess.steps || {}).filter(r => r.pass).length;
if (S.course) return `Your last run of lessons, ${esc(S.course.domain)}, ended with ${courseDone()}/${S.course.lessons.length} lessons done. <a href="#" id="lastResults">View its summary</a>`;
return `Your last lesson, ${esc(S.lesson.objective)}, ended with ${c}/${n} steps checked. <a href="#" id="lastResults">View its summary</a>`; }; return `Your last lesson, ${esc(S.lesson.objective)}, ended with ${c}/${n} steps checked. <a href="#" id="lastResults">View its summary</a>`; };
$('#lastLine').innerHTML = !ended ? 'Every task gets its own cluster: a drill is ready in about a minute, a full exam in three or four.' $('#lastLine').innerHTML = !ended ? 'Every task gets its own cluster: a drill is ready in about a minute, a full exam in three or four.'
: sess.aborted ? 'Your last session was terminated: nothing was graded or recorded.' : sess.aborted ? 'Your last session was terminated: nothing was graded or recorded.'
: sess.mode === 'lesson' && S.lesson ? lessonLine() : sess.mode === 'lesson' && S.lesson ? lessonLine()
: `Your last ${sess.mode === 'drill' ? 'drill' : 'exam'} ended with ${sess.score.pct}%. <a href="#" id="lastResults">View its results</a>`; : `Your last ${sess.mode === 'drill' ? 'drill' : 'exam'} ended with ${sess.score.pct}%. <a href="#" id="lastResults">View its results</a>`;
const lr = $('#lastResults'); if (lr) lr.onclick = e => { e.preventDefault(); go('results'); }; const lr = $('#lastResults'); if (lr) lr.onclick = e => { e.preventDefault(); go('results'); };
paintPath(); paintDomains(); paintPath(); paintTeach(); paintDomains();
} }
function paintPath() { function paintPath() {
@ -736,6 +744,23 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
}); });
} }
// a domain's lessons, back to back: what each domain has left
function teachPlan(d) {
const ls = (S.objectives || []).filter(o => o.lesson && o.domain === d), left = ls.filter(o => !o.lesson.done), go = left.length ? left : ls;
return {all: ls, left, steps: go.reduce((a, o) => a + o.lesson.steps, 0)};
}
const teachDomains = () => [...new Set((S.objectives || []).filter(o => o.lesson).map(o => o.domain))];
function paintTeach() {
const doms = teachDomains();
$('#teachCard').classList.toggle('hidden', !doms.length);
$('#teachPills').innerHTML = doms.map(d => {
const p = teachPlan(d), n = p.all.length;
return `<button class="dpill ${p.left.length ? '' : 'done'}" data-teach="${d}" title="./ksim lesson ${d}: ${p.left.length ? 'the lessons you have not done' : 'every lesson again'}, back to back"><b>${d}</b><span class="mk">${n - p.left.length}/${n}</span><span>${esc(S.track.domains[d])}</span>`
+ `<span>${p.left.length ? `${p.left.length} lesson${p.left.length > 1 ? 's' : ''} to go · ${p.steps} steps` : `all done · take the ${n} again`}</span></button>`;
}).join('');
$$('#teachPills [data-teach]').forEach(el => el.onclick = () => startTeach(el.dataset.teach));
}
function paintDomains() { function paintDomains() {
const drilled = S.drilled || {}, skipped = S.known || []; const drilled = S.drilled || {}, skipped = S.known || [];
$('#coverage').innerHTML = Object.keys(S.track.domains).map(d => { $('#coverage').innerHTML = Object.keys(S.track.domains).map(d => {
@ -765,7 +790,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
function sessionOptions() { function sessionOptions() {
const o = []; const o = [];
if (S.path) o.push(['path', `Continue the path — ${S.path.next.cmd.replace('./ksim ', '')}`]); if (S.path) o.push(['path', `Continue the path — ${S.path.next.cmd.replace('./ksim ', '')}`]);
if ((S.objectives || []).some(x => x.lesson)) o.push(['lesson', 'Take a lesson — a guided lab, no clock']); if ((S.objectives || []).some(x => x.lesson)) o.push(['lesson', 'Take a lesson — a guided lab, no clock'], ['teach', 'Teach a domain — its lessons back to back, no clock']);
if ((S.objectives || []).length) o.push(['objective', 'Drill an objective']); if ((S.objectives || []).length) o.push(['objective', 'Drill an objective']);
o.push(['domain', 'Drill a domain'], ['next', `Drill the next domain — ${S.track.domains[nextDomain()]}`], o.push(['domain', 'Drill a domain'], ['next', `Drill the next domain — ${S.track.domains[nextDomain()]}`],
['all', 'Drill the whole bank'], ['pick', 'Pick the tasks to drill']); ['all', 'Drill the whole bank'], ['pick', 'Pick the tasks to drill']);
@ -783,6 +808,9 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
const withLesson = (S.objectives || []).filter(o => o.lesson); const withLesson = (S.objectives || []).filter(o => o.lesson);
$('#pkLesson').innerHTML = withLesson.map(o => `<option value="${o.id}">${o.lesson.done ? '✓' : '·'} ${o.id} ${esc(o.lesson.title)}</option>`).join(''); $('#pkLesson').innerHTML = withLesson.map(o => `<option value="${o.id}">${o.lesson.done ? '✓' : '·'} ${o.id} ${esc(o.lesson.title)}</option>`).join('');
const firstOpen = withLesson.find(o => !o.lesson.done); if (firstOpen) $('#pkLesson').value = firstOpen.id; const firstOpen = withLesson.find(o => !o.lesson.done); if (firstOpen) $('#pkLesson').value = firstOpen.id;
$('#pkTeach').innerHTML = teachDomains().map(d => { const p = teachPlan(d);
return `<option value="${d}">${d} — ${esc(S.track.domains[d])} · ${p.all.length - p.left.length}/${p.all.length} lessons done</option>`; }).join('');
const teachOpen = teachDomains().find(d => teachPlan(d).left.length); if (teachOpen) $('#pkTeach').value = teachOpen;
$('#pkDomain').innerHTML = Object.entries(S.track.domains).map(([d, n]) => `<option value="${d}">${d} — ${esc(n)}${(S.drilled || {})[d] ? ' ✓' : (S.known || []).includes(d) ? ' »' : ''}</option>`).join(''); $('#pkDomain').innerHTML = Object.entries(S.track.domains).map(([d, n]) => `<option value="${d}">${d} — ${esc(n)}${(S.drilled || {})[d] ? ' ✓' : (S.known || []).includes(d) ? ' »' : ''}</option>`).join('');
$('#pkTasks').dataset.filled = ''; $('#pkTasks').dataset.filled = '';
$('#hardPicker').checked = hard; $('#pkErr').classList.add('hidden'); $('#hardPicker').checked = hard; $('#pkErr').classList.add('hidden');
@ -792,6 +820,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
const v = $('#pkSession').value, T = S.track, per = hard ? 5 : 6; const v = $('#pkSession').value, T = S.track, per = hard ? 5 : 6;
$('#pkObjRow').classList.toggle('hidden', v !== 'objective'); $('#pkObjRow').classList.toggle('hidden', v !== 'objective');
$('#pkLessonRow').classList.toggle('hidden', v !== 'lesson'); $('#pkLessonRow').classList.toggle('hidden', v !== 'lesson');
$('#pkTeachRow').classList.toggle('hidden', v !== 'teach');
$('#pkDomRow').classList.toggle('hidden', v !== 'domain'); $('#pkDomRow').classList.toggle('hidden', v !== 'domain');
$('#pkPickRow').classList.toggle('hidden', v !== 'pick'); $('#pkPickRow').classList.toggle('hidden', v !== 'pick');
let desc = '', warn = ''; let desc = '', warn = '';
@ -800,6 +829,10 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
const o = (S.objectives || []).find(x => x.id === $('#pkLesson').value); const o = (S.objectives || []).find(x => x.id === $('#pkLesson').value);
desc = `A guided lab on a host of your own: an introduction, then ${o ? o.lesson.steps : 'a few'} steps, each with a Check button, a hint and the commands. No clock, and nothing goes into your history or scores. It ends by sending you to the objective's timed drill.`; desc = `A guided lab on a host of your own: an introduction, then ${o ? o.lesson.steps : 'a few'} steps, each with a Check button, a hint and the commands. No clock, and nothing goes into your history or scores. It ends by sending you to the objective's timed drill.`;
} }
else if (v === 'teach') {
const d = $('#pkTeach').value, p = teachPlan(d), k = (p.left.length || p.all.length);
desc = `${p.left.length ? `The ${k} lesson${k > 1 ? 's' : ''} of ${S.track.domains[d]} you have not done` : `All ${k} lessons of ${S.track.domains[d]} again`} (${p.steps} steps), one after another. Every lesson has a host of its own, all made before you start, so there is no waiting between them. No clock, nothing goes into your history or scores, and a lesson counts as done the moment its last step is checked. It ends by sending you to the domain's timed drill.`;
}
else if (v === 'objective') desc = `Its tasks that have not passed against the clock yet, up to five in a sitting, ${per} minutes each. Feedback per task while you work; a task's solution once it is graded.`; else if (v === 'objective') desc = `Its tasks that have not passed against the clock yet, up to five in a sitting, ${per} minutes each. Feedback per task while you work; a task's solution once it is graded.`;
else if (v === 'domain') desc = `Five tasks of that domain, ${per} minutes each, the ones you have seen least recently first. Feedback per task while you work.`; else if (v === 'domain') desc = `Five tasks of that domain, ${per} minutes each, the ones you have seen least recently first. Feedback per task while you work.`;
else if (v === 'next') desc = `${T.domains[nextDomain()]} — 5 tasks, ${per} min each. Drills rotate through all domains before the exam.`; else if (v === 'next') desc = `${T.domains[nextDomain()]} — 5 tasks, ${per} min each. Drills rotate through all domains before the exam.`;
@ -816,7 +849,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
} }
$('#pkDesc').textContent = desc; $('#pkDesc').textContent = desc;
$('#pkWarn').textContent = warn; $('#pkWarn').classList.toggle('hidden', !warn); $('#pkWarn').textContent = warn; $('#pkWarn').classList.toggle('hidden', !warn);
$('#pkStart').textContent = warn ? 'Start anyway' : v === 'lesson' ? 'Start lesson' : 'Start exam'; $('#pkStart').textContent = warn ? 'Start anyway' : v === 'lesson' ? 'Start lesson' : v === 'teach' ? 'Start the lessons' : 'Start exam';
} }
async function fillTaskPicker() { async function fillTaskPicker() {
const box = $('#pkTasks'), want = hard ? 'hard' : 'normal'; const box = $('#pkTasks'), want = hard ? 'hard' : 'normal';
@ -831,6 +864,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
const v = $('#pkSession').value; const v = $('#pkSession').value;
if (v === 'path') return continuePath(); if (v === 'path') return continuePath();
if (v === 'lesson') return startLesson($('#pkLesson').value); if (v === 'lesson') return startLesson($('#pkLesson').value);
if (v === 'teach') return startTeach($('#pkTeach').value);
if (v === 'objective') return start('drill', null, false, false, null, $('#pkObjective').value); if (v === 'objective') return start('drill', null, false, false, null, $('#pkObjective').value);
if (v === 'domain') return start('drill', null, false, false, $('#pkDomain').value); if (v === 'domain') return start('drill', null, false, false, $('#pkDomain').value);
if (v === 'next') return start('drill', null, true); if (v === 'next') return start('drill', null, true);
@ -867,6 +901,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
launch({mode, ids, next: !!next, campaign: !!campaign, domain: domain || null, objective: objective || null, touch: touch || null, hard}); launch({mode, ids, next: !!next, campaign: !!campaign, domain: domain || null, objective: objective || null, touch: touch || null, hard});
const startSim = name => launch({mode: 'exam', sim: name, hard: true}); const startSim = name => launch({mode: 'exam', sim: name, hard: true});
const startLesson = obj => launch({lesson: obj}); const startLesson = obj => launch({lesson: obj});
const startTeach = d => launch({teach: d});
const isLesson = () => !!(S && S.lesson && S.session && S.session.mode === 'lesson'); const isLesson = () => !!(S && S.lesson && S.session && S.session.mode === 'lesson');
async function resumeSeeding() { async function resumeSeeding() {
polling = true; openModal('#mPrep'); polling = true; openModal('#mPrep');
@ -878,6 +913,20 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
skew = Date.now() / 1000 - S.session.server_time; autoSubmitted = false; $('#vnc').dataset.ready = ''; skew = Date.now() / 1000 - S.session.server_time; autoSubmitted = false; $('#vnc').dataset.ready = '';
cur = S.questions[0] && S.questions[0].id; lpage = fresh ? 0 : null; go('exam'); cur = S.questions[0] && S.questions[0].id; lpage = fresh ? 0 : null; go('exam');
if (!fresh) return; if (!fresh) return;
if (isLesson() && S.course) { // a domain's run: how it goes
const C = S.course, n = C.lessons.length;
$('#startTitle').textContent = `${C.name}: ${n} lesson${n > 1 ? 's' : ''}`;
$('#startBody').innerHTML = `<div class="alert alert-info"><b>No clock.</b> Take your time: the lessons teach, the drills test.</div>
<ul>
<li>every lesson has a host of its own, and all ${n} are up already: the top of each lesson says which to <code class="ic">ssh</code> to;</li>
<li>a lesson is an introduction, then steps - each with a <b>Check step</b> button, a hint, and the commands if you are stuck; steps count in order;</li>
<li>a lesson's wrap-up leads to the next one, and the list at the top goes to any lesson of the run;</li>
<li>a lesson counts as done the moment its last step is checked; nothing goes into your history or scores;</li>
<li>finish the run from <b>Controls</b>, then drill ${esc(C.domain)} against the clock.</li>
</ul>`;
openModal('#mStart');
return;
}
if (isLesson()) { // no clock to start: the dialog only says how a lesson goes if (isLesson()) { // no clock to start: the dialog only says how a lesson goes
const L = S.lesson; const L = S.lesson;
$('#startTitle').textContent = `Lesson ${L.objective}: ${L.title}`; $('#startTitle').textContent = `Lesson ${L.objective}: ${L.title}`;
@ -917,14 +966,15 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
function paintMenus() { function paintMenus() {
const sess = S.session; const sess = S.session;
if (isLesson()) { if (isLesson()) {
const what = S.course ? 'the run' : 'lesson';
$('#mnControls').innerHTML = live() $('#mnControls').innerHTML = live()
? `<button class="dd-item" data-act="finishLesson">Finish lesson</button> ? `<button class="dd-item" data-act="finishLesson">Finish ${what}</button>
<div class="dd-sep"></div> <div class="dd-sep"></div>
<button class="dd-item danger" data-act="terminate">Terminate lesson</button>` <button class="dd-item danger" data-act="terminate">Terminate ${what}</button>`
: `<button class="dd-item" data-act="results">Back to the summary</button> : `<button class="dd-item" data-act="results">Back to the summary</button>
<button class="dd-item" data-act="dashboard">Back to the dashboard</button> <button class="dd-item" data-act="dashboard">Back to the dashboard</button>
<div class="dd-sep"></div> <div class="dd-sep"></div>
<button class="dd-item danger" data-act="terminate">Delete the host</button>`; <button class="dd-item danger" data-act="terminate">Delete the host${S.course ? 's' : ''}</button>`;
return; return;
} }
$('#mnControls').innerHTML = live() $('#mnControls').innerHTML = live()
@ -1001,7 +1051,11 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
catch (e) { alert(e.message); b.disabled = false; b.textContent = 'Check this task'; } catch (e) { alert(e.message); b.disabled = false; b.textContent = 'Check this task'; }
} }
function step(d) { function step(d) {
if (isLesson()) { lpage = Math.max(0, Math.min(lpage + d, S.lesson.steps.length + 1)); paintExam(); $('#qContent').scrollTop = 0; return; } if (isLesson()) {
const nx = nextLesson();
if (d > 0 && lpage === S.lesson.steps.length + 1 && nx) return goLesson(nx.id);
lpage = Math.max(0, Math.min(lpage + d, S.lesson.steps.length + 1)); paintExam(); $('#qContent').scrollTop = 0; return;
}
const i = S.questions.findIndex(q => q.id === cur), j = i + d; const i = S.questions.findIndex(q => q.id === cur), j = i + d;
if (j >= 0 && j < S.questions.length) { cur = S.questions[j].id; paintExam(); $('#qContent').scrollTop = 0; } if (j >= 0 && j < S.questions.length) { cur = S.questions[j].id; paintExam(); $('#qContent').scrollTop = 0; }
} }
@ -1013,11 +1067,21 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
const n = S.lesson.steps.length, k = [...Array(n).keys()].find(i => !stepDone(i)); const n = S.lesson.steps.length, k = [...Array(n).keys()].find(i => !stepDone(i));
return k === undefined ? n + 1 : k + 1; return k === undefined ? n + 1 : k + 1;
} }
// a domain's run: the lessons in it, the one after the current, how many are done
const courseIdx = () => S.course ? S.course.lessons.findIndex(x => x.id === S.course.at) : -1;
const nextLesson = () => S.course ? S.course.lessons[courseIdx() + 1] || null : null;
const courseDone = () => S.course.lessons.filter(x => x.steps.every(s => s.pass)).length;
async function goLesson(id) {
try { S = await api('/api/lesson/at', {lesson: String(id)}); }
catch (e) { alert(e.message === 'not found' ? oldServer('Teach a domain') : e.message); return; }
lpage = Object.values(S.session.steps || {}).some(r => r.pass) ? null : 0; // a lesson not started opens on its introduction
paintExam(); $('#qContent').scrollTop = 0;
}
function paintLesson() { function paintLesson() {
const L = S.lesson, sess = S.session, on = live(), n = L.steps.length; const L = S.lesson, sess = S.session, on = live(), n = L.steps.length, C = S.course, nx = nextLesson();
if (lpage === null) lpage = lessonResume(); if (lpage === null) lpage = lessonResume();
lpage = Math.max(0, Math.min(lpage, n + 1)); lpage = Math.max(0, Math.min(lpage, n + 1));
$('#xTitle').textContent = `${S.track_id.toUpperCase()} Lesson · ${L.objective} ${L.title}`; $('#xTitle').textContent = `${S.track_id.toUpperCase()} Lesson${C ? ` · ${C.domain} ${courseIdx() + 1}/${C.lessons.length}` : ''} · ${L.objective} ${L.title}`;
document.title = `Lesson ${L.objective} · Kubestronaut exam simulator`; document.title = `Lesson ${L.objective} · Kubestronaut exam simulator`;
paintMenus(); paintMenus();
const checked = [...Array(n).keys()].filter(k => stepDone(k)).length, firstOpen = [...Array(n).keys()].find(k => !stepDone(k)); const checked = [...Array(n).keys()].filter(k => stepDone(k)).length, firstOpen = [...Array(n).keys()].find(k => !stepDone(k));
@ -1027,13 +1091,18 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
$('#qMenu').innerHTML = [...Array(n + 2).keys()].map(p => `<button class="dd-item ${p >= 1 && p <= n && stepDone(p - 1) ? 'pass' : ''}" data-lp="${p}">` $('#qMenu').innerHTML = [...Array(n + 2).keys()].map(p => `<button class="dd-item ${p >= 1 && p <= n && stepDone(p - 1) ? 'pass' : ''}" data-lp="${p}">`
+ (p === 0 ? 'Introduction' : p === n + 1 ? `Wrap-up · ${checked}/${n} steps checked` : `Step ${p} · ${esc(L.steps[p - 1].title)}`) + '</button>').join(''); + (p === 0 ? 'Introduction' : p === n + 1 ? `Wrap-up · ${checked}/${n} steps checked` : `Step ${p} · ${esc(L.steps[p - 1].title)}`) + '</button>').join('');
$$('#qMenu [data-lp]').forEach(b => b.onclick = () => { lpage = +b.dataset.lp; closeMenus(); paintExam(); $('#qContent').scrollTop = 0; }); $$('#qMenu [data-lp]').forEach(b => b.onclick = () => { lpage = +b.dataset.lp; closeMenus(); paintExam(); $('#qContent').scrollTop = 0; });
let body = `<div class="qhead"><div class="line"><b>This lesson runs on:</b> <code class="ic" title="click to copy">${esc('ssh ' + L.host)}</code></div> const pick = !C ? '' : `<div class="line"><b>Lesson ${courseIdx() + 1} of ${C.lessons.length}</b> in ${esc(C.name)}:<select class="select" id="lessonPick">`
+ C.lessons.map(x => { const c = x.steps.filter(s => s.pass).length;
return `<option value="${x.id}"${x.id === C.at ? ' selected' : ''}>${c === x.steps.length ? '✓' : '·'} ${esc(x.objective)} ${esc(x.title)} (${c}/${x.steps.length})</option>`; }).join('')
+ '</select></div>';
let body = `<div class="qhead">${pick}<div class="line"><b>This lesson runs on:</b> <code class="ic" title="click to copy">${esc('ssh ' + L.host)}</code></div>
<div class="line sub">${L.nodes}-node cluster · files in ${esc(S.workspace)} on that host · no clock</div></div><hr>`; <div class="line sub">${L.nodes}-node cluster · files in ${esc(S.workspace)} on that host · no clock</div></div><hr>`;
if (lpage === 0) { if (lpage === 0) {
body += `<div class="stepsum">Lesson ${esc(L.objective)} · ${n} steps</div><h2 class="qtitle">${esc(L.title)}</h2><div class="qbody">${render(L.intro)}</div>`; body += `<div class="stepsum">Lesson ${esc(L.objective)} · ${n} steps</div><h2 class="qtitle">${esc(L.title)}</h2><div class="qbody">${render(L.intro)}</div>`;
} else if (lpage === n + 1) { } else if (lpage === n + 1) {
body += `<div class="stepsum">Wrap-up · ${checked} of ${n} steps checked</div><h2 class="qtitle">${esc(L.title)}</h2><div class="qbody">${render(L.outro)}</div>` body += `<div class="stepsum">Wrap-up · ${checked} of ${n} steps checked</div><h2 class="qtitle">${esc(L.title)}</h2><div class="qbody">${render(L.outro)}</div>`
+ (on ? `<div class="lstate"><button class="btn btn-primary" id="btnFinish">Finish lesson</button>` + (on ? `<div class="lstate">${nx ? `<button class="btn btn-primary" id="btnNextLesson">Next lesson · ${esc(nx.objective)} ${esc(nx.title)}</button>` : ''}`
+ `<button class="btn ${nx ? 'btn-outline-primary' : 'btn-primary'}" id="btnFinish">Finish ${C ? 'the run' : 'lesson'}</button>`
+ (checked < n ? `<span class="lock">${n - checked} step(s) not checked yet - the lesson counts as done only when every step is.</span>` : '') + '</div>' : ''); + (checked < n ? `<span class="lock">${n - checked} step(s) not checked yet - the lesson counts as done only when every step is.</span>` : '') + '</div>' : '');
} else { } else {
const k = lpage - 1, s = L.steps[k], r = (sess.steps || {})[k], locked = firstOpen !== undefined && k > firstOpen; const k = lpage - 1, s = L.steps[k], r = (sess.steps || {})[k], locked = firstOpen !== undefined && k > firstOpen;
@ -1051,10 +1120,12 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
copyable($('#qContent')); copyable($('#qContent'));
const bs = $('#btnStep'); if (bs) bs.onclick = () => checkStep(lpage - 1); const bs = $('#btnStep'); if (bs) bs.onclick = () => checkStep(lpage - 1);
const bf = $('#btnFinish'); if (bf) bf.onclick = finishLesson; const bf = $('#btnFinish'); if (bf) bf.onclick = finishLesson;
const bn = $('#btnNextLesson'); if (bn) bn.onclick = () => goLesson(nx.id);
const lp = $('#lessonPick'); if (lp) lp.onchange = () => goLesson(lp.value);
$('#btnFlag').className = 'btn btn-outline-warning hidden'; $('#btnFlag').className = 'btn btn-outline-warning hidden';
$('#btnBack').classList.remove('hidden'); $('#btnBack').disabled = lpage <= 0; $('#btnBack').classList.remove('hidden'); $('#btnBack').disabled = lpage <= 0;
$('#btnSatisfied').innerHTML = (lpage === 0 ? 'Start: step 1' : lpage === n ? 'Wrap-up' : lpage === n + 1 ? 'Wrap-up' : 'Next step') + ' <span aria-hidden="true">→</span>'; $('#btnSatisfied').innerHTML = (lpage === 0 ? 'Start: step 1' : lpage === n ? 'Wrap-up' : lpage === n + 1 ? (nx ? 'Next lesson' : 'Wrap-up') : 'Next step') + ' <span aria-hidden="true">→</span>';
$('#btnSatisfied').disabled = lpage >= n + 1; $('#btnSatisfied').disabled = lpage >= n + 1 && !nx;
paintDesk(); paintDesk();
} }
async function checkStep(k) { async function checkStep(k) {
@ -1064,10 +1135,12 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
paintExam(); paintExam();
} }
async function finishLesson() { async function finishLesson() {
const n = S.lesson.steps.length, c = [...Array(n).keys()].filter(k => stepDone(k)).length; const n = S.lesson.steps.length, c = [...Array(n).keys()].filter(k => stepDone(k)).length, C = S.course;
if (c < n && !(await ask({title: 'Finish the lesson?', ok: 'Finish lesson', if (C ? courseDone() < C.lessons.length && !(await ask({title: 'Finish the run?', ok: 'Finish the run',
body: `<p class="mb0">${c} of ${n} steps are checked. The lesson counts as done only when every step is - you can take it again any time.</p>`}))) return; body: `<p class="mb0">${courseDone()} of ${C.lessons.length} lessons have every step checked. The others count as done only when every step is - you can take them again any time.</p>`}))
$('#busyTitle').textContent = 'Finishing the lesson'; : c < n && !(await ask({title: 'Finish the lesson?', ok: 'Finish lesson',
body: `<p class="mb0">${c} of ${n} steps are checked. The lesson counts as done only when every step is - you can take it again any time.</p>`}))) return;
$('#busyTitle').textContent = C ? 'Finishing the run' : 'Finishing the lesson';
$('#busyBody').innerHTML = '<p class="mb0">Nothing is graded: the steps were checked as you went.</p>'; $('#busyBody').innerHTML = '<p class="mb0">Nothing is graded: the steps were checked as you went.</p>';
openModal('#mBusy'); openModal('#mBusy');
try { S = await api('/api/end', {}); } try { S = await api('/api/end', {}); }
@ -1123,9 +1196,12 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
} }
async function terminate() { async function terminate() {
const on = live(), what = isLesson() ? 'lesson' : 'session'; const on = live(), what = isLesson() ? 'lesson' : 'session';
const ok = await ask(on && isLesson() const ok = await ask(on && isLesson() && S.course
? {title: 'Terminate the run', ok: 'Terminate the run', okClass: 'btn-danger',
body: `<p class="mb0">This deletes the run's ${S.course.lessons.length} hosts right away and ends it. Lessons whose every step was checked stay done; nothing else is recorded.</p>`}
: on && isLesson()
? {title: 'Terminate the lesson', ok: 'Terminate lesson', okClass: 'btn-danger', ? {title: 'Terminate the lesson', ok: 'Terminate lesson', okClass: 'btn-danger',
body: '<p class="mb0">This deletes the lesson\'s host right away and ends the lesson. Nothing is recorded; you can take it again any time.</p>'} body: '<p class="mb0">This deletes the lesson\'s host right away and ends the lesson. Nothing goes into your history or scores; you can take it again any time.</p>'}
: on : on
? {title: 'Terminate session', ok: 'Terminate session', okClass: 'btn-danger', ? {title: 'Terminate session', ok: 'Terminate session', okClass: 'btn-danger',
body: `<div class="alert alert-warning"><p><b>Warning!</b> Terminating this session will:</p><ul><li>delete every host of this session right away;</li><li>end it without grading it - nothing is written to your history.</li></ul></div><p class="mb0">This cannot be undone. Terminate the session?</p>`} body: `<div class="alert alert-warning"><p><b>Warning!</b> Terminating this session will:</p><ul><li>delete every host of this session right away;</li><li>end it without grading it - nothing is written to your history.</li></ul></div><p class="mb0">This cannot be undone. Terminate the session?</p>`}
@ -1138,7 +1214,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
try { S = await api('/api/terminate', {}); } try { S = await api('/api/terminate', {}); }
catch (e) { alert(e.message === 'not found' ? oldServer('Terminate session') : e.message); return; } catch (e) { alert(e.message === 'not found' ? oldServer('Terminate session') : e.message); return; }
finally { closeModal('#mBusy'); } finally { closeModal('#mBusy'); }
if (on) { go('landing'); lobbyNote(`${what === 'lesson' ? 'Lesson' : 'Session'} terminated: nothing was graded or recorded, and its hosts are deleted.`); } if (on) { go('landing'); lobbyNote(`${S.course ? 'Run of lessons' : what === 'lesson' ? 'Lesson' : 'Session'} terminated: nothing was graded, and its hosts are deleted.`); }
else { route(); alert('The hosts are deleted. Your results and your history stay.'); } else { route(); alert('The hosts are deleted. Your results and your history stay.'); }
} }
function toggleFullscreen() { function toggleFullscreen() {
@ -1154,7 +1230,27 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
$('#btnDelete').textContent = lesson ? 'Delete the host' : 'Delete the hosts'; $('#btnDelete').textContent = lesson ? 'Delete the host' : 'Delete the hosts';
$('#resLabel').textContent = lesson ? 'Steps checked' : 'Points scored'; $('#resLabel').textContent = lesson ? 'Steps checked' : 'Points scored';
} }
function paintCourseResults() {
const C = S.course, s = S.session, n = C.lessons.length, c = courseDone(), all = c === n;
document.title = `Lessons ${C.domain} · Kubestronaut exam simulator`;
resultButtons(true);
$('#btnDelete').textContent = 'Delete the hosts'; $('#resLabel').textContent = 'Lessons done';
$('#resTitle').textContent = `${C.name}: lessons`;
$('#resMeta').innerHTML = `${n} lesson${n > 1 ? 's' : ''}, ${C.lessons.reduce((a, x) => a + x.steps.length, 0)} steps<br>Finished ${new Date(s.ended_at * 1000).toLocaleString()}<br>No clock - nothing went into your history`;
$('#resEarned').textContent = c; $('#resTotal').textContent = n; $('#resPct').textContent = all ? 'done' : '';
$('#resBadge').textContent = all ? 'Every lesson done ✓' : `${n - c} lesson(s) to go`;
$('#resBadge').className = 'rank-badge ' + (all ? 'rank-high' : 'rank-mid');
$('#resDomains').innerHTML = '';
$('#resPath').innerHTML = (s.milestones || []).map(m => `<div class="${m.startsWith('✓') ? 'milestone' : ''}">${esc(m)}</div>`).join('');
$('#resQuestions').innerHTML = C.lessons.map(x => { const k = x.steps.filter(t => t.pass).length;
return `<div class="qcard ${k === x.steps.length ? 't-pass' : 't-partial'}"><div class="qcard-h"><h3>${esc(x.objective)} · ${esc(x.title)}</h3><span class="qs">${k} of ${x.steps.length}</span></div>
<ul class="vitems">${x.steps.map((t, j) => `<li class="vitem"><span>Step ${j + 1} · ${esc(t.title)}</span><span class="vstat ${t.pass ? 'ok' : 'no'}">${t.pass ? '✓' : '✗'}</span></li>`).join('')}</ul><div class="pad"></div></div>`; }).join('');
$('#btnDrillMissed').disabled = false;
$('#btnDrillMissed').textContent = `Drill ${C.domain} against the clock`;
$('#btnDrillMissed').onclick = () => start('drill', null, false, false, C.domain);
}
function paintLessonResults() { function paintLessonResults() {
if (S.course) return paintCourseResults();
const L = S.lesson, s = S.session, n = L.steps.length, c = [...Array(n).keys()].filter(k => stepDone(k)).length, all = c === n; const L = S.lesson, s = S.session, n = L.steps.length, c = [...Array(n).keys()].filter(k => stepDone(k)).length, all = c === n;
document.title = `Lesson ${L.objective} · Kubestronaut exam simulator`; document.title = `Lesson ${L.objective} · Kubestronaut exam simulator`;
resultButtons(true); resultButtons(true);
@ -1234,7 +1330,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
resetPanels: () => { $('#qpanel').style.width = ''; try { localStorage.removeItem('ksim.split'); } catch (e) { /* fine */ } }, resetPanels: () => { $('#qpanel').style.width = ''; try { localStorage.removeItem('ksim.split'); } catch (e) { /* fine */ } },
desktopFullscreen: () => { const f = $('#term'); if (f && f.requestFullscreen) f.requestFullscreen().catch(() => {}); }, desktopFullscreen: () => { const f = $('#term'); if (f && f.requestFullscreen) f.requestFullscreen().catch(() => {}); },
history: showHistory, answers: showAnswers, results: () => go('results'), dashboard: () => go('landing'), review: () => { lpage = null; go('exam'); }, history: showHistory, answers: showAnswers, results: () => go('results'), dashboard: () => go('landing'), review: () => { lpage = null; go('exam'); },
finishLesson, retake: () => startLesson(S.lesson.objective), finishLesson, retake: () => S.course ? startTeach(S.course.domain) : startLesson(S.lesson.objective),
}; };
document.addEventListener('click', e => { document.addEventListener('click', e => {
const t = e.target.closest('.dd-toggle'); const t = e.target.closest('.dd-toggle');
@ -1264,6 +1360,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
$('#hardPicker').onchange = e => setHard(e.target.checked); $('#hardPicker').onchange = e => setHard(e.target.checked);
$('#pkSession').onchange = pickerChanged; $('#pkSession').onchange = pickerChanged;
$('#pkLesson').onchange = pickerChanged; $('#pkLesson').onchange = pickerChanged;
$('#pkTeach').onchange = pickerChanged;
$('#pkTrack').onchange = async e => { const keep = $('#pkSession').value; if (await switchTrack(e.target.value)) openPicker(keep); else e.target.value = S.track_id; }; $('#pkTrack').onchange = async e => { const keep = $('#pkSession').value; if (await switchTrack(e.target.value)) openPicker(keep); else e.target.value = S.track_id; };
$('#pkStart').onclick = pickerStart; $('#pkStart').onclick = pickerStart;
$('#btnBegin').onclick = async () => { $('#btnBegin').onclick = async () => {