diff --git a/internal/web/pages_sweatbox.go b/internal/web/pages_sweatbox.go index 3cc2763..4b8feac 100644 --- a/internal/web/pages_sweatbox.go +++ b/internal/web/pages_sweatbox.go @@ -35,6 +35,19 @@ func (s *Server) handleFrontendSweatbox(c *gin.Context) { s.writeTemplate(c, "sweatbox", page) } +// handleFrontendSweatboxManual GET /sweatbox/manual — full instructor user manual. +// Same Administrator gate as the control panel. Static HTML (no FSD dependency). +func (s *Server) handleFrontendSweatboxManual(c *gin.Context) { + claims, ok := requireJwtContext(c) + if !ok { + return + } + s.writeTemplate(c, "sweatbox_manual", basePage{ + User: pageUserFromClaims(claims), + CSRFToken: s.issueCSRFToken(c), + }) +} + func (s *Server) newSweatboxPage(c *gin.Context) sweatboxPage { claims, ok := requireJwtContext(c) if !ok { diff --git a/internal/web/pages_sweatbox_test.go b/internal/web/pages_sweatbox_test.go index a2ca8eb..7af0dd5 100644 --- a/internal/web/pages_sweatbox_test.go +++ b/internal/web/pages_sweatbox_test.go @@ -125,6 +125,84 @@ func TestSweatboxPOSTRequiresCSRF(t *testing.T) { } } +func TestSweatboxManualUnauthRedirect(t *testing.T) { + ts := newTestServer(t) + req := httptest.NewRequest(http.MethodGet, "/sweatbox/manual", nil) + w := httptest.NewRecorder() + ts.engine.ServeHTTP(w, req) + if w.Code != http.StatusSeeOther { + t.Fatalf("status %d want 303", w.Code) + } + if loc := w.Header().Get("Location"); loc != "/login" { + t.Fatalf("Location=%q want /login", loc) + } +} + +func TestSweatboxManualObserverRedirect(t *testing.T) { + ts := newTestServer(t) + obs := createTestUser(t, ts, "pw", int(protocol.NetworkRatingObserver)) + cookies := formLogin(t, ts, obs.CID, "pw") + + w, _ := authedGET(t, ts, "/sweatbox/manual", cookies) + if w.Code != http.StatusSeeOther { + t.Fatalf("status %d want 303", w.Code) + } + if loc := w.Header().Get("Location"); loc != "/dashboard" { + t.Fatalf("Location=%q want /dashboard", loc) + } +} + +func TestSweatboxManualAdminOK(t *testing.T) { + ts := newTestServer(t) + admin := createTestUser(t, ts, "admin-pass", int(protocol.NetworkRatingAdministator)) + cookies := formLogin(t, ts, admin.CID, "admin-pass") + + w, _ := authedGET(t, ts, "/sweatbox/manual", cookies) + if w.Code != http.StatusOK { + t.Fatalf("status %d body %s", w.Code, w.Body.String()) + } + body := w.Body.String() + for _, want := range []string{ + "Sweatbox instructor manual", + "Command language", + "SWEATBOX_ENABLED", + `href="/sweatbox"`, + "add rules weight engine", + "Pattern & arrival", + } { + if !strings.Contains(body, want) { + t.Fatalf("manual missing %q, body=%s", want, clip(body, 600)) + } + } + // Manual is static HTML — no FSD dependency required. + if strings.Contains(body, "Unavailable.") { + t.Fatal("manual must not depend on FSD availability banner") + } +} + +func TestSweatboxPageLinksManualNewTab(t *testing.T) { + ts := newTestServer(t) + admin := createTestUser(t, ts, "admin-pass", int(protocol.NetworkRatingAdministator)) + cookies := formLogin(t, ts, admin.CID, "admin-pass") + + w, _ := authedGET(t, ts, "/sweatbox", cookies) + if w.Code != http.StatusOK { + t.Fatalf("status %d body %s", w.Code, w.Body.String()) + } + body := w.Body.String() + // Non-invasive titlebar link; opens in a new tab. + if !strings.Contains(body, `href="/sweatbox/manual"`) { + t.Fatal("expected /sweatbox/manual link on control panel") + } + if !strings.Contains(body, `target="_blank"`) || !strings.Contains(body, `rel="noopener noreferrer"`) { + t.Fatalf("manual link should open in new tab with noopener, body=%s", clip(body, 500)) + } + // Link text should stay quiet (not a primary action button). + if !strings.Contains(body, ">Manual") { + t.Fatal("expected muted Manual link text") + } +} + func TestSweatboxCommandEmptyRedirectsErrorFlash(t *testing.T) { ts := newTestServer(t) admin := createTestUser(t, ts, "pw", int(protocol.NetworkRatingAdministator)) diff --git a/internal/web/routes.go b/internal/web/routes.go index acf21a4..83f1c17 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -136,6 +136,7 @@ func (s *Server) setupFrontendRoutes(parent *gin.RouterGroup) { // Sweatbox instructor UI: server-rendered forms; proxies FSD /sweatbox/* service HTTP. admin.GET("/sweatbox", s.handleFrontendSweatbox) + admin.GET("/sweatbox/manual", s.handleFrontendSweatboxManual) admin.POST("/sweatbox/airport", s.handleFrontendSweatboxAirport) admin.POST("/sweatbox/scenario", s.handleFrontendSweatboxScenario) admin.POST("/sweatbox/command", s.handleFrontendSweatboxCommand) diff --git a/internal/web/static/css/openfsd/sweatbox-manual.css b/internal/web/static/css/openfsd/sweatbox-manual.css new file mode 100644 index 0000000..9bdd4ce --- /dev/null +++ b/internal/web/static/css/openfsd/sweatbox-manual.css @@ -0,0 +1,281 @@ +/* + * Sweatbox instructor manual — readable document layout. + * Separate from the dense operator console (sweatbox.css). + */ + +.sbxm { + --sbxm-max: 52rem; + --sbxm-toc-w: 14rem; + --sbxm-gap: 1.5rem; + --sbxm-border: #d0d4d9; + --sbxm-muted: #5c636a; + --sbxm-bg: #f8f9fa; + --sbxm-panel: #fff; + --sbxm-link: #0b57d0; + --sbxm-mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; + --sbxm-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + + box-sizing: border-box; + font-family: var(--sbxm-font); + font-size: 0.9375rem; + line-height: 1.55; + color: #1a1a1a; + background: var(--sbxm-bg); + max-width: none; + margin: 0; + padding: 1rem 1.25rem 3rem; +} + +.sbxm *, +.sbxm *::before, +.sbxm *::after { + box-sizing: border-box; +} + +.sbxm-header { + max-width: calc(var(--sbxm-max) + var(--sbxm-toc-w) + var(--sbxm-gap)); + margin: 0 auto 1.25rem; +} + +.sbxm-kicker { + margin: 0 0 0.35rem; + font-size: 0.8125rem; +} + +.sbxm-kicker a { + color: var(--sbxm-muted); + text-decoration: none; +} + +.sbxm-kicker a:hover, +.sbxm-kicker a:focus-visible { + color: var(--sbxm-link); + text-decoration: underline; +} + +.sbxm-header h1 { + margin: 0 0 0.5rem; + font-size: 1.5rem; + font-weight: 700; + letter-spacing: -0.01em; +} + +.sbxm-lead { + margin: 0; + max-width: 42rem; + color: var(--sbxm-muted); + font-size: 0.95rem; +} + +.sbxm-layout { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: var(--sbxm-gap); + max-width: calc(var(--sbxm-max) + var(--sbxm-toc-w) + var(--sbxm-gap)); + margin: 0 auto; + align-items: start; +} + +@media (min-width: 56rem) { + .sbxm-layout { + grid-template-columns: var(--sbxm-toc-w) minmax(0, 1fr); + } + + .sbxm-toc { + position: sticky; + top: 0.75rem; + max-height: calc(100dvh - 1.5rem); + overflow: auto; + } +} + +.sbxm-toc { + background: var(--sbxm-panel); + border: 1px solid var(--sbxm-border); + border-radius: 0.35rem; + padding: 0.75rem 0.9rem; + font-size: 0.8125rem; +} + +.sbxm-toc h2 { + margin: 0 0 0.4rem; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--sbxm-muted); +} + +.sbxm-toc ol { + margin: 0; + padding-left: 1.15rem; +} + +.sbxm-toc li { + margin: 0.2rem 0; +} + +.sbxm-toc a { + color: inherit; + text-decoration: none; +} + +.sbxm-toc a:hover, +.sbxm-toc a:focus-visible { + color: var(--sbxm-link); + text-decoration: underline; +} + +.sbxm-doc { + background: var(--sbxm-panel); + border: 1px solid var(--sbxm-border); + border-radius: 0.35rem; + padding: 1.25rem 1.35rem 2rem; + max-width: var(--sbxm-max); +} + +.sbxm-doc section { + margin: 0 0 1.75rem; + scroll-margin-top: 0.75rem; +} + +.sbxm-doc section:last-child { + margin-bottom: 0; +} + +.sbxm-doc h2 { + margin: 0 0 0.65rem; + font-size: 1.15rem; + font-weight: 700; + border-bottom: 1px solid var(--sbxm-border); + padding-bottom: 0.35rem; +} + +.sbxm-doc h3 { + margin: 1rem 0 0.4rem; + font-size: 1rem; + font-weight: 650; +} + +.sbxm-doc p, +.sbxm-doc ul, +.sbxm-doc ol { + margin: 0 0 0.75rem; +} + +.sbxm-doc ul, +.sbxm-doc ol { + padding-left: 1.35rem; +} + +.sbxm-doc li { + margin: 0.25rem 0; +} + +.sbxm-doc code { + font-family: var(--sbxm-mono); + font-size: 0.88em; + background: #f0f2f4; + padding: 0.08em 0.28em; + border-radius: 0.2rem; +} + +.sbxm-doc a { + color: var(--sbxm-link); +} + +.sbxm-doc a:focus-visible { + outline: 2px solid var(--sbxm-link); + outline-offset: 2px; +} + +.sbxm-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + margin: 0 0 0.85rem; +} + +.sbxm-table th, +.sbxm-table td { + border: 1px solid var(--sbxm-border); + padding: 0.4rem 0.55rem; + vertical-align: top; + text-align: left; +} + +.sbxm-table th { + background: #eef1f4; + font-weight: 650; +} + +.sbxm-cmd td:first-child { + white-space: nowrap; + font-family: var(--sbxm-mono); + font-size: 0.82em; + width: 38%; +} + +.sbxm-dl { + margin: 0 0 0.85rem; +} + +.sbxm-dl dt { + font-weight: 650; + margin-top: 0.5rem; +} + +.sbxm-dl dd { + margin: 0.15rem 0 0.35rem 0; + color: #2a2a2a; +} + +.sbxm-note { + font-size: 0.875rem; + color: var(--sbxm-muted); + border-left: 3px solid var(--sbxm-border); + padding-left: 0.65rem; +} + +.sbxm-cols { + columns: 1; +} + +@media (min-width: 40rem) { + .sbxm-cols { + columns: 2; + column-gap: 1.5rem; + } +} + +.sbxm-foot { + margin-top: 1.25rem; + font-size: 0.8125rem; + color: var(--sbxm-muted); +} + +@media print { + .sbxm { + background: #fff; + padding: 0; + } + + .sbxm-toc { + display: none; + } + + .sbxm-doc { + border: none; + padding: 0; + } + + .sbxm-kicker { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .sbxm-doc section { + scroll-margin-top: 0; + } +} diff --git a/internal/web/templates.go b/internal/web/templates.go index 6b51356..6bcc819 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -25,6 +25,7 @@ var pageTemplateKeys = []string{ "usereditor", "configeditor", "sweatbox", + "sweatbox_manual", "airport_editor", } diff --git a/internal/web/templates/sweatbox.html b/internal/web/templates/sweatbox.html index 4cb7bbb..034ef19 100644 --- a/internal/web/templates/sweatbox.html +++ b/internal/web/templates/sweatbox.html @@ -22,7 +22,7 @@

Sweatbox

-

Control panel · forms work without JS · Refresh reloads snapshot

+

Control panel · forms work without JS · Refresh reloads snapshot · Manual

{{ if .FlashSuccess }} diff --git a/internal/web/templates/sweatbox_manual.html b/internal/web/templates/sweatbox_manual.html new file mode 100644 index 0000000..d0299a7 --- /dev/null +++ b/internal/web/templates/sweatbox_manual.html @@ -0,0 +1,460 @@ +{{ define "title" }}Sweatbox manual{{ end }} + +{{ define "body" }} + + +
+
+

← Sweatbox control panel

+

Sweatbox instructor manual

+

+ How to run openfsd’s in-process traffic simulator for tower training. + Synthetic aircraft appear on the FSD network as normal pilots so ATC clients + (vSTARS, Euroscope, vatSys, etc.) see realistic traffic without N pilot logins. +

+
+ +
+ + +
+
+

1. Overview

+

+ The sweatbox is a native, in-process simulator inside the FSD process. + Aircraft are synthetic registry participants (no TCP pilot sockets). You drive them + from the Administrator web page at /sweatbox with text commands that + follow TWRTrainer-style vocabulary. +

+
    +
  • Control plane: web forms → openfsd web → FSD service HTTP (/sweatbox/*).
  • +
  • Wire: synthetics inject #AP, position @, flight plans, and #DP like real pilots.
  • +
  • Primary path: HTML forms work with JavaScript disabled. JS only refreshes the table live.
  • +
+
+ +
+

2. Requirements & enablement

+ + + + + + + + + + + + + + + + + + + + + + +
NeedDetail
RoleAdministrator network rating (same as Config / Airport Editor).
FSD processRunning and reachable by the web process over service HTTP (default :13618).
Feature flagSWEATBOX_ENABLED=true on the FSD process (default is off). Restart after changing.
Optional envSWEATBOX_CID (default 900001) — synthetic pilot CID shared by all sweatbox aircraft.
+

+ If the page says Disabled, sweatbox routes are absent on FSD. + If it says Unavailable, the web UI cannot reach FSD service HTTP. +

+
+ +
+

3. Control panel UI

+

Dense 16:9 operator layout at /sweatbox.

+
+
Status strip
+
ICAO, pause state, elapsed sim time, arrival/departure counts. Pause / Unpause / Refresh.
+
Aircraft table
+
+ Callsign, type, rules, squawk, heading, altitude, speed, status, instruction. + Click a row (or Tab + Enter with JS) to copy the callsign into Selected CS. + Per-row Del removes that synthetic; Delete all needs the confirm checkbox. +
+
Command rail
+
+ Selected CS + command Line + Run. + Target forms: embed callsign in the line (AAL123, taxi A) or fill Selected CS and type only the verb. +
+
Airport / scenario
+
Load .apt and .air via file upload or paste. Scenario load auto-pauses the sim.
+
+

+ With JavaScript enabled, the table polls about every 1.5 s. Draft fields may persist in browser storage. + Commands always execute on the server — client JS never authorizes mutations. +

+
+ +
+

4. Typical workflow

+
    +
  1. Enable sweatbox on FSD and open /sweatbox as Administrator.
  2. +
  3. + Load an airport (.apt). Use + Airport Editor to author/download files if needed + (editor never injects into a live session — you load files here). +
  4. +
  5. Optionally load a scenario (.air). Sim pauses automatically.
  6. +
  7. Unpause when ready for motion.
  8. +
  9. + Spawn or select aircraft, issue taxi / takeoff / pattern / vector commands while students + control as normal ATC on the FSD port. +
  10. +
  11. Delete aircraft when finished, or use Delete all.
  12. +
+
+ +
+

5. Airport & scenario files

+

Airport (.apt)

+

+ TWRTrainer-compatible airport geometry: ICAO, magnetic variation, parking spots, + runways, taxiways, holds. Defines the graph used for taxi routing (~100 ft intersection snap). +

+
    +
  • Replace checkbox clears existing aircraft when loading a new airport.
  • +
  • Max upload/paste size is 2 MiB.
  • +
  • + Bulk XP12 conversion: operator-fetched apt.dat via + go run ./cmd/aptdat2apt … (see repo docs on X-Plane airport data). +
  • +
+

Scenario (.air)

+

+ Colon-delimited aircraft snapshots (callsign, type, engine, rules, position, plan fields, …). + Load is best-effort: soft parse issues may be reported while accepted aircraft still spawn. + Loading a scenario auto-pauses the engine. +

+

Airport Editor

+

+ /airport-editor is map-first authoring with Blob download only (no server save). + Workflow: edit → download .apt/.air → load on Sweatbox. +

+
+ +
+

6. Command language

+

Commands are plain text. Soft failures return a message (flash); success is often silent motion.

+

Targeting

+
    +
  • Global (no aircraft): add, p/pause, un/unpause, ops/stats.
  • +
  • Callsign prefix: AAL123, taxi A B hs 33 or AAL123 taxi A B hs 33 (comma optional).
  • +
  • Selected CS: fill Selected CS, then taxi A B hs 33.
  • +
+

Aliases

+

+ Many verbs accept short forms (e.g. p=pause, ph=pos, cancel=ctoc, + speed/slow=spd, tcn=tc). Prefer the canonical names below when teaching. +

+
+ +
+

7. Spawn & session commands

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CommandMeaning
add rules weight engine runway distance [type] + Place on approach to runway at distance NM. + rules: v/i (VFR/IFR). + weight: s/m/l/h. + engine: p/t/j/h (prop / turboprop / jet / helo). + Optional ICAO type override. +
add rules weight engine @parking [type]Parked at named parking space (e.g. @GA1).
add rules weight engine -bearing distance altitude [type]Airborne on radial from field: negative bearing, distance NM, altitude feet.
delDelete selected / targeted aircraft (disconnect synthetic).
p / pauseFreeze motion; aircraft remain online.
un / unpauseResume motion.
ops / statsSession stats: elapsed, arrivals, departures, rates.
+

+ Example approach spawn: add i h j 33 8 — IFR heavy jet on 8 NM final for runway 33. + Example parking: add v s p @GA1 C172. +

+
+ +
+

8. Ground commands

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CommandMeaning
taxi path… [hs name…] + Taxi via taxiway/runway names. Optional hs tokens insert hold-shorts + (e.g. taxi K C D 33 hs 15). First step must intersect current surface. + Max path length is limited (on the order of 100 steps). +
holdHold present position (stop taxi).
resResume after hold / hold-short / cross wait.
cross nameCross the named surface currently holding short of.
pos / phLine up and wait (position and hold) on departure runway.
cto [hdg]Cleared for takeoff; optional departure heading.
ctoc / cancelCancel takeoff clearance.
ctomlt / ctomrtCleared takeoff into left / right closed traffic.
nostop / noholdCancel planned stop/hold-short behavior (e.g. during rollout taxi intent).
+
+ +
+

9. Airborne / vector commands

+ + + + + + + + + + + + + + +
CommandMeaning
fh hdgFly heading (shortest turn).
fhn hdgFly heading now (snap / immediate).
tr hdg / tl hdgTurn right / left to heading (forced direction, may long-arc).
fphFly present heading.
cm altClimb/maintain altitude (feet).
spd nSpeed control (knots). Aliases include speed, slow.
gaGo around from approach/pattern/landing context.
goContinue after stop-and-go wait on the runway.
+

+ Kinematics are training-oriented (constant turn/climb rates), not a full flight model. + There is no collision AI between aircraft. +

+
+ +
+

10. Pattern & arrival commands

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CommandMeaning
erc / erd / erb rwyEnter right crosswind / downwind / base for runway.
elc / eld / elb rwyEnter left crosswind / downwind / base.
ef rwyEnter final.
tgTouch and go.
sg [sec]Stop and go; optional seconds to hold, else wait for go.
laLow approach.
fsFull stop landing.
extExtend current pattern leg (upwind/crosswind/downwind).
mlt / mrtMake left / right traffic.
msa / mnaMake short approach / normal approach (short cuts base from downwind).
ps nmPattern size in nautical miles.
tc / td / tbTurn crosswind / downwind / base now (end extend / advance leg).
+

+ Closed-traffic takeoff (ctomlt/ctomrt) joins the circuit after climb. + Midfield downwind is reflected in the Instruction column when crossed. +

+
+ +
+

11. Transponder & flight plan

+ + + + + + + + + + + + + + +
CommandMeaning
sq codeSquawk code.
sqi codeSquawk code and ident.
snSquawk normal (Mode C).
ssSquawk standby.
idIdent.
fp type alt route…IFR flight plan fields (type, cruise alt, route tokens).
vp type alt route…VFR flight plan fields.
remarks text…Flight plan remarks.
+

+ ATC clients can query and amend flight plans on the wire. The instructor table merges + live session plan fields after ATC amends, so the UI stays honest about what controllers see. +

+
+ +
+

12. Status vocabulary

+

Common Status column values (aligned with TWRTrainer where practical):

+
    +
  • Parked, Taxiing, Holding Short, Holding in Position, Holding
  • +
  • Takeoff, Departing, On Approach, Airborne, Landed
  • +
  • Upwind, Crosswind, Downwind, Base, Final
  • +
+

+ Instruction is a short human-readable cue (taxi plan, pattern leg, + midfield report, landing type, etc.). Use it together with Status when briefing or debugging. +

+
+ +
+

13. What students see

+
    +
  • Normal pilot targets: callsigns, positions, flight plans, squawk.
  • +
  • Late-joining ATC does not receive historical #AP for already-online traffic (same as real pilots); they see subsequent positions in range and can query plans.
  • +
  • Direct text / CPDLC-like traffic to synthetics is not answered (drain-and-drop). Instructors use the web command box, not the radio, to control aircraft.
  • +
  • Dashboard may badge synthetic connections; public datafeed can include them when sweatbox is enabled—know your ops policy.
  • +
+
+ +
+

14. Ops notes & troubleshooting

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SymptomCheck
Page shows DisabledSWEATBOX_ENABLED=true on FSD; restart FSD/binary.
Page shows UnavailableWeb can reach FSD service HTTP; shared JWT secret/config; FSD process up.
Command soft-failsRead flash message: missing CS, not taxiing, runway not found, no intersection, etc.
No motionSim paused (scenario load pauses). Unpause.
Callsign conflictShared registry with human pilots; pick another CS or remove the human connection.
Taxi “do not intersect”Path steps must chain via ~100 ft graph snap; fix airport geometry in Airport Editor.
+

Limits (defaults)

+
    +
  • Max aircraft on the order of 64 (clamped; see server config).
  • +
  • Upload bodies capped at 2 MiB for airport/scenario.
  • +
  • One active airport / scenario namespace per FSD process.
  • +
+

Related tools

+ +

+ This manual describes the openfsd instructor control plane. It is not a substitute for + local training procedures or client-specific ATC software docs. +

+
+
+
+
+{{ end }} diff --git a/wiki/Home.md b/wiki/Home.md index b9730b0..3f8f1ed 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -18,6 +18,7 @@ Operator documentation for [openfsd](https://github.com/renorris/openfsd). | URL | Who | Notes | |-----|-----|--------| | `/sweatbox` | Administrator | Live ground/taxi simulator control (needs FSD + sweatbox enabled) | +| `/sweatbox/manual` | Administrator | Instructor user manual (also linked from the control panel, opens in a new tab) | | `/airport-editor` | Administrator | Map-first `.apt` / `.air` authoring; **download only** (no server save). Validate tab has live client checks + optional server confirm. Download files, then load them on `/sweatbox`. Design: repo `docs/design/apt-air-editor.md` | ## Quick links