120 Commits

Author SHA1 Message Date
itsrubberduck
2c5c2d5239 feat(classroom): show the full curriculum and raise the mastery bar
The overview rendered four anonymous module tiles and half a page of empty
space, which undersold 63 existing lessons. It now stacks four module
sections that list every lesson as a chip with status and variant dots, and
the header states the real scope: 4 modules, 63 lessons, 262 variations.

Mastery moves from a flat two-variation counter to per-module thresholds
(3/4/4/5) backed by persisted variant signatures — a hash over each roll's
expected answers. That triples the practice runs to full mastery without
authoring a single new lesson, and fixes a dedup hole where the same
variation counted twice after a page reload.

Existing progress is preserved: `done` is sticky, so raising the bar never
revokes a check mark someone already earned.

Also fixes `yarn test`, which passed quoted globs Node 20 does not expand —
it exited 0 without running a single test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:08:55 +02:00
itsrubberduck
56d3c991d7 fix(sso): hand the issuer /auth/callback so the code gets redeemed
The auth guard asked the issuer to come back to the page the visitor wanted.
The issuer appends ?code= to whatever URL it is given, but only /auth/callback
redeems a code — so it arrived on the target page, sat there unread, the guard
found no session and bounced back for a fresh code. The browser ping-ponged
between the two hosts until the user gave up.

The guard now hands over /auth/callback and carries the wanted page in its
redirect parameter, which is exactly what that page already expected. A spent
code in the URL is dropped rather than carried along, so a stale link cannot
turn into a redemption error one hop later.

URL building moved into shared/utils/ssoHandoff.ts because the same mistake
existed twice — the retry button on the callback page had it too — and because
a redirect loop deserves a regression test that does not need a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 19:14:20 +02:00
itsrubberduck
c8c2365c4d refactor(split): make the app self-hosting ready
Remove the transitional website auth, admin hooks, SEO and hosted analytics together, then align the app routes, runtime configuration, tests and dependencies. These changes form one atomic cleanup because the filtered app must switch its identity and runtime surfaces as a unit.
2026-07-27 19:17:27 +02:00
itsrubberduck
3ed3865ebb Merge branch 'codex/classroom-overhaul' 2026-07-27 18:52:53 +02:00
itsrubberduck
b470d805f8 Improve classroom readback tolerance 2026-07-27 12:29:37 +02:00
itsrubberduck
6ce79145e6 merge: classroom overhaul 2026-07-27 11:55:08 +02:00
itsrubberduck
d6b2b328f6 feat(landing): align homepage with current alpha 2026-07-27 11:46:39 +02:00
itsrubberduck
ed8fe7366d feat(live-atc): send the filed route so the clearance can grant it
The route reached the local engine already — genSID() takes it and throws it
away — but never reached the backend, so the clearance could only issue a SID.

The payload mapping moves to shared/utils so it can be tested at all: app/
composables are outside the test glob, and the fallback chains and string
coercion in it were entirely uncovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:12:43 +02:00
itsrubberduck
8fb186ef60 fix(classroom): keep pilot answers text-only 2026-07-27 10:49:27 +02:00
itsrubberduck
e58c55cffc feat(classroom): rebuild curriculum and mastery assessment 2026-07-27 10:26:45 +02:00
itsrubberduck
cf4b295cc6 feat(frequency): work out which stations are on offer and which are reachable
A session loaded one airport's frequencies once, at the start, and never
revisited it: on a departure the destination's tower never appeared however
close you got, and every listed station worked from any distance.

Two separate questions, kept apart. Which airports are *on offer* — both of
them, always, since hiding one would stop a pilot dialling ahead — with the one
belonging to the current phase marked primary. And which stations are
*reachable*, which is line-of-sight from height: the standard 1.23·√ft with the
ground antenna's own horizon added, checked against the tests' textbook figures
at 1000 ft, 10000 ft and FL350.

The governing constraint is that the software has to work with no bridge
connected, which is the normal case for someone practising without a simulator.
Position is therefore an enhancement, never a requirement: without one, both
airports are offered and nothing is marked unreachable, because a station that
cannot be *proven* out of range must not be taken away. The phase decides which
airport leads instead.
2026-07-27 10:06:47 +02:00
itsrubberduck
c397ef6f95 test(live-atc): cover the wait auto-tune actually depends on
The decision to tune was tested; the three seconds between announcing it and
doing it were not, and that gap is where the feature can go wrong — it is the
only window in which the radio moves on its own.

Moves the scheduling into a testable unit and wires the composable to it, so
the tested code is the code that runs. Covered: nothing is announced when no
change is due, the change is announced immediately but made only after the
delay, and it is dropped when the pilot tuned the radio themselves, when the
session ended, and when a second handoff superseded it — the last of which would
otherwise have tuned to a stale frequency.

Leaving the flight or unmounting now cancels a pending change explicitly rather
than relying on the fire-time guards to notice.
2026-07-27 10:04:07 +02:00
itsrubberduck
67541a48b6 fix(tts): keep a SID in one piece and speak the approach variant letter
Two of the items from the combined phraseology report were real.

The taxi-route rule treated the "via" of a departure clearance as the start of
a taxi route and comma-joined every token after it, so "cleared via MARUN7F
departure, climb 5000 feet" was read as "Marun, seven, Foxtrot, departure,
climb, fife, thousand, feet" — the SID's own name, number and suffix split into
three separate items, with the break in exactly the wrong place. The rule now
converts a run only when it is actually made of taxiway designators, which
leaves real taxi routes ("via N3, U4, A") reading as before.

"ILS Z" was read as a bare letter. The rule that should have expanded it
required the runway in the same match, and by the time it ran the runway had
already been rewritten to words, so it never fired on the phrasing the flows
use. Reading the variant is now independent of what follows it — where two
approaches serve the same runway, that letter is the only thing telling them
apart.

Checked the rest of the report and left it alone: taxiway sequences already
paused correctly, parallel runways already spoke their side in full, and no
approach clearance names a runway literally. Tests now pin all of it, including
two structural invariants over every flow — an approach clearance takes its
runway from the variable rather than a literal, and a readback quotes the
clearance items back in the order they were given.
2026-07-27 01:37:19 +02:00
itsrubberduck
1bd1aac76d feat(live-atc): optional auto-tune after a frequency handoff
Tuning is manual, so after a handoff nothing the pilot says goes through until
they dial the new frequency in. With the setting on, the radio does it for them
three seconds after the handoff was accepted, announcing "OpenSquawk changing
frequency to …" first so it is never a surprise.

Whether a change is due is decided from state rather than from an event, which
is what makes the two must-not-tune cases safe without a special case: a
frequency readback that was wrong and one not yet given both leave the session
on a state that still expects the frequency already dialled in, so nothing is
due. A pending change is dropped if the session ends or the pilot reaches for
the radio themselves — theirs wins.

Off by default: working the radio is part of what is being practised, so
handing it to the aircraft has to be a deliberate choice. The decision itself
is a pure function and covered by tests; the announcement goes through the same
speech and comm-log path as everything else, so the browser sim and the bridge
both see the tuned frequency the way they already do for a manual change.

Also folds the two copies of normalizedFrequencyValue into one in shared/, so
the auto-tune logic can compare frequencies without a composable import.
2026-07-27 00:51:53 +02:00
itsrubberduck
a40ce0a7eb fix(live-atc): arm the silence timer on readback states too
The timer was only armed for states carrying auto_advance_on_silence. Readback
states carry none — they wait for the pilot — so nothing was armed and ATC
never asked again when the readback did not arrive.

Which window applies to a state is now a pure function: the flow-authored one
for an auto-advance state, the much shorter server-published one for a readback
state. That also makes both cases testable instead of inline in the composable.
2026-07-27 00:30:59 +02:00
itsrubberduck
132f5faf9c fix(live-atc): stop swallowing "roger" while still dropping dead air
The PTT gate dropped any spoken transmission under two words, which is exactly
the length of the calls a pilot most often makes: "roger", "wilco", "affirm",
"negative". Those never reached the engine at all.

The gate is now phraseology-aware rather than length-only, and moved into a
pure function so it can be tested: standard short calls pass regardless of
length, while the things push-to-talk actually produces when nothing was said
are still dropped — punctuation from near-silent audio, a syllable clipped by
an early key release, and the phrases Whisper hallucinates on silence
("Thank you.", "Bye."), which are rejected only as a whole transcript so a real
sign-off still passes.

The ignore log now names the reason and the state it happened in, so a
transmission that vanished can be traced.
2026-07-27 00:11:04 +02:00
itsrubberduck
7f0443f70e fix(live-atc): match the D-registration to the aircraft class
VFR scenarios drew from a fixed pool of D-E** registrations regardless of type,
so an A320 was assigned "D-ETMO". The letter after "D-" is assigned by weight
and type class (LuftVZO Anlage 1): D-E is single-engine pistons up to 2 t, so
an airliner has to be D-A.

Pick the class from the ICAO type designator — D-A for airliners, D-I for light
twins, D-H for helicopters, D-E for light singles and anything unrecognised,
since VFR scenarios are overwhelmingly light aircraft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 20:05:04 +02:00
itsrubberduck
9780ced052 feat(live-atc): drive ATIS letter, runway, QNH and wind from the resolved report
The broadcast and the flow variables used to read different sources: the audio
came from VATSIM while `information` was genATIS() — a random A-Z letter — and
the runway came from genRunway(), a hard-coded list unrelated to the airport.
So the pilot could hear "information Q" and be expected to call "information T".

Both now read the same AtisReport. The frequency list backfills the letter and
broadcast text from it, so an ATIS station always announces an information
letter even when no VATSIM controller is online. QNH and surface wind come from
the same observation, so the controller stops contradicting the ATIS.

A synthesised ATIS is labelled "Simulated ATIS" in the frequency picker rather
than being credited to VATSIM.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:40:40 +02:00
itsrubberduck
e0dbb40f0e feat(atis): resolver core for reliable information letter and runway
VATSIM is the only ATIS source today, so an airport with no controller online
(EDDF, on the day this was reported) falls back to a raw METAR — which carries
neither an information letter nor a runway in use.

Add shared/utils/atisReport.ts, which turns whatever the live sources returned
into one AtisReport:

  vatsim   station broadcasting text; letter from atis_code or parsed from the
           text; runway parsed from the text
  metar    no station: letter derived from the observation time, runway from the
           headwind component over the published runway ends
  fallback no METAR either: letter from the clock, airport's main runway

Deriving the letter from the observation keeps it stable across background
refetches while still advancing once per METAR cycle. Runway selection compares
METAR true wind against OpenAIP trueHeading (no magnetic correction needed) and
honours takeOffOnly/landingOnly ends.

The synthesised broadcast leaves the METAR groups coded, because
normalizeAtisForSpeech() already expands them for TTS.

Also fixes runway extraction for "RUNWAY IN USE 22" — the phrasing German
VATSIM ATIS actually uses, which the previous pattern missed entirely.

Tests run offline against VATSIM/METAR/OpenAIP responses recorded 2026-07-26,
including the EDDC case where the wind-derived runway matches what the live
controller published.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:32:59 +02:00
itsrubberduck
9f43062805 fix(clearance): generate octal squawks and skip reserved codes
genSquawk() drew a decimal number in 1000-8999, so clearances could contain
the digits 8 or 9 — codes no transponder can dial. Replace it with a shared
generateSquawk() that draws four octal digits and re-rolls the reserved codes
(7500/7600/7700, 7000, 2000, 1200, 0000).

shared/learn/scenario.ts had its own octal generator that could still draw an
emergency code; it now uses the same helper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:27:21 +02:00
itsrubberduck
a53442b1d1 fix(tts): back to fast Piper controllers, classroom voice setting
Kokoro-82M generates several times slower than Piper — since the controller
pool moved onto it, every ATC reply arrived seconds late. Controllers return
to distinct Piper speakers with the standard US voice (ryan) as the product
default, so replies are fast again and the default sounds like before.

The classroom gets an instructor-voice setting: standard US voice by default,
'random per module' (stable instructor per module), or one of ten named
US/GB voices.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:36:19 +02:00
itsrubberduck
73be2a3b84 fix(voice-pool): avalanche hash bits before pool modulo
fnv1a's low bits barely mix: for the live-atc persona keys (shared session
prefix, ':TYPE:freq' suffix) hash % 4 was identical for Delivery, Ground and
Tower in 100% of sessions — every position spoke with one voice. A murmur3
fmix32 finalizer before the modulo restores uniform assignment; verified in
the browser (three stations, three voices, three paces).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:09:38 +02:00
itsrubberduck
1731690095 feat(live-atc): squelch open/close transients around transmissions
Carrier-open click at speech start, ~230ms noise tail cut off by a closing
click at speech end. Playback holds the effect chain alive past the voice so
the tail rings out instead of being killed with the noise generators.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 10:49:56 +02:00
itsrubberduck
a5545d2084 feat(live-atc): per-position controller personas with voice and pace
Each ATC position (session + airport + type + frequency) now gets a stable
persona: a controller-pool voice and a base pace of 1.1-1.3x, jittered +/-0.05
per transmission. Anything spoken without an explicit voice — the controller
reply path and AI-traffic ATC lines — uses the persona; pilot voices are
untouched. A new session reshuffles the shift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 10:48:19 +02:00
itsrubberduck
2fec78df91 feat: add complete SEO metadata and indexing controls 2026-07-18 21:29:31 +02:00
itsrubberduck
e004843aa9 feat(live-atc): forward aircraft position, un-throttle bridge polling
- NormalizedTelemetry carries lat/lon (0/0 no-GPS guard, position in the
  change-detection signature) so the backend can derive distance triggers
- bridge polling moves from setInterval to a Worker tick: with the sim in the
  foreground the /live-atc tab is backgrounded and Chrome throttles plain
  intervals to ~1/min — telemetry-driven ATC lagged behind the silence fallback

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:48:09 +02:00
itsrubberduck
34e3787164 fix(websim): keep bridge connected in background tabs, fix PFD clipping and layout
- Move physics and telemetry/status ticking from requestAnimationFrame/setInterval
  to a Web Worker (new shared/utils/intervalWorker.ts) — the intended usage is
  WebSim in one tab and /live-atc in another, so the WebSim tab is almost always
  backgrounded, and rAF stops entirely / setInterval throttles to ~1/min in that
  state, dropping the bridge connection after a few minutes.
- Surface bridge failures in the header badge (connecting/error/connected) instead
  of showing "verbindet…" forever when telemetry POSTs are failing.
- Replace the PFD's hardcoded 0.85 scale with a ResizeObserver-driven fit so it
  no longer clips the VS tape/altitude readout at typical panel widths.
- Give the sidestick pad the same panel shell as the FCU/radio panels and stretch
  all three to equal height.
- ExteriorView: camera height now reacts to altitude, the runway renders at its
  real distance/lateral offset (was a fixed spot only shown under 3 NM) with
  centerline/threshold markings, widened for visibility from the 12 NM range
  it's now shown from, plus fog and a gradient sky.

Verified live: bridge badge stays green, PFD fits inside its panel at 1280x720,
and the runway becomes visible on approach for all four spawn presets.
2026-07-16 23:48:18 +02:00
itsrubberduck
3fe431ea47 fix(live-atc): stop ATC from repeating/looping and hanging on silent frequencies
- Neutralize the local engine's autonomous auto-advance (evaluateAutoTransitions/
  evaluateSimpleAutoFlow) — the Python backend now drives state exclusively via
  moveToSilent; the old walker could self-answer pilot states and race the
  backend, producing loops.
- Dedupe applyBackendDecision's ATC log entry + TTS: moveToSilent no longer logs
  say_tpl for backend-driven auto-advanced states (suppressSay), and a
  lastAppliedSay guard drops a repeated decision from overlapping sources
  (transmit reply, telemetry tick, silence timeout).
- Cap consecutive silence-timeout re-fires on the same state at 2 instead of
  re-arming forever.
- Pause telemetry forwarding while a pilot transmission is in flight, and guard
  against overlapping telemetry POSTs.
- Add request timeouts to TTS (20s) and backend transmit/telemetry/timeout
  (30s)/createSession (60s) calls so a hung request can no longer freeze the
  whole session.

Verified live against the Python backend: a full clearance→taxi chain now logs
each ATC line exactly once instead of 2-3x.
2026-07-16 23:47:52 +02:00
itsrubberduck
4799b2b89f feat(websim): browser A320 cockpit to test /live-atc without MSFS (WIP)
Flight model (ground/air physics, SELECTED/NAV/APPR/AUTOLAND autopilot with
STAR sequencing and ILS capture), bridge client that feeds the existing
/api/bridge/* endpoints so /live-atc can't tell it apart from a real bridge,
and the cockpit UI (PFD reuse, FCU, radio panel, Leaflet ND, three.js
exterior, spawn presets at EDDF/EDDS). Design doc:
docs/plans/2026-07-16-websim-design.md.

Also adds a local-dev-only auto-login (/dev-login, server/api/dev/login.post.ts)
that bypasses the invite-only login and MongoDB entirely via a fixed in-memory
user, so require-auth pages are reachable for local testing even when the dev
DB is unreachable. Hard-disabled outside development.

Status: unit tests green (yarn test) and typecheck clean (yarn typecheck).
Browser walkthrough of the actual cockpit (flying a preset, confirming
telemetry reaches /live-atc) is not yet done — picking up from a fresh dev
server + /dev-login?redirect=/flightlab/websim confirmed the spawn screen
renders past auth, but full instrument/map/exterior verification is still
outstanding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 18:49:31 +02:00
itsrubberduck
a49cd577e4 fix(sim-control): narrow SimControlParseResult via type predicates
ab04411 left `nuxt typecheck` failing on main:

  useLiveAtcSession.ts: Property 'reason' does not exist on type
  'SimControlParseResult'.

Cause: the union is discriminated by a boolean, and the project builds
with `strict: false` (nuxt.config.ts). With strictNullChecks off,
TypeScript does not treat `true`/`false` literal types as discriminants,
so `if (r.matched) … else r.reason` never narrows. Nothing about the
sim-control types themselves is wrong — the same three lines with any
boolean-discriminated union fail identically.

Adds isSimControlMatch/isSimControlRejection type predicates, which narrow
regardless of strictNullChecks, and routes the one caller through them.
Turning on strictNullChecks would fix it at the root but is a repo-wide
change, not a bug fix.

The pre-push hook was correctly refusing to push this; origin/main is
clean, so the breakage never escaped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:51:09 +02:00
itsrubberduck
70f0b26d90 feat(live-atc): add simulated AI background traffic on the tuned frequency
Implements the ai-traffic roadmap item per
docs/plans/2026-07-14-ai-traffic-architecture-design.md.

Simulated other aircraft on the user's frequency — callsigns, ATC
instructions, readbacks in their own stable voice, handovers — as pure
scenery. It never touches radioBackend: the Python backend keeps owning
the dialogue *with* the user, useAiTraffic owns the radio *around* the
user. The two share only the speech queue (arbitration) and the log.

Rules live as pure, seeded, framework-free modules under
shared/utils/aiTraffic/ so they run in tsx --test without a browser:
callsign collision rules, wake/in-trail separation, runway slots, the
speed ladder, direct validation, the §3 decision table, and the gating
chain. app/composables/useAiTraffic.ts wires them to Vue (1 Hz tick,
spawner, scheduler).

Gating is evaluated twice — before enqueue and again at playback, since
seconds pass in between. Traffic never keys up while the user holds PTT,
while their transmission is out at the backend, or inside the fresh
readback window. Off by default; the toggle surfaces the feature's v1
limitations rather than burying them in a doc.

Zero LLM calls: variance comes from seeded RNG over template variants.

Deviations from the design, both documented in the design doc:
- Adds SimAircraft.quietUntilSec. The design's rule table says "first
  matching row per tick" but never says an instruction must be allowed to
  take effect before the next one. Without it the planner re-derives the
  same unresolved condition every second and nags one aircraft with the
  same vector: 624 calls/30min measured, vs 90 with the cooldown.
- Airline pool limited to the 14 designators DEFAULT_AIRLINE_TELEPHONY
  already knows; UAE/AUA/WZZ from the design would be spelled out letter
  by letter instead of spoken as airline names.

Verified: 406 tests pass (176 new), no new typecheck errors, /live-atc
compiles and serves. The manual in-session walkthrough (audible traffic,
toggle mid-session) is NOT verified — it needs a login and the Python
backend. The 30-minute deterministic integration run stands in for it and
caught two of the three bugs found during development.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:46:52 +02:00
itsrubberduck
ab04411bc5 feat(sim-control): wire frequency-sim-control command channel end-to-end
Implements the open items from docs/plans/2026-07-14-frequency-sim-control-design.md
§4/§"Offen für die Implementierungsphase": a per-bridge-token in-memory command
queue piggybacked on the existing telemetry channel, plus the client-side gate
and TTS confirmations.

- server/utils/simControlQueue.ts: TTL-based queue keyed by bridge token
  (enqueue → drainPending → resolve → drainResultsForClient).
- server/api/bridge/data.post.ts: response gains a `commands` field the
  bridge drains on its next telemetry POST.
- server/api/bridge/command.post.ts (new): client enqueues a parsed command,
  re-validated server-side via isValidSimControlCommand.
- server/api/bridge/command-result.post.ts (new): bridge reports ok/failed.
- server/api/bridge/live.get.ts: response gains `commandResults` so the
  client can announce outcomes.
- shared/utils/simControl.ts: wire types, isValidSimControlCommand, and
  simControlRejectionSpeech/simControlResultSpeech TTS phrasing.
- useLiveAtcSession.ts: parseSimControl() gated on bridgeConnected, wired in
  right after the local special cases and before the frequency check —
  matched commands never reach radioBackend.transmit().
- useSimBridgeSync.ts / live-atc.vue: bridgeToken threaded through, command
  results forwarded from the telemetry poll to TTS.

43 new tests (shared parser/validation/speech + server queue lifecycle/TTL).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 09:31:17 +02:00
itsrubberduck
e18995d7cd feat(sim-control): add fail-closed intent parser for frequency-driven sim setup
Roadmap item "frequency-sim-control": pilot speaks a self-service
sim setup command on frequency ("set me up for an approach from 5000
ft to EDDF 07R", "change my altitude to 8000"). Rule-based, not an
LLM call per transmission — a misparsed command changes the user's
real sim state, and it must never be confused with real ATC
phraseology routed through sttMatch.ts.

parseSimControl() gates on explicit self-service anchors first
("set me up", "put me", "change/set my <param>"); anything without
one of those anchors returns no_intent, so regular readbacks and
clearances can never be misrouted. Matched intents still refuse on
any ambiguous slot (missing unit, out-of-range, invalid runway) with
a typed reason instead of guessing.

Parameter vocabulary follows NormalizedTelemetry (altitude_ft,
ias_kts, heading_deg) since the command travels toward the bridge/sim,
not the decision engine's DecisionNodeAutoTrigger vocabulary.

Design doc covers the still-missing server→bridge write channel
(piggyback on the existing telemetry POST response) — not
implemented yet, this commit is the parser only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 17:47:21 +02:00
itsrubberduck
44b1f28bc4 fix(stt): anchor callsign digits verbatim, fuzzy only the airline name
The whole-string fuzzy fallback in callsignMatches() ran before the
digit-anchored check and let single-digit typos slip through (e.g.
"Lufthansa 350" matched expected "DLH359" at Levenshtein distance 1).
Spoken-digit-word candidates ("three five niner") bypassed the digit
anchor entirely since they contain no digit characters pre-fold.

Fold candidates through denormalizeSpokenAtc before checking for
digits, then require the flight number verbatim (word-boundary regex,
zero tolerance) while keeping the existing ~25% fuzzy tolerance on the
airline-name portion. Single-digit flight numbers (DLH4) now get the
same anchoring as longer ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 17:47:11 +02:00
itsrubberduck
f96d99dc32 refactor(live-atc): move scenario catalogue to shared/constants/scenarios.ts
SCENARIOS, CHAIN_DEFS and the Scenario type are pure data with no
reactive inputs, but the session controller needs them — leaving them in
the page would mean threading the catalogue through as a dependency.

drillScenarios/chainGroups were computed() over module constants only, so
they can never invalidate; they become plain DRILL_SCENARIOS/CHAIN_GROUPS
consts, evaluated once at module load. Verified the derived shape is
unchanged: 15 scenarios, 2 drills, 3 chains with 4/4/3 segments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:45:11 +02:00
itsrubberduck
869fee08ba refactor(live-atc): extract ATIS playback/loop scheduling to useAtisPlayback
Moves ATIS carrier/broadcast loop, TTS audio caching, METAR fallback,
and the background airport-data refresh scheduler out of live-atc.vue.
Also extracts the pmLog debug logger to shared/utils/pmLog.ts since it
has no Vue/component dependency and multiple composables need it —
threading it through every composable's parameter list would've been
worse than giving it one shared home. Verified via typecheck plus a
browser mount/redirect smoke test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 09:33:03 +02:00
itsrubberduck
cf9c81c23f refactor(live-atc): extract encodeWav to shared/utils, add tests
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 09:10:29 +02:00
itsrubberduck
c8a07b980f refactor(live-atc): extract bridge-telemetry normalization to shared/utils, add tests
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 09:09:17 +02:00
itsrubberduck
964e33414a refactor(live-atc): extract normalizeManualFreq to shared/utils, add tests
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 09:07:33 +02:00
itsrubberduck
5b6ffe4d73 feat(onboarding): add /start mode chooser, fix pilot-profile-setup UX
- Fix missing icon for the free-tier/self-host pricing option
- Make selected cards visually obvious (checkmark badge, stronger border)
- Add Back navigation between onboarding steps
- Network-experience screen: button reads "No, not yet" instead of an
  ambiguous "Continue" when nothing is selected
- classroom-introduction reverts to Classroom-only content; the
  Classroom/Live ATC chooser moves to a new /start page instead
- Remove the "Skip to Classroom" button in favor of a subtle text link
- New /start page: choose Classroom or Live ATC after login (when
  there's no specific redirect target) or right after finishing
  onboarding. Shows a "start with Classroom" hint only for first-time
  arrivals from pilot-profile-setup. Live ATC is locked for 24h after
  registration.
2026-07-09 23:53:12 +02:00
itsrubberduck
9b245a3f07 fix(onboarding): also ask OS for X-Plane, not just 'other'
X-Plane runs on Windows/Mac/Linux same as any non-MSFS sim, so it
should trigger the OS follow-up too.
2026-07-09 17:26:11 +02:00
itsrubberduck
068299de56 feat(onboarding): add shared pilot-profile option config 2026-07-09 16:24:00 +02:00
leubeem
4b07609c52 feat(pm): VFR registration callsigns and pronunciation (#28)
- VFR scenarios get a German D-registration (e.g. D-EKLM) and its
  abbreviated form (callsign_short, D-EKLM -> D-LM) instead of the airline
  callsign; the pilot's first call uses the full registration, ATC uses the
  short form thereafter.
- radioSpeech: spell aircraft registrations phonetically for TTS
  ("D-EKLM" -> "Delta Echo Kilo Lima Mike", "D-LM" -> "Delta Lima Mike").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 10:17:52 +02:00
leubeem
9345bf3614 fix(pm): tester-round readback, frequency tuning, and PTT fixes
From /pm tester bug reports:
- pre-tune COM1 to the opening pilot state's frequency on scenario start,
  so the first call isn't met with a "wrong frequency" rejection (#5/#6/#21)
- taxi-route phonetics no longer stop at the first comma: "via A, V" now
  speaks "via Alfa, Victor" (#31)
- barge-in: keying the mic stops any ATC speech still playing
- ignore empty / punctuation-only transmissions (silence, stray PTT taps)
- PTT pad turns green while transmitting (was red)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 06:02:03 +02:00
itsrubberduck
22dffc5d69 feat(bridge): map GPS position + true heading into telemetry
Bridge now sends latitude_deg/longitude_deg/heading_deg; map them to
PLANE_LATITUDE/PLANE_LONGITUDE/PLANE_HEADING_DEGREES_TRUE so position and
course land in the telemetry store and surface in /api/bridge/live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 13:36:30 +02:00
itsrubberduck
bcd48159b5 feat(pm): auto-tune radio from SimBridge telemetry via ?token
When /pm is opened with ?token=<bridge-token>, poll /api/bridge/live for
fresh telemetry. While the bridge keeps posting, mirror the sim's COM1
active frequency into the radio (only on actual sim change, so manual/flow
tuning isn't clobbered) and show a "Bridge connected" badge in the HUD.

Telemetry now carries COM_ACTIVE_FREQUENCY/COM_STANDBY_FREQUENCY from the
bridge's com_active_frequency/com_standby_frequency fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:30:23 +02:00
leubeem
6fae54c89f feat(stt): seed Whisper prompt with expected readback + per-field debug UI
Whisper prompt seeding (per request):
- ptt.post.ts builds the prompt as generic ICAO bias + this state's expected
  readback appended LAST (survives the 224-token truncation), in both raw token
  form and spoken ICAO form via new radioSpeech.speakToken().
- pm.vue passes the expected phrase + active variable values; classroom.vue
  passes the lesson's expected field values.

Per-field readback debug:
- sttMatch.matchTranscriptionToFields returns fields[] (matched/missing + which
  view matched) plus normalized/denormalized transcription views.
- useRadioBackend types readback_report on the transmit response.
- pm.vue renders a "Readback check" panel in the right log rail; classroom.vue
  renders per-field rows under the STT panel.

Radio-pronunciation fixes (radioSpeech.ts):
- callsign expander handles multi-letter suffixes (DLH6RK -> Lufthansa six Romeo
  Kilo).
- toRadioSpeech now expands airports (EDDC -> Echo Delta Delta Charlie).
- bare altitudes >=1000 in a clearance context are spoken ("climb initially
  5000" -> "climb initially five thousand feet"); speeds/headings untouched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:12:54 +02:00
leubeem
813e989532 docs: align CLAUDE.md/AGENTS.md/README with the two-repo architecture; drop dead LLM routing
The LLM decision routing moved to the external Python backend
(OpenSquawk-LiveATC-api), but the core docs still described the old in-Nuxt
path (routeDecision(), /api/llm/decide, a Nuxt /api/decision-flows/runtime
route) — none of which exist. Every agent session started with a wrong mental
model.

- Rewrite CLAUDE.md to describe the real /pm flow (useRadioBackend -> Python
  backend owns authoritative state; Nuxt owns STT/TTS/audio/auth/editor) and
  fix stale commands (bun -> yarn).
- Fix the two stale AGENTS.md lines (openai.ts is getOpenAIClient() only;
  decisionFlowService is editor-only, no Nuxt runtime route).
- README: note the Python backend is required for /pm; correct server/ desc.
- Remove dead shared/utils/openaiDecision.ts (called the non-existent
  /api/llm/decide) and the now-orphaned LLM decision contract types in
  shared/types/llm.ts. Trace types used by pm.vue are kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:46:57 +02:00
leubeem
ca86abf799 feat(pm): live ATIS broadcast loop with METAR-slot refresh and multi-station support
- per-airport ATIS loop keyed by station with a virtual start epoch, so
  re-tuning resumes where the broadcast would be instead of restarting
- refetch airport data at :23/:53 to follow VATSIM ATIS regeneration from
  real-world METAR publication, with faster retries while no ATIS is on
  the feed; prefetch audio when the info letter changes
- support separate arrival/departure ATIS stations on different frequencies
- cancel the deferred audio teardown on retune so a fresh broadcast is not
  killed by the previous stop()'s fade-out timer (atisAudioLoop)
- comm log shows newest entries first

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:10:35 +02:00
leubeem
20b88ca74d fix(radio): speak SID names and waypoints as words, expand more ATIS elements
- SID basenames and 5-letter waypoints (ANEKI 7S, SULUS) are pronounceable
  by design and are now spoken as words instead of letter-by-letter phonetics
- skip acronyms (ATIS, RNAV, MAIN, ...) when spelling 4-letter ICAO codes
- expand stand/gate designators, ATIS information letter, and surface wind
  groups for TTS
- normalizeATCText now runs full client-side radiotelephony expansion
  (callsigns, airports) since preNormalized texts skip the server normalizer

Note: tests/radioSpeech "normalizes SID suffix and METAR data" still expects
the old spelled-out SID behavior and fails until updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:10:22 +02:00